Skip to article
PlanToCodeDocsGet the app

HandbookRelay & accounts

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

Each hop checks requests against its own copy of the method list
Each hop checks requests against its own copy of the method listNo running program reads rpc-contract.v1.json. The phone app, the relay, and the desktop router each keep their own method list and reject what it lacks, in that order. Before anything ships, pnpm check:rpc-contract reads the manifest and every copy and fails on any difference. For the phone apps it compares method names only.
before shipping
rpc-contract.v1.json65 methods · 22 mutations
Contract checkpnpm check:rpc-contract
reads
reads every copy
on every request
Phone appRpcMethod enum
Relayrequest_validation.rs
Desktop routerrouter/mod.rs · handlers
names only
a method outside the enum or a mutation without a key is never sent
-32601method not in its list-32027API key lacks a scope-32026mutation without a key
-32601unknown method-32602blank or oversized key-32602unknown or snake_case field
  • 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.

NamespaceExample methods
systemsystem.ping, system.codexAuthProfiles, system.getVoiceVocabulary
sessionsession.listProjects, session.create, session.getHistoryState
runrun.codexChatLoad, run.codexChatLoadPageBefore, run.codexChatOutboxSubmit, run.cancel
filesfiles.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.

Compare every copy with the manifest from the repository root
pnpm check:rpc-contract

Inspect the request and response shapes

The relay adds who asked and where the reply goes
The relay adds who asked and where the reply goesA phone sends only what it asks for. The relay adds who asked from the authenticated socket and files a pending record for the desktop connection it forwards to. The desktop answers with the same id and route, the reply is delivered only if it matches that record, and the relay removes the route before the phone sees the reply.
Phone
Relay
Desktop
identity comes from this socket
rpc.request
idmethodparamsidempotencyKeytraceparent
rpc.request
clientIduserIdtraceparentidmethodparamsidempotencyKey
filed
Pending record(user, desktop, id) → route, desktop connection
any other field, such as userId or a target desktop, fails the whole frame
a userId other than the signed-in account is forbidden
rpc.response
clientIdidresulterrorisFinaltraceparent
rpc.response
idresulterrorisFinaltraceparentclientId
must match
  • 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.

Companion → relay: a read-only RPC request
{
  "type": "rpc.request",
  "payload": {
    "id": "request-101",
    "method": "system.ping",
    "params": {},
    "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
  }
}
Desktop → relay: response envelope shape; result content is method-specific
{
  "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

Submission shape — replace identities with a real session; timestamps are illustrative
{
  "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 decisionCode 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 paramsRejected 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

What a repeated mutation gets depends on when it arrives
What a repeated mutation gets depends on when it arrivesThe first copy claims its key in memory and in SQLite. A repeat that arrives while it runs waits for its reply, one that arrives later gets the stored reply, and 48 hours after the reply was stored the key is forgotten and a repeat runs again. If the first copy is cut off, its SQLite row stays running, so repeats get busy until the 5-minute lease ends and an ambiguous reply after that.
First copyfinishes
First copyis cut off
first copy runs
reply kept
a repeat arrives
waits, gets the same reply
stored reply, nothing runs
key forgotten, runs again
runs
reconnect or restart
row still running
busy, nothing runs
ambiguous, never runs
claim
reply stored
claim + 5 min
stored + 48 h
not to scale
replay-safe methods run again instead
same key, other params → conflict · a new key at 4,096 rows → capacity
  • 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.