Two protocols meet in the bridge:
- the phone wire — phone ⇄ bridge over Nostr relays. Source of truth:
crates/protocol(commands.rs,events.rs,common.rs). Both sides decode at ingest with total decoders (an invalid payload is logged and dropped, never thrown).crates/protocol/fixtures/corpus.jsonpins the JSON shape of every message. - the driver protocol — bridge ⇄ agent host over a pipe. Source of truth:
crates/agent-protocol; the agent host's TypeScript types are generated from it (packages/agent-host/src/generated/, CI fails on drift).
This document covers what the types alone cannot tell you: conventions, invariants, and why.
Each version is a clean break from the one before: no compatibility in either direction. v11 made the protocol agent-neutral (and carried no v10 data over); v12 sends a copy of each message per client of an identity (see Clients and copies).
| Purpose | Kind | Storage |
|---|---|---|
Session-list heartbeat (sessions) |
30515 | NIP-33 replaceable, one per client (d = the copy's recipient tag); NIP-40 expiry 1 day |
| Live output / usage / partial text, terminal output and keystrokes | 24515 | ephemeral — never stored; partial goes to the clients watching that session and terminal-output to those watching that terminal, loss is recoverable (sync / re-request / terminal-replay / the completing entry) |
| 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 — the phone's identity, or a session key it granted (see
Session keys). Every event the phone writes is signed by its
identity, and every event for the phone is p-tagged to its identity and
c-tagged with its recipient tag (see Clients and copies):
identity is ALWAYS the event author's pubkey — payload claims (e.g.
pair-request.pubkeyHex) are display-only.
The phone opens three separate subscriptions, one per storage class,
each for the bridges it is paired with (authors), its identity (#p) and
its own recipient tags (#c), with deliberately different since filters:
| Kind | since |
Why |
|---|---|---|
| 30515 | none | Replaceable — the relay always has exactly this client's 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. |
The stored cursor moves at command/response cadence, not at output cadence — that is what keeps a burst of output from starving a response.
The bridge subscribes to 4515 from paired authors with
since = lastSeen − 5s (crash-gap grace). When the owner allows a terminal
over relays it subscribes to the live kind (24515) from the same authors
too, so a phone's keystrokes reach it there; off, the live kind is not
subscribed and a relay sees none. The relay therefore REPLAYS
recently processed commands after a restart — the bridge persists its dedup
event-id set alongside the cursor so the replay is a no-op.
Nothing in the wire names a particular coding agent. The heartbeat carries
agents: AgentDescriptor[] — per agent its id, displayName, modes[],
efforts[], defaultMode, defaultEffort, defaultPersona, supports {models, usage, providers, providerModels, interrupt, commands, plugins, mcp, tasks, permissionRules, accounts, personas}, credentials[] status,
install (below) and styleHash (see Agent styles). Phones build every
picker from it and offer a feature only when the session's agent supports
it. Mode, effort and model values are opaque strings the bridge validates
against the catalog.
Defaults are reported, never left implicit: a session created without a mode
or effort records the agent's defaultMode / defaultEffort, and one created
without a model reports (in the session list) the model the agent runs it on.
The default model is per agent and can change with the agent's own
configuration, so it rides the models reply as defaultModel rather than
the catalog. The agent refuses a model it does not offer; the session then
fails with that reason.
An agent the bridge has but cannot run here (not configured) is not advertised; creating a session on it fails with the reason.
install says whether the agent is on the machine: {state: "ready", removable?} (removable: CodeDeck installed it and can remove it; absent
means false, and a missing install means ready), {state: "not_installed"}, {state: "installing"} or {state: "failed", reason}.
An agent that is not ready is listed by name only, supports nothing, and
runs no session: creating one on it fails with the reason. A session the
bridge already has (one resuming after an agent host restart) waits while
its agent installs, and starts once it is ready or fails with the install's
reason. A phone changes it with agent-action {agent, action} (install or remove) → agent-ack {agent, action, success, error?}; the ack says the bridge took it up (or why not), and the install
itself shows in the next session list's install. Removing ends the
agent's sessions and deletes only what CodeDeck installed; an agent the
machine has of its own is not removable.
An agent's sessions can look like its own terminal UI. Its AgentStyle is
a fixed set of knobs, each optional (an unset knob keeps the phone's own
look; an agent with no style looks like any other):
spinner {frames, intervalMs, bounce?}while it works (bounce: played forward, then back);workingWordsbeside it, one per turn, andworkingCounter(time and tokens beside the word);toolGlyphs, the glyph before a tool row byToolKindwire name (otherfor every kind not listed), andresultMarkbefore a result line;userMessage {shape: "bubble" | "prompt" (+ mark) | "block"}— an unknown shape is drawn as a bubble;wordmark, block-art lines (one character per terminal cell) shown on an empty session, withwordmarkSplitleading cells drawn muted for a two-tone logo;accent(#RRGGBB), worn only by the agent's own marks — the spinner, the working word, the wordmark — andmodeColors, a colour by mode id, worn by the mark on the user's messages and by the mode in the composer.
Colours that mean a state (running, waiting, failed) stay the phone's. The
catalog rides every heartbeat and a style is about a kilobyte, so the
catalog carries only styleHash: the first 16 hex digits of the SHA-256 of
the style's JSON as AgentStyle::hash writes it (keys in declaration
order), the one definition of it. A phone that has no style under that
hash sends agent-style-request {agent}; the bridge answers that phone
alone with agent-style {agent, styleHash?, style?}, both absent for an
agent with no style or one it does not know. Phones cache styles by hash,
keep one only under the hash it really has, and ask only while a session of
that agent is on screen — so a style is fetched once per agent version,
shared by every bridge that runs it.
pendingId == sessionId.create-session {agent, …}→session-pending {pendingId}→session-ready {pendingId, session}/session-failed {pendingId, reason}. The bridge uses the future sessionId as the pendingId; a pending placeholder is also resolved by the session simply appearing in a heartbeat'ssessions[].- Ordering: each
sessionslist carriesrev, strictly greater than the last one the bridge published (its clock in ms, kept ahead of the stored last one, so a restart or a clock set back cannot lower it). The same list arrives over every relay and the direct link, and may be re-published or replayed, so a phone applies a list only if itsrevis greater than that of the newest one it applied for that machine (kept across restarts); an older one changes nothing and announces nothing. - Options:
set-option {sessionId, option: mode|effort|model|persona, value}→option-confirmed {sessionId, option, value}. A refused value publishes nothing (the phone keeps what it knew); a refused effort or model confirms the value still in force.option-confirmedis also sent when the agent changes its own mode (it entered plan mode, a plan approval switched it) or persona (the one it ran as was removed: it falls back to its default). turnStartedAt: while a turn runs (waiting on the user included), when it started, as an ISO 8601 time on the bridge's clock; absent otherwise. A turn need not start with the user's message (a background task's end can start one), so the bridge is what says it; a phone shows the time worked from it.committed: the session's folder got a git commit after the session started (the agent's own, caught at once, or one made by hand, caught within seconds); a phone shows it as a badge.- Resume: every session in the bridge's registry comes back when the
bridge starts. When its agent dies it is restarted (continuing the agent's
conversation when it has one) up to twice, each with a
noticeentry.
output {sessionId, seq, entries} carries a run of consecutive typed
OutputEntrys, entries[i] being seq seq + i. The bridge sends what the
agent wrote in one go as one run, and the entries written while the relay
was still taking the previous event join the next one, up to what one event
holds unfragmented; a quiet session still sends each entry as it comes. Each
entry has a timestamp (ISO 8601 in UTC with milliseconds,
2026-09-25T13:04:05.123Z, like every timestamp on the wire), optional subagent {label?, parentCallId?} (parentCallId: the
agent call that started it, so a client can nest its steps under that call), optional agentExtras (the one
sanctioned escape hatch — agent-specific data no client depends on) and a body
tagged by entryType:
entryType |
What |
|---|---|
text |
conversation text; `role: user |
plan |
a proposed plan (markdown) |
thinking |
model reasoning; redacted when the provider withheld it |
tool_call |
callId, toolName (display only), kind (read, edit, delete, move, search, execute, think, fetch, switch_mode, agent — a sub-agent launch —, other), title, locations, input (the whole input as text, bounded; absent for a file change, whose diff carries it) |
tool_result |
callId, text, isError |
diff |
path, add/del/context lines, truncated |
todos |
the agent's plan, whole each time: `items[] {text, status: pending |
background_task |
work left running beside the turn: taskId, `kind: shell |
permission_request |
a card: requestId, the tool, and `options[] {id, label, kind: allow_once |
question |
one question of an ask: requestId, index/count, options, multiSelect |
plan_approval |
a card: requestId, options[]; optional revise (the option that sends the plan back to revise, which the user's feedback may go with) |
resolved |
a card was answered or cancelled: requestId, summary |
notice |
session_restart, session_died, session_failed, auth_error |
status, error, turn_complete |
one-line status, an error, the end of a turn |
Clients branch on entryType and kind, never on tool names.
Live text (partial). While an agent writes a reply or its reasoning,
the bridge may send partial {sessionId, key, entryType: text | thinking, subagent?, offset, text}: the characters of part key from offset on,
offset counted in UTF-16 code units. It is an ephemeral, unnumbered
message with no tags. Each carries only what is new since the last one; the
agent's driver coalesces its deltas so a part costs a few events a second,
never one per token. The text / thinking entry that completes the part
carries part: key and replaces the live row; an update whose offset is
not the row's length (one was lost) freezes the row until then, and the end
of a turn drops any row left over. An agent that does not stream sends no
partial and its entries carry no part. Today the drivers stream only the
main agent's reply: reasoning and a sub-agent's text are shown inside their
tool group, so the kinds stay on the wire for later but cost nothing now.
A partial goes only to the clients watching that session — the phone says
which one it shows with its presence, and shows live rows for no other —
so a backgrounded app and every session not on screen cost nothing per
piece. The cost is one presence per change of screen (the phone coalesces
rapid changes into one) and, for a client that starts watching a session
mid-part, one partial per live part to catch it up: the bridge keeps what
it passed on for each part, up to 256 KiB, and resends it whole from
offset 0. The phone takes a piece from offset 0 as replacing the row, so
the row is complete at once instead of waiting for the entry that completes
the part. Past that size nothing is kept, and the row stays empty until
that entry.
Cards. Every permission_request, question group and plan_approval is
closed by exactly one resolved entry with the same requestId — answered,
timed out (after an hour), interrupted, or cancelled because the agent died.
Answers: permission-response {requestId, optionId},
plan-response {requestId, optionId, feedback?} (feedback only with the
card's revise option; the bridge records it as the user's message),
question-response {requestId, index, answer: {kind: options, selected} | {kind: text, text}}.
Plain input while a question is pending answers its first unanswered
question.
sync-request {sessionId, haveRanges} → sync-begin {syncId, seqHigh, ranges}
→ sync-chunk {range, entries} (acked with sync-ack {syncId, ranges})
→ sync-end {deliveredRanges}.
-
The bridge cuts the missing seqs into chunks by the size of their entries, up to what one event holds unfragmented: many short entries share a chunk, a long one may take one alone. A chunk's
rangemay cover seqs it has no entry for (pruned), which the phone then has as far as sync goes. -
Each range in a
sync-ackis exactly one chunk'srange. One ack may cover several chunks: every command is signed by the phone's identity, possibly in an external signer, so a phone batches the acks for chunks that arrive together instead of signing one per chunk. The bridge waits 10 s for acks before resending a pass, so a batch must go out well within that. -
Seqs are assigned once by the bridge and are never renumbered; they continue across bridge restarts. A seq that arrives twice with different content is a contract violation (phones detect and count it).
-
sync-begin.rangesis advisory. The truth issync-end.deliveredRanges— only what the phone acked. Phones re-request the difference on the next connect. -
Unacked chunks are resent twice with a doubling wait, then reported honestly.
input {sessionId, text, inputId?} → input-ack {sessionId, inputId} or
input-failed {sessionId, inputId?, reason}:
| Reason | Meaning |
|---|---|
no-session |
The bridge knows no session with this id. |
error |
The session exists but is not running. |
The bridge writes the user's transcript entry itself (agents do not reliably echo input) and drops an agent's echo of it.
interrupt {sessionId} stops the running turn. stop-task {sessionId, taskId} stops one background task, for agents with supports.tasks; the
task's next background_task entry says it stopped.
With the files capability, a phone attaches a file of any kind to a session
with upload-file {sessionId, filename, mimeType, text, …} in one of two
shapes: {hash, url, key, iv, sizeBytes} for a file it uploaded, AES-256-GCM
encrypted, to the user's own Blossom server (the key travels only in this
message), or {uploadId, base64Data, chunkIndex, totalChunks} pieces through
the relays when no server is set (or it failed). The bridge saves the file
under its own name in <first root>/.codedeck/uploads and runs text with
the path added as the session's next input — an image to be looked at, any
other file named. At most 25 MiB; a relay-only upload must fit 200 chunks.
With the terminal capability — which a bridge advertises only when its
owner has terminals switched on, unlike files and session-keys — a phone
opens a real shell on the bridge's machine:
terminal-open {openId, cols, rows, session?}opens one.openIdis the phone's own, echoed in the answer, so it can match a reply to the request that earned it;sessionstarts the shell in that session's directory (the phone never names a path). Answer:terminal-opened {openId, term, shell}, orterminal-exitedwithopenIdand a refusalreason(limit,not-allowed,disabled).terminal-input {term, seq, data}types into it, writer only.seqcounts up by one from 1 per terminal and per device: the bridge applies input in that order and drops aseqit has already applied, and a device's count only ever grows — a takeover does not start it over — so a relay replaying a signed event types nothing twice however many writers came in between.datais base64 of the bytes (a key press need not be valid UTF-8). It rides LIVE_KIND (24515), ephemeral like the output — a keystroke is never stored on a relay.terminal-input-from {term, seq}says resend fromseq: the first one that never arrived, or — to a device that has just taken the pen — the seq its own count resumes at, which the phone adopts.terminal-output {term, offset, data, replay?}is what the shell wrote,database64 andoffsetthe byte offset of its first byte in everything this terminal has output. Also LIVE_KIND, and likepartialit goes ONLY to the present clients watching that terminal (presence.watchingTerminal) — a client that missed a run reattaches withterminal-replay {term, from}, which resends the output fromfromand what follows, read-only.replayis set (true) only on the bridge's answer to that request: itsoffsetmay be pastfromwhen the bridge no longer holds those bytes, and the client takes it from there.terminal-resize {term, cols, rows},terminal-takeover {term}(become the writer) andterminal-close {term}(hang up).terminal-exited {term?, openId?, code?, reason, device?}says a terminal is gone:exitwith the shell'scode, orkilled/closed/idle/transport/restart; the vocabulary is open, and a phone shows an unknown reason as "ended".openIdis set instead oftermwhen an open was refused,devicewhen it was refused for that device.terminal-event {term, what, device, name?, via, at}tells EVERY present client of the identity what happened (opened,attached,takeover,closed,exited), with the device that acted, itsnameand how its command arrived (via:direct,onionorrelay), so a phone that did not act learns at once — takeovers and closes are audited this way.- The heartbeat's
terminals[](TerminalInfo {term, shell, opened, device, name?, writer, cols, rows}) lists what is open, so a phone shows one it did not open. It lists only the receiving identity's OWN terminals: a terminal belongs to the identity that opened it, so another identity's heartbeat does not name it at all — not its id, its shell, nor the device running it. An identity with a terminal open receives a heartbeat event of its own, carrying the samerevas the shared list; an identity with none receives the shared list unchanged. - The device id names a client across its key rotations: the first 16
bytes, in hex, of
sha256("codedeck/device\0" + first session key + "\0"), the FIRST key the client granted this bridge — the one itsreplaceschains back to (crates/protocol/src/recipients.rspins it). Re-pairing or reinstalling makes a new device.
set-credentials {agent?, values: {id: secret | null}} → credentials-ack {agent?, success, credentials[] status}. agent scopes the write to that
agent's advertised credentials; absent = the bridge's own (a GitHub token). An
id outside the scope refuses the whole write. A string sets, null clears, an
unlisted id is unchanged. A value the bridge's environment provides wins
(fromEnv) and the phone cannot clear it. Secrets ride the wire only on
this message, inbound; status only ever goes out.
Phone-managed, bridge-stored profiles of another endpoint: a provider's own
API, or a gateway in front of several. Each is for one agent — the
endpoint must speak the API that agent uses, and one that speaks one
agent's need not speak another's. What a profile does is the agent's
catalog entry:
supports.providers: a session can be bound to one of the agent's profiles (create-session.providerId), which then serves the whole session;supports.providerModels: the agent's profiles add their models to its own model list, beside every provider it already has; a session picks one as any other model, and is never bound.
Messages:
set-provider-profile {profileId, profile | null}: upsert or delete (nulldeletes).profile.agentnames an agent with either flag.profile.authTokenis tri-state: absent = keep,null= clear, string = set. The base URL must be https, or http to this machine (localhost,127.0.0.1,[::1]) or to an IP address of the user's own network (10/8, 172.16/12, 192.168/16, 100.64/10, fc00::/7 — addresses, never names) — the bridge never stores another, and refuses to start a session on one written before that rule.profile.modelsFromProvider: true: the phone lists no models; the agent host reads them from the endpoint's/v1/models(/modelswhen the base URL already ends in/v1), signing in as the profile's agent does, with the profile's token, on every save, and the bridge stores at most 200. No redirect is followed, and the save is refused when the list cannot be read or is empty. AdefaultModelthe list does not name is dropped. The stored profile reports the flag back, so a later save (with the token kept) reads the list again. A model read so keeps, when the endpoint says, theprovidera gateway routes it to (the part of the id before its first/, which stays in the id) and itscontextWindow; phones group such a model under "profile · provider".- The token is checked by the profile's agent, with the smallest request on
the API it speaks (
tokenValidin the ack). - For an agent with
supports.providerModels, a save waits on the agent taking the new list: a profile it leaves out (its own provider, or an older profile, has the name) is not saved — the earlier version stands — and the ack carries the agent's reason. A stored profile the agent leaves out later (after an operator adds a provider of that name) carries the reason aserrorinprovider-profiles. - A profile stored before profiles named their agent has an empty
agent: no agent uses it until a save names one. provider-profiles-request→provider-profiles {profiles[]}to the asking phone; after every change the bridge broadcasts the new list to all phones.provider-profile-ack {profileId, success, tokenValid?, error?};tokenValidabsent = the check could not run.create-session.providerIdbinds the session to a profile for its whole life. The profile is looked up at every start, so a rotated token reaches restarts and a deleted profile ends the session loudly — never a silent fallback to the agent's own account.
The token never leaves the bridge: phones see hasToken only. No usage is
published for provider-bound sessions (the numbers would be priced for the
wrong provider).
models-request {agent} → models {agent, models[], defaultModel?, error?}.
Each model is {id, label?, provider?, efforts?}: provider names who serves it (an
OpenCode provider, a router's channel such as OpenCode Go), since the same
model can be offered by more than one. efforts are the model's own
reasoning levels, from an agent whose levels differ by model: its catalog
entry lists no efforts, the phone offers the session model's instead, and
the bridge leaves checking a level to the agent. An empty list always comes with an error saying why, so the phone can tell
"no answer yet" from a lost message. Models are correlated by the machine that
sent them (the event author), never by a payload field.
usage-request {sessionId} → usage {sessionId, usage}, for agents with
supports.usage; the phone asks when a session opens, after each turn, and
when the context sheet opens. usage is {available, plan?, windows[] {label, utilization?, resetsAt?}, sessionCostUsd?, context?, fetchedAt}:
the plan's limits (available false when the agent has none to report), the
session's cost, and context — what fills the window, as the agent counts
it: usedTokens, windowTokens, categories[] {name, tokens, kind: used | free | buffer | deferred} in the agent's order (buffer is the reserve kept
for compaction, deferred what loads only on demand and sits outside the
window), and groups[] {name, tokens, items[] {name, tokens}}, the lists
behind some parts (each MCP tool, each memory file). Provider-bound sessions
get no usage (above).
commands-request {sessionId} → commands {sessionId, commands[], error?},
for agents with supports.commands. Each command is {name, description?, argumentHint?}; name has no leading slash and may be namespaced
(plugin:command). The bridge asks the agent every time — a session's
commands change while it runs (plugins, skills) — and answers an unknown,
stopped or command-less session at once. As with models, an empty list
always carries an error. A command runs by sending /name args as plain
input; the agent's driver turns it into whatever its agent needs.
Some agents carry named configurations of their own — instructions, a
model, tools they may never use — that a session can run as (OpenCode's
primary agents, from .opencode/agents/*.md in a project or the machine's
global configuration). For agents with supports.personas:
personas-request {agent, sessionId?}→personas {agent, sessionId?, personas[], error?}: with a session, those it can switch to (its project's and the machine's); without, the machine-wide ones a new session can start as anywhere. Each is{id, label, description?, color?}(colora#RRGGBBthe client may ignore). As withmodels, an empty list always carries anerror; a stopped or unknown session is answered at once.set-option {option: persona}switches who runs the session;create-session {persona}names one to start as. A persona the session's directory lacks leaves the agent'sdefaultPersona, and the session says so in its transcript; the session listing'spersonais always the one it runs as.
A persona is who runs the session; the mode stays how much it may do without asking. What the persona forbids holds in every mode, the auto-approve one included.
Cost: one small request and reply when a running session is opened and each time its options are opened, and one when an agent's page is opened — never on a timer, never pushed.
For agents with supports.plugins, a phone lists and changes the agent's
plugins on the bridge's machine (all its sessions share them):
plugins-request {agent, available?}→plugins {agent, installed[], marketplaces?, toggles, available?, error?}.installed[]is{id, name, marketplace?, version?, description?, enabled};available(only when asked for) is what the known marketplaces offer and is not installed,{id, name, marketplace, description?, installCount?}. An agent without marketplaces — it installs plugins by package name — sends nomarketplaces;togglessays whether a plugin can be switched off without uninstalling it. A list that could not be read comes empty, witherror.plugin-action {agent, action, target}→plugin-ack {agent, action, target, success, error?, message?}, then (when done) the newplugins, to every phone — with the freshavailablewhen the change was to a marketplace (add, remove or update), since that alters the catalog.actionisinstall,uninstall,enable,disable,update(target: a pluginid— update brings an installed plugin to its marketplace's latest version;installtakes a package name where there are no marketplaces) oradd-marketplace(target:owner/repoor a URL),remove-marketplace,update-marketplace(target: its name). The bridge refuses a target that is empty, starts with-or holds control characters.messageis what was done, in the agent's words, when it says (e.g. an update's from/to versions). A change reaches running sessions: Claude Code reloads its plugins in place; OpenCode reloads, restarting its running sessions.
For agents with supports.mcp, a phone manages the agent's MCP servers on
the bridge's machine and switches them in a running session. The agent's own
configuration is the library — Claude Code's user scope, OpenCode's global
config — so a server added here also works in that agent's terminal, and one
added there shows here. The bridge keeps no MCP state.
A server is McpServerSpec {name, transport}: transport is {type: "stdio", command, args?, env?}, {type: "http", url, headers?} or {type: "sse", url, headers?}. A name is 1–64 letters, digits, -, _ or .,
starting with a letter or digit; a command must not start with -; a URL is
http(s)://. McpServerSpec::problem is that rule set, applied by the phone
before sending and by the bridge on receipt.
Secrets flow one way. Env and header values (a bearer token, an API key)
ride phone → bridge only, cross the host link as Secret (with the args and
the URL), and are written into the agent's config. Nothing sent back carries
them: McpServerInfo {name, transport, target, envKeys?, headerKeys?, enabled} names a stdio server's program (not its args) or a remote server's
URL without user info, query or fragment, and only the names of what is set.
Changing a secret means adding the server again.
mcp-request {agent}→mcp-servers {agent, servers[], toggles, error?}.toggles: a server can be switched off without removing it (OpenCode; Claude Code and the DeepSeek Harness have no such switch). A list that could not be read comes empty, witherror.mcp-action {agent, action, servers?, names?}→mcp-ack {agent, action, names, success, error?}, then (when done) the newmcp-serversto every phone.addtakes up to 50servers(a name that exists is replaced);remove,enable,disabletakenames. One bad server refuses the whole action. Running sessions pick the change up (Claude Code reloads in place; OpenCode reloads, restarting them; the harness attaches its servers as it starts, so its next process uses the new list).session-mcp-request {sessionId}andsession-mcp-toggle {sessionId, name, enabled}→session-mcp {sessionId, servers[], toggles, projectWide, error?}, each server{name, status, error?, tools?}withstatusone ofconnected,pending,failed,needs-auth,disabled. WithprojectWide(OpenCode) a switch applies to every session of the agent in the same project. A server wanting an OAuth sign-in reportsneeds-auth; the sign-in is done on the machine.
Phones also import servers from the JSON other clients use ({"mcpServers": …}, VS Code's servers, OpenCode's mcp, or a bare name → config map):
client_core::mcp_import turns it into specs, reporting each entry it
cannot add.
For agents with supports.permissionRules, an "always allow" answer on a
permission card becomes a rule the agent keeps for the project, outliving the
session that made it: every later session there runs a matching call without
asking — over the mode the session runs in — until the rule is removed.
OpenCode keeps them in its database (they survive its restarts). A phone
lists and revokes them; the bridge keeps no rule state.
permission-rules-request {agent}→permission-rules {agent, rules[], error?}, each rulePermissionRule {id, project, tool, pattern, createdAt}: the project's directory, the tool in the agent's own name (shell,edit), the pattern a call must match in the agent's own syntax (git status *,*for every call), and when it was saved (ms).permission-rules-remove {agent, ids}(1–200 ids) → thepermission-rulesafter. An id the agent no longer has is skipped, not an error.
Both are answered to the asking client only: the page that asked is the only
one showing the rules, and nothing else fetches them. When the rules could
not be read or removed, rules is empty and error says why; a phone keeps
showing what it had. Cost: one command and one reply per page opening or
removal, never sent unasked.
For agents with supports.accounts, a phone gives the agent access to
providers on the bridge's machine — a key, or a sign-in to a subscription —
through the agent's own API, so it takes effect on the running agent with
no restart. The agent's store keeps the keys and tokens; nothing sent back
carries one. The bridge keeps no account state beyond who started each
sign-in.
An AgentAccount {id, name, methods?, connections?} is one provider.
A method {id, kind: key|sign-in, label?, fields?} may ask questions first:
a field {key, title, description?, required?, options?, placeholder?, default?, when?} is asked only when every condition {key, value, not?} in
when holds (an answer equal to value, or with not different), and a
field not asked is not required. A connection {id, label, kind: key|sign-in|env, active?, removable?} is one access the provider has; env (a variable of
the machine's environment) cannot be changed from a phone.
accounts-request {agent, all?}→accounts {agent, accounts[], all?, error?}: the accounts with a connection, or withallevery provider the agent can connect (OpenCode: about 230, some 25 KB — asked only by the picker that shows them).account-action {agent, account, action},actiontagged bykind:key {method, key, label?, answers?},activate {connection}andremove {connection}→ the connectedaccountsafter;sign-in {method, label?, answers?}→account-sign-in {agent, account, status: started, attemptId, attempt}whereSignInAttempt {attemptId, url, instructions, expiresAt, pasteBack?}says what to do;finish {attempt, callbackUrl}andcancel {attempt}→account-sign-inwith where it stands. A sign-in that started ends with one moreaccount-sign-in(complete,failed,expired,cancelled).
A device sign-in shows a code to enter on any device, and the agent watches
the provider itself. One whose provider sends the browser back to the
machine's loopback is pasteBack: the phone pastes the address its browser
failed to open (finish), and the agent opens it on the machine — only when
it is that sign-in's own origin and path (the bridge refuses anything that
is not http:// on loopback first). key and callbackUrl are secrets:
phone → bridge only, Secret to the host, never logged.
Everything is answered to the asking client only. When an action could not
be done, accounts comes with error (and nothing in accounts, so a
phone keeps showing what it had) or account-sign-in with failed. Cost:
one command and one reply per page opening or change; a sign-in is two
replies (started, ended) whatever it takes — the agent host watches it
locally, nothing polls across the wire.
A pairing window is a time-boxed subscription with no author filter — the
only way an unpaired phone reaches the bridge — plus a QR:
codedeck://pair?npub=…&relays=…&machine=…&token=….
Only a pair-request echoing the window's one-time token pairs. A successful
pair-ack carries relays and host: the phone keeps the machine's relays
(the pairing's own plus the ack's) and reaches it over them from then on. Rejections (bad-token,
window-closed) are answered at most five times per ten minutes. A
pair-request may carry sessionKey to grant the first session key with
the pairing; the pair-ack is then already encrypted to that key.
An identity may run any number of clients — phones, desktops, several of
each — and each holds its own session key, so a bridge knows a client by its
key. A bridge encrypts a message once per client it is for, to that client's
newest key, and tags each copy with its recipient tag: c = the first 16
bytes, in hex, of sha256("codedeck/recipient\0" + bridge pubkey + "\0" + key + "\0") (crates/protocol/src/recipients.rs pins it). Both ends derive
it; a relay can link neither the tags of one client at two bridges nor a
client's tags before and after a key rotation.
- Who gets a copy. A reply to a command (an ack, a list, the heartbeat a
refresh-sessionsasks for, a sync's begin, chunks and end) goes to the client whose key encrypted the command, alone. Everything else (the heartbeat, cards, session changes) goes to each client present now — except live text (partial), which goes only to the present clients watching that session (see Presence). An identity that has granted no key (or is not paired) is addressed as itself, with the identity's own tag. - Presence. Any command keeps its client present for 25 minutes
(
PRESENCE_LEASE_SECS); a running client with nothing else to say sendspresence {watching?}every 10 (PRESENCE_RENEW_SECS), andpresence {away: true}when it stops, which ends its lease at once.watchingis the session whose live text this client shows —nullor absent meaning none. Everypresencesets what a client watches (a renewal says it again),awayclears it, and any other command leaves it as it was. A client that has not said — one that has just connected, or whose bridge has just restarted — gets every session's live text, so nothing on its screen is left frozen while it is being asked. A client away gets nothing; when it is back its first command (orpresence) earns it a heartbeat of its own, and it syncs what it missed. A restarted bridge counts every client present for the renewal period and a minute. - Reading. A client subscribes with the tags of its live keys and of its identity, and drops any copy tagged otherwise, before decrypting it.
- The heartbeat is addressable per copy:
d= its recipient tag, so a client's latest heartbeat stays on the relay whatever the others get; a heartbeat split into fragments gives fragmentithedsha256("codedeck/fragment\0" + tag + "\0" + i + "\0")[..16], so it replaces fragmentiof the previous one. Heartbeats expire after a day, clearing a gone client's or bridge's and the fragments a shorter one left. - An identity keeps at most 16 clients; a new one past that drops the client heard from least recently.
A phone whose identity key lives in an external signer (NIP-55) should not
ask it to decrypt every message. It grants a local key, once:
session-key {sessionKey: {pubkeyHex, bridgePubkeyHex, expiresAt, replaces?, label?}} (or
sessionKey on its pair-request). label is the device's name as its user
set it (the phone's model by default) — display only, for the bridge's audit
and allowlist; what the bridge trusts is the device id it derives from the
client's first key (see Terminal). bridgePubkeyHex names the one bridge
the grant is for; a bridge refuses a grant naming another, so a grant one
bridge saw cannot be replayed to a second. A session key only ever keys NIP-44 payloads; it never
signs. Every event keeps the same parties — the phone signs everything it
publishes (commands, grants, relay AUTH, image-server auth) with its
identity, and the bridge p-tags everything to the identity — so allowlists
on relays and image servers only ever see the identity. From the grant on:
- the bridge reads a command's payload encrypted with any live key of the identity's clients (a client's newest, or the one it replaced), or with the identity itself;
- a grant of a key the bridge does not know adds a client; one naming the
key it
replaces, sent encrypted with that key, moves that client to the new key (only the client holding a key can retire it; areplacessent otherwise is ignored). Granting a key again may move its lapse later, never earlier. Each client's copy is encrypted to its newest key; the first heartbeat after the grant is the client's confirmation; expiresAt(seconds) is at most 90 days ahead; a client whose key lapses is dropped, and an identity left with none is encrypted to again. The phone grants a new key before then;- an event authored by a session key is not heard (only paired identities are); a grant naming a key the bridge already knows for anyone (a paired identity, another identity's client, its own) or that a client rotated away from is refused.
A phone grants a session key to a bridge advertising session-keys, or to
one it has not heard from yet (a bridge that does not honour grants never
confirms one, and the phone goes on with its identity). A bridge answers a
grant with its heartbeat, so a phone with no key at a bridge grants before
sending anything else, and needs nothing the bridge wrote to its identity
before then. A message a bridge it granted a key copies to its identity
(the recipient tag says so) tells the phone the bridge has no live key for
it, and it grants again.
Renewal is rotation: a phone never extends a key's life by granting it
again. Every grant of a key runs until the same expiresAt, the key's own;
a month before it, the phone makes a fresh key and grants that to each
bridge (under the key the bridge holds, so the grant itself stays
readable), naming the key it replaces. It keeps the previous key, and keeps
encrypting to a bridge with it, until that bridge confirms the new one by
encrypting to it; once every bridge has, or its grants lapsed, the previous
key is deleted.
A bridge may also serve its phones directly, over its own WebSocket (LAN,
VPN, or an onion service), besides the relays. It carries the SAME signed
events, nothing else: no REQ/EOSE, a handshake and then events both ways.
Pairing still happens over the relays, and the bridge keeps publishing
everything there too, so a phone falls back to the relays whenever no direct
endpoint answers. Both sides drop an event they have seen by its id, so one
arriving both ways is handled once. crates/protocol/src/direct.rs is the
spec.
- Discovery. The heartbeat's
direct {endpoints, certSha256?}lists where to connect, in order.wss://endpoints serve a self-signed certificate the phone pins bycertSha256(no CA, any host name: private addresses and VPN names work);ws://is allowed only for.onion. The heartbeat is signed by the bridge and encrypted to the phone, so the pin is as authentic as the pairing. While the phone routes through Orbot it only uses.onionendpoints. - Frames, each a JSON array in one text message:
["CHALLENGE", c](bridge, on connect) →["HELLO", auth, since, recipients](phone, within a minute:autha kind-22242 event signed by its identity, tagged["challenge", c],created_atwithin 10 minutes;recipientsthe recipient tags it reads, at most 8) →["READY"]or["CLOSED", reason]. Then["EVENT", event]both ways, each phone event answered with["OK", id, accepted, message]. - After
READYthe bridge sends what it published for that identity sincesince(seconds; it keeps an hour of it), then everything new — only the copies tagged with one of therecipients, never another client's. It takes command events (4515) and terminal input on the live kind (24515), each authored by the identity that saidHELLO; the engine accepts onlyterminal-inputon the live kind, so nothing else can ride it in.
The bridge serves a web page on its direct link's wss:// port (the
bridge's side is in BRIDGE.md). A browser signs in
with a paired phone, never a password: the page shows a code of 8
characters (ABCD-EFGH, from an alphabet without look-alikes) and a QR of
codedeck://web-sign-in?machine=<npub>&code=<code>.
web-sign-in {code}(phone → bridge, a signed command like any other). The bridge reads the code in any case, with or without its dash, and matches it against the codes its page is showing: each works once, for 5 minutes.web-sign-in-result {code, signedIn}to the asking client.codeis the code exactly as the phone sent it, so the phone can tell which attempt this answers;signedInfalse means mistyped, expired or already used.- A phone sends a scanned code only to the machine its QR names: a code shown by one bridge must never sign in another's page.
The phone can also see and end the browsers already signed in, the same thing
the page's own GET /api/browsers and POST /api/browsers/sign-out do:
web-browsers-request {}(phone → bridge, a signed command like any other). No fields. Sent when the user opens the screen that lists them, never on a timer: the list changes only when a browser signs in or out, so nothing else would make it stale.web-browsers {browsers}to the asking client. Each entry is{id, by, sinceMs, lastSeenMs, expiresMs, peer, agent}:idis the page's short public id — the first 16 hex characters of the stored key's SHA-256, never the key and never the session token —byis the npub that signed it in, the three times are ms since the Unix epoch,peerthe address it came from andagentitsUser-Agent(cleaned and bounded). Oldest signed in first; empty when the bridge runs no web page.web-browser-sign-out {id}(phone → bridge). Ends the browser whose page id isid; any paired client may end any of the owner's browsers. Answered withweb-browsers, the fresh list — a sign-out is confirmed by the list it produced, and an id that matched nothing is answered with the list as it is, not an error.
Cost: two small command-class events per sign-in, one request and one response per screen opening, and the same again per sign-out; nothing while the screen is closed.
Every message, both ways, carries v: its sender's protocol version (12),
first in the object. The codec stamps it and the decoder checks it before
the body: a message of another version, or of none, is refused as such
(DecodeError::Version) rather than read by the wrong version's rules. Two
ends of different versions cannot talk; each says so instead of failing at
random. A chunk fragment carries no v of its own — the message it
reassembles into does.
Within one version, additions do not break an older peer. Unknown fields are
ignored. The open vocabularies a bridge or agent may grow decode an unknown
value as a catch-all instead of failing the whole message: an entry type as
unsupported (it keeps its seq; the phone says it needs an update to show
it), a tool or task kind as other, a notice kind as other (its text still
shows), and an MCP status, session state, install state, terminal end
reason, terminal event kind or terminal via as unknown (an agent in an
unknown install state runs no session; a terminal with an unknown reason is
shown as ended). A known value with a
missing or mistyped field is still an error.
The heartbeat carries capabilities[], what this BRIDGE offers; what an
AGENT can do is catalog data (supports). Each capability gates something
a phone does, and a string is added only when a phone relying on it against
a bridge without it would otherwise fail:
files: the phone shows the attach control only when present;session-keys: the phone grants a session key only when present;terminal: the phone shows the terminal only when present (a bridge that has terminals switched off does not advertise it).
A bridge→phone message whose encoded JSON would exceed one event's content
cap (65535 B) is split into N independently encrypted chunk events
({cid, i, n, part}) and reassembled by the receiver before decode — seq
and every semantic field are identical to the unfragmented form. Each
fragment carries the copy's recipient tag (and, for a heartbeat, its own
d). chunk is not a message type (crates/protocol/src/chunking.rs).
Before it is fragmented and encrypted, a bridge→phone message of 1 KiB or
more is packed when that makes it smaller: ~ followed by the base64 of
its raw deflate stream (crates/protocol/src/packing.rs). Wire JSON repeats
itself, so a packed message is typically several times smaller and takes
fewer fragments. A plain message is a JSON object and starts with {, so the
decoders tell the two apart and accept either, in both directions; inflating
is capped at 32 MiB.
- 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.
The bridge (Rust) runs every agent SDK in one Node process, the agent host
(packages/agent-host), and talks to it over its stdin/stdout. Its stderr is
log output. The split is what keeps the bridge agent-neutral:
- a driver (one per agent, inside the host) owns everything specific to its agent — starting it, translating its events into transcript entries, what its modes mean, its permission policy (which tool calls ask the user), how credentials and provider profiles reach it, detecting a lost conversation;
- the bridge owns everything the phone sees — sessions and restarts, seqs and transcripts, cards and their timeouts, persistence.
One JSON object per line (at most 16 MB): {"v":1, "id"?:string, "kind":…, "payload"?:…}. Kinds are kebab-case, payload fields camelCase, enum values
snake_case. Requests carry an id; every request gets exactly one reply with
that id — its typed reply or error {message}. Notifications have no id.
The bridge's ids are b1, b2, …; the host's are h1, h2, ….
| Request | Reply |
|---|---|
initialize {bridgeVersion} |
initialized {hostVersion, agents: AgentInfo[]} |
start-session {sessionId, agent, cwd, mode?, effort?, model?, persona?, resume?, credentials, env, provider?} |
ack once starting (progress follows as events), or error |
end-session {sessionId} |
ack; no ended follows |
delete-conversation {sessionId, agent, cwd, conversationId} |
ack once the agent's own record of a deleted session's conversation is gone (also when there was none), after sessionId finished ending; or error |
prompt {sessionId, text} |
ack |
interrupt {sessionId} |
ack |
stop-task {sessionId, taskId} |
ack once asked (the task's next background_task entry says it stopped), or error |
set-option {sessionId, option, value} |
ack when applied, else error |
list-models {agent} |
models {models, defaultModel?} |
get-usage {sessionId} |
usage {usage?}: plan limits, the session's cost, and context? — what fills its window (usedTokens, windowTokens, categories[{name, tokens, kind: used|free|buffer|deferred}], groups[{name, tokens, items[{name, tokens}]}]) when the agent can say |
list-commands {sessionId} |
commands {commands} |
list-personas {agent, sessionId?} |
personas {personas}: the session's, or without one the machine-wide ones |
list-plugins {agent, available?} |
plugins {installed, marketplaces?, toggles, available?} |
plugin-action {agent, action, target} |
plugins {…} once done (available? after a marketplace change, message? saying what was done), or error with the agent's reason |
list-mcp {agent} |
mcp-servers {servers, toggles} |
mcp-action {agent, action, servers?, names?} |
mcp-servers {…} once done, or error with the agent's reason |
session-mcp {sessionId} |
session-mcp {servers, toggles, projectWide} |
session-mcp-toggle {sessionId, name, enabled} |
session-mcp {…} once done, or error |
list-permission-rules {agent} |
permission-rules {rules}: every project's |
remove-permission-rules {agent, ids} |
permission-rules {rules} after, or error with the agent's reason |
list-accounts {agent, all?} |
accounts {accounts}: the connected ones, or every one with all |
account-action {agent, account, change} |
accounts {accounts} (the connected ones) after key, activate, remove; account-sign-in {status, attemptId, attempt?, error?} after sign-in, finish, cancel; or error with the agent's reason. A sign-in that started ends with an account-sign-in-ended {agent, account, attemptId, status, error?} notification |
check-credential {agent, credential, value} |
credential-checked {valid?} |
check-provider {agent, provider, model} |
credential-checked {valid?}: a provider profile's token, checked on the API agent speaks |
list-provider-models {agent, baseUrl, authToken} |
provider-models {models} (never empty; each {id, label?, provider?, contextWindow?}), read the way agent signs in; or error with the reason there is none |
install-agent {agent} |
ack at once; the install's progress follows as agent-changed; or error |
remove-agent {agent} |
ack once removed — after agent-changed and the agent's sessions' ended — or error (e.g. an agent the machine has of its own) |
set-providers {agent, providers} |
providers-set {refused: [{id, reason}]} once an agent with supports.providerModels offers these profiles' models (all of its profiles, oldest saved first, sent after initialize and on every change) — all but the refused ones, which it cannot add (a name one of its own providers, or an earlier profile, has); or error |
AgentInfo is the catalog entry minus credential status (the bridge adds
that) and styleHash (the bridge hashes the style it carries), plus
credentials[].envVar and unavailableReason. The host
reports a change to one with the notification agent-changed {agent: AgentInfo} (an install starting, finishing or failing, a removal); the
bridge replaces that entry, and once the agent is no longer installing
starts the sessions that waited on it.
Session events are notifications: session-event {sessionId, event} with
event.type:
| Event | Meaning |
|---|---|
ready |
The agent accepts prompts (once per start-session). |
info |
Changed facts only: nativeSessionId (the resume target), model, mode, persona (the one it runs as, chosen or fallen back to), title (the agent named the session; wins over the bridge's title from the first message and the session-meta topic), contextWindow, contextPercentage. |
entries |
Transcript entries, in order (the bridge assigns seqs). |
turn |
running / idle. |
ended |
The session is gone: no error = a normal end; resumeLost = the conversation to resume no longer exists. The host forgets the session. |
Requests the host makes (the bridge answers each exactly once):
| Request | Reply |
|---|---|
request-permission {sessionId, requestId, toolName, kind, title, …, options, reason?, hook?, hookPlugin?} |
permission-outcome {outcome: selected {optionId} | cancelled {reason}} |
ask-question {sessionId, requestId, questions} |
question-outcome {outcome: answered {answers} | cancelled {reason}} |
request-plan-approval {sessionId, requestId, options, revise?} |
plan-outcome {outcome: selected {optionId, feedback?} | cancelled {reason}} (feedback only with revise) |
answers holds one reply per question, in order: {type: "selected", labels} for the options chosen (in the order the question offers them), or
{type: "text", text} for what the user typed instead. A cancelled outcome
means nobody chose: the card timed out, the user interrupted, or the session
is ending.
The bridge restarts a host that exits, with a backoff. Every session that was
running is treated as crashed (its cards are cancelled, a restart notice is
written) and is started again, resuming its conversation, once the new host
answers initialize. Input sent meanwhile is queued and delivered after the
restart.
- Write a driver in
packages/agent-host/src/drivers/<agent>/implementingDriver(src/sdk/driver.ts):info()advertises the agent (modes, efforts,supports, credentials, and itsstyle— the look of its own terminal UI, kept in the folder'sstyle.ts, each value taken from the agent's own source; check that Android draws each glyph as text, not as an emoji);startSession()returns aDriverSessionand reports through theSessionContextit is handed —emit()session events,requestPermission()/askQuestion()/requestPlanApproval()when the user must decide. - Translate the agent's own events into typed
OutputEntryvalues in the driver — nothing agent-specific may reach the bridge. For provider profiles, say insupportshow the agent uses one (providers: bound to a session throughStartSession.provider;providerModels: added to its models throughsetProviders()), and implementlistProviderModels()andcheckProvider()with the API the agent speaks to an endpoint (src/sdk/providerApi.ts). - Export a
DriverModule(src/sdk/module.ts) from the folder'smodule.ts: the agent's id, how its driver is built from the environment, and its runtime (what the machine already has, and how to install the pinned one). Add it to the list insrc/host/modules.ts; it is enabled throughCODEDECK_AGENT_HOST_DRIVERS. The host ships four:drivers/opencode/,drivers/claude/,drivers/deepseek/anddrivers/qwen/(the last two over the sharedacp/machinery, which is what an ACP agent's driver reuses). - Test it beside the driver, in
src/drivers/<agent>/__tests__/, likedrivers/claude/__tests__/claudeDriver.test.tsanddrivers/opencode/__tests__/opencodeDriver.test.tsdo, with the recordingSessionContextinsrc/sdk/__tests__/context.ts. Everything the driver owns — its permission policy included — lives in that folder too. A driver imports onlysrc/sdk/,src/install/,src/generated/and its own folder;src/host/__tests__/layout.test.tsenforces it.
The bridge and the phone need no change: the new agent appears in the
catalog, and its entries render through the typed vocabulary above. Only a
genuinely new kind of interaction needs a protocol change — in
crates/protocol (phone wire) or crates/agent-protocol (driver protocol),
then regenerate the host's types with
cargo test -p agent-protocol --test gen_ts_bindings -- --ignored.