Ringer: how it's built
Gmail triage that rings the phone only when an email needs it.
Two inboxes feed one event stream. A classifier scores each email and code decides call, push, digest or label, with a digest at 8am. Labels and pushes are live; calls are built but off (as of 2026-10-05).
Architecture
How an email becomes a ring
Event-driven, no polling of the mailbox. The service scales to zero, so delayed work rides on a queue.
- Gmail inboxes → Pub/Sub topic: new-mail event from a watch on the inbox (push, async)
- Pub/Sub topic → Triage service: push delivery; a 503 asks for redelivery (push, async)
- Triage service → Gmail inboxes: history sync, message read, then one label write (request, sync)
- Triage service → Firestore: claim, dedupe and log the decision (request, sync)
- Triage service → Jev classifier: classify the email, one request each (request, sync)
- Triage service → Pushover: emergency or normal push (request, sync)
- Triage service → Cloud Tasks queue: ring check scheduled 300 seconds out (timer, async)
- Cloud Tasks queue → Triage service: still unread and unacknowledged? (request, async)
- Triage service → Twilio Voice: place the call (request, async)
- Twilio Voice → Owner's phone: ring with a press-1 script (push, sync)
- Twilio Voice → Triage service: voice and keypress webhooks, signed (webhook, sync)
- Cloud Scheduler → Triage service: watch renewal, 8am digest, hourly heartbeat (cron, async)
- Triage service → Claude Haiku: write the morning digest (request, sync)
- Triage service → Healthchecks: heartbeat ping (request, sync)
- Pushover → Owner's phone: lock-screen notification (push, async)
The call path
The phone rings only if the push was not enough; the checks run once, at the five-minute mark.
- Decide to call → Emergency push: emergency push goes out first (request, sync)
- Decide to call → Wait 5 minutes: a queued check, 300 seconds out (timer, async)
- Wait 5 minutes → Still unread?: unread and unacknowledged? limits re-checked once (request, async)
- Still unread? → Place the call: still unread, so place the call (request, async)
- Place the call → Owner's phone: ring with the script; press 1 to acknowledge (push, sync)
- Place the call → Redial in 2 minutes: no press within 2 minutes (timer, async)
- Redial in 2 minutes → Place the call: dial again, up to three dials in all (request, async)
- Redial in 2 minutes → Missed-call push: no press after three dials (request, async)
- Missed-call push → Owner's phone: missed-call push (push, async)
Wiremocks
A mock-up of a screen — Here, a static picture of a screen, drawn to show layout and flow, not the working app. — a static picture, not the real app
What the owner sees
Drawings of the real screens with made-up senders. The call screen shows a path that is built but off (as of 2026-10-05).
Incoming call
Ringer
Critical email from Recruiting team. Subject: Next steps for the onsite interview. Press 1 to acknowledge.
Calls are built but off, so this screen is a drawing of the path.
Lock-screen pushes
Tier 1, critical: needs action within hours — Needs my action within hours or something important is lost. interview next steps: Recruiting team
Next steps for the onsite interview
Emergency priority, trusted sender
Tier 2, high: needs attention today — Needs my attention today but a few hours' delay is harmless. recruiter outreach: Sourcing team
A quick chat about a platform role
Normal priority, untrusted sender
Morning digest, 8:00
Mail digest: 5 emails, 1 held overnight
- Recruiting team (hiring.example) wants to chat next week about the platform role.
- Careers team (jobs.example): not moving forward.
- Receipts: two from shop.example.
- Newsletters: one from news.example.
Gmail labels
- Triage/1 Critical1
- Triage/2 High2
- Triage/3 Major3
- Triage/4 Minor12
- Triage/5 Not urgent18
- Triage/6 Spam4
Running cost
| Resource | Figure | Status | Source |
|---|---|---|---|
| Jev input tokens | $0.042 per One million — Shorthand in prices and limits, as in $1 per 1M tokens. tokens | Published price | cost page (demo/index.html), line 522 Read on 2026-10-17 |
| Jev output tokens | free | Published price | cost page (demo/index.html), line 522 Read on 2026-10-17 |
| Twilio phone number | $1.15 per month | Published price | cost page (demo/index.html), line 520 Read on 2026-10-17 |
| Twilio outbound call | $0.014 per minute | Published price | cost page (demo/index.html), line 520; commit 9ffa675 Read on 2026-10-17 |
| Pushover | $5 once | Published price | cost page (demo/index.html), line 509 Read on 2026-10-17 |
| Healthchecks.io | free | Published price | cost page (demo/index.html), line 529 Read on 2026-10-17 |
| Google Cloud services | $0 per month | Estimate (the project's own) | cost page (demo/index.html), lines 505, 524-527 (free tier at this volume) Read on 2026-10-17 Conflicts with commit c14541d (free-tier fit unverified, read on 2026-10-17): The commit says the free-tier fit was never checked. The project's own page still states $0 and no measurement exists either way, so the row stays an estimate. |
| Twilio number and calls | ~$1.50 per month | Estimate (the project's own) | cost page (demo/index.html), line 520 (includes the number) Read on 2026-10-17 |
| Claude Haiku digest | ~$0.70 per month | Estimate (the project's own) | cost page (demo/index.html), line 521 Read on 2026-10-17 |
| Jev, monthly | ~$0.25 per month | Estimate (the project's own) | cost page (demo/index.html), line 522 Read on 2026-10-17 |
| Monthly total | ~$2.45 per month | Estimate (the project's own) | cost page (demo/index.html), lines 504-510 (estimates summed from components, not quotes) Read on 2026-10-17 |
| First year | ~$34 for year one | Derived from an estimate | see inputs Inputs: 2.45 × 12 + 5 = 34.4 (the monthly total, projected; Pushover, published) Read on 2026-10-17 |
| Call minutes | ~25 per month | Derived from an estimate | see inputs Inputs: (1.50 − 1.15) ÷ 0.014 = 25 (projected calls line, published number and minute rate); calls were off as of 2026-10-05 Read on 2026-10-17 |
| Jev backfill | ~$0.02 once | Derived | see inputs Inputs: ~527k tokens (measured, commit 8fb90a2) × $0.042 per One million — Shorthand in prices and limits, as in $1 per 1M tokens. = $0.022 Read on 2026-10-17 Conflicts with cost page (demo/index.html), line 531 (~$0.03, read on 2026-10-17): The page gives a rounder figure for the same backfill. The commit's measured token count governs. |
No row of the owner's metered table names this project. Google Cloud runs on a trial credit.
Design decisions
- The model classifies; code decides whether to ringThresholds live in code, so the policy is auditable and tunable without a model rewrite. An email's text cannot talk its way into a call.
- Sender trust is computed in codeA call needs a sender that is authenticated and known: a hiring system, an allowlist or past mail. An email can argue for its own urgency, so the model gets no vote.
- Push first, ring laterAn emergency push goes out at once; the call waits five minutes and rings only if the email is still unread and the push unacknowledged. A queue carries the delay.
- One Gmail write path, guarded by a testEvery label write goes through one module; a test fails on any other write. The second inbox is read-only, so a bug can mislabel mail but never send or delete it.
- Shadow mode first, then a staged rolloutRollout order: labels, pushes, calls. In the label-only days one failing deploy would have sent 24 alerts in four days, so untrusted urgent mail became a normal ping.
Open questions
- Jev tokens per email: three repo figures disagree (1,300 unmeasured on the cost page; ~1,400 to ~1,600 in commit 16f864b; ~1,800 in the project backlog)
- Load: ~150 emails a day is the outside spec's assumption, not a measurement