The single source of truth is @codedeck/protocol (packages/protocol/src/):
every wire message is a zod schema, TS types are z.infer<>, and BOTH sides
safeParse at ingest (invalid payloads are logged and dropped, never thrown).
This document covers the parts of the contract that are conventions on top of
the schemas — the things a schema alone cannot tell you.
| Purpose | Kind | Storage |
|---|---|---|
Session-list heartbeat (sessions) |
30515 | NIP-33 replaceable (d = machine name) — relay keeps exactly one |
| Live output / usage / gsd-state | 24515 | ephemeral — broadcast, never stored; loss is recoverable (sync / re-request) |
| Phone→bridge commands | 4515 | stored, NIP-40 expiry 1 h |
| Bridge→phone responses (sync chunks, acks, lifecycle, pairing) | 4516 | stored, NIP-40 expiry 1 h |
All content is NIP-44 encrypted between the bridge keypair and the phone
keypair. Identity is ALWAYS the event author's pubkey — payload claims (e.g.
pair-request.pubkeyHex) are display-only.
| Purpose | Kind | Storage |
|---|---|---|
| NIP-17 gift wrap (DMs + Marmot welcomes ride inside) | 1059 | stored; accepted when a p-tag recipient is registered (wrap sigs are ephemeral keys) |
| NIP-17 DM relay list | 10050 | replaceable |
| Marmot/MLS KeyPackage (MDK 0.8 / MIP-00 — 30443, NOT the 443 the plan guessed; fixed in CDX-012) | 30443 | addressable (d tag required) |
| Marmot welcome rumor (only ever travels inside a 1059) | 444 | stored (spec completeness; clients never publish bare) |
Marmot group message, routed by h tag |
445 | stored; signed by MLS-exporter-derived EPHEMERAL keys — accepted without registration (narrow carve-out, rate-limited per IP since CDX-013) |
| Marmot KeyPackage relay list | 10051 | replaceable |
The phone opens three separate subscriptions, one per storage class, with
deliberately different since filters:
| Kind | since |
Why |
|---|---|---|
| 30515 | none | Replaceable — the relay always has exactly the current heartbeat; any since risks filtering it out and falsely marking the machine offline. |
| 4516 | lastStoredSeen − 60s |
lastStoredSeen is a persisted high-water mark advanced ONLY by stored (4516) events. High-frequency live output can never push it past a response the phone still needs. |
| 24515 | none | Ephemeral — the relay stores nothing, so since filters nothing; a fresh subscription simply starts receiving the live stream. |
This split is what structurally kills the old since-starvation bug: the stored cursor moves at command/response cadence, not at output cadence.
The bridge subscribes to 4515 from paired authors with
since = lastSeen − 5s (crash-gap grace). The relay will therefore REPLAY
recently processed commands after a restart — the bridge persists its dedup
event-id set alongside the cursor so the replay is a no-op.
Two-phase session creation publishes session-pending {pendingId} →
session-ready {pendingId, session} / session-failed {pendingId, reason}.
The bridge uses the future sessionId as the pendingId — they are the same
string. Phones rely on this equivalence: a pending placeholder is resolved not
only by session-ready but also by the session simply appearing in a
heartbeat's sessions[] (matching info.id === pendingId).
sync-request {sessionId, haveRanges} → sync-begin {syncId, seqHigh, ranges}
→ sync-chunk {range, entries} (each acked with sync-ack {syncId, range})
→ sync-end {deliveredRanges}.
- Seqs are assigned once by the bridge's transcript store at append time and are never renumbered. A seq that arrives twice with different content is a contract violation (phones detect and count it).
sync-begin.rangesis advisory. It announces what the bridge intends to deliver (the complement ofhaveRangesat request time). The truth issync-end.deliveredRanges— after retries, the delivered set may be smaller. Phones must reconcile againstdeliveredRanges(re-requesting the difference on the next connect), never assumerangeswas fulfilled.- The phone acks every chunk; the bridge retries unacked chunks a bounded
number of times, then reports honestly in
deliveredRanges.
models-request / models carry no machine identifier in the payload, and
they don't need one: the machine is the NIP-44 sender, i.e. the event author's
pubkey. The rule is: correlate (and dedupe) models by machine pubkey. Two
bridges on one box (host: 'cli' + host: 'vscode') are two pubkeys and keep
two model lists; the host field is a UI badge, never identity.
| Reason | Meaning |
|---|---|
no-session |
The bridge knows no session with this id. |
error |
The session exists but rejected the input (runner dead/ended). |
busy |
Reserved: the session cannot take input right now. Not currently emitted. |
expired |
The command aged out before the bridge processed it. |
input-ack {inputId} / input-failed {inputId?} drive the phone outbox
(pending → published → confirmed / failed). inputId is echoed verbatim.
A successful pair-ack carries relays (the bridge's relay list) and host.
This exists for the manual-npub pairing fallback, whose input carries no
relay list — the phone merges relays into its settings (deduped) so it can
actually reach the bridge afterwards. QR pairing already carries relays in the
URL; the ack's list is merged the same way.
Phone-managed, bridge-stored profiles that point a session at any Anthropic-compatible backend (Kimi K3, OpenRouter, …). Four messages plus one field:
set-provider-profile {profileId, profile | null}(phone→bridge): upsert or delete one profile.profile: nulldeletes the whole profile.profile.authTokenis tri-state: undefined = keep the stored token, null = delete it, string = set it.provider-profiles-request(phone→bridge): ask for the stored-profile list.provider-profiles {machine, profiles[]}(bridge→phone, 4516): the redacted list. Noerrorfield — unlikemodels, it is always answerable straight from bridge storage. Audience: aprovider-profiles-requestis answered to the REQUESTING phone only; after everyset-provider-profilechange (upsert or delete) the bridge broadcasts the fresh list to ALL paired phones, so every device converges on the same profile set.provider-profile-ack {machine, profileId, success, tokenValid?, error?}(bridge→phone, 4516): per-set ack.tokenValidis tri-state likecredentials-ack.keyValid— absent means the probe could not run.create-session.providerId?: binds the session to a profile for its whole lifetime (provider is chosen ONLY at creation, like the model).
Redaction rule: the auth token never leaves the bridge. It rides the wire
exactly once — inbound, on set-provider-profile.authToken — and is stored in
the host's secret storage. Everything bridge→phone carries hasToken: boolean
only; phones persist zero secrets and zero profile copies.
Capability gating is two-directional on custom-providers. The bridge
advertises it on the heartbeat ("I store profiles and honor
create-session.providerId"); the phone must gate ALL provider UI and sends
on it — an old bridge's zod silently strips the unknown providerId field and
would run the session on Anthropic (wrong provider, wrong account's bill).
Usage withholding: the bridge publishes NO usage for provider-bound
sessions. total_cost_usd uses Anthropic's price table and the 5h/7d windows
are Anthropic-subscription concepts — showing nothing beats showing wrong
numbers. Phones render no cost/subscription figures for these sessions.
entryType: text | tool_use | tool_result | system | error |
progress | thinking.
thinkingis the model's extended-thinking block, emitted as its own entry (phones render it collapsed).metadata.redacted: truemarksredacted_thinkingblocks, whosecontentis empty.- Interaction cards ride
systementries withmetadata.special:permission_request,plan_approval,ask_question, plus lifecycle markers (session_restart,session_died,session_failed,auth_erroronerrorentries).
The bridge advertises protocolVersion + capabilities[] on every heartbeat;
phones stamp commands with v (+ optional caps). Gate features on
capability strings (sync/1, folders, gsd, images, device-actions,
usage, models, diff, custom-providers, chunked), never on version
comparisons.
Not every string is a runtime gate — see the three-tier note at the top of
packages/protocol/src/capabilities.ts:
- HARD GATE — behaviour changes when the string is absent:
imagesandcustom-providers(phone gates on the bridge heartbeat),diff(bridge gates diff-entry emission on every heard-from phone's commandcaps). - PRESENCE MARKER — the feature is unconditional in v10; real detection is
on payload data (
folders[]/roots[],gsd.available, theusage/modelsresponses) or the feature just always runs (sync/1).device-actionstoo: implemented on the bridge, but no phone UI sendscreate-session.testSession. - TRANSPORT BEACON —
chunked: advertised on both sides, gated by neither.
A bridge→phone message whose encoded JSON would exceed one Nostr event's
content cap (65535 B) is split into N independently NIP-44-encrypted chunk
events ({cid, i, n, part}) and reassembled by the receiver before decode —
seq and every semantic field are byte-identical to the un-fragmented form.
This lives below the semantic layer: chunk is deliberately NOT a member of
bridgeToPhoneSchema, and neither side consults the chunked capability. See
packages/protocol/src/chunking.ts.
- Absence from
sessions[]NEVER deletes on the phone — it marksstale. - Removal happens only via
removedSessions[]tombstones (or a user delete). - A clean bridge shutdown publishes the full list with every session
state: 'offline'andmachineOffline: true— never an empty list.