Cross-Platform Mobile Contract

What this document is. The living wire/behavioral contract between a beebox (its frontend and server) and every native mobile client that embeds it — today the iOS companion app, tomorrow a planned Android app. It is not a point-in-time report: it is the specification that all three sides implement and must be kept true.

The sync rule. Any commit that changes a contract surface listed here — a URL scheme param, an auth-token carrier, a webview query param, a bridge channel name, a JSON field, an HTTP request/response shape, or a server-side mobile-awareness branch — must update this document in the same commit. The Contract Surface Index (§7) and the mirrored-constants list (§8) are the artifacts an agent diffs a change against; if your change touches a row there, it touches this doc. The process that enforces this — how iOS and Android are kept in parity, who reviews a contract change — is defined in docs/implemented-plans/mobile-parity-sync.md.

Path conventions. Box code paths are relative to beebox/; iOS paths are under ios-app/BeeBox/. Anchors name a file plus the identifier (function/struct/const) inside it — never line numbers, which rot. Box-relative wire values (landmark/share-destination dirs, uploaded-file paths) are opaque tokens iOS round-trips unmodified; under the one-root box layout (shapeVersion 3, docs/implemented-plans/one-root-box-layout.md) they land in underscore areas (_content/…, _tmp/…) — the wire shape is unchanged, only the values moved. A paired box's baseURL already includes the hub slug (e.g. http://127.0.0.1:3210/main/test1 in dev, https://host/<slug> in prod), so every native HTTP path below is <baseURL>/api/....

Drift legend. For each surface, drift is either LOUD (a mismatch produces a visible error / 4xx / 5xx / status message) or SILENT (the mismatch is swallowed — message lost, composer not suppressed, wrong attribution — with no error surfaced).


1. Pairing

1.1 beebox://pair deep link

1.2 Pairing-ticket mint (any box access)

1.3 POST /api/pairing/redeem

1.4 Device listing / revocation (box UI only, not native)


2. Auth — device-token carriage

The redeem step (§1.3) yields a durable device token. It is header-only on the wire: it must never appear in a URL. Because a device token has no expiry (MobileDevice tracks only revokedAt), any copy that lands in an access log, a Referer, or WebKit history is replayable until a human manually revokes the device — so a URL carrier turns a routine leak into an unbounded one.

Ordinary requests therefore carry the durable token in an Authorization header, and everything the browser sends on its own — navigations it initiates itself, and the tRPC WebSocket upgrade, which the browser WebSocket API cannot attach headers to — rides a short-lived bbx_mobile cookie minted from that token.

2.1 Carriers

carrierwire shapewho sets itwho reads it
localStoragekey beebox.mobileAuthToken = <token>native startup WKUserScript (ChatWebView.swift) writes it, gated on origin === allowedOrigin, forMainFrameOnly:trueweb mobile-auth.ts — getMobileAuthToken(), mobileAuthHeaders(), withMobileAuth()
Authorization: Bearer <token> headerHTTP headernative ChatAPI.applyAuth; web mobileAuthHeaders() (applied to /chat/send, /chat/transcribe-audio)box verifyMobileBearer
bbx_mobile cookieSet-Cookie: bbx_mobile=<signed>; HttpOnly; Secure; SameSite=Lax; Path=/<slug>; Max-Age=3600box webapp/mobile-cookie.ts — issued on any mobile-authenticated response, and by POST /api/pairing/sessionbox core/mobile/mobile-session.ts · verifyMobileSession

2.2 Verification (box)

anchorrole
src/core/mobile/pairing.ts — verifyMobileBearer(boxRoot, authHeader)parses Bearer prefix, delegates
src/core/mobile/pairing.ts — verifyMobileToken(boxRoot, token)SHA-256 + timing-safe compare vs non-revoked devices; writes lastUsedAt back
src/core/mobile/pairing.ts — isMobileDeviceActive(boxRoot, deviceId)read-only revocation check, used when renewing a cookie
src/core/mobile/session.ts — verifyMobileSession(boxRoot, cookie)per-box HMAC + exp check; no filesystem access
src/core/mobile/request-auth.ts — resolveMobileRequestAuth(boxRoot, headers)the one resolver every mobile gate uses — cookie first, then bearer
src/webapp/server-box-scope.ts — addBoxAuthHookbox auth preHandler: accepts agent bearer or resolveMobileRequestAuth; renews the cookie
src/webapp/server-box-scope.ts — createContexttRPC context: mobileOk → authed:true
src/webapp/server-root/root-routes.ts — listMobileAuthorizedBoxes / isMobileAuthorizedForBoxreal verify for /api/boxes box list

2.3 Identity: a device acts as whoever paired it

A mobile request resolves to the person recorded in the device's createdBy (server-box-scope.ts — createContext reads resolveMobileRequestAuth, not just its truthiness). So user is that person and isOwner is true only when that person is the owner. A session identity on the same request still wins; the device credential fills in when there is no session.

The device is valid only while that person can still open the box (canAccessBox, checked in resolveMobileRequestAuth on every request, for the bearer and the cookie alike). When the owner removes a member from allowedEmails, that member's phones get the same 401 an unknown device gets, at once and with no revoke step; the device record stays, so adding the member back restores the phone. A device with createdBy: null is not affected. A refused cookie does not refuse the request: a valid device token on the same request still authenticates it. Changed 2026-10-22.

Changed 2026-09-12. Previously a pure mobile request set authed: true but left user: null and isOwner: false, so native chat sends attributed to nobody and a paired phone could never be its owner — nor correctly fail to be, when a non-owner paired it.

2.4 Failure semantics


3. Webview embedding

3.1 The URL the app loads

3.2 What nativeComposer changes web-side

3.3 Script-message channels (web → native)

The following channels are web→native. On iOS they are WKScriptMessageHandler names registered on the userContentController; the transport is platform glue (§10), the channel names and payloads are the contract.

channel namepayload (web → native)native handler
beeboxSessionwindow.location.href (string)Coordinator.userContentController → onSessionChange(visibleSessionID)
beeboxEmissionReceiptReceipt object (§4.2)→ receiveEmissionReceipt
beeboxLocationResult{ id, success, message }→ receiveLocationResult
beeboxHqDictationState{ enabled: true, diarized }→ receiveHqDictationState (reads diarized only)
beeboxComposerCommandV1 or V2 composer command (§4.7, §4.8)→ receiveComposerCommand
beeboxLastAudioRequestV1 last-audio request (§4.9)→ receiveLastAudioRequest

3.4 Navigation policy

3.5 The box screen navigation (web → native, by URL)


4. Bridge channels — emission, location, and composer mutation

4.1 Emission (native → web)

4.1a Attachment tokens (shared vocabulary, no wire of its own)

An attachment is anchored in the message text by a token, so a photo can sit mid-sentence rather than being appended in an arbitrary order. Both composers mint them independently; the ids are per-emission and per-kind.

4.2 Emission receipt (web → native)

4.3 Location preference toggle (native → web) + state/result (web → native)

| native request + state/result decode | ios-app/BeeBox/Views/ChatWebView.swift — beeboxNativeShareLocation script, receiveLocationState, receiveLocationResult (15s timeout) | | web handle | src/frontend/src/components/chat/everywhere/InteractiveChat/use-native-bridge.ts — useNativeLocationBridge, handleNativeLocationRequest (→ captureAndStore(boxSlug, Date.now())), postNativeLocationResult, drainNativeLocationQueue |

4.4 Narration state (web → native)

4.4a HQ dictation state (web → native)

4.5 Speech playback state (web → native)

4.6 Response generation state (web → native)

4.7 Companion selection command (web → native) + durability acknowledgement

4.8 Command envelope V2 (web → native) + command result (native → web)

The V1 envelope in §4.7 cannot carry a command that has no selection — native decodes selection unconditionally — and the acknowledgement has no room for an answer. V2 fixes both, and is what the UI scan (docs/plans/agent-points-at-ui.md, Track 5) rides.

4.9 Last-audio request (web → native), answered by direct HTTP

The relay that lets a box agent retranscribe a message dictated in the native composer. Design: docs/implemented-plans/ios-audio-retranscription.md.

4.10 Speech control (native → web)

Barge-in. The native composer owns its microphone but not the speech it would talk over — the page plays that (lib/audio/tts-client.ts), so only the page can stop it.

4.11 Screen wake — native owns the idle timer (no wire)

Not a channel: nothing crosses the bridge. It is here because it is a split of responsibility that both sides must honour, and honouring it web-side means not acting.

iOS sleeps the display on a system idle timer that only user interaction resets, and a screen lock suspends an app with no background-audio mode — so an open microphone in a silent room looks like an abandoned phone, and the recording dies. Only the native layer can prevent that (UIApplication.isIdleTimerDisabled); the Screen Wake Lock API requested from inside a WKWebView is not a substitute for it.


5. Direct HTTP calls from native code

The main app and its Share Extension use the endpoints below. Ordinarily /chat/send is called by the web layer inside the webview; the Share Extension is the deliberate native exception.

5.1 POST /api/pairing/redeem

See §1.3 (full request/response/errors).

5.2 POST /api/chat/transcribe-audio — HQ audio transcription

5.3 GET /api/chat/default — session resolution

5.4 POST /api/chat/upload-file — composer file upload

5.5 (web layer, for completeness) POST /api/chat/send

A "new" send may now carry engine and model — the engine and model chosen in the web picker before a chat's first message (docs/model-policy.md). Both are optional and absence means the box's defaults, so the native path is unaffected: ChatAPI.resolvedSession() returns a session id or the literal "new" and never sends a choice. A chat created by a native-originated first message therefore takes the box defaults, which is correct — no choice was made. The picker itself lives in the webview.

5.6 Bulk file-upload batch (/api/bulk/...)

5.7 POST /api/trpc/debugLog.submit — native log forwarding

5.8 Share Extension — textual destinations and delivery

5.9 POST /api/pairing/push-token — APNs registration

5.10 APNs notification payload (box → Apple → native)

5.11 Quick chat — quickChat.submit, choose, retry, discard, home

5.12 Quick capture — durable source acceptance

Siri thoughts, Shortcuts material, native voice notes, share material plus words, and explicit later context use this path. The box-screen composer and existing quick-chat outbox retain §5.11; old pending entries keep their decoder and route. The App Intent no longer calls BoxScreenStore.captureExternalThought or awaits quickChat.submit before acknowledging capture.

Acceptance. POST /api/quick-captures/:captureId is bearer-authenticated, file-backed multipart/form-data. It contains one JSON field declaration and, for image/audio, exactly one file part material. No second finalize call is required after the originating extension exits. Version 1 declaration:

{"version":1,"capturedAt":"2026-10-12T12:00:00Z","source":"apple-share-extension","input":"Find the problem shown here","material":{"kind":"image","id":"10000000-0000-4000-8000-000000000001","mimeType":"image/png","byteLength":123,"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","fileName":"screenshot.png"}}

The example describes a shape; the server verifies the actual length and digest. Client capture/item UUIDs are normalized lowercase. source is one of apple-app-intents, apple-share-extension, apple-voice-recording, or apple-native-input; these identify acquisition APIs, not inferred Siri versus Action Button callers. input is optional authored human text (10,000 UTF-16 code units). material is one optional discriminated value: URL {kind:"url", url,title?} (HTTP(S) only), clipping {kind:"clipping",text,title?}, image, or audio. File declarations require item UUID, supported MIME, positive byte length at most 50 MiB, SHA-256, and safe display filename. No client filesystem path is accepted. A capture must have nonempty words or material. A text-only thought uses input; a clipping uses material.text and does not become a command. An interrupted audio declaration may add recordingInterrupted:true; it is invalid on non-audio/context events. This is recording provenance, not authored input.

Context. A new capture/event UUID with parentCaptureId and input adds words to the explicit original capture. Version 1 context has no material. The phone persists it offline and waits for the original receipt; the server checks same owner and original identity. Context events cannot become context parents. Original and event box identity stay fixed. Events use independent stable chat message IDs; they never amend/resubmit original quick-chat text.

Receipt and conflicts. Success {id,state:"accepted"} means all declared source bytes and immutable manifest are durably owned by the box. It does not mean transcription, chat dispatch, or requested work completed. Identical replay returns the same receipt; changed content/owner fails without overwrite. Missing, extra, truncated, or mismatched material is refused. Missing/revoked credentials are explicit failures. Explicit deletion keeps a Gone tombstone: late upload replay cannot recreate the source. Owner-scoped deletion also seals a tombstone for a not-yet-accepted ID, so cancellation cannot race a buffered upload into revival.

Status API. Nonbatched bearer-authenticated tRPC quickCapture.capabilities returns {version:1,maxFileBytes,maxInputCharacters,maxClippingCharacters,materialKinds}; quickCapture.list returns {captures:[...]}; detail takes {id}. retry takes {id}, choose takes {id,candidateId}, delete takes {id}. All item operations and lists enforce original owner. Views carry {id,state,capturedAt,source,input?,material?,parentCaptureId?,sourceRef?, transcript?,recordingInterrupted?,error?,attempts,nextAttemptAt?,serverAccepted:true,delivery?}; delivery is a §5.11 QuickChatView. States: accepted, preparing, saved, needs-choice, sending, sent, failed, blocked, gone. Blocked context exposes the original pending choice where applicable; choose does not guess a different box/chat. Gone views omit deleted source words, material, transcript, and chat message payload. Context blocked by deleted parent retains its own words. An uncaptured deletion tombstone has no source/capturedAt payload; other views retain those fields.

Source and work. The source is a doc card (<id>.doc.card, title "Voice note" or "Saved capture" until the filing chat retitles it) plus its attach scope, and lives outside the automatic intake inbox. A capture with typed or dictated words, and no recording, is written uncommitted to the git-ignored _content/incoming/, and the chat that receives it files it with bbx mv --commit. A capture with no words, or with a recording, is committed in _content/captures/ on arrival. The doc's share-id is the stable source identity; via.channel records the capture source; a shared URL is the doc's sources entry, and a clipping is a quoted ## Clipping section of the body. An image is an image card in the attach scope; a recording is recording.audio.card there, which holds the transcript or transcription error. Its via.channel is microphone for an apple-voice-recording capture and the capture source otherwise. Reconciliation that finds a leftover .memo.card source (a box whose retire-memo migration has not run) fails with migration-pending (conflict) instead of treating the source as deleted. Source materialization (and its commit, in _content/captures/) precedes dispatch. Stored source refs are resolved through the shared containment helper and checked against the box's real path before source reads or transcription updates; an escaping ref fails closed. Material without authored input is saved without inventing an agent task. Saved image, URL and clipping material with no words is then filed: two minutes after the owner's last such capture, the server queues the doc on the box's intake job and starts a box cycle, whose agent titles, describes and moves it, or leaves it in _content/captures/ when no place fits. The state stays saved. If six cycles pass without filing, error reads "Filing is waiting for the box's next cycle." until a cycle files it; Retry starts the attempts again. Context added before queuing takes the capture out of filing. Voice audio persists before asynchronous transcription; empty/failed transcript retains playable audio and an explicit retry state. An empty transcript has no automatic paid retry; Retry remains available. External-input framing names the source doc and retained-recording status, without implying a live speech turn. input-kind="words" identifies separately supplied instructions; input-kind="transcript" identifies derived speech. retained-recording="1" asserts audio availability, not that separately supplied words are a transcript. recording-interrupted="1" marks playable partial recording evidence that may end early. Six-day chat delivery expiry and seven-day dedup protection are retained.

Native ownership. QuickCaptureStore owns an app-group directory per capture, copies and flushes files, then atomically publishes an immutable manifest plus separate coordinated status. No credentials or box URL enter the manifest. CaptureDestinationStore uses a separate app-group captureDefaultBoxID preference, not browsed selectedBoxID. One paired box may initialize it once; multiple boxes or a removed default require explicit choice. Siri/AppIntent paths refuse requiresDeviceUnlock regardless of phone unlock; share UI uses explicit device-owner authentication. Accepted box UUID never changes on retry. Removed-pairing phone copies remain accessible through local recovery/explicit export with their original protection. That path makes no server request and never rebinds ownership. Interrupted-copy recovery is not acceptance: voice Save repairs only a never-uploaded incomplete copy from the original finalized audio and verifies retained replay bytes.

Transfers. One persisted multipart body is scheduled with a process-specific app.beebox.ios.quick-capture.<bundle-id> background URLSession identifier. Extension sessions set sharedContainerIdentifier to group.app.beebox.ios; main app routes completion events and resumes waiting contexts before finishing background callbacks; foreground acceptance also advances waiting contexts. Confirmed server receipt removes the temporary multipart copy, while retaining original media for playback. A local discard request is durable immediately, stops new uploads, and stays discard-pending until remote Gone confirmation. It does not claim completed deletion while offline. Foreground recovery refreshes capabilities/receipts before replay; automatic retry is bounded to seven days, retaining terminal failures. Background tasks have a one-hour resource timeout and new automatic scheduling stops in the last hour of that window, so a delayed transfer does not outlive it. Force quit may cancel system transfers; recovery then waits for reopening. Siri says “Saved on your phone for <box>” after local persistence and scheduling, without awaiting routing or promising a second spoken response.

Voice/system surfaces. Start/Stop intents use the main recording coordinator; iOS 18+ AudioRecordingIntent requires a Live Activity. Widget/control extension opens beebox://capture/voice?action=toggle|stop; it owns no recorder or fake process-local recording state. Foreground task UI is the explicit fallback. Stop/show links for an existing protected draft require its in-app unlock; Siri/App Intents still refuse it. iOS 17 records through foreground UI without newer control APIs. AAC recording has a ten-minute/advertised-byte cap and finalized short segments: interruption retains completed audio, while sudden termination can lose an unfinished tail of at most ten seconds. Never claim complete audio from that recovered draft.

Fixtures and anchors. test/mobile-contract/fixtures/quick-capture/ is parsed by TypeScript and XCTest. Core/server anchors: src/shared/quick-capture.ts, src/core/quick-capture/, src/webapp/routes/quick-capture/, src/webapp/trpc/routers/quick-capture.ts. Native anchors: Storage/QuickCaptureStore.swift, Storage/CaptureDestinationStore.swift, Services/QuickCaptureAPI.swift, Services/QuickCaptureUploadCoordinator.swift, Services/VoiceNoteCoordinator.swift, Intents/CaptureThoughtIntent.swift, Intents/VoiceNoteIntents.swift, and share extension. No bridge emission shape or mounted-chat ownership change.


6. Server-side "mobile" awareness

Every place the box server branches on mobile-ness. The contract is entirely token- and query-param-driven — there is no user-agent gating anywhere.

anchorbrancheffect
src/hub/server/core.ts — hasMobileAuthAttemptBearer present OR ?mobileToken= present (presence only)bypasses the hub auth wall (HTTP catch-all + WS upgrade); box re-verifies — S1
src/hub/server/core.ts — listMobileAuthorizedBoxesreal verifyMobileBearer/verifyMobileToken/api/boxes returns the mobile-authorized box list
src/webapp/server-box-scope.ts — isPairingRedeemUrlPOST + redeem URLbox auth hook lets redeem through
src/webapp/server-box-scope.ts — addBoxAuthHookverifyMobileBearer OR verifyMobileToken(?mobileToken=)passes box auth preHandler
src/webapp/server-box-scope.ts — createContextmobileBearerOk/mobileTokenOkauthed:true; user stays null, isOwner:false (§2.3)
src/webapp/server-root/root-routes.ts — listMobileAuthorizedBoxes / isMobileAuthorizedForBoxreal verifymobile box list / per-box authorization for standalone server
src/core/mobile/pairing.ts (whole module)device store, tokenssource of truth

7. Contract Surface Index

The authoritative surface list. A platform implements exactly these rows. Anchors are file + symbol; drift is LOUD or SILENT (§Drift legend).

#TouchpointDirectionWire shape (exact fields)Native side (file · symbol)Box side (file · symbol)Drift
P1beebox://pair deep linkext→nativebaseURL|url, label, pairingToken|token, session, authToken(DEBUG)Storage/PairedBoxStore.swift · pair(from:); Info.plist · URL schemesettings/CompanionPairingSection.tsx · pairingDeepLinkSILENT
P2Pairing-ticket mintUI→boxout {token,expiresAt}— (UI only)trpc/routers/pairing.ts · createTicket; core/mobile/pairing.ts · createMobilePairingTicketLOUD
P3POST /api/pairing/redeemnative→boxreq {pairingToken,deviceLabel}; res {boxSlug,label,deviceId,deviceLabel,token} (native reads only token)Storage/PairedBoxStore.swift · redeemPairingroutes/pairing.ts · redeem route, RedeemBodyLOUD server / SILENT native
A1Device token → localStoragenative→webkey beebox.mobileAuthToken = <token>Views/ChatWebView.swift · startup WKUserScriptlib/mobile-auth.ts · MOBILE_AUTH_TOKEN_STORAGE_KEYSILENT
A2Device token → Authorization: Bearernative/web→boxheaderServices/ChatAPI.swift · applyAuth; web lib/mobile-auth.ts · mobileAuthHeaderscore/mobile/pairing.ts · verifyMobileBearer; server-box-scope.ts · addBoxAuthHookLOUD
A3Device token → Set-Cookie: bbx_mobilebox→browserbbx_mobile=<signed>; HttpOnly; Secure; SameSite=Lax; Path=/<slug>; Max-Age=3600Views/ChatWebView.swift · request() sets Authorization, WebKit stores the response cookiewebapp/mobile-cookie.ts · setMobileSessionCookie; core/mobile/mobile-session.ts · MOBILE_COOKIE_NAMELOUD
A5POST /api/pairing/sessionweb→boxreq Authorization: Bearer <token>; res 204 + Set-Cookieweb lib/mobile-auth.ts · refreshMobileSessionroutes/pairing.ts · session routeLOUD
A4tRPC context identitybox internalauthed from mobile token; user=null,isOwner=false—server-box-scope.ts · createContextSILENT
W1Chat webview URLnative→web/chat?nativeComposer=1[&session] — carries NO credentialModels/PairedBox.swift · chatURL; Views/ChatWebView.swift · request()pages/ChatPage.tsx; router.tsxSILENT
W2Session reportweb→nativebeeboxSession = location.href (string)Views/ChatWebView.swift · userContentController, visibleSessionIDnative-authored startup scriptSILENT
B1Native emissionnative→webV3 adds immutable binding + bindingRevision to V2 {version:2,id,text,origin,diarized,hqText?,hqService?,hqFallback?,images,files,selections}; legacy {id,text,origin,diarized,images} remains accepted; delivered via beeboxNativeReceive, queue beeboxNativeQueue, event beebox:native-emissionModels/NativeComposerContract.swift · NativeEmissionV2; Views/ChatWebView.swift · NativeChatEmissionuse-native-bridge.ts · useNativeEmissionBridge; native-emission.ts · parseNativeEmissionDetailLOUD V2/V3 / SILENT legacy
B12Composer destinationweb→nativeV1 selection/assigned publications via beeboxComposerBinding; native acknowledges beeboxComposerBindingVersion=1 + beebox:composer-binding-readyNativeComposerContract.swift · NativeComposerBinding; PendingEmissionStore.swift · receiveBindingshared/chat-composer-binding.ts; everywhere/BoxConversationShell.tsxLOUD: unresolved disables send
B2Emission receiptweb→native{disposition:sent|queued|rejected, emissionId, deduplicated?/reason?/definitive?} via beeboxEmissionReceiptViews/ChatWebView.swift · receiveEmissionReceiptuse-native-bridge.ts · postNativeReceipt → native-post.ts · postNativeMessage; input/targets/receipts.ts · ReceiptSILENT→LOUD
B3Location togglenative→webbeeboxNativeShareLocation("<uuid>","toggle"), queue beeboxNativeLocationQueue, event beebox:native-share-location, detail {id,action:"toggle"}Views/ChatWebView.swift · location scriptuse-native-bridge.ts · useNativeLocationBridgeSILENT→LOUD
B4Location state/resultweb→nativestate {enabled} via beeboxLocationState; result {id,success,enabled,message} via beeboxLocationResultViews/ChatWebView.swift · receiveLocationState, receiveLocationResultuse-native-bridge.ts · postNativeLocationState, postNativeLocationResult → native-post.ts · postNativeMessageLOUD
B5Companion selection commandweb→nativeV1 {version:1,id,kind:add-selection,selection:{ref,text,position}} via beeboxComposerCommandModels/NativeComposerContract.swift · NativeComposerCommand; Views/ChatWebView.swift · receiveComposerCommand; Storage/ComposerDraftStore.swift · applySelectionCommandnative-composer-command.ts; use-native-composer-commands.ts; InteractiveChat/view.tsxLOUD
B6Composer command acknowledgementnative→webaccepted {version:1,id,accepted:true} or rejected {version:1,id,accepted:false,reason} via beeboxNativeComposerCommandAck, queue + beebox:native-composer-command-ack eventModels/NativeComposerContract.swift · NativeComposerCommandAcknowledgement; Views/ChatWebView.swift · deliverComposerCommandAcknowledgementsnative-composer-command.ts · nativeComposerCommandAcknowledgementFromDetail; use-native-composer-commands.tsLOUD
B11Speech control (barge-in)native→webV1 {version:1,action:"stop"} via beeboxNativeSpeechCommand, queue beeboxNativeSpeechCommandQueue, event beebox:native-speech-command; no ack — §4.5 {playing:false} reports the stopModels/NativeComposerContract.swift · NativeSpeechCommand; Services/SpeechDictation.swift · NativeVoiceTurnState; Views/ChatWebView.swift · deliverSpeechStopRequestnative-speech-command.ts · nativeSpeechCommandFromDetail; use-native-bridge.ts · useNativeSpeechCommandBridgeSILENT-degraded (speech plays into an open mic)
B10Last-audio request relayweb→nativeV1 {version:1,requestId,messageId,sessionId|null} via beeboxLastAudioRequest; answered by H6, not by an ackModels/NativeComposerContract.swift · NativeLastAudioRequest; Views/ChatWebView.swift · receiveLastAudioRequest; Views/RootView.swift · answerLastAudioRequestnative-last-audio-request.ts; lib/audio/last-audio.ts · fulfillLastAudioRequestQUIET (asleep phone is indistinguishable)
B7Narration stateweb→native{enabled} via beeboxNarrationStateViews/ChatWebView.swift · receiveNarrationState; Views/NativeComposerView.swift · sendKeywordIntentuse-native-bridge.ts · useNativeNarrationBridgefail-local
B14HQ dictation stateweb→native{enabled:true,diarized} via beeboxHqDictationState; native reads only diarized, which keeps the HQ pass off the deviceViews/ChatWebView.swift · receiveHqDictationState; Views/NativeComposerView.swift · send, sendKeywordIntent, transcribeOnDeviceuse-native-bridge.ts · useNativeHqDictationBridgefail-local
B8Speech playback stateweb→native{playing} via beeboxSpeechPlaybackStateViews/ChatWebView.swift · receiveSpeechPlaybackState; Views/NativeComposerView.swift · applyVoiceTurnuse-native-bridge.ts · useNativeSpeechPlaybackBridgefail-local
B9Response generation stateweb→native{active} via beeboxResponseStateViews/ChatWebView.swift · receiveResponseState; Services/NativeEarcons.swift · NativeEarconStateuse-native-bridge.ts · useNativeResponseBridgefail-local
B12Command envelope V2web→native{version:2,id,kind,payload?}, kinds add-selectionscan-controls, via beeboxComposerCommandModels/NativeComposerContract.swift · NativeComposerCommand.Payload; Views/RootView.swift · handleComposerCommandnative-composer-command.ts · nativeComposerCommandFromDetail; native-control-scan.ts
B13Command resultnative→web{version:2,id,kind,ok:true,controls[]} or {…,ok:false,reason} via beeboxNativeCommandResult, queue + beebox:native-command-result eventModels/NativeComposerContract.swift · NativeComposerCommandResult; Models/NativeControlRegistry.swift · controlAnchor; Views/ChatWebView.swift · deliverComposerCommandResultsnative-composer-command.ts · nativeCommandResultFromDetail; native-control-scan.ts · requestNativeControlsLOUD in the dump
R1Screen awake (device idle timer)native-only, no wire— (a responsibility split, §4.11): held for a voice turn, page speech playing, or capture recording; released by re-derivation incl. scenePhaseServices/ScreenAwake.swift · ScreenAwakeHold; Views/NativeComposerView.swift · screenAwakeReasons; Views/NativeCaptureController.swift · applyScreenAwake; Services/SpeechDictation.swift · NativeVoiceTurnEvent.dictationFailed/.dictationWentIdlecomponents/chat/everywhere/InteractiveChat/voice.ts · useDebouncedWakeLock (suppressed under nativeComposer); hooks/useWakeLock.tsSILENT both ways
H1POST /api/chat/transcribe-audionative→boxmultipart session + file(segment.wav, audio/wav); res {text,diarized,service?}; 500 {error,permanent,code?}Services/ChatAPI.swift · transcribeAudioroutes/chat-audio-routes.tsLOUD on rejection / SILENT on HTTP 200 with unusable text; Float32 WAV verified — I8
H6POST /api/chat/last-audio/:requestIdnative→boxmultipart file(last-message.wav, audio/wav) + recordedAt,text,messageId,sessionId?; or JSON {"none":true}; res {ok} / 404 when already settledServices/ChatAPI.swift · answerLastAudio; Storage/VoiceAudioRetentionStore.swiftroutes/chat-last-audio-routes.ts; core/last-audio-pending.ts · fulfill/reportNoneQUIET — a missing echo is IGNORED, not rejected
H2GET /api/chat/defaultnative→boxres {sessionId?}Services/ChatAPI.swift · resolvedSessionroutes/chat.ts · default-session routeSILENT (→ "new")
H3POST /api/chat/send (web layer)web→box{session,message,messageId,images?,channel?,…}; res {turnId?}|{queued}|{deduplicated}api-chat.tsroutes/chat-send-routes.ts; routes/chat-helpers.ts · sendBodySchemaLOUD / SILENT dedup
H4POST /api/chat/upload-filenative→boxmultipart batch? then file; res {path,originalName,size,mimetype}Services/ChatAPI.swift · uploadFileroutes/chat-uploads.ts · registerChatUploadRoutesLOUD
H5POST /api/trpc/debugLog.submitnative→boxreq {source?,entries:[{level,message,at?}]}; res {"result":{"data":{"ok":true}}} (tRPC envelope)Services/LogForwarder.swifttrpc/routers/debugLog.ts · submit; lib/rolling-log.ts · appendRollingLogStrictfail-local
P4POST /api/pairing/push-tokennative→boxreq {token,environment:sandbox|production}; res 204Services/PushRegistrar.swift · PushRegistrar.syncRegistrations, PushTokenRequest; BeeBoxAppDelegate.swift · didRegisterForRemoteNotificationsWithDeviceTokenroutes/pairing.ts · PushTokenBody; core/mobile/pairing.ts · registerDevicePushLOUD server / SILENT (no push)
N1APNs payloadbox→APNs→native{aps,target,loudness?,notificationId,box}; headers apns-push-type: alert, apns-topic, apns-collapse-id?Services/NotificationCenterDelegate.swift · NotificationCenterDelegate; Models/NotificationTarget.swift · NotificationTarget, NotificationLoudness, NotificationTapcore/notification/apns-payload.ts · buildApnsRequestSILENT
S1GET /api/trpc/share.destinationsextension→boxres tRPC {chats:[…],saves:[…]}BeeBoxShareExtension/ShareExtensionAPI.swift · destinationstrpc/routers/share.ts · destinationsLOUD
S2POST /api/trpc/share.saveTextualextension→boxURL or text + shareId, capturedAt, destination; res {created:[path]}BeeBoxShareExtension/ShareExtensionAPI.swift · savetrpc/routers/share.ts · saveTextualLOUD
S3POST /api/chat/send exact modeextension→box{message,messageId,session,exactSession:true,channel:"ios-native"}BeeBoxShareExtension/ShareExtensionAPI.swift · sendroutes/chat-send-target.ts · assertExactSessionTargetLOUD
Q1quickChat.submit / choose / retry / discard / home (§5.11)native→boxPOST {id,message,origin,source?,channel:"ios-native"} / {id,candidateId,channel:"ios-native"} / {id,channel:"ios-native"} / {id}; external requires origin:"external",source:"apple-app-intents"; GET home; res tRPC QuickChatView / {open,recentlySent,recentChats,shortcuts,asleep?} (asleep only from the hub, §5.11)Services/QuickChatAPI.swift · QuickChatAPI; Models/QuickChatView.swifttrpc/routers/quick-chat.ts; core/chat/routing/quick-chat-record.ts · quickChatViewSchemaLOUD
Q2Quick capture (§5.12)native/extension→boxone complete multipart POST plus owner-scoped tRPC capabilities/list/detail/retry/choose/deleteStorage/QuickCaptureStore.swift; Services/QuickCaptureAPI.swift; Services/QuickCaptureUploadCoordinator.swift; Intents/CaptureThoughtIntent.swift; Intents/VoiceNoteIntents.swiftshared/quick-capture.ts; routes/quick-capture/; trpc/routers/quick-capture.ts; core/quick-capture/LOUD; local source retained
W3Box screen navigation (§3.5)web→nativemain-frame navigation to <baseURL>/box, cancelled by nativeViews/ChatWebView.swift · isBoxScreenURL, Coordinator.mainFramePolicy(for:)the /<box>/box route; the landmark menu's box rowSILENT-degraded
M1Hub mobile-auth wallbox internalfull verification of bearer or bbx_mobile for the request's slug—hub-server.ts · hasMobileAuth → core/mobile/request-auth.ts · verifyMobileRequestLOUD
U1POST /api/bulk/sessionsnative/web→boxreq {targetSessionId,items?} (context dir derived server-side from targetSessionId); res {sessionId,startedAt,capabilities}— (deferred)routes/bulk-upload.ts · registerBulkUploadRoutesLOUD (400 no target)
U2POST /api/bulk/sessions/:id/itemsnative/web→boxreq {items:BulkItem[]}; res {registered}— (deferred)routes/bulk-upload.tsLOUD
U3POST /api/bulk/sessions/:id/items/:itemId/uploadnative/web→boxoctet-stream body, X-Upload-Filename + X-Upload-Original-Name/-Mime-Type; res {success,filename,itemId,size,sha256}— (deferred)routes/bulk-upload.ts; core/capture/staging-stream.ts · addFileStreamedLOUD (400/409/413)
U4GET /api/bulk/sessions/:idnative/web→boxres {sessionId,state,targetSessionId,registered,received}— (deferred)routes/bulk-upload.tsLOUD
U5DELETE /api/bulk/sessions/:idnative/web→boxres {success}— (deferred)routes/bulk-upload.tsLOUD
U6POST /api/bulk/sessions/:id/finalizenative/web→boxreq {failedItems?}; res {sessionId,staged}— (deferred)routes/bulk-upload.ts; core/bulk-upload/worker.ts · prepareAndDeliverBulkBatchLOUD (503 no runtime)

8. Mirrored constants (diff targets)

String/shape constants that exist in two places and must move together. A change to either side without the other is a contract break.


9. Known open contract risks

Still-open items to carry into the Android plan and the sync process. Full analysis (severity, reproduction, proposed fixes) is in docs/plans/ios-companion-review-2026-07-17.md.


10. Adding a platform

An Android (or any future) client implements exactly the surfaces in §7 — same query params, same JSON shapes, same channel names, same HTTP request/response shapes. The server side does not branch on platform (no UA gating), so a conformant client needs no server changes for the surfaces as specified.

Where platform-specific glue lives. How web→native postMessage is transported is platform glue, not contract: on iOS it's WKScriptMessageHandler names registered on the userContentController; on Android it's an androidx.webkit WebMessageListener (or a @JavascriptInterface-annotated bridge object). This transport belongs in the native-authored startup script the client injects — the same slot the iOS app uses to define beeboxNativeReceive / beeboxNativeShareLocation and the queues. Keeping it there lets the web-side JS stay platform-agnostic: web code reads/writes the queues and dispatches the CustomEvents, blind to how the native side is wired.

The return path is now platform-neutral (Track 0, landed 2026-07-17). The web side posts through window.beeboxNativePost(channel, payload) (§3.3) with a transitional fallback to the legacy webkit.messageHandlers object form for pre-neutral iOS builds. A new platform therefore defines beeboxNativePost in its own document-start script and routes however its WebView delivers messages — the Android plan's shape is a single {"channel", "payload"} JSON-string envelope through an androidx.webkit WebMessageListener object, plus an optional window.webkit.messageHandlers compatibility façade so the shell also works against a box whose web code predates Track 0 (see docs/plans/android-companion-app.md Track 0). Payloads on the neutral path are strings; receipt and location-result payloads are JSON, the session href is raw.


11. Anchor manifest (tripwire input)

The machine-readable distillation of §7 — the small set of files on all three sides that are the contract surface. It is the input to the two-hook tripwire (bin/mobile-contract-check.ts, wired into .husky/pre-commit + .husky/commit-msg; mechanism 5 of docs/implemented-plans/mobile-parity-sync.md): if a commit stages any file listed here but does not also stage this document, the commit is blocked at commit-msg time unless its message carries a Contract-Unchanged: <reason> trailer. Keep this list and the surface it guards in sync — adding a contract surface means adding its file here in the same change.

Paths are repo-relative (from the monorepo root, so box files carry the beebox/ prefix, unlike the beebox/-relative anchors in §7). A trailing / marks a directory prefix — every file beneath it counts as an anchor (fixtures are part of the contract). Blank lines and # comments are ignored. The Android bridge/native-shell files (android-app/…) join this list when android-app/ exists — add them alongside their iOS counterparts at that point.

# Web-side bridge, auth, and receipt surface
beebox/src/frontend/src/components/chat/native-post.ts
beebox/src/frontend/src/components/chat/everywhere/InteractiveChat/use-native-bridge.ts
beebox/src/frontend/src/components/chat/everywhere/InteractiveChat/native-emission.ts
beebox/src/frontend/src/components/chat/native-composer-command.ts
beebox/src/frontend/src/components/chat/native-command-bridge.ts
beebox/src/frontend/src/components/chat/everywhere/InteractiveChat/native-control-scan.ts
beebox/src/frontend/src/components/chat/native-control-point.ts
beebox/src/frontend/src/components/chat/everywhere/InteractiveChat/ui-scan-request-handler.ts
beebox/src/frontend/src/components/chat/native-last-audio-request.ts
beebox/src/frontend/src/components/chat/everywhere/InteractiveChat/native-speech-command.ts
beebox/src/frontend/src/components/chat/everywhere/InteractiveChat/use-native-composer-commands.ts
beebox/src/frontend/src/components/chat/everywhere/InteractiveChat/use-companion-selection.ts
beebox/src/frontend/src/lib/mobile-auth.ts
beebox/src/frontend/src/input/targets/receipts.ts

# Box server: pairing, mobile-token verification, native HTTP endpoints
beebox/src/core/mobile/pairing.ts
beebox/src/core/mobile/session.ts
beebox/src/core/mobile/request-auth.ts
beebox/src/webapp/mobile-cookie.ts
beebox/src/webapp/routes/pairing.ts
beebox/src/webapp/routes/chat/audio-routes.ts
beebox/src/webapp/routes/chat/last-audio-routes.ts
beebox/src/webapp/routes/chat/uploads.ts
beebox/src/webapp/routes/bulk-upload/register.ts
beebox/src/core/capture/staging-stream.ts
beebox/src/webapp/trpc/routers/debug-log.ts
beebox/src/webapp/trpc/routers/quick-chat.ts
beebox/src/core/chat/routing/quick-chat-record.ts
beebox/src/core/notification/apns-channel/payload.ts
beebox/src/core/notification/target.ts

# Shared Apple code: pairing link parser and redeem (used by ios-app/, and mac-app/ next)
apple-shared/Sources/BeeBoxShared/PairingLink.swift
apple-shared/Sources/BeeBoxShared/PairingRedeemer.swift

# iOS native shell: webview bridge, pairing model, paired-box storage
ios-app/BeeBox/Views/ChatWebView.swift
ios-app/BeeBox/Models/NativeComposerContract.swift
ios-app/BeeBox/Models/NativeControlRegistry.swift
ios-app/BeeBox/Services/SpeechDictation.swift
ios-app/BeeBox/Services/ScreenAwake.swift
ios-app/BeeBox/Storage/ComposerDraftStore.swift
ios-app/BeeBox/Services/ChatAPI.swift
ios-app/BeeBox/Services/QuickChatAPI.swift
ios-app/BeeBox/Models/QuickChatView.swift
ios-app/BeeBox/Storage/VoiceAudioRetentionStore.swift
ios-app/BeeBox/Models/PairedBox.swift
ios-app/BeeBox/Storage/PairedBoxStore.swift
ios-app/BeeBox/Services/LogForwarder.swift
ios-app/BeeBox/Services/PushRegistrar.swift
ios-app/BeeBox/Services/NotificationCenterDelegate.swift
ios-app/BeeBox/Models/NotificationTarget.swift
ios-app/BeeBox/BeeBoxAppDelegate.swift
ios-app/BeeBox/BeeBox.entitlements

# Shared golden fixtures — any fixture change is a contract change (directory prefix)
# Box-level quick capture acceptance and native mirrors (§5.12)
beebox/src/shared/quick-capture.ts
beebox/src/core/quick-capture/
beebox/src/webapp/routes/quick-capture/
beebox/src/webapp/trpc/routers/quick-capture.ts
ios-app/BeeBox/Storage/QuickCaptureStore.swift
ios-app/BeeBox/Storage/CaptureDestinationStore.swift
ios-app/BeeBox/Services/QuickCaptureAPI.swift
ios-app/BeeBox/Services/QuickCaptureUploadCoordinator.swift
ios-app/BeeBox/Services/VoiceNoteCoordinator.swift
ios-app/BeeBox/Intents/VoiceNoteIntents.swift

beebox/test/mobile-contract/

Native box screen

The iOS app opens on a native box screen (ios-app/BeeBox/Views/BoxScreenView.swift) on a cold launch, after 30 minutes or more in the background, or after the web content process ended in the background; otherwise it returns to where it was. A notification tap, or pending chat messages waiting in PendingEmissionStore, open the web app instead. The rule is ios-app/BeeBox/Models/RootSurface.swift · RootSurfaceRule, driven by RootView.

The box screen mounts no web content. Its composer sends through quickChat.submit (§5.11) with a new-thought draft stored apart from the chat draft. Its links (recent chats, "All chats", the box's nav.card shortcuts, whose card refs arrive as /views/<path>) show the web app at a box-relative path through the same ChatWebView.NavigationRequest a notification tap uses (§3.1). The web app returns to the box screen through the navigation in §3.5. The web view, once created, stays mounted and hidden behind the box screen. No emission version, native target type, or binding JSON changes for the box screen.

When the person's own Send, choice, or Retry on the showing box screen comes back sent with a session id, the box screen opens that chat with the same NavigationRequest as its "Open chat" button. Any other answer (sent without a session id, needs-choice, "Not delivered", "Waiting to send") stays on the box screen, scrolls the row into view, and posts an accessibility announcement of its status line. Outbox retries on the backoff or at launch, rows from quickChat.home, and answers that land after the person left the box screen, switched boxes, or backgrounded the app only update the rows. ios-app/BeeBox/Storage/BoxScreenStore.swift · followUp decides this; tests in BoxScreenStoreTests. The web box screen does the same from its reducer's followUp (pages/box-screen/state.ts).