1 · One-pager
Mayday is an iPhone personal-safety app that turns "Hey Siri, Mayday" into a full incident: camera burst + SMS to contacts + outbound call to an AI agent + GPS pings for 10 min. The MVP mocks Twilio entirely — backend records the intent to call/text/SMS so we can build the incident pipeline, the dashboard, and the admin console against real data without paying per-minute.
Timeline
5 phases, 2-3 weeks each, each ending with a verifiable demo. No "research sprints".
Team
2 senior + 2 mid. Mobile is the riskiest hire; everything else has a clear playbook.
Burn
No Twilio/voice costs in MVP — everything flows through the mock layer. Real voice tier deferred to Phase 4.
2 · Runtime architecture
iPhone fires an App Intent → phone posts to the relay → relay fans out the four primitives (record, sms, call, locate) → all events stream to a WebSocket that both the per-user dashboard and the admin console consume.
TwilioAdapter interface. The mock implementation is the default in MVP; production simply sets TWILIO_ADAPTER=real and injects the SDK. Same call sites, same response shapes, same webhook signatures.
3 · API primitives (mocked Twilio)
Every endpoint the iPhone app and the admin console consume. Authentication is short-lived JWT signed with HS256, scoped per role (user / admin / agent).
3.1 · Mobile → Relay
POST /v1/incidents # create incident, returns {id, started_at} POST /v1/incidents/:id/events # batched: gps_ping, sensor, app_state POST /v1/incidents/:id/media # multipart upload, 30s clip chunks GET /v1/incidents/:id # full record incl. contact status POST /v1/incidents/:id/cancel # user aborts; requires reason POST /v1/incidents/:id/contacts/notify # server-side SMS fan-out (mocked) POST /v1/incidents/:id/voice/initiate # server-side call to contact (mocked) WS /v1/incidents/:id/stream # real-time event stream
3.2 · Twilio mock → Relay (webhooks, scripted)
POST /v1/webhooks/twilio/sms/status # queued · sent · delivered · failed POST /v1/webhooks/twilio/voice/status # ringing · answered · completed POST /v1/webhooks/twilio/voice/recording # recording_url · duration · transcript POST /v1/webhooks/twilio/voice/dtmf # user pressed 1/2/3 in call tree
3.3 · Admin → Relay
GET /v1/admin/incidents # filter by status, user, region GET /v1/admin/incidents/:id # full timeline + media POST /v1/admin/incidents/:id/intervene # take over, push 911, dispatch field GET /v1/admin/users # all registered + last active GET /v1/admin/audit # operator actions · exportable
3.4 · Response shapes (canonical)
// Incident — server-authoritative { "id": "inc_01HXY...", "user_id": "usr_8x9k", "status": "active" | "resolved" | "cancelled" | "failed", "started_at": "2026-06-27T17:42:11Z", "ended_at": null, "last_ping": { "lat": 37.4220, "lng": -122.084, "t": "..." }, "media": [{ "kind": "video", "url": "r2://...", "duration_s": 30 }], "contacts": [{ "name":"Sarah", "phone":"+1...", "status":"answered", "ts":"..." }], "voice": { "call_sid":"CAxxx", "status":"in_call", "recording":null } } // Event — append-only audit row { "id": "evt_01HXY...", "incident_id":"inc_01HXY...", "kind": "trigger" | "gps_ping" | "media_uploaded" | "contact_sms_sent" | "contact_call_initiated" | "contact_call_answered" | "voice_dtmf" | "user_cancelled" | "admin_cancelled" | "admin_intervened", "at": "2026-06-27T17:42:13Z", "data": { ... kind-specific ... } }
POST accepts an Idempotency-Key header. Mobile retries on flaky network; the mock layer replays the same scripted sequence if the key matches. This is the discipline that makes the mock indistinguishable from real Twilio.
4 · iPhone trigger flow — what the user sees
The mockup below shows the iPhone screen the moment after "Hey Siri, Mayday" fires. The relay has already accepted the incident; the iPhone is now streaming events back as the mock layer "dials" and "sends".
Mock incident · synthetic data
What fires when
| t (ms) | Actor | Action |
|---|---|---|
| 0 | Siri | Recognizes "Mayday" → invokes TriggerEmergencyIntent |
| +80 | iPhone | POST /v1/incidents with device GPS, contacts list, battery level |
| +150 | iPhone | AVCaptureSession starts dual-cam 30s capture to local buffer |
| +200 | Relay | Persists incident row + opens WS stream |
| +220 | Mock | Enqueues 4 SMS jobs (scripted delay 200ms-2s each) |
| +250 | Mock | Enqueues voice.dial to first contact (scripted ring-time 4-8s) |
| +400 | iPhone | Opens WS to relay, displays Active screen |
| +800 | iPhone | Starts GPS ping every 5s; uploads first 5s video chunk |
| +5,000 | Mock | Contact #1 SMS delivered; webhook received |
| +8,000 | Mock | Contact #2 voice "answered"; DTMF handler engaged |
| +10,000 | Admin | Console shows new incident row; live updates begin |
kill_all() on the adapter is reserved for admin console intervention only. The kill contract still matters: if an admin pulls the plug, every in-flight SMS halts and every active call hangs up. Idempotent, auditable, signed.
5 · Animated incident dashboard (per-user)
The user's own incident view — same data the admin sees, but scoped to their incidents only. Mockup below is a static frame of the live view; in production this is a WS-driven React/Next.js page with the same colour grammar.
useIncidentStream(id) which subscribes to the WS endpoint. State updates are diff'd, not full re-renders. Below is the same UI in test mode — the admin can scrub through a recorded incident frame-by-frame.
6 · Admin console — multi-user ops
Staff console for ops, support, and trust & safety. Real users (us, beta cohort) appear as rows in the feed; the map shows cluster density. This page is the first thing we'll show investors when we demo the "ops layer" — it proves we're not just building an app, we're building a service.
ops can intervene · support read-only on incidents · admin full audit access. Every operator action writes to the audit log with the operator id, the affected user, and a reason field that's required for sensitive actions (intervene, export, terminate).
7 · Data model (Drizzle / Postgres)
Three tables that survive every state of the system. The events table is append-only — that single discipline lets us replay any incident deterministically.
// users — minimal PII; identity managed by Clerk/Auth0 in production users ( id text primary key, phone text unique not null, name text, created_at timestamptz default now(), last_seen_at timestamptz, contacts jsonb -- [{name, phone, priority}] ) // incidents — one row per trigger; mutable status incidents ( id text primary key, user_id text references users(id), status text not null -- active | resolved | cancelled | failed, started_at timestamptz not null, ended_at timestamptz, last_ping jsonb, contacts_notified jsonb -- [{phone, status, ts}], voice jsonb -- {call_sid, status, recording_url}, cancel_reason text ) create index on incidents (status, started_at desc); create index on incidents (user_id, started_at desc); // events — append-only audit; INSERT-only, never UPDATE events ( id bigserial primary key, incident_id text references incidents(id), kind text not null, -- trigger | gps_ping | media_uploaded | contact_sms_sent | contact_call_initiated | contact_call_answered | voice_dtmf | user_cancelled | admin_cancelled | admin_intervened at timestamptz default now(), data jsonb ) create index on events (incident_id, at); -- Append-only enforced via revoke update/delete on app role
7.1 · Media storage
Video clips and call recordings go to Cloudflare R2 with server-side encryption (AES-256). URLs are short-lived signed (5 min) so the admin console can render evidence without exposing permanent links. Retention: 90 days for user-initiated, 365 days for incidents involving 911 escalation.
8 · Twilio mock layer — primitives + behaviour
The mock is a real, scriptable component — not a stub. It implements the full surface Twilio exposes and replays deterministic sequences so tests can assert exact state.
Adapter interface
interface TwilioAdapter {
sms: {
send(to: string, body: string):
Promise<{sid: string, status: 'queued' | 'sent' | 'delivered' | 'failed'}>
}
voice: {
dial(to: string, onAnswer: (sid: string) => void):
Promise<{sid: string, status: 'queued' | 'ringing' | 'answered' | 'completed'}>
hangup(sid: string): Promise<void>
recording(sid: string):
Promise<{url: string, duration: number, transcript?: string}>
}
kill_all(): Promise<{killed: number}> // cancel path
}
Scripted scenarios (mock)
| Scenario | Sequence |
|---|---|
| happy_path | SMS 200ms · call rings 4s · answers · DTMF 1 safe |
| voicemail | SMS 200ms · call rings 22s · voicemail |
| no_answer | SMS 200ms · call rings 30s · no-answer · next contact |
| network_fail | SMS fails 3× · call fails · marked failed |
| user_cancel | any state · drain queue · hangup all |
8.1 · Swap-in checklist (when Twilio turns on)
- Set
TWILIO_ADAPTER=realin env - Replace mock URLs with real Twilio webhooks; verify HMAC signatures match
- Verify each of the 5 scripted scenarios still produces the same event sequence (allow wall-clock jitter ±2s)
- Confirm kill_all() maps to Twilio
Calls.update({status:'completed'})for all in-flight - Re-run load test at 50 concurrent incidents · verify no missed webhooks
9 · Tech stack
Mobile (iOS)
Swift 5.10 SwiftUI App Intents ActivityKit CallKit AVFoundation WatchConnectivityBackend
Node 20 Fastify 5 TypeScript strict Drizzle ORM Postgres 16 Redis 7 (pub/sub) BullMQWeb / Admin
Next.js 15 React 19 TanStack Query Zustand Tailwind v4 shadcn/ui MapLibre GLData / infra
Fly.io (API + WS) Neon (Postgres) Upstash (Redis) Cloudflare R2 (media) Cloudflare Pages (web) Sentry Grafana CloudExternal (Phase 4)
Twilio Voice Twilio SMS ElevenLabs v2 OpenAI Realtime RapidSOS Ready Clerk (auth)Tooling
pnpm + Turborepo ESLint + Prettier Vitest Playwright Detox (iOS e2e) GitHub Actions9.1 · Env vars (server refuses to start if missing)
NODE_ENV production | staging | dev PORT 8080 DATABASE_URL postgres://... REDIS_URL redis://... R2_BUCKET mayday-evidence R2_ACCESS_KEY ... R2_SECRET_KEY ... WS_AUTH_SECRET # HS256 JWT TWILIO_ADAPTER mock | real # default mock in MVP TWILIO_ACCOUNT_SID # required only if TWILIO_ADAPTER=real TWILIO_AUTH_TOKEN ... TWILIO_FROM_NUMBER +1... SENTRY_DSN ... ADMIN_JWT_SECRET ...
10 · 14-week sprint plan
Monorepo · API scaffold · Twilio mock
MockTwilioAdapter with all 5 scenarios.🚦 Gate 1 — Mock end-to-end
curl POST /v1/incidents → adapter queues SMS + voice → webhooks fire → events table populated. Tested with 3 scenarios.
App Intents · camera burst · incident UI
TriggerEmergencyIntent + AppShortcutPhrase("Mayday").🚦 Gate 2 — Siri fires end-to-end
"Hey Siri Mayday" on TestFlight build → camera records → API receives → mock fires webhooks → Live Activity updates. Manual test on 3 devices.
Per-user Next.js dashboard · WS-driven
🚦 Gate 3 — Dashboard visible to user
Trigger an incident on iPhone → user dashboard shows same view as admin in real time, ≤500ms latency.
Multi-user ops console · roles · audit
/v1/admin/incidents, /intervene, /audit.ops / support / admin with reason field on sensitive actions.🚦 Gate 4 — Admin can intervene
Trigger incident as user → admin sees in feed → admin hits "intervene" → all in-flight mocks drain (kill_all). Audit row written.
Apple Watch app · Action Button integration
TriggerEmergencyIntent as system action in Settings.CMMotionActivityManager triggers intent on high-G event.🚦 Gate 5 — Three trigger surfaces
Siri, Action Button, Watch crown, and crash detection all fire the same intent. Manual test on iPhone 16 Pro + Watch Ultra 2.
Latency · tests · observability
🚦 Gate 6 — Production-ready binary
Load test green · all e2e green · dashboards live · p95 time-to-first-contact < 9s.
TestFlight · safety narrative · App Store
🚦 Gate 7 — Beta live
50 users actively using the app · 0 SEV1 incidents in mock layer · App Review first-pass response received.
11 · Engineering risks & mitigations
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| App Store rejection on safety/911/camera grounds | High | Critical | Pre-submission call with Apple review board · safety narrative doc · on-screen indicators · privacy nutrition labels accurate |
| False-positive Siri triggers burning trust | Medium | High | NegativeAppShortcutPhrase set · admin console can resolve-but-not-cancel after the fact · log every dispatch + every reason |
| Mock/real drift — what works in mock breaks in prod | High | High | Adapter contract tests run against both adapters · swap-in checklist (Phase 4) · load test at 50 concurrent on real before turning on |
| Webhook loss — Twilio never confirms a status | Medium | High | Idempotency keys · reconcile job every 5min queries Twilio for in-flight SIDs · alert if > 5% unconfirmed |
| Camera + upload battery drain drains user's phone mid-incident | Medium | High | Stream at 720p default · chunk size 5MB · upload pauses when battery < 20% · show battery indicator in Active UI |
| iOS background-mic denial if we ever try to add it | Low | Critical | Don't · Siri trigger is the only path · documented as design principle |
| Single engineer ships safety-critical bug at 11pm | Medium | Critical | 2-reviewer rule on safety paths · CI blocks merges without approval · on-call rotation starts Wk 8 |
| Provider model change (Twilio pricing, ElevenLabs API) | Medium | Medium | Adapter abstraction · monthly cost review · Gemini Live as backup · 2-week swap budget per provider |
12 · Use-case coverage (audit vs. usecases.html)
Every UC in usecases.html mapped to the implementation section that covers it. MVP scope is what ships in the first TestFlight build; deferred are explicit post-MVP per the original UC scoping.
Pre-event
| UC | Name | Implementation | Status |
|---|---|---|---|
| UC-1 | Discover | App Store listing + landing page (§9 stack) | MVP |
| UC-2 | Install | TestFlight + App Store (Wk 13-14) | MVP |
| UC-3 | Onboard | NextAuth Google + phone OTP · 5-screen flow (§3.4 + §10 Wk 1) | MVP |
| UC-4 | Drill | Drill button → triggers mock incident end-to-end (§10 Wk 2) | MVP |
| UC-5 | Shift-arm | App in foreground (deliberate arm) — no background daemon in MVP | deferred |
The event
| UC | Name | Implementation | Status |
|---|---|---|---|
| UC-6 | Trigger | "Hey Siri Mayday" → AppShortcutPhrase (§4 + §10 Wk 1) | MVP |
| UC-7 | Capture | AVCaptureSession multi-cam 30s → R2 (§10 Wk 2) | MVP |
| UC-8 | Broadcast | Twilio mock: SMS to 5 contacts with live link (§3.2 + §8) | MVP |
| UC-9 | Voice | Mock call + scripted AI tree (§8 scenarios) — real AI voice Phase 4 | mocked |
| UC-10 | Dispatch | Admin console "intervene" button (§6) — RapidSOS mocked | mocked |
| UC-11 | Resolve | Safe-word flow (§10 Wk 6) — admin marks resolved | MVP |
Post-event
| UC | Name | Implementation | Status |
|---|---|---|---|
| UC-12 | Evidence | Admin export · signed R2 URLs (§6) | MVP |
| UC-13 | Aftercare | Counseling referral email — partner org list | deferred |
Coverage summary
| Bucket | Total UCs | MVP-ready | Mocked | Deferred |
|---|---|---|---|---|
| Pre-event | 5 | 4 | 0 | 1 |
| The event | 6 | 4 | 2 | 0 |
| Post-event | 2 | 1 | 0 | 1 |
| Total | 13 | 9 | 2 | 2 |
9 UCs ship in first TestFlight · 2 mocked (UC-9 voice, UC-10 dispatch) · 2 deferred (UC-5 background arm, UC-13 aftercare partner integration).
13 · Open decisions & locked items
13.1 · Locked (your call this turn)
| Decision | Choice | Implication |
|---|---|---|
| Beachhead user | Rideshare drivers (Maria persona) | Use cases & GTM unchanged; consent flow stays phone-first |
| Twilio go-live | Mocked through Phase 4 (real later) | TwilioAdapter interface ships in Wk 1; TWILIO_ADAPTER=mock default |
| 911 provider | Mocked through Phase 4 | Admin "intervene" = simulated dispatch · real RapidSOS wired in Phase 4 |
| Auth | NextAuth.js — Google + phone OTP providers | Replaces custom JWT — saves ~1 wk; use-case UC-3 unchanged |
| DB hosting | Self-hosted Postgres — own box (no Fly.io) | Reduces dependency surface; ops cost is your time not $$ |
| Apple account | Reuse existing organisation account | TestFlight external testing unlocked from Wk 7 |
| Admin console auth | None for MVP — runs on secure host | View-only dashboard · no public exposure |
13.2 · Cancel posture — design decision
Your point about coercion is sharp and the literature backs it: most safety apps have a "cancel within X seconds" because false positives are catastrophic for trust. But the same UI is a vulnerability under threat. Resolution we agreed on: no user-visible cancel in MVP; admin console is the off-switch.
| Phase | Cancel behavior | Reasoning |
|---|---|---|
| MVP (TestFlight beta) — what we ship | No cancel on iPhone UI · admin can resolve-but-not-cancel · incident is irreversible from user side | Coercion resistance is non-negotiable for a safety app — once dispatched, only an operator pulls the plug. Beta cohort = friendly users, false positives get classified post-hoc. |
| Phase 4 (real-world users) | Same posture, but admin dashboard gets a single "Cancel incident" button with reason field · signed audit row | Admin (or pre-authorised trusted contact via phone tree) is the off-switch. Even emergency-stop has accountability. |
| V2 | Optional "duress PIN" — entering PIN silently escalates to police instead of cancelling | Standard duress pattern · Morsy's mention of PIN aligns with this. PIN entry looks like cancel but actually triggers a hidden second alert. |
13.3 · Still pending (non-blocking)
- Domain + SSL for admin console —
ops.mayday.devon a Cloudflare proxy in front of your box? - iOS minimum version — iOS 17+ confirmed (App Intents + negative phrases require 17). Confirm we drop iOS 16 support?
- Beachhead city — SF / Atlanta / NYC for first 50-user TestFlight?
- Drill cadence — UC-4 says drill in onboarding only. Should it be monthly push-notification prompt?
14 · Audit trail
| # | Command / action | Result |
|---|---|---|
| 1 | skill_view strategy-html-artifact | loaded — confirms 3-file trilogy pattern |
| 2 | skill_view cloudflare-pages-deploy | loaded — wrangler 4.105 works non-interactively |
| 3 | wrangler pages project list | confirmed mayday-impl not in list — new project |
| 4 | curl POST api.cloudflare.com/.../pages/projects name=mayday-impl | project created · subdomain mayday-impl.pages.dev |
| 5 | write_file /Users/morsy/dev/mayday-impl/index.html | v1 · 64,939 bytes · dark theme · 13 sections |
| 6 | wrangler pages deploy via mktemp -d staging | success · 1.62s · URL https://ed8341a0.mayday-impl.pages.dev |
| 7 | curl -L GET verify | HTTP 200 · 64,939 bytes · text/html — live |
| 8 | curl usecases.html → extract 13 UCs (UC-1 → UC-13) | use-case coverage matrix built · 9 MVP · 2 mocked · 2 deferred |
| 9 | patch index.html × 3 (meta chips, TOC, coverage + locked + cancel sections) | v2 ready · 7 locked decisions + cancel posture table |
| 10 | wrangler pages deploy v2 (next step) | pending |