Relay wire protocol
The WebSocket envelopes, method contract, routing authority, and mutation replay rules used by the native companion.
Checked against the source on 17 September 2026
On this page
Find the canonical method contract
- rpc-contract.v1.jsonmethods · mutationsRequiringIdempotency
The method inventory. No program loads it, and no code is generated from it. The check reads it beside every copy and fails on any difference.
- Relay listrequest_validation.rs
Checked before forwarding, so a method missing only here fails every call with -32601, even when both apps and the desktop know it. -32027 applies only to API-key connections.
- Desktop routerrouter/mod.rs
Demands a nonblank idempotency key of at most 256 bytes for the 22 listed mutations. An unknown method fails in its namespace handler.
- Phone enumsRpcMethod · requiresIdempotencyKey
iOS and Android can’t build a request outside the enum or a mutation without a key. The check compares their method names and ignores these flags.
| Namespace | Example methods |
|---|---|
| system | system.ping, system.codexAuthProfiles, system.getVoiceVocabulary |
| session | session.listProjects, session.create, session.getHistoryState |
| run | run.codexChatLoad, run.codexChatLoadPageBefore, run.codexChatOutboxSubmit, run.cancel |
| files | files.readContent, files.readBinary, files.getGitFileDiffPatch, files.beginChatAttachmentUpload |
A registered connection sends a type and payload envelope. RegisterPayload uses deviceId, relayProtocolVersion, and optional deviceName, sessionId, resumeToken, and targetDesktopDeviceId. The device UUID must match a device row the account registered over HTTP that is not forgotten and matches the connection’s role and relay eligibility, and the accepted protocol floor is the higher of 1.6 and the database compatibility policy. Request payloads reject unknown fields, so a client that puts a userId into its own payload fails the whole frame.
pnpm check:rpc-contractInspect the request and response shapes
- Written by the phonerpc.request
Everything a phone may send. traceparent is required and tracestate is optional. The target desktop comes from registration and never from a request.
- Written by the relayclientId · userId
Taken from the authenticated socket. clientId is the route mobileDeviceId::desktopDeviceId, which the desktop uses as the device in idempotency keys and the phone never sees.
- Written by the desktoprpc.response
The desktop keeps the phone’s id as its correlationId and answers with it, so one id labels the whole round trip.
- Pending record(user, desktop, id)
Filed for the desktop connection the request went to. A reply is delivered only if its id and clientId match a record of that same connection, and a final reply removes the record.
{
"type": "rpc.request",
"payload": {
"id": "request-101",
"method": "system.ping",
"params": {},
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
}{
"type": "rpc.response",
"payload": {
"clientId": "<source-client-id>",
"id": "request-101",
"result": {},
"isFinal": true
}
}On the desktop every relay RPC waits for one of two semaphores, 8 concurrent light calls and 2 concurrent heavy ones such as timeline loads, reconciliation, media, attachments, and Git diffs. Waiting more than 8 seconds returns busy, and dispatch is capped at 45 seconds, or 95 for run.codexChatLoad and its thread variant.
A concrete mutation: submit an outbox message
{
"type": "rpc.request",
"payload": {
"id": "request-102",
"method": "run.codexChatOutboxSubmit",
"idempotencyKey": "submit-operation-201",
"params": {
"queueId": "queue-201",
"operationId": "operation-201",
"sessionId": "<existing-session-id>",
"userText": "Explain the settings flow.",
"promptText": "Explain the settings flow.",
"attachments": [],
"speedMode": "standard",
"createdAt": 1789214400000,
"updatedAt": 1789214400000,
"intent": "enqueue",
"sendMode": "queue",
"source": "composer"
}
}
}The remote adapter decodes WorkspaceChatOutboxSubmitRequest, validates that the session exists, then calls submit_outbox_request. The result contains entries and receipt; receipt has queueId and operationId. This enqueue example saves the message at the back of the queue and sends it automatically when the session is idle.
To retry the same uncertain operation, keep its immutable operation payload and idempotency identity. Do not generate a new operationId merely because a network request timed out. An edited message is a different operation and must go through the explicit queue or composer action.
Routing is bound to live connection generations
Pending RPC admission takes the user authority gate and the routing transition lock. It rejects a draining server, stale source registration, or a changed desktop connection generation. The pending key includes the user UUID, normalized desktop device ID, and trimmed request ID. The record also retains the desktop connection ID and source client.
This prevents a late reply from a previous desktop socket from being treated as the current request’s result. Pending requests are bounded at process, user, desktop, and client levels. An identical existing pending record is a duplicate request; a different record under the same key is a conflict. Neither case is a new untracked forward.
| Relay decision | Code and behavior |
|---|---|
| Method absent from the compiled allow-list | -32601 method-not-found. |
| API-key connection lacks a scope | -32027 with requiredScopes, retryable false. A PlanToCode JWT always carries rpc, read, and write, so a phone never sees this. |
| Mutation lacks idempotencyKey | -32026, reason protocolUpdateRequired, retryable false. |
| Malformed trace context, or malformed session.syncHistoryState params | Rejected before forwarding. That is the only method whose params the relay type-checks; every other payload is opaque to the relay and validated on the desktop. |
| Pending bound reached | -32020 with scope, limit, and current, retryable. Bounds are 4,096 per process, 512 per user, 128 per desktop, 64 per client. |
| Same request ID pending | -32021 for an identical duplicate, -32022 for a different request under the same key. IDs are unique per user and desktop, so two phones reusing an ID against one desktop collide. |
| Routing moved under the request | -32023 when the desktop generation changed before forwarding, -32024 while routing authority is changing; both retryable. |
| Desktop unreachable | -32010 offline, -32011 reconnecting, -32012 relay timeout, all retryable. A record expires after 90 seconds, or 31 minutes for run.codexChatOutboxSubmit, and a desktop disconnect answers every pending request of that generation with -32011 at once. |
Two layers protect different operations
- In-memory claimregistry.rs
Process-local and empty after a restart. A repeat joins the running copy, up to 32 waiters per key. It keeps at most 512 finished outcomes and 64 running claims.
- Durable claimremote_rpc_idempotency
One SQLite row per user, device, method, and key, with a SHA-256 of the params. It survives restarts, holds at most 4,096 unexpired rows, and is deleted 48 hours after its reply is stored.
- Running leaselease_expires_at
Five minutes from the claim and never extended. A row still running with no live owner answers busy until then, then stores an ambiguous reply that repeats receive until it expires.
The scope key joins user, device, method, and key, so the same key from another phone or for another method is a different claim, and the fingerprint is a SHA-256 of the serialized params. A retryable row with the same fingerprint is reclaimed and run again. A stored response over 256 KiB is recorded as ambiguous. 48 hours after its reply is stored, a key is forgotten, and a repeat runs as a new mutation.
Errors classify by code. Invalid request, method not found, invalid params, unauthorized, forbidden, not found, conflict, validation, billing, payment required, and not implemented are terminal and replay as stored; parse errors and anything flagged retryable are retryable; internal, database, external-service, and unknown codes are ambiguous. If acquiring the durable claim fails, no mutation executes, and if execution finishes but its result cannot be saved, the router reports an ambiguous outcome rather than a success that hides a missing replay record.
Replay after interruption is method-specific. Chunk append can reconcile completion through its own domain ledger. The outbox’s separate (sessionId, operationId) fingerprint protects message delivery after the RPC handler returns. These layers do not make every arbitrary side effect exactly-once.