Bridge protocol
The versioned RPC contract every Bridge client speaks — the Tauri desktop
shell today; the bridged daemon, TUI, web, and CI clients as they land. The
single source of truth is the bridge-protocol crate
(src-tauri/bridge-protocol/); everything under docs/protocol/schemas/ and
src/protocol/generated/ is generated from it.
Regenerate artifacts: cargo run --manifest-path src-tauri/Cargo.toml -p bridge-protocol --bin generate-protocol-artifacts
Drift is test-enforced: cargo test --manifest-path src-tauri/Cargo.toml -p bridge-protocol
Shape
JSON-RPC 2.0 semantics. Bridge's surface — commands, approvals, cancellation, bidirectional events — is RPC-shaped, so REST+SSE was considered and rejected.
| Transport | Audience |
|---|---|
| Unix-domain socket | Local clients (Tauri shell, TUI, bridge exec) — served by bridged today |
| WebSocket | Browser and remote clients (follow-on; the auth token and origin rules are designed for it) |
| Tauri invoke/event adapter | The desktop webview; proxied to bridged by default, in-process only in embedded fallback |
| Plain HTTP | /healthz and /readyz only |
The bridged daemon
bridged (src-tauri/bridged/) is the single local owner of a data
directory's sessions, stores, PTYs, and provider processes:
- Ownership. An exclusive OS file lease on
<data_dir>/owner.lock(bridge_core::ownership). Exactly one owner per data directory — daemon or the desktop app in embedded mode — and the loser fails fast with the holder's identity. The lock dies with its process, sokill -9needs no stale-lock recovery; boot-time recovery handles what the dead owner left. - Framing. Newline-delimited JSON-RPC 2.0 over
<data_dir>/bridged.sock(mode 0600). One frame per line in both directions; notifications interleave between responses. - Auth. The handshake must carry
authTokenfrom<data_dir>/daemon.token(created 0600 on first start; an existing file is trusted only if it is a regular owner-only file holding a 64-hex token — anything else is refused, never adopted). A wrong or missing token is rejected with 2001unauthorizedbefore the server reveals anything, version included, and the whole handshake must complete within a deadline (10s) or the slot is reclaimed. - Dispatch. An exhaustive match over the method registry: params are
validated against the contracted types (wrong casing, unknown fields, and
out-of-set enum values fail with
invalid_params), then routed tobridge_core::api— the same bodies the Tauri shell calls. - Limits. Frames over 1 MiB →
invalid_request, connection closed. Connections beyond the cap → one 2004overloadedframe, closed. Requests per connection are sequential; backpressure is the socket. - Events. One event hub per daemon subscribes to the core bus and fans
out to per-connection bounded queues. A connection that falls behind is
told so: it receives a
stream-laggednotification plus the idempotent refetch hints as soon as it drains, and replays durable history viasessions/replay_session_events. Lag is never silent. - Shutdown. SIGINT/SIGTERM stop the accept loop, drain in-flight
connections (idle ones close within one poll interval; requests arriving
after the flag are refused with 2003
shutting_downand their own id), then stop live adapters so sessions land recoverable.kill -9recovery rides boot-time recovery on the next owner. - Shipping.
bridgedis staged byscripts/prepare-daemon.shand ships as a Tauri external binary next to the app binary; the bundled browser extension is resolved relative to the executable at runtime. - Health.
/healthzand/readyzon127.0.0.1:4318by default (--health-addr nonedisables). Bind and startup failures are fatal and printed — never a silent no-op. Port 4317 stays private and unextended.
bridged --data-dir ~/Library/Application\ Support/dev.bridge.deck
Clients
bridge-client (src-tauri/bridge-client/) is the shared Rust client:
token handshake, sequential calls with interleaved notifications, and the
recovery rules encoded once — SessionEventStream yields a session's durable
events gap-free across disconnects, live-channel lag (stream-lagged →
cursor replay), and sequence gaps. The Tauri proxy and TUI build on it.
The desktop app runs as a daemon client by default. On startup it
attaches to a bridged serving its data directory, or starts the bundled
binary and waits for it (stdout/stderr land in <data_dir>/bridged.log).
Every Tauri invoke is proxied generically: the command's registry method is
called with the invoke payload as params (invoke argument names are the
wire names; the shell's signature-parity test pins that), and every daemon
notification is re-emitted to the webview with an unchanged name and payload
— the frontend cannot tell which host it is on. A supervisor thread owns the
connection: after a daemon restart it reattaches on its own, and after any
reconnect or stream-lagged marker it emits the state-changed /
adapters-changed refetch hints, so the UI recovers durable history (open
approvals included) from the session forest exactly as the embedded host's
lag path always worked. BRIDGE_DESKTOP_HOST selects the host: auto
(default: attach → start → fall back to embedded), daemon (no embedded
fallback — the acceptance configuration), embedded (the in-process runtime,
kept during migration; never concurrent with a daemon thanks to the lease).
BRIDGE_DAEMON_BIN overrides which daemon binary is started.
bridge exec --json is the CI one-shot. It attaches to a running daemon
when one owns the data directory, and otherwise hosts the runtime itself for
exactly the duration of the command — CI never keeps a user daemon alive, and
an embedded desktop owner is reported by identity instead of failing opaquely.
bridge exec --json --data-dir "$DIR" --method state/get_state # one call
bridge exec --json --data-dir "$DIR" --harness codex "run the tests" # one turn, JSONL events
Prompt mode requires --harness naming an available structured adapter
(checked against health/health before any session is created). The
--timeout budget covers the whole command — connect, setup calls, and the
event stream; on timeout the turn is interrupted via
sessions/interrupt_turn. Turn failure is recognized in both provider
shapes: a turn.completed carrying failed, and a completion followed by a
trailing failed error event (grace-drained, then settled by the session's
own status). Exit codes: 0 success, 1 failure (RPC error / failed turn),
2 usage, 3 timeout.
Envelope
Requests, responses, and notifications follow JSON-RPC 2.0
(docs/protocol/schemas/rpc-*.json), and the contract types enforce the spec
rather than merely describing it:
jsonrpcis the literal"2.0"— any other value fails to decode.- Request ids are client-chosen strings or numbers, echoed back verbatim. Numeric ids must stay within JavaScript's safe-integer range (±(2^53 − 1)); the wire rejects anything larger, because a browser client would silently round it and correlate the response with the wrong request.
params, when present, must be an object or an array. A literal"params": nullis rejected — omit the key instead.- A response is a union of success and failure: exactly one of
resultorerror, never both, never neither."result": nullis a valid success (commands returning nothing succeed with it). - A response id may be
nullwhen the request id could not be recovered — the parse-error and invalid-request cases the spec calls out.
Handshake
The first request on a connection must be protocol/handshake
(handshake-request.json / handshake-response.json); any other first
request is answered with invalid_request. The server advertises:
- its
protocolVersion(this document describes 1.7), - its identity (
server.name/server.version— the application version), and - its
capabilities: the method domains it serves.
Versioning policy: a major bump is breaking; a minor bump is additive
(new methods, notifications, or optional fields). A server accepts a client
when majors match and the client's minor is not newer than the server's.
Incompatible clients are rejected with the stable code 2000
incompatible_protocol, with both versions in error.data.
1.7 adds worker prompt proposal grants and attributed prompt revisions.
An older daemon must reject a new client during handshake so it cannot silently
discard workerPromptProposalRoles when saving the permission policy. Older
clients remain compatible with the current daemon.
1.0 is a breaking bump, not a stability claim. Opening HarnessId (below)
widened a value domain that appears in results, so a 0.x client — whose
generated HarnessId is a closed enum — would handshake successfully and then
fail decoding a snapshot containing a gemini session. Widening a result domain
is the "clients must upgrade" case the major is reserved for. 0.x clients are
refused at the handshake rather than served values they cannot decode.
Since 0.6 the request carries an optional authToken; hosts serving
remote-capable transports (the daemon) require it, and nothing ever echoes it.
Methods
docs/protocol/schemas/methods.json is the registry. Wire names are
domain/command, where command is the exact Tauri command name — the
compatibility adapter maps an invoke to its method by name alone, and a test
in the shell crate keeps the registry 1:1 with generate_handler![...].
Domains: approvals, browser, completion, config, health,
learning, marketplace, models, projects, routing, sessions,
skills, slash, state, terminal, workspaces.
Params
Every method in the registry carries a paramsSchema: a schema file, or an
explicit null meaning contracted to take no parameters (a validator rejects
any params sent to it). There is no "not yet contracted" state left — a method
without a params contract fails cargo test. See BridgeMethodParams in the
generated TypeScript.
Params fields are camelCase, matching Tauri's invoke-argument conversion, and
mirror the command's signature argument for argument — a test in the shell
crate parses each #[tauri::command] signature and compares it against the
contracted field names, so renaming an argument without renaming the field
fails the build.
Params objects first contracted in 0.5 reject unknown fields. The 19 params
schemas published in 0.4 remain open until the next major version because minor
versions are additive: a 0.5 server must keep accepting every request permitted
by the 0.4 contract. Newly contracted methods have no older schema to preserve,
so an unknown field is a client bug and invalid_params is preferable to
silently ignoring it.
Payload types that mirror a bridge-core DTO (CheckRun, RouterPreferences,
HarnessConfig, LearningSchedule, the browser payloads, and the closed enums
alongside them) exist twice on purpose: the contract does not depend on the
runtime it describes. A test-only module in bridge-core
(src/protocol_mirror.rs) is the drift gate — exhaustive matches so a new core
enum variant fails to compile, and JSON round-trips so a renamed or added struct
field fails the test. Input DTOs derive testable serialization too, so optional
field additions cannot slip through a one-way deserialization check.
Results
Every method's result is contracted or a documented exception — never
silently absent. Registry rows carry either resultSchema (a schema file,
with a matching type in the generated TypeScript BridgeMethodResults map) or
resultDeferred (the named core DTO a future slice must mirror). The
aggregate BridgeState and SessionForestSnapshot trees are fully
contracted, including the nested completion summary; the deferred set is the
remaining domain snapshots (learning runs/state, model setup, the OpenCode
catalog, the browser bridge snapshot, and the marketplace/skill catalogs).
Commands returning no value use the explicit UnitResult contract
(result: null).
Harness ids
HarnessId is an open string naming which agent runs a session:
HarnessId := [a-z0-9][a-z0-9._-]{0,63}
claude, codex, opencode, shell, gemini, cline, … Through 0.8 this
was an enum of the first four; every one of those values is unchanged. Do not
generate a closed union over the values that exist today — an agent installed
from the ACP registry did not exist when your client was compiled, which is
the whole point.
The id is the agent, never how Bridge runs it. Claude reached through the
Agent SDK and Claude reached through an ACP shim are the same agent and share
the id claude; the transport is Bridge's problem, recorded separately, and
never something a user picks. This is why the live registry's opencode entry
and Bridge's own OpenCode adapter are one harness with one id and one
session history, rather than two competing products. It also means a bespoke
adapter can replace a generic one later without renaming anything or migrating
a single session.
Whether an id is runnable is a separate question from whether it is valid. A well-formed id Bridge has no adapter for parses fine and fails at start with an error naming the harness.
Params and results use different types, and the schemas say so. A
parameter is HarnessId, whose schema carries the pattern above — a
malformed id is invalid_params. A result carries StoredHarnessId, an
unconstrained string: a session persisted by a newer Bridge, or one whose
agent was uninstalled, still reports the id it was stored with, so history
does not disappear because an agent was removed. Publishing the strict pattern
on the result side would let state/get_state return a document that fails
its own contract.
So: read any string from sessions[].harness; send back only one that matches
the grammar. An id outside the grammar is readable, never actionable.
Cancellation
$/cancel is a notification (cancel-params.json) naming the in-flight
request id. Cancellation is best-effort, LSP-style: the cancelled request
still receives a response — its result if it won the race, otherwise error
2002 cancelled.
On the daemon's socket transport, requests are handled sequentially per
connection, so a $/cancel is only ever read after the request it names has
completed: it is consumed as a valid no-op, exactly as best-effort allows.
Sending $/cancel as a request is answered with invalid_request — the
daemon will not claim a cancellation that cannot have happened. The
domain-level interrupt for a running turn is sessions/interrupt_turn.
Errors
Codes are the contract; message text is not. Never reuse or renumber. The
full registry with summaries is docs/protocol/schemas/error-codes.json.
| Range | Meaning |
|---|---|
-32700..-32600 |
JSON-RPC envelope errors (spec-reserved values) |
1000..1999 |
Runtime errors — variant-for-variant with bridge_core::BridgeError |
2000..2999 |
Protocol lifecycle: incompatible_protocol, unauthorized, cancelled, shutting_down |
Notifications (events)
docs/protocol/schemas/notifications.json is the registry: every
notification a host pushes, marked durable or transient. Wire names
are the Tauri event names, so the compatibility adapter forwards them
unchanged.
| Notification | Delivery | Meaning |
|---|---|---|
agent-event |
mixed | Persisted entries carry a positive per-session sequence; sequence-zero streaming frames are transient |
state-changed |
transient | Refetch hint: re-read the state snapshot |
adapters-changed |
transient | Refetch hint: re-read adapter availability |
learning-job-changed |
transient | Refetch hint carrying the changed run/state |
memory-changed |
transient | Refetch hint naming the changed scope ({"scopeKey": …}); the records come from memory/list_memory_records |
session-output |
transient | Terminal bytes; worthless once stale |
account-usage |
transient | Provider usage tick for the ambient meter |
session-startup |
transient | Cold-start phase observed at a real adapter launch boundary ({"sessionId", "phase": "spawning"|"handshake"|"session_open"}); never replayed, never timer-driven |
stream-lagged |
transient | Host-synthesized: this connection's live channel dropped events ({"missed": n}); refetch hints follow, replay durable history from your cursors |
The delivery rules are contract: the session forest (SQLite) is the
authoritative history; the live channel is bounded and notify-only — it
drops the oldest events when a receiver lags and must never be treated as
the source of truth. Durable events are published only after their DB
transaction commits and expose the cursor needed to reconcile from the
session forest after a disconnect or lag. Sequence-zero agent-event frames
are transient and never replayed. Refetch hints are coalesced and re-emitted
after live-channel lag so clients converge even when the original hint was
evicted. The Tauri compatibility UI also polls the forest; the dedicated
replay method exists as sessions/replay_session_events — pass the last
seen durable cursor (afterSequence) and receive the next page of missed
durable events in order, no gaps, no duplicates. limit is optional (default
500, maximum 1000); continue from the last returned sequence until the method
returns fewer than the requested limit. Transient and sequence-zero frames are
never replayed. To close the subscribe-before-replay race, clients discard live
durable events at or below the highest sequence returned by replay, then process
newer live events normally. The daemon brings the remote transport for it;
in-process hosts call it like any other method.
Generated client types
src/protocol/generated/protocol.ts is generated from the JSON Schemas
(one source of truth) and type-checked by tsc in bun run check/build.
It exports the envelope and handshake interfaces, the BridgeMethod string
union, the BRIDGE_METHODS table, ERROR_CODES, and PROTOCOL_VERSION. The
frontend migrates onto these types as the compatibility adapter lands.