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.tsgetMobileAuthToken(), 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.tsverifyMobileBearer(boxRoot, authHeader)parses Bearer prefix, delegates
src/core/mobile/pairing.tsverifyMobileToken(boxRoot, token)SHA-256 + timing-safe compare vs non-revoked devices; writes lastUsedAt back
src/core/mobile/pairing.tsisMobileDeviceActive(boxRoot, deviceId)read-only revocation check, used when renewing a cookie
src/core/mobile/mobile-session.tsverifyMobileSession(boxRoot, cookie)per-box HMAC + exp check; no filesystem access
src/core/mobile/request-auth.tsresolveMobileRequestAuth(boxRoot, headers)the one resolver every mobile gate uses — cookie first, then bearer
src/webapp/server-box-scope.tsaddBoxAuthHookbox auth preHandler: accepts agent bearer or resolveMobileRequestAuth; renews the cookie
src/webapp/server-box-scope.tscreateContexttRPC context: mobileOkauthed:true
src/webapp/server-root.tslistMobileAuthorizedBoxes / 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.tscreateContext 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.

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.userContentControlleronSessionChange(visibleSessionID)
beeboxEmissionReceiptReceipt object (§4.2)receiveEmissionReceipt
beeboxLocationResult{ id, success, message }receiveLocationResult
beeboxHqDictationState{ enabled }receiveHqDictationState
beeboxComposerCommandV1 or V2 composer command (§4.7, §4.8)receiveComposerCommand
beeboxLastAudioRequestV1 last-audio request (§4.9)receiveLastAudioRequest

3.4 Navigation policy


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.swiftbeeboxNativeShareLocation script, receiveLocationState, receiveLocationResult (15s timeout) | | web handle | src/frontend/src/components/chat/use-native-bridge.tsuseNativeLocationBridge, 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/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


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/hub-server.tshasMobileAuthAttemptBearer present OR ?mobileToken= present (presence only)bypasses the hub auth wall (HTTP catch-all + WS upgrade); box re-verifies — S1
src/hub/hub-server.tslistMobileAuthorizedBoxesreal verifyMobileBearer/verifyMobileToken/api/boxes returns the mobile-authorized box list
src/webapp/server-box-scope.tsisPairingRedeemUrlPOST + redeem URLbox auth hook lets redeem through
src/webapp/server-box-scope.tsaddBoxAuthHookverifyMobileBearer OR verifyMobileToken(?mobileToken=)passes box auth preHandler
src/webapp/server-box-scope.tscreateContextmobileBearerOk/mobileTokenOkauthed:true; user stays null, isOwner:false (§2.3)
src/webapp/server-root.tslistMobileAuthorizedBoxes / 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=falseserver-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?,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 · postNativeReceiptnative-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, postNativeLocationResultnative-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} via beeboxHqDictationStateViews/ChatWebView.swift · receiveHqDictationState; Views/NativeComposerView.swift · send, sendKeywordIntentuse-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/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 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
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
M1Hub mobile-auth wallbox internalfull verification of bearer or bbx_mobile for the request's slughub-server.ts · hasMobileAuthcore/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/use-native-bridge.ts
beebox/src/frontend/src/components/chat/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/native-control-scan.ts
beebox/src/frontend/src/components/chat/native-control-point.ts
beebox/src/frontend/src/components/chat/ui-scan-request-handler.ts
beebox/src/frontend/src/components/chat/native-last-audio-request.ts
beebox/src/frontend/src/components/chat/native-speech-command.ts
beebox/src/frontend/src/components/chat/use-native-composer-commands.ts
beebox/src/frontend/src/components/chat/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/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.ts
beebox/src/core/capture/staging-stream.ts
beebox/src/webapp/trpc/routers/debugLog.ts

# 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/Storage/VoiceAudioRetentionStore.swift
ios-app/BeeBox/Models/PairedBox.swift
ios-app/BeeBox/Storage/PairedBoxStore.swift
ios-app/BeeBox/Services/LogForwarder.swift

# Shared golden fixtures — any fixture change is a contract change (directory prefix)
beebox/test/mobile-contract/