Onewheel, EUC, esk8, and scooter crews coordinate through noisy group texts where "anyone riding today?" gets asked into the void and spontaneous rides go un-joined because nobody knew. Rollcall is one bit of state per rider — riding / not riding — broadcast only to the people you chose. Think Discord status, for wheels.
You flip your status on by texting a number. Your crew gets a ping. Anyone who wants in shows up. Status auto-expires, so the board is never stale. No app to install — half of any crew never would — the phone number is the product.
You → RIDING piedmont park
🛞 → You're live at piedmont park til ~9:15pm. Crew notified.
…meanwhile, your crew gets:
┌─────────────────────────────────────────────┐
│ 🛞 Stiwi is riding at piedmont park │
│ (til ~9:15pm) │
│ Reply WHO to see everyone out, MUTE to hush. │
└─────────────────────────────────────────────┘
Riri → WHERE stiwi
🛞 → Stiwi is at piedmont park (til ~9:15pm).
You → DONE (or just let it expire — crew isn't pinged either way)
🛞 → You're off the board. Ride safe. 🛞
- 📟 Zero-install presence — text
riding, your crew gets an SMS. That's onboarding. - 📍 Optional spot, not a trail — drop
riding piedmont park; friends textwherefor the pin. No breadcrumbs, no history feed. - ⏳ Never stale — every session auto-expires (default 3h,
+3hto extend). A beacon you can't trust is worse than none. - 🤫 Departures are silent —
doneand expiry never ping the crew. Arrivals are interesting; leaving is noise. - 👥 Crew-scoped, full stop — your status goes only to people you chose. No public map of strangers.
- 🔕 Mute without leaving —
mutestops pings but keeps you a member;unmuteany time. - 🖥️ Admin web panel — a password-gated
/adminUI to seed crews, manage riders, watch who's live, and read the message log — no hand-edited JSON. - 🔒 Verified inbound — every webhook's
X-Twilio-Signatureis validated or rejected. - ♻️ Carrier-proof — duplicate webhooks deduped on
MessageSid;STOP/START/HELPhonored. - 🧪 Runs with no Twilio —
DRY_RUNmode logs SMS to the console so you can drive the whole grammar withcurl.
Keywords are case-insensitive; the first word is the command.
| Text | Who | Effect |
|---|---|---|
riding |
rider | Go live, no location; fan out to crew |
riding <place> |
rider | Go live with a location; fan out |
done |
rider | End your ride — crew not pinged |
who |
anyone | List who's out in your crew |
where <name> |
anyone | Get that rider's spot, if they're live |
+3h / extend |
rider | Push your expiry out |
name <displayname> |
anyone | Set your display name |
mute / unmute |
anyone | Stop / resume receiving fan-out (still a member) |
help |
anyone | Command list |
stop / start |
anyone | Carrier opt-out / opt-in (honored) |
Warning
Ending a ride is done, not stop. Carriers reserve STOP for opting
out of all messages — texting it unsubscribes you entirely.
Twilio number
│ inbound SMS webhook (POST /webhooks/twilio)
▼
Rollcall service (Hono, single process) SQLite (WAL)
│ validate signature → parse → mutate ──► crews / riders / ride_sessions / message_log
├─► Twilio REST API: crew fan-out
└─► expiry sweep (in-process, every minute)
TypeScript + Hono on Node ≥20, better-sqlite3, the
official twilio SDK. SQLite is plenty — this is tens of writes a day. The
domain layer carries no HTTP or Twilio concerns, so the command handler is
unit-tested against an in-memory database.
📂 Project layout
src/
index.ts server + sweep bootstrap
config.ts env-driven config (one place, no scattered process.env)
db/
schema.sql DDL
index.ts shared WAL connection + time helpers
migrate.ts `npm run migrate`
domain/ state + business logic (no HTTP/Twilio here)
riders.ts sessions.ts messages.ts types.ts
sms/
parse.ts text → Command
commands.ts Command → reply + fan-out (pure, unit-tested)
format.ts all outbound copy
twilio/
validate.ts X-Twilio-Signature check
client.ts REST send (+ DRY_RUN console mode)
admin/ password-gated /admin web panel (server-rendered)
auth.ts session cookie sign/verify + gate
routes.ts pages + form actions
views.ts HTML (no client build)
routes/webhook.ts POST /webhooks/twilio
seed.ts `npm run seed` — admin-seeds crews/riders from JSON
test/ vitest: parser + command handler
docs/ vision.md, mvp-spec.md, assets/
Requires Node ≥ 20.
npm install
cp .env.example .env # fill in Twilio creds (or set DRY_RUN=true)
npm run migrate # create the SQLite databaseSeed your crew. No self-serve signup in the MVP — the admin seeds riders:
cp seed.example.json seed.json # add your riders (phones must be E.164)
npm run seed # idempotent; safe to re-run as the crew growsRun it.
npm run dev # tsx watch, reloads on change
# or
npm startThe service listens on PORT (default 8080):
POST /webhooks/twilio— point your Twilio number's inbound SMS webhook hereGET /health— liveness check
Set DRY_RUN=true: signature checks are skipped and outbound SMS is printed to
the console instead of sent — so you can exercise the whole grammar with curl.
Note
Send From with --data-urlencode so the leading + isn't decoded as a space.
DRY_RUN=true npm start
curl -X POST localhost:8080/webhooks/twilio \
--data-urlencode "From=+14045551234" \
--data-urlencode "Body=riding piedmont park" \
--data-urlencode "MessageSid=SM_test_1"
# → TwiML reply; the crew fan-out prints as [DRY_RUN SMS] linesnpm test # vitest: parser + command handler against an in-memory DB
npm run typecheckA server-rendered admin UI lives at /admin — no separate build, no SPA. Set
ADMIN_PASSWORD to enable it (if unset, the panel refuses to serve). Log in with
that password and you get a signed, HttpOnly, SameSite=Strict session cookie.
From it you can:
- 👥 Create crews and see rider counts and how many are out right now
- 📇 Add / rename / re-crew / remove riders with inline E.164 validation (replaces
seed.json) - 🔕 Mute or unmute any rider
- 🛰️ Watch live sessions across every crew and end one (silently — the rider isn't texted)
- 📜 Read the message log for debugging
ADMIN_PASSWORD='a-long-random-string' \
ADMIN_SESSION_SECRET="$(openssl rand -hex 32)" \
npm start
# → open http://localhost:8080/adminNote
Set ADMIN_SESSION_SECRET to a stable value so sessions survive restarts;
without it a fresh secret is generated each boot (you'll be logged out on
restart). In production the session cookie is Secure (HTTPS only).
All via environment variables (see .env.example):
| Variable | Default | Purpose |
|---|---|---|
PORT |
8080 |
HTTP port |
DATABASE_PATH |
./data/rollcall.db |
SQLite file (:memory: for tests) |
TWILIO_ACCOUNT_SID |
— | Twilio account SID |
TWILIO_AUTH_TOKEN |
— | Used to validate inbound signatures |
TWILIO_FROM_NUMBER |
— | The Twilio number, E.164 |
PUBLIC_WEBHOOK_URL |
— | Exact webhook URL Twilio calls (signature is hashed over it) |
DRY_RUN |
false |
Skip signature checks + real sends; log instead |
DEFAULT_EXPIRY_HOURS |
3 |
Ride session lifetime |
SWEEP_INTERVAL_SECONDS |
60 |
Expiry sweep cadence |
LOG_RETENTION_DAYS |
30 |
message_log prune horizon |
ADMIN_PASSWORD |
(empty) | Enables /admin; empty = panel disabled |
ADMIN_SESSION_SECRET |
(random) | Signs admin session cookies; set a stable value |
ADMIN_SESSION_HOURS |
24 |
Admin session lifetime |
Rollcall just needs a reachable HTTPS webhook and a writable disk for SQLite.
There's a multi-stage Dockerfile (slim, non-root, prod deps
only) and a fly.toml ready to go.
Fly.io:
fly launch --no-deploy # match the app name in fly.toml
fly volumes create rollcall_data --size 1 --region atl # SQLite lives here
fly secrets set TWILIO_ACCOUNT_SID=… TWILIO_AUTH_TOKEN=… \
TWILIO_FROM_NUMBER=+1… \
PUBLIC_WEBHOOK_URL=https://<app>.fly.dev/webhooks/twilio
fly deployHomelab (Proxmox + Cloudflare Tunnel): docker build -t rollcall . && docker run
with /data mounted and the env vars set; point the Tunnel at PORT and set
PUBLIC_WEBHOOK_URL to the public hostname.
Note
SQLite is single-writer, so Rollcall runs as exactly one instance — the
fly.toml pins one always-on machine (no auto-stop) so the file isn't shared
and the expiry sweep keeps ticking.
Set PUBLIC_WEBHOOK_URL to exactly what's in the Twilio console — signature
validation hashes that URL, so it must match byte-for-byte.
Important
Twilio setup is the slow part — verification can take days, so start it first. Full walkthrough in docs/twilio-setup.md: choosing a toll-free vs 10DLC number, verification, wiring the webhook, and the secrets. In short: US application SMS must be registered (there's no hobby exemption), but costs are trivial once live — number ~$1–2/mo, ~$0.008 per segment, and fan-out copy stays under one segment.
- MVP (this repo) — SMS-only presence + crew fan-out.
- v1.1 — PWA: live "who's riding" view, one-tap go-live, geocoded map pins (the
lat/lngcolumns are already there, waiting). - v2 — auto-start via telemetry (VESC / BLE / phone motion), scheduled rides, multiple crews.
- Someday — the fun stuff (streaks, group-ride stats) — only once the beacon is rock solid.
- Presence, not surveillance. Ephemeral status, coarse opt-in location. No breadcrumb trails.
- Crew-scoped. Your status goes only to people you chose.
- Stale state is death. Everything expires.
- Meet riders where they are. SMS first, PWA second, native maybe never.
📖 Vision · MVP spec · Twilio setup · Contributing
🛞 Ride safe.
