Skip to article
HB CodeDocsGet the app

HandbookRelay & accounts

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

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.json69 methods · 24 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 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.

NamespaceExamples
system, 13 methodssystem.ping, system.codexAuthProfiles, system.claudeAccounts, system.activateCodexAuthProfile, system.activateClaudeAccount, system.agentUsageHistory, system.getVoiceVocabulary, system.getReplyStyle
session, 15 methodssession.listProjects, session.create, session.getHistoryState
run, 23 methodsrun.chatLoad, run.chatLoadPageBefore, run.chatOutboxSubmit, run.chatOutboxStopAndSendNow, run.cancel, run.chatContextUsage
files, 23 methodsfiles.readContent, files.readBinary, files.getGitFileDiffPatch, files.beginChatAttachmentUpload

Four methods serve the reply style, the text improvement, and the context overview. The desktop does all of their work.

MethodParams and result
system.getReplyStyleNo 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.updateReplyStyleMutation. Param: style, ste or default. Saves the one style of all agents and returns the same setting as system.getReplyStyle.
run.improveTextParams: appSessionId, text, and mode, improve or ste. Runs one ephemeral Codex turn on the desktop and returns text. improve uses the text improvement prompt of the server. ste uses the rewrite rules of the reply style.
run.chatContextUsageParam: appSessionId. Returns contextUsage, the last context overview that the agent measured for the session, or null before the first measurement.

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.

LimitValue and method
Relay frame32 MiB, every frame
Sessions per call16, run.chatReconcileSessions
Result size8 MiB, run.chatReconcileSessions
Outbox entries per session256, run.chatReconcileSessions
Raw command output page256 KiB, run.chatCommandOutputRead
Raw binary read16 MiB, files.readBinary
Compare every copy with the manifest from the repository root
pnpm check:rpc-contract

The 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 wireContract
Create a sessionsession.create. agent is required and is codex or claude. conversationId is optional and names a saved conversation of that agent to continue: a Codex thread id or a Claude session id. When a session in the same project already holds that conversation, the desktop returns it and creates nothing.
Load and reconcilerun.chatLoad and run.chatReconcileSessions. The result carries storageFormat. A Codex session answers indexed and a Claude session answers native. The manifest pins both values under timelineStorageFormat.byAgent, and the contract check fails on any other pair.
Send, approve, and stoprun.chatOutboxSubmit, run.chatOutboxStopAndSendNow, run.chatResolveApproval, and run.cancel. Same params for both agents. The session id selects the agent.
Codex accountssystem.codexAuthProfiles, system.activateCodexAuthProfile, and system.codexUsageLimits. Codex only. activateCodexAuthProfile takes profileId and returns the fresh profile list.
Claude accountssystem.claudeAccounts and system.activateClaudeAccount. Claude only. activateClaudeAccount takes accountId and returns the fresh account list.
Usage historysystem.agentUsageHistory. Takes agent. Any value other than codex or claude is an invalid-params error.

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

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.

Phone → relay: a read-only RPC request
{
  "type": "rpc.request",
  "payload": {
    "id": "request-101",
    "method": "system.ping",
    "params": {},
    "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
  }
}
Desktop → relay: the system.ping reply
{
  "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

The desktop answers before the phone stops waiting, except for an outbox submit from iOS
The desktop answers before the phone stops waiting, except for an outbox submit from iOSThree requests on one time axis in seconds, each with a desktop, a phone, and a relay lane. For an older timeline page the desktop answers within 58 seconds, the phone waits 60, and the relay keeps its pending record 90. For the initial timeline load the numbers are 108, 115, and 120. For an outbox submit the iOS app stops after 20 seconds, before the desktop limit of 58. The Android app waits 60, and the relay keeps the record 31 minutes.
An older timeline pagerun.chatLoadPageBefore
Desktop
Phone
Relay
8 s
dispatch, at most 45 s
answer by 58 s
waits 60 s
pending record 90 s
The initial timeline loadrun.chatLoad
Desktop
Phone
Relay
8 s
Codex load deadline 90 s
by 108 s
waits 115 s
pending record 120 s
An outbox submitrun.chatOutboxSubmit
Desktop
iOS
Android
waits 60 s
Relay
8 s
dispatch, at most 45 s
answer by 58 s
waits 20 s
pending record, 31 min
stops first, a repeat reuses the key
0 s
20 s
40 s
60 s
80 s
100 s
120 s
wait for a permit
queue the reply, 5 s
  • 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 groupMethods
Timelinerun.chatLoad, run.chatLoadPageBefore, run.chatLoadThread, run.chatLoadThreadPageBefore, run.chatReconcileSessions, run.chatPreviewExistingSession
Files and mediafiles.getPreviewMetadata, files.readBinary, files.readMediaRendition, files.prepareMediaStream, files.readMediaRange
Attachmentsfiles.saveChatAttachment and the four chunked upload methods
Git and webfiles.getGitDiffDetail, files.getGitFileDiffPatch, run.chatFetchWebPageMarkdownPreview
Desktop stageLimitReply when it ends
Wait for a permit8 seconds-32603 with the text “Desktop is busy; retry the request shortly.”
Dispatch45 seconds, or 95 seconds for run.chatLoad and run.chatLoadThread-32603 with the text “Desktop request timed out.”
Send the reply to the socket queue5 secondsNone. 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

Submission shape. Replace the identities with a real session. The timestamps are examples.
{
  "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 decisionCode 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.request with a request ID of at most 128 bytes and a valid traceparent, from a separate token bucket of 300 burst and 150 per second. Every other frame over the limit gets no answer.
Method absent from the compiled allow-list-32601 method-not-found.
API-key connection lacks a scope-32027 with requiredScopes, retryable false. An HB Code JWT always carries rpc, read, and write, so a phone never sees this.
Mutation without a keyidempotencyKey is absent. -32026, reason protocolUpdateRequired, retryable false.
Malformed trace context or paramsMalformed trace context and malformed session.syncHistoryState params are 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 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.chatLoad and run.chatLoadThread, or after 31 minutes for run.chatOutboxSubmit. 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
four 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. 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.