ArchitectureDraft
ADR 0027: One app for the phone, the box's web app and app.vyre.run
Status: proposed, 27 Sep 2026 · Workstream: mobile (apps/app) · Supersedes: the native
SwiftUI and Compose apps of ADR 0018 (paused, kept on work/mobile) · Builds on ADR 0004
(presence), ADR 0011 (web push), ADR 0024 (chat), ADR 0026 (relay), ADR 0029 (resilience),
ADR 0030 (sessions) · Design of record: docs/design/one-app/ (Direction A, tokens.json)
Context
ADR 0018 started two native apps, one in SwiftUI and one in Compose, beside the Deck on the web. That is three clients of the same API, each redrawing Now, Chat, the approval sheet and the Capsule, and each drifting from the others a little every week. Paseo, the closest product, ships one Expo codebase to iOS, Android, the web and its desktop shell, and its users call it the smoothest remote for coding agents. The user chose the same shape on 27 Sep 2026.
Two constraints shape the install path:
- No Apple Developer account yet. Without it an iPhone can run a native build signed with a free Apple ID for 7 days, with no push. A web app added to the Home Screen needs no account, never expires, and gets Web Push (iOS 16.4 and later).
- The user's bar is "smooth". A web app on iOS can miss it where WebKit differs from a native view: gestures, the keyboard, long lists, memory. So the bar is written as numbers below and measured on a real iPhone before the web app is committed to on iOS.
Decision
One Expo app (apps/app) builds every client: the web target is served by the box as its app
and hosted at app.vyre.run; Android ships the native build as an APK the box installs and
updates; iPhone installs the web app over Tailscale (the relay as fallback), and a native iOS
build of the same code is the fallback if the web app misses the smoothness bar in a one-week
spike on a real iPhone.
1. One codebase, three targets
| Target | Built by | Reaches the box | Installed by |
|---|---|---|---|
| Web on the box | expo export --platform web, bundled into vyre.tgz | same origin, https://vyre.<tailnet>.ts.net | Add to Home Screen (iPhone), open in any browser (desktop) |
| Hosted web | the same export, one immutable folder per version | the relay (ADR 0026), end to end encrypted | app.vyre.run, when the phone has no Tailscale |
| Android | expo prebuild + Gradle in CI (android.yml), release-signed with the box's own key | Tailscale, else the relay | vyre phone add --android --usb (adb), then self-updates from the box |
| iOS native (fallback) | expo prebuild + Xcode in CI (ios.yml) | Tailscale, else the relay | free Apple ID now (7 days, no push), TestFlight once the $99 account exists |
Stack, as Paseo's: Expo 54 with expo-router, React Native Web, Reanimated and Gesture Handler,
zustand stores read only through selector hooks, lucide icons. android/ and ios/ are
generated (expo prebuild) and never committed. Platform differences live in file suffixes
(.web.tsx, .native.tsx, .ios.tsx); layout never branches on the platform, only on width
(one breakpoint at 720, then 1100 and 1400 from tokens.json).
The web target replaces the Deck by stages: it is served at /app/ beside the Deck, and moves
to / when it covers every Deck page (a parity list in team/archive/work-journals/mobile.md). The Deck's
pages that are not rebuilt yet open in the app as plain links, never as a webview.
2. What is shared, and with whom
- Tokens.
docs/design/one-app/tokens.jsonis the one source. A generator writesapps/app/src/theme/tokens.ts, the Deck'stokens.cssand the Capsule'sTheme.swift; the generated files are never edited by hand. Attention isbeacon(violet), and the teal alternative is one key. - The session core. Chat owns
deck/chat/core/(ESM, JSDoc, no DOM): the transcript model, tool detail, line diffs, command matching and the ask cards' state. The app imports it as it is. Anything the app needs there goes in through chat's contract, never as a copy. - The event model. The app speaks ADR 0030's session events (
thread.turn,thread.text,thread.tool,thread.state,thread.queued,ask.raised,ask.answered,thread.finished,thread.stopped) and callsthreads.send,threads.unqueue,threads.answer,threads.interrupt. The provider is a chip on the session (records carrydriver), never a separate screen. The app keeps no Claude-specific code. Three states read differently:thread.stoppedwith reasonidleis idle, not ended (the next send resumes it);threads.interruptends the running turn and its asks show as cancelled;threads.startansweringbusy(the box's cap of six busy sessions) says so and offers to queue. The system prompt settings (sessions.prompt.*, person-only) live in Settings. - Resilience. The app runs ADR 0029's client code as it is, from
core/resilience/: the stream (stream.js, cursor and resume), the outbox (outbox.js, anIdempotency-Keyon every write, entries shown as sending at once and removed only on the box's answer), and on the webweb.js(fetch transport, IndexedDB outbox, cursor and view cache, the page lifecycle). Native builds reuse the same stream and outbox with a small XHR reader for the event stream (React Native's fetch cannot stream a body) and a native store behind the same{load, save}. Metro resolvescore/resiliencefrom the repo, so there is one copy.
3. The phone is a full client
Every tool the Deck calls, the app calls, subject to the same caller lists. The phone reaches the
box as a tailnet caller or a relay device:<id> caller (ADR 0026); both are people callers.
Presence follows ADR 0004 and the no-nagging rule: a proof only to pair, reveal a secret, or
send, post, pay or delete outside; one proof opens a 30-minute presence session.
- Web targets prove with a platform passkey bound to the box's origin (the box's own name on Tailscale, or the relay's pinned origin for app.vyre.run).
- Native targets prove with the
devicemethod: a P-256 key in the Secure Enclave or StrongBox behind the biometric prompt (ADR 0018, already on the box).
3a. Signing in: the person session
Over the tailnet the box treats a device as the owner's device, not as the owner: person-only
calls, and calls that need presence, also need a person session (ADR 0004, e2e). Without one
the box answers 401 person_session_required, and the app signs in.
- Served by the box (same origin). A passkey sign-in at the box's
/person/signinsets a__Host-cookie (HttpOnly, Secure, SameSite=Strict). The app stores nothing. - app.vyre.run and other origins. PKCE: the app makes a verifier and an S256 challenge,
sends the page to
/person/signin?cc=<challenge>&return=<app>, and trades the returned code atPOST /v1/person/token {code, verifier, key}.keyis the public half (a JWK) of a non-extractable WebCrypto P-256 key kept in IndexedDB. Every request then carriesauthorization: Vyre <id>.<secret>andx-vyre-proof: t n sig, an ES256 signature over the method, path and query, the body's hash, the time and a nonce, so a copied token is useless without the key. The CORS list names only https://app.vyre.run. - Native. The same flow through the system's authentication browser, the key in the Secure
Enclave or StrongBox (the
devicekey). Hermes has no WebCrypto, so native sign-in waits on a native signer; until then the native build has no person session, and its person-only actions say to use the web app. - Sessions last 30 days sliding (90 at most), are listed and revocable in Settings, and end with
POST /v1/person/end. The 30-minute presence session for sends and secrets is separate.
3b. Alarms on the phone
The planner (ADR 0025) rings alarms on every device, and the first answer clears the rest. Each
firing has one key, planner-<item>-<due>, used as the local notification id, the
apns-collapse-id and the Android notification tag; the app answers with {key}.
- Android (native) schedules
planner.upcomingas exact local alarms, refreshed on everyplanner.*event exceptfiredand on foreground, so an alarm rings with the phone offline. - iPhone (web app) cannot schedule local notifications. Alarms there depend on the box's Web Push arriving on time: an alarm made while the phone is offline rings only once it is back online. The native iOS build (the fallback, or later with the $99 account) removes this limit. The app says so where an alarm is set on an iPhone web install.
4. The hosted app at app.vyre.run
A hosted page that could change under the user would be a way into every box. So:
- app.vyre.run serves only immutable, content-addressed folders (
/v/<sha>/), built by CI from a tagged release, with the manifest's hashes signed by the release key. - The box names the version it trusts. The page first loads a tiny, fixed loader (the trust
root: SRI-pinned, swapped by its service worker only on a signed update; relay/app/) that asks
the box over the relay with
relay.web.releasefor{ sha, manifest }, the owner's pin or the newest known release, then loads only that folder with Subresource Integrity. A box on version X is never served app code from version Y. CI's signature is a second check (ADR 0026). - Nothing about a box, a session or a secret is stored on app.vyre.run. The relay only forwards sealed frames (ADR 0026).
- The Cloudflare plan is approved; the relay team deploys it, and this ADR adds no other hosting.
4a. The Android APK from the box
CI builds an unsigned release APK, vyre-<version>-<sha7>.apk, and android.json (version,
versionCode, sha, sha256, size, minSdk, built, file). The box signs it (APK
Signature Scheme v2 and v3, in plain JavaScript, so no JDK on the box; minSdk 24 or higher) with
the owner's own release key: an EC P-256 key and a self-signed certificate the box makes the
first time anything needs them and keeps in its vault, so updates install over each other. It
serves /v1/releases/android (the manifest, with the signed file's sha256 and size, and cert_sha256) and
/v1/releases/android?file=<file> (the APK) to the owner's devices only. If the key is lost, the box makes a new
one, cert_sha256 changes, and re-pairing the phone reinstalls the app.
vyre phone add --android --usb checks the sha256 and installs over adb; the app checks the
manifest on foreground, at most hourly, and offers the update through the system installer.
5. The smoothness bar
Measured on an iPhone 12 (iOS 18) and a Pixel 6a, installed app, direct over Tailscale. These are acceptance criteria: a target does not ship while it misses one.
| Id | What | Bar |
|---|---|---|
scroll | page swipe, list scroll, row swipe | 60 fps, under 1% dropped frames over a 10 s fling |
tabSwitch | Now, Chats, Agents (mounted) | first content under 100 ms (p95) |
coldOpen | Home Screen, offline | Needs you drawn from cache under 1 s (p95) |
warmResume | back from the background | under 300 ms (p95) |
approve | approve swipe | the row collapses on the frame the swipe commits (17 ms at 60 Hz), optimistic, outbox, Undo for 4 s |
keyboardJump | the keyboard opens | 0 px jump in the transcript; the composer moves in the same frame |
streamGap | a streaming reply | p95 gap between visible updates under 50 ms |
longTasks | while streaming | no main-thread task over 50 ms |
terminalEcho | the terminal, direct path | key to echo under 50 ms (p95) |
| (manual) | a long session | 2,000 turns scroll at 60 fps; a 1 h session under 300 MB with no WebKit reload |
How the design meets it is in docs/design/one-app/DIRECTION.md ("Smooth: the bar"): gestures
on the compositor (scroll-snap strips on the web, Reanimated on the UI thread natively), one fixed
shell with inner scrollers, one visualViewport inset for the keyboard, an inverted transcript,
paced streaming with content-visibility, virtualized lists above 100 rows, three mounted tabs
(an LRU, one when memory is low), the shell precached and the last Needs, Chats and Agents in
IndexedDB.
6. The perf harness
apps/app/perf/meter.js is a DOM-free frame meter: the web feeds it from requestAnimationFrame
(only while the ?perf=1 overlay is on and the page is visible, so it costs nothing otherwise),
native from Reanimated's useFrameCallback on the UI thread. It keeps bounded rings of frames,
named measures (tab.switch, open.cold, open.warm, approve.collapse, term.echo),
gaps (stream), values (keyboard.jump in px) and long tasks, and its report() returns a
verdict against BAR, the table above as data. The overlay (built in the spike) shows the live fps and dropped
frames and copies the report as JSON.
Numbers of record come from the real phones above. CI runs the web target in Chrome with a 4x
CPU throttle against apps/test/world.js as a regression guard only; it fails a build when a
measure regresses by more than 20% from the last green run, not against the phone bar.
7. The one-week spike
Before the web app is the iPhone default, one week builds the three hardest pieces as the web target, with the perf overlay on, against the test world and then the user's box:
- Now: Needs you from cache, live over SSE, three mounted tabs.
- A session: an inverted, virtualized transcript with paced streaming, the composer and the keyboard, queue and unqueue.
- The approve swipe: a scroll-snap row, the optimistic collapse, the outbox and Undo.
The user runs the report on a real iPhone. If every bar passes, the web app is the iPhone default. If any misses and the design notes in section 5 cannot close it within the week, the same code ships as the native iOS build (free Apple ID now, TestFlight later), and the web app stays installed for push until APNs is available.
Consequences
- One app to design, test and ship. The native SwiftUI and Compose code stays on work/mobile, unmerged, as reference for the device presence method and the push transports.
- The box grows a static folder (the web export, about 3 MB gzipped) in
vyre.tgz. - The Deck and the app coexist until parity; new Deck features land in the app first.
- iPhone users get push through Web Push only, until the Apple Developer account exists.
- The relay team owns app.vyre.run's hosting; the release pipeline owns the signed manifests.
Alternatives considered
- Keep native SwiftUI and Compose. The smoothest ceiling, but three clients to keep in step, and no iPhone install without the $99 account.
- The Deck as a PWA only. No Android native features (share sheet, reliable push, haptics) and no path to a native iOS build if WebKit misses the bar.
- Capacitor around the Deck. One codebase, but the UI stays a web page inside a native shell on every platform, so the fallback would not be native where it matters.
Open questions
- The Apple Developer account ($99/yr): not approved. It unlocks TestFlight, APNs and system autofill; nothing in this ADR depends on it.
- Violet or teal for attention: violet until the user says otherwise.
- The Android release key: kept in the box's vault; how it is backed up follows ADR 0028.