Note: the visuals in this demo recording have since been refreshed with sharper brand assets. The conversation flow is identical to what you'll get from a fresh clone.
What's inside
- Find nearby stops by sharing location through RCS
- Real-time arrivals from 511.org StopMonitoring API
- Coverage across BART, Muni, AC Transit, Caltrain, Golden Gate Transit, more
- GTFS database imported into local SQLite for stop lookup
- Recent stops and routes per rider
A Bay Area transit chatbot that runs over RCS. Riders share their location through RCS, see the closest stops across BART, Muni, AC Transit, Caltrain, Golden Gate Transit, and other 511.org agencies, then pull real-time arrivals — all from inside the messages app.
This guide walks you from a fresh clone to a working transit demo on your phone in about 15 minutes (the GTFS import is the slow step).
What you'll build
- A Pinnacle RCS agent that handles location sharing, button taps, and free-form text in three dedicated handlers
- A local SQLite GTFS database for nearby-stop lookup across all 511.org agencies
- Real-time arrivals from the 511.org StopMonitoring API
- Per-rider recently-viewed stops and routes
Prerequisites
- Node.js 18+
- A Pinnacle account — sign up. Add an RCS test agent for development
- An API key and a webhook signing secret
- A free 511.org API key — 511.org/open-data/token
- Optional: a Mapbox API key for richer geocoding
1. Clone and install
git clone https://github.com/pinnacle-samples/Apex-Transit
cd Apex-Transit
npm install2. Configure environment
cp .env.example .envPINNACLE_API_KEY=your_pinnacle_api_key_here
PINNACLE_AGENT_ID=your_agent_id_here
PINNACLE_SIGNING_SECRET=your_pinnacle_signing_secret_here
TEST_MODE=false
PORT=3000
# 511.org Open Data
API_511_KEY=your_511_api_key_here
# Optional: Mapbox geocoding
MAPBOX_API_KEY=your_mapbox_api_key_here3. Import the GTFS database
The agent looks up nearby stops from a local SQLite copy of the regional GTFS feed. Import it once:
npm run update-dbThis downloads the latest GTFS feed for every supported agency and writes them to cache/gtfs.db. Re-run whenever you want fresh stop data.
4. Expose your webhook
ngrok http 30005. Connect the webhook
In the Webhooks dashboard:
- Add
https://<your-tunnel-domain>/webhook - Attach it to your RCS agent
- Copy the signing secret into
PINNACLE_SIGNING_SECRET
6. Run it
npm run devSend MENU or START to your agent. You'll see Apex Transit's main menu with Stops Near Me, Recently Viewed, and Help buttons. Tap Stops Near Me and share your location — the agent returns the closest stops with live arrivals.
How the pieces fit together
Apex-Transit/
├── server.ts # Express bootstrap
├── router.ts # /webhook POST — dispatches by message type
├── update-db.sh # GTFS importer
├── handlers/
│ ├── index.ts # Re-exports the three handlers below
│ ├── button.ts # Trigger button handler
│ ├── location.ts # Location-share handler
│ └── text.ts # Free-form text handler
├── cache/
│ ├── gtfsCache.ts # SQLite reader
│ ├── import-gtfs.ts # GTFS feed → SQLite importer
│ ├── schema.sql # Stops / routes / agencies tables
│ └── gtfs.db # Generated SQLite DB (after import)
└── lib/
├── rcsClient.ts # PinnacleClient instance
├── baseAgent.ts # Shared send + typing helpers
├── typing.ts # Fire-and-forget typing indicator
├── agent.ts # Agent — recently viewed state + presentation
└── transit/
├── arrivals.ts # 511.org StopMonitoring fetcher
├── nearbyStops.ts # Geo lookup over GTFS DB
├── types.ts # ArrivalInfo, StopData, AGENCY_NAMES
└── util.ts # Distance, formatting helpers
Routing by message type
Unlike the other samples, router.ts doesn't switch on a trigger action — it dispatches by RCS message type:
RCS_BUTTON_DATA(trigger) →handleButtonClickRCS_LOCATION_DATA→handleLocationRCS_TEXT→handleTextMessage
This makes location sharing a first-class flow rather than a special case inside a giant switch statement.
How nearby-stop lookup works
When a user shares their location, handlers/location.ts calls findNearestStops(lat, lng) which runs a haversine query over the SQLite stops table. For each result, it calls the 511.org StopMonitoring API to fetch the routes that actually serve that stop right now (instead of all routes that could serve it).
The result is a small set of cards with current arrivals — usually 3 to 5 stops, ranked by walking distance.
Customize coverage
AGENCY_NAMES in lib/transit/types.ts is the allowlist of supported agencies. Add a new agency, re-run update-db.sh, and the next location share picks it up.
Going to production
- Set
TEST_MODE=falseand submit your agent for carrier approval - Move
gtfs.dbto a managed Postgres or MySQL instance with PostGIS for a real geo index - Schedule
npm run update-dbas a nightly cron so the stop catalog stays fresh - Add proactive arrival alerts by storing favorite stops per rider and pushing updates from a worker
Resources
- Repo: github.com/pinnacle-samples/Apex-Transit
- Docs: docs.pinnacle.sh
- 511.org open data: 511.org/open-data
- Support: founders@trypinnacle.app

