Inspiration

A modern phone is a firehose. On an average day ours take several hundred notifications, emails, and texts, and maybe five of them actually matter: a delivery window, a card debit, an appointment confirmation, a bill due Friday.

Every "smart inbox" we tried solved this the same way — an opaque model decides what you see, quietly deletes what it thinks is noise, and does it on a server holding a permanent plaintext copy of your mail. You get quiet, but you can never ask why it was quiet, and you can't tell "nothing happened" apart from "something important was silently swallowed."

Relay takes the opposite trade. It is built on one rule: convenience never outranks privacy, consent, provenance, or idempotency. Quiet by default — but every decision (category, extraction, filter, disclosure, action) keeps a record of how it was made.

What it does

Relay is an Android-first app plus an edge pipeline that turns three noisy sources into one calm, searchable archive and a short list of things you might want to do.

Capture, with consent as the gate. Gmail through its own connector OAuth (deliberately not your login session), Android notifications through NotificationListenerService, and SMS in sideload builds. Each source needs an explicit OS permission plus an in-app disclosure, supports per-app allowlists, and is revocable. Relay never requests QUERY_ALL_PACKAGES — the installed-app list is resolved through a narrow package-visibility query, stays on the device, and is never logged or uploaded.

Encrypt before anything leaves the device. The Android capture queue stores only AES-256-GCM ciphertext in SQLite under a non-exportable Android Keystore key, bounded to 500 items / 2 MiB per tenant, and deletes an item only when the server returns a validated durable acknowledgement for that exact envelope. Sign-out and device revocation destroy the Keystore key before the rows, so an interrupted wipe can't leave decryptable data behind. Server-side, every raw payload is envelope-encrypted with a per-record data key wrapped by a versioned, Cloudflare-held KEK. Tenant, record, and purpose are bound into the AES-GCM associated data, so a row cannot be replayed into another user's context. Raw payloads expire after seven days — and the expiry is authenticated inside the ciphertext, so a delayed or replayed queue message can't quietly extend retention.

Deduplicate like it matters. Cloudflare Queues are at-least-once, so every stage can repeat after a timeout or a deploy. Relay runs two independent layers: a per-tenant Durable Object that serializes decrypt → dedupe → persist and keeps bound markers across evictions, and Postgres unique constraints on (user_id, source, source_account_id, external_id) and (user_id, content_fingerprint) as final arbitration. The second layer is what makes a lost HTTP response safe — the coordinator genuinely cannot tell "committed, reply lost" from "never arrived", so it always retries, and the retry converges on duplicate rather than a second row. Content fingerprints are a versioned algorithm, not a formatting detail: changing the canonicalization is a version bump with a migration plan, never a silent behaviour change.

Normalize into typed facts, not a text blob. A version-pinned normalizer emits sender, date, amount, currency, merchant, location, and reference facts from allowlisted envelope fields — the source kind never selects the extraction logic. Amounts stay bounded decimal strings and never pass through a JavaScript number. Dates resolve to exactly 30 UTC characters with nine fractional digits, so equivalent offset forms agree before contradiction checks while genuinely distinct sub-millisecond instants stay distinct. Every fact carries a normalizer version, a deterministic ordinal, a certainty, and the field path it came from; provenance stores paths and offsets, never source snippets. A candidate that fails validation becomes uncertain/invalid, and two candidates that disagree become uncertain/contradictory, with the values omitted. Relay would rather say "I'm not sure" than guess.

Deterministic filters first; AI only where nothing else will do. You state intent in natural language and Relay compiles it into a versioned, inspectable FilterPlan of typed predicates. Evaluation is tri-state: match, no-match, undecided. Only an explicitly declared semantic clause may reach a model — using your OpenAI key (validated server-side, envelope-encrypted, never returned to the client), disclosing only the allowlisted fields that clause declares, after redaction. Model, disclosed fields, redactions, purpose, confidence, and rationale are all recorded. Low confidence stays undecided and can never trigger an effect. Filter versions are immutable once used, so an audit record from last month can still explain itself.

Propose actions; never take them. Filters produce typed action proposals for Google Tasks, Nextcloud Budget, and signed webhooks. Provider, operation, connection, and input template come from a user-owned rule — never from source content, never from model output. That single constraint closes the prompt-injection path from "an email said so" to "an external thing happened." Default state is awaiting-approval; automatic execution is per-rule opt-in. Every action carries a stable Relay UUID plus a provider-specific idempotency strategy (Nextcloud accepts it directly, webhook receivers dedupe on it, Google Tasks needs reconcile-before-retry because its insert offers no equivalent guarantee). Dismissing a notification is deliberately harder than any of that: explicit source and filter scope, a deterministic match, a completed dry-run period, and an audit record. An AI-only match can never dismiss anything.

How we built it

A pnpm + Turborepo TypeScript monorepo with boundaries that are enforced, not just documented:

  • apps/mobile — Expo (SDK 57) / React Native 0.86 with Expo Router, plus a local Kotlin module for notification capture, the Keystore-backed queue, and SMS. Continuous Native Generation, two EAS profiles (development with SMS permissions stripped at prebuild, sideload with them present), and a checked-in build-config gate that runs before any build.
  • apps/api — Next.js on Cloudflare (OpenNext). Auth boundary and connector callbacks only. It never classifies and never calls providers. It doesn't decode unverified tokens either — it asks Supabase Auth who the user is, validates the returned UUID, and forwards only that.
  • apps/pipeline — Cloudflare Worker with Queues, a per-tenant Durable Object, Workflows, dead-letter recovery, and KEK rotation.
  • packages/contracts — Zod schemas for every cross-runtime wire shape; untrusted input is validated at each boundary, including internal queue messages, because retries and rolling deploys mix versions.
  • packages/domain — pure, runtime-neutral filter and dedupe logic. packages/crypto — WebCrypto envelope encryption.
  • Supabase / Postgres — seven migrations, RLS tenant isolation, and the important invariants pushed down into SQL: fixed-result persistence RPCs that report stored / duplicate / fact-integrity-conflict / tenant-conflict without leaking database detail, plus checks that reject impossible calendar values and non-canonical persisted precision.

Verification is the part we are proudest of: 38 test suites across contracts, crypto, domain, pipeline, API routes and mobile; pnpm verify chaining format/lint/typecheck/test/build/config gates; and pnpm e2e:local — a real end-to-end harness that resets a local Supabase stack, boots API + Wrangler, pushes one deterministic synthetic fixture through the full encrypted path, and asserts encrypted persistence, typed facts with exact money provenance, both dedupe layers, acknowledge-after-durability, cold-database conflicts, dead-letter metadata, operator inspection, exact encrypted replay, and seven-day retention cleanup. It refuses any non-loopback Supabase URL and creates zero remote resources. Child-process logs are scanned as streams for fixture or credential leakage.

The whole thing is developed one GitHub issue at a time against a dependency map, with accepted ADRs as the record for every architectural decision, and documentation treated as a source of truth to be fixed in the same change rather than left to drift.

Challenges we ran into

"Committed, but the response was lost." The single hardest problem. Once you accept that at-least-once delivery and a dropped reply are indistinguishable from a failure, you stop trying to detect the difference and instead make retry converge — unique constraints as arbitration, digests as idempotency identity, and acknowledgement only after both durable stages agree.

Money and time are traps. Parsing an amount into a float is the kind of bug you don't notice until a budget line is wrong by a cent, so amounts stay strings end to end. Dates were worse: two representations of the same instant were being flagged as contradictory until normalization resolved everything to one canonical nine-digit UTC form, with the database checking that the stored precision is exactly that.

Rotating keys without decrypting anything. Getting KEK rotation to rewrap data keys — via compare-and-set batches, stable encryption scope, and historical canaries — without ever touching plaintext took several attempts, and the dead-letter path had to preserve original AAD casing because those IDs are authenticated data, not just identifiers.

Android lifecycle versus cryptographic state. Development-local mode, real sessions, sideload transitions that retain app data, and unexpected session loss all had to converge on one rule: clear the previous tenant's key, queue, and capture config before anything else, with generation ordering so a superseded auth transition can't mutate current capture state.

Platform policy is a design input. READ_SMS / RECEIVE_SMS are restricted Play permissions, and iOS exposes no equivalent notification or SMS API at all. That is why SMS lives in a sideload profile, why a Play-safe non-SMS build is an architectural requirement rather than a later chore, and why iOS scope is Gmail plus Relay's own inbox.

Accomplishments that we are proud of

An end-to-end encrypted ingestion path that is genuinely testable locally, with no cloud resources and no real user data. A fact normalizer whose output is reproducible enough to be used as an idempotency key. A privacy model where "AI cannot select an endpoint, credential, provider operation, or irreversible action" is a structural property of the code rather than a promise on a policy page. And a dead-letter recovery flow whose operator API exposes metadata only, with no decrypt route anywhere in it.

What we learned

Explainability is an architectural decision made early, not a UI feature added late — provenance has to be carried from the first parse or it simply doesn't exist later. Idempotency is a product feature: anything users trust with money and calendars needs a stable ID and a defined ambiguous-retry strategy per provider. And the most valuable thing we built was the local harness; being able to run the entire encrypted path against a synthetic fixture in one command changed how fast everything else could move.

What's next for Relay

The foundation — encrypted ingestion, durable deduplication, typed facts, connector OAuth for Gmail / Google Tasks / BYOK OpenAI, Android notification capture with consent, and dead-letter recovery — is in place. Next, in dependency order: the natural-language filter compiler and deterministic evaluator, redacted semantic evaluation with disclosure audit, the action approval ledger, and provider dispatch behind durable workflows; then the curated quiet inbox, the approval timeline, and the notification-dismissal dry run. After that: Gmail OAuth verification and Limited Use review, an SMS distribution policy with a Play-safe build, a pre-release privacy and security audit, and resilience/disaster-recovery validation before public release.

Everything is AGPL-3.0-or-later and developed in the open at github.com/redsteadz/relay.

Built With

Share this project:

Updates

Submission history