Relay wire protocol
The WebSocket envelopes, the method contract for both agents, the version gate, the deadline of every hop, routing authority, and mutation replay rules used by the phone apps.
Checked against the source on 5 October 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 24 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.
The manifest lists 74 methods in four namespaces. 25 of them are mutations that need an idempotency key. The relay and the desktop router each hold the same two lists in code.
| Namespace | Examples |
|---|---|
| system, 13 methods | system. |
| session, 15 methods | session. |
| run, 23 methods | run. |
| files, 23 methods | files. |
Four methods serve the reply style, the text improvement, and the context overview. The desktop does all of their work.
| Method | Params and result |
|---|---|
| system. | No params. Returns the reply style setting: label, description, style, and options. Each option has a value, a label, and a description. The desktop writes all of these words, and the clients show them as they are. |
| system. | Mutation. Param: style, ste or default. Saves the one style of all agents and returns the same setting as system. |
| run. | Params: app |
| run. | Param: app |
A new context measurement arrives in the runtime event of chat:stream.
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. Request payloads reject unknown fields, so a client that puts a userId into its own payload fails the whole frame.
The manifest also pins size limits that the desktop enforces in code.
| Limit | Value and method |
|---|---|
| Relay frame | 32 Mi |
| Sessions per call | 16, run. |
| Result size | 8 Mi |
| Outbox entries per session | 256, run. |
| Raw command output page | 256 Ki |
| Raw binary read | 16 Mi |
pnpm check:rpc-contractThe version gate sits at registration
The relay protocol version is 1.7. The manifest names 1.7 as current and as minimum, and the desktop, the iOS app, and the Android app all report 1.7 in RegisterPayload. Version 1.7 gave the chat methods their agent-neutral run.chat names. The relay holds no translation for an older wire.
At registration the relay computes the required version as the higher of 1.7 and the highest minimum_relay_protocol_version of the compatibility rules in the database. It caches the rules for 60 seconds. A connection below the required version is refused before it can send a request.
The registration fails with one of three error codes.
- protocolUpdateRequired: relayProtocolVersion is absent or lower than the required version. The message asks the user to update the app.
- invalidRelayProtocolVersion: relayProtocolVersion does not parse as a version.
- relayProtocolPolicyUnavailable: the relay cannot read the compatibility rules, or a rule holds a version that does not parse.
A second check runs over HTTP before the socket opens. POST /public/compatibility/check takes a desktop descriptor, a mobile descriptor, or both, each with appVersion, platform, and relayProtocolVersion. The answer holds compatible, a decision, a message, policyVersion, requiredRelayProtocolVersion, and one result per descriptor with knownVersion and protocolSupported. The decision is one of compatible, missingClient, invalidVersion, unsupportedDesktopVersion, unsupportedMobileVersion, unsupportedPair, protocolUpdateRequired, and blockedByPolicy.
The desktop calls this check with a 5-second timeout before it connects. It sends only its own descriptor. The check fails open: when it is unreachable, returns an error status, or returns a body that does not parse or has no desktop result, the desktop connects anyway, and the registration gate remains the enforcing check. A negative answer stops the connection with a compatibility error.
Both agents use the same RPC methods
Codex and Claude sessions use the same methods. No method name contains an agent, except the account and usage methods in the system namespace. The desktop reads the agent from the session record and routes each run.chat call to that agent.
| Where the agent shows on the wire | Contract |
|---|---|
| Create a session | session. |
| Load and reconcile | run. |
| Send, approve, and stop | run. |
| Codex accounts | system. |
| Claude accounts | system. |
| Usage history | system. |
Both activate methods are mutations. The desktop switches the account, emits a desktop event that names the agent, and answers with the new list. A failed event only delays the desktop window until its next periodic read.
Inspect 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": {
"ok": true,
"service": "desktop-relay",
"timestampMs": 1789214400000,
"capabilities": ["run.cancel.exact-run.v1"]
},
"isFinal": true
}
}The desktop does not trust the envelope alone. It compares the relay’s userId with the user of its own sign-in and answers forbidden on a mismatch. It uses the relay’s clientId as the device id in idempotency scope keys. The permission list of a relay request is always rpc.
Before a reply leaves the desktop, the sender scans the result for a snake_case key. One such key replaces the whole reply with a validation error, so a handler cannot leak an internal field name to a phone.
Every hop has its own deadline
- Desktopserver_message_handler.rs
At most 8 seconds for a permit, then 45 seconds of dispatch, or 95 for run.chatLoad and run.chatLoadThread, then 5 seconds to queue the reply. A Codex timeline load has its own 90-second deadline inside the 95.
- PhonemobileChatPolicy
Both apps wait 60 seconds for an older page and 115 for the initial load, longer than the desktop needs. For an outbox submit the iOS app waits only 20 seconds and the Android app waits 60. A repeat must carry the same operation and the same key.
- Relaypending_requests.rs
Keeps the pending record 90 seconds, 120 for the two initial loads, and 31 minutes for run.chatOutboxSubmit. When the record expires, the relay answers -32012 and a later desktop reply finds no route.
On the desktop every relay RPC waits for one of two semaphores. 8 permits serve light calls. 2 permits serve heavy calls, so a slow media read cannot hold up system.ping.
| Heavy group | Methods |
|---|---|
| Timeline | run. |
| Files and media | files. |
| Attachments | files. |
| Git and web | files. |
| Desktop stage | Limit | Reply when it ends |
|---|---|---|
| Wait for a permit | 8 seconds | -32603 with the text “Desktop is busy; retry the request shortly.” |
| Dispatch | 45 seconds, or 95 seconds for run. | -32603 with the text “Desktop request timed out.” |
| Send the reply to the socket queue | 5 seconds | None. The desktop logs the failure, and the relay’s pending record decides. |
Both -32603 replies are plain internal errors without a retryable flag. A phone must not read them as proof that nothing ran.
The dispatch limit is a timeout around the handler future. At the limit the desktop drops that future, and the handler stops at its next await point. A mutation that is cut there leaves its durable claim in the running state. A handler whose work must finish starts it as a separate task and awaits only the result. run.chatOutboxStopAndSendNow does this: it stops the run and then sends the message, also when the relay no longer waits.
A concrete mutation: submit an outbox message
{
"type": "rpc.request",
"payload": {
"id": "request-102",
"method": "run.chatOutboxSubmit",
"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, and receipt has queueId and operationId. This enqueue example saves the message at the back of the queue and sends it when the session is idle.
Four more fields are optional: threadId, expectedChatSettings, accessMode, and expectedActiveRunId. The struct rejects any field it does not know.
To retry the same uncertain operation, keep its immutable operation payload and idempotency identity. Do not generate a new operationId because a network request timed out. An edited message is a different operation and must go through the explicit queue or composer action.
run.chatOutboxStopAndSendNow is the second outbox mutation with a precondition. Its params are sessionId, queueId, operationId, expectedRunId, expectedThreadId, and expectedTurnId. The three expected values name the run that the phone saw, so the desktop never stops a different run.
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 |
|---|---|
| Request over the connection rate limit | -32028, retryable. The relay drops the frame before validation and forwards nothing, so a retry cannot run a mutation twice. It answers only an rpc. |
| Method absent from the compiled allow-list | -32601 method-not-found. |
| API-key connection lacks a scope | -32027 with required |
| Mutation without a key | idempotency |
| Malformed trace context or params | Malformed trace context and malformed session. |
| 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 are retryable. |
| Desktop unreachable | -32010 offline, -32011 reconnecting, -32012 relay timeout, all retryable. A pending record expires after 90 seconds, after 120 seconds for run. |
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. 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.
Four methods may run again after an interrupted first copy: run.chatOutboxSubmit, files.appendChatAttachmentUploadChunk, files.finishChatAttachmentUpload, and files.cancelChatAttachmentUpload. Each has its own domain record that makes a second run harmless. Chunk append also releases its finished claim, because the upload ledger reconciles completion. 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.