--- summary: "Gateway WebSocket protocol: handshake, frames, versioning" read_when: - Implementing or updating gateway WS clients - Debugging protocol mismatches or connect failures - Regenerating protocol schema/models title: "Gateway protocol" --- The Gateway WS protocol is the single control plane and node transport for OpenClaw. Every client (CLI, web UI, macOS app, iOS/Android nodes, headless nodes) connects over WebSocket and declares a **role** and **scope** at handshake time. ## Transport and framing - WebSocket, text frames, JSON payloads. - First frame **must** be a `connect` request. - Pre-connect frames are capped at 64 KiB (`MAX_PREAUTH_PAYLOAD_BYTES`). After handshake, follow `hello-ok.policy.maxPayload` and `hello-ok.policy.maxBufferedBytes`. With diagnostics enabled, oversized inbound frames and slow outbound buffers emit `payload.large` events before the gateway closes or drops the frame. These events carry `surface`, byte sizes, limits, and a safe reason code, never message bodies, attachment contents, raw frame bytes, tokens, cookies, or secrets. Frame shapes: - Request: `{type:"req", id, method, params}` - Response: `{type:"res", id, ok, payload|error}` - Event: `{type:"event", event, payload, seq?, stateVersion?}` Side-effecting methods require idempotency keys (see schema). ## Handshake Gateway sends a pre-connect challenge: ```json { "type": "event", "event": "connect.challenge", "payload": { "nonce": "…", "ts": 1737264000000 } } ``` Client replies with `connect`: ```json { "type": "req", "id": "…", "method": "connect", "params": { "minProtocol": 4, "maxProtocol": 4, "client": { "id": "cli", "version": "1.2.3", "platform": "macos", "mode": "operator" }, "role": "operator", "scopes": ["operator.read", "operator.write"], "caps": [], "commands": [], "permissions": {}, "auth": { "token": "…" }, "locale": "en-US", "userAgent": "openclaw-cli/1.2.3", "device": { "id": "device_fingerprint", "publicKey": "…", "signature": "…", "signedAt": 1737264000000, "nonce": "…" } } } ``` Gateway responds with `hello-ok`: ```json { "type": "res", "id": "…", "ok": true, "payload": { "type": "hello-ok", "protocol": 4, "server": { "version": "…", "connId": "…" }, "features": { "methods": ["…"], "events": ["…"] }, "snapshot": { "…": "…" }, "auth": { "role": "operator", "scopes": ["operator.read", "operator.write"] }, "policy": { "maxPayload": 26214400, "maxBufferedBytes": 52428800, "tickIntervalMs": 15000 } } } ``` `server`, `features`, `snapshot`, `policy`, and `auth` are all required by `HelloOkSchema` (`packages/gateway-protocol/src/schema/frames.ts`). `auth` reports the negotiated role/scopes even when no device token is issued (shape above). `pluginSurfaceUrls` is optional and maps plugin surface names (e.g. `canvas`) to scoped hosted URLs; it may expire, so nodes call `node.pluginSurface.refresh` with `{ "surface": "canvas" }` for a fresh entry. The deprecated `canvasHostUrl` / `canvasCapability` / `node.canvas.capability.refresh` path is not supported; use plugin surfaces. While the gateway is still finishing startup sidecars, `connect` can return a retryable `UNAVAILABLE` error with `details.reason: "startup-sidecars"` and `retryAfterMs`. Retry within your connection budget instead of treating it as a terminal handshake failure. When a device token is issued, `hello-ok.auth` adds it: ```json { "auth": { "deviceToken": "…", "role": "operator", "scopes": ["operator.read", "operator.write"] } } ``` Built-in QR/setup-code bootstrap is a mobile handoff path. A successful baseline setup-code connect returns a primary node token plus one bounded operator token: ```json { "auth": { "deviceToken": "…", "role": "node", "scopes": [], "deviceTokens": [ { "deviceToken": "…", "role": "operator", "scopes": ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"] } ] } } ``` This operator handoff is bounded on purpose: enough to start the mobile operator loop and native setup, including `operator.talk.secrets` for Talk config reads, but no pairing-mutation scopes and no `operator.admin`. Broader pairing/admin access needs a separate approved pairing or token flow. Persist `hello-ok.auth.deviceTokens` only when bootstrap auth ran over a trusted transport (`wss://` or loopback/local pairing). Trusted same-process backend clients (`client.id: "gateway-client"`, `client.mode: "backend"`) may omit `device` on direct loopback connections when authenticating with the shared gateway token/password. This path is reserved for internal control-plane RPCs (e.g. subagent session updates) and avoids stale CLI/device pairing baselines blocking local backend work. Remote, browser-origin, node, and explicit device-token/device-identity clients still go through normal pairing and scope-upgrade checks. ### Node connect example ```json { "type": "req", "id": "…", "method": "connect", "params": { "minProtocol": 4, "maxProtocol": 4, "client": { "id": "ios-node", "version": "1.2.3", "platform": "ios", "mode": "node" }, "role": "node", "scopes": [], "caps": ["camera", "canvas", "screen", "location", "voice"], "commands": ["camera.snap", "canvas.navigate", "screen.record", "location.get"], "permissions": { "camera.capture": true, "screen.record": false }, "auth": { "token": "…" }, "locale": "en-US", "userAgent": "openclaw-ios/1.2.3", "device": { "id": "device_fingerprint", "publicKey": "…", "signature": "…", "signedAt": 1737264000000, "nonce": "…" } } } ``` Nodes declare capability claims at connect time: - `caps`: high-level categories such as `camera`, `canvas`, `screen`, `location`, `voice`, `talk`. - `commands`: command allowlist for invoke. - `permissions`: granular toggles (e.g. `screen.record`, `camera.capture`). The gateway treats these as claims and enforces server-side allowlists. ## Roles and scopes For the full operator scope model, approval-time checks, and shared-secret semantics, see [Operator scopes](/gateway/operator-scopes). Roles: - `operator`: control-plane client (CLI/UI/automation). - `node`: capability host (camera/screen/canvas/system.run). Operator scopes (`src/gateway/operator-scopes.ts`), the full closed set: - `operator.read` - `operator.write` - `operator.admin` - `operator.approvals` - `operator.pairing` - `operator.talk.secrets` `talk.config` with `includeSecrets: true` requires `operator.talk.secrets` (or `operator.admin`). When secrets are included, read the active Talk provider credential from `talk.resolved.config.apiKey`; `talk.providers..apiKey` stays source-shaped and may be a SecretRef object or a redacted string. Plugin-registered gateway RPC methods may request their own operator scope, but these reserved core prefixes always resolve to `operator.admin` (`src/shared/gateway-method-policy.ts`): `config.*`, `exec.approvals.*`, `wizard.*`, `update.*`. Method scope is only the first gate. Some slash commands reached through `chat.send` apply stricter command-level checks: persistent `/config set` and `/config unset` writes require `operator.admin` even for gateway clients that already hold a lower operator scope. `node.pair.approve` has an extra approval-time scope check on top of the base method scope (`operator.pairing`), based on the pending request's declared `commands` (`src/infra/node-pairing-authz.ts`): | Declared commands | Required scopes | | -------------------------------------------------------------- | ------------------------------------- | | none | `operator.pairing` | | non-exec commands | `operator.pairing` + `operator.write` | | includes `system.run`, `system.run.prepare`, or `system.which` | `operator.pairing` + `operator.admin` | ## Presence - `system-presence` returns entries keyed by device identity, including `deviceId`, `roles`, and `scopes`, so UIs can show one row per device even when it connects as both operator and node. - `node.list` includes optional `lastSeenAtMs` and `lastSeenReason`. Connected nodes report current connection time with reason `connect`; paired nodes can also report durable background presence via a trusted node event. ### Node background alive event Nodes call `node.event` with `event: "node.presence.alive"` to record that a paired node was alive during a background wake, without marking it connected: ```json { "event": "node.presence.alive", "payloadJSON": "{\"trigger\":\"silent_push\",\"sentAtMs\":1737264000000,\"displayName\":\"Peter's iPhone\",\"version\":\"2026.4.28\",\"platform\":\"iOS 18.4.0\",\"deviceFamily\":\"iPhone\",\"modelIdentifier\":\"iPhone17,1\",\"pushTransport\":\"relay\"}" } ``` `trigger` is a closed enum: `background`, `silent_push`, `bg_app_refresh`, `significant_location`, `manual`, `connect`. Unknown values normalize to `background` (`src/shared/node-presence.ts`). The event only persists for authenticated node device sessions; device-less or unpaired sessions return `handled: false`. Successful gateways return a structured result: ```json { "ok": true, "event": "node.presence.alive", "handled": true, "reason": "persisted" } ``` Older gateways may return only `{ "ok": true }` for `node.event`; treat that as an acknowledged RPC, not durable presence persistence. ## Broadcast event scoping Server-pushed broadcast events are scope-gated so pairing-scoped or node-only sessions do not passively receive session content (`src/gateway/server-broadcast.ts`): - Chat, agent, and tool-result frames (streamed `agent` events, tool-result events) require at least `operator.read`. Sessions without it skip these frames entirely. - Plugin-defined `plugin.*` broadcasts are gated to `operator.write` or `operator.admin` by default; explicit entries such as `plugin.approval.requested` / `plugin.approval.resolved` use `operator.approvals` instead. - Status/transport events (`heartbeat`, `presence`, `tick`, connect/disconnect lifecycle) stay unrestricted so transport health is observable to every authenticated session. - Unknown broadcast event families are scope-gated by default (fail-closed) unless a registered handler explicitly relaxes them. Each client connection keeps its own per-client sequence number, so broadcasts stay monotonically ordered on that socket even when different clients see different scope-filtered subsets of the event stream. ## RPC method families `hello-ok.features.methods` is a conservative discovery list built from `src/gateway/server-methods-list.ts` plus loaded plugin/channel method exports — it is not a generated dump of every method, and some methods (for example `push.test`, `web.login.start`, `web.login.wait`, `sessions.usage`) are intentionally excluded from discovery even though they are real, callable methods. Treat this as feature discovery, not a full enumeration of `src/gateway/server-methods/*.ts`. - `health` returns the cached or freshly probed gateway health snapshot. - `diagnostics.stability` returns the recent bounded diagnostic stability recorder: event names, counts, byte sizes, memory readings, queue/session state, channel/plugin names, session ids. No chat text, webhook bodies, tool outputs, raw request/response bodies, tokens, cookies, or secrets. Requires `operator.read`. - `status` returns the `/status`-style gateway summary; sensitive fields only for admin-scoped operator clients. - `gateway.identity.get` returns the gateway device identity used by relay and pairing flows. - `system-presence` returns the current presence snapshot for connected operator/node devices. - `system-event` appends a system event and can update/broadcast presence context. - `last-heartbeat` returns the latest persisted heartbeat event. - `set-heartbeats` toggles heartbeat processing on the gateway. - `models.list` returns the runtime-allowed model catalog. See "`models.list` views" below. - `usage.status` returns provider usage windows/remaining quota summaries. - `usage.cost` returns aggregated cost usage summaries for a date range. Pass `agentId` for one agent, or `agentScope: "all"` to aggregate configured agents. - `doctor.memory.status` returns vector-memory / cached embedding readiness for the active default agent workspace. Pass `{ "probe": true }` or `{ "deep": true }` only for an explicit live embedding provider ping. Pass `{ "agentId": "agent-id" }` to scope Dreaming store stats to one agent workspace; omitting it aggregates configured Dreaming workspaces. - `doctor.memory.dreamDiary`, `doctor.memory.backfillDreamDiary`, `doctor.memory.resetDreamDiary`, `doctor.memory.resetGroundedShortTerm`, `doctor.memory.repairDreamingArtifacts`, and `doctor.memory.dedupeDreamDiary` accept optional `{ "agentId": "agent-id" }`; omitted, they operate on the configured default agent workspace. - `doctor.memory.remHarness` returns a bounded, read-only REM harness preview for remote control-plane clients, including workspace paths, memory snippets, rendered grounded markdown, and deep promotion candidates. Requires `operator.read`. - `sessions.usage` returns per-session usage summaries. Pass `agentId` for one agent, or `agentScope: "all"` to list configured agents together. - `sessions.usage.timeseries` returns timeseries usage for one session. - `sessions.usage.logs` returns usage log entries for one session. - `channels.status` returns built-in + bundled channel/plugin status summaries. - `channels.logout` logs out a specific channel/account where the channel supports it. - `web.login.start` starts a QR/web login flow for the current QR-capable web channel provider. - `web.login.wait` waits for that flow to complete and starts the channel on success. - `push.test` sends a test APNs push to a registered iOS node. - `voicewake.get` returns the stored wake-word triggers. - `voicewake.set` updates wake-word triggers and broadcasts the change. - `send` is the direct outbound-delivery RPC for channel/account/thread-targeted sends outside the chat runner. - `logs.tail` returns the configured gateway file-log tail with cursor/limit and max-byte controls. - `terminal.open` starts a host PTY for an explicit `agentId` or the default agent and returns the resolved agent, working directory, shell, and confinement state. - `terminal.input`, `terminal.resize`, and `terminal.close` operate only on sessions owned by the calling connection. - `terminal.data` and `terminal.exit` events stream only to the connection that owns the session. - Sessions whose connection drops are detached, not killed: they stay reattachable for `gateway.terminal.detachedSessionTimeoutSeconds` (default 300; `0` restores kill-on-disconnect) while recent output accumulates in a bounded server-side buffer. - `terminal.list` returns attachable sessions; `terminal.attach` rebinds a live-or-detached session to the calling connection and returns the replay buffer (tmux-style take-over — a previous live owner receives `terminal.exit` with reason `detached`); `terminal.text` reads the buffer as plain text without attaching. - Every terminal method requires `operator.admin`; `gateway.terminal.enabled` must be explicitly true. Fully sandboxed agents are refused, and an agent policy change closes existing and in-flight PTYs, detached ones included. - `talk.catalog` returns the read-only Talk provider catalog for speech, streaming transcription, and realtime voice: canonical provider ids, registry aliases, labels, configured state, an optional group-level `ready` result, exposed model/voice ids, canonical modes, transports, brain strategies, and realtime audio/capability flags, without returning provider secrets or mutating global config. Current gateways set `ready` after applying runtime provider selection; treat its absence as unverified on older gateways. - `talk.config` returns the effective Talk config payload; `includeSecrets` requires `operator.talk.secrets` (or `operator.admin`). - `talk.session.create` creates a gateway-owned Talk session for `realtime/gateway-relay`, `transcription/gateway-relay`, or `stt-tts/managed-room`. For `stt-tts/managed-room`, `operator.write` callers that pass `sessionKey` must also pass `spawnedBy` for scoped session-key visibility; unscoped `sessionKey` creation and `brain: "direct-tools"` require `operator.admin`. - `talk.session.join` validates a managed-room session token, emits `session.ready` or `session.replaced` as needed, and returns room/session metadata plus recent Talk events, never the plaintext token or its hash. - `talk.session.appendAudio` appends base64 PCM input audio to gateway-owned realtime relay and transcription sessions. - `talk.session.startTurn`, `talk.session.endTurn`, and `talk.session.cancelTurn` drive managed-room turn lifecycle with stale-turn rejection before state clears. - `talk.session.cancelOutput` stops assistant audio output, primarily for VAD-gated barge-in in gateway relay sessions. - `talk.session.submitToolResult` completes a provider tool call emitted by a gateway-owned realtime relay session. Pass `options: { willContinue: true }` for interim tool output when a final result follows, or `options: { suppressResponse: true }` when the tool result should satisfy the provider call without starting another realtime response. - `talk.session.steer` sends active-run voice control into a gateway-owned agent-backed Talk session: `{ sessionId, text, mode? }`, where `mode` is `status`, `steer`, `cancel`, or `followup`; omitted mode is classified from the spoken text. - `talk.session.close` closes a gateway-owned relay, transcription, or managed-room session and emits terminal Talk events. - `talk.mode` sets/broadcasts the current Talk mode state for WebChat/Control UI clients. - `talk.client.create` creates a client-owned realtime provider session using `webrtc` or `provider-websocket` while the gateway owns config, credentials, instructions, and tool policy. - `talk.client.toolCall` lets client-owned realtime transports forward provider tool calls to gateway policy. The first supported tool is `openclaw_agent_consult`; clients get a run id and wait for normal chat lifecycle events before submitting the provider-specific tool result. - `talk.client.steer` sends active-run voice control for client-owned realtime transports. The gateway resolves the active embedded run from `sessionKey` and returns a structured accepted/rejected result instead of silently dropping steering. - `talk.event` is the single Talk event channel for realtime, transcription, STT/TTS, managed-room, telephony, and meeting adapters. - `talk.speak` synthesizes speech through the active Talk speech provider. - `tts.status` returns TTS enabled state, active provider, fallback providers, and provider config state. - `tts.providers` returns the visible TTS provider inventory. - `tts.enable` and `tts.disable` toggle TTS prefs state. - `tts.setProvider` updates the preferred TTS provider. - `tts.convert` runs one-shot text-to-speech conversion. - `tts.speak` (`operator.write`) renders non-empty `text` with the configured general TTS provider chain and returns one whole clip inline as `audioBase64`, plus `provider` and optional `outputFormat`, `mimeType`, and `fileExtension` metadata. Unlike `tts.convert`, it does not return a Gateway-local path; unlike `talk.speak`, it does not require a Talk provider. Text above `messages.tts.maxTextLength` returns `INVALID_REQUEST`; synthesis failures return `UNAVAILABLE`. - `secrets.reload` re-resolves active SecretRefs and swaps runtime secret state only on full success. - `secrets.resolve` resolves command-target secret assignments for a specific command/target set. - `config.get` returns the current config snapshot and hash. - `config.set` writes a validated config payload. - `config.patch` merges a partial config update. Destructive array replacement requires the affected path in `replacePaths`; nested arrays under array entries use `[]` paths such as `agents.list[].skills`. - `config.apply` validates + replaces the full config payload. - `config.schema` returns the live config schema payload used by Control UI and CLI tooling: schema, `uiHints`, version, generation metadata, plugin + channel schema metadata when loadable. It includes `title` / `description` metadata from the same labels/help text as the UI, including nested object, wildcard, array-item, and `anyOf` / `oneOf` / `allOf` composition branches when matching field documentation exists. - `config.schema.lookup` returns a path-scoped lookup payload for one config path: normalized path, a shallow schema node, matched hint + `hintPath`, optional `reloadKind`, and immediate child summaries for UI/CLI drill-down. `reloadKind` is one of `restart`, `hot`, or `none` (`src/config/schema.ts`) and mirrors the gateway config reload planner for the requested path. Lookup schema nodes keep the user-facing docs and common validation fields (`title`, `description`, `type`, `enum`, `const`, `format`, `pattern`, numeric/string/array/object bounds, `additionalProperties`, `deprecated`, `readOnly`, `writeOnly`). Child summaries expose `key`, normalized `path`, `type`, `required`, `hasChildren`, optional `reloadKind`, plus the matched `hint` / `hintPath`. - `update.run` runs the gateway update flow and schedules a restart only if the update succeeded; callers with a session can include `continuationMessage` so startup resumes one follow-up agent turn through the restart continuation queue. Package-manager updates and supervised git-checkout updates from the control plane use a detached managed-service handoff instead of replacing the package tree or mutating checkout/build output inside the live gateway. A started handoff returns `ok: true` with `result.reason: "managed-service-handoff-started"` and `handoff.status: "started"`; unavailable or failed handoffs return `ok: false` with `managed-service-handoff-unavailable` or `managed-service-handoff-failed`, plus `handoff.command` when a manual shell update is required. Unavailable means OpenClaw lacks a safe supervisor boundary or durable service identity, such as `OPENCLAW_SYSTEMD_UNIT` for systemd. During a started handoff, the restart sentinel may briefly report `stats.reason: "restart-health-pending"`; the continuation is delayed until the CLI verifies the restarted gateway and writes the final `ok` sentinel. - `update.status` refreshes and returns the latest update restart sentinel, including the post-restart running version when available. - `wizard.start`, `wizard.next`, `wizard.status`, and `wizard.cancel` expose the onboarding wizard over WS RPC. - `agents.list` returns configured agent entries, including effective model and runtime metadata. - `agents.create`, `agents.update`, and `agents.delete` manage agent records and workspace wiring. - `agents.files.list`, `agents.files.get`, and `agents.files.set` manage the bootstrap workspace files exposed for an agent. - `audit.list` returns a bounded metadata-only ledger of agent run and tool action events. - `agents.workspace.list` and `agents.workspace.get` (`operator.read`) expose read-only, paginated browsing of an agent's workspace directory for clients in the trusted operator domain described in [Operator scopes](/gateway/operator-scopes). Requests accept workspace-relative paths only; reads stay confined to the realpathed workspace root (symlink and hardlink escapes rejected), size-capped, and limited to UTF-8 text plus common image types (base64). Responses do not expose the host workspace path. There are no write operations in this namespace. - `tasks.list`, `tasks.get`, and `tasks.cancel` expose the gateway task ledger to SDK and operator clients. See [Task ledger RPCs](#task-ledger-rpcs) below. - `artifacts.list`, `artifacts.get`, and `artifacts.download` expose transcript-derived artifact summaries and downloads for an explicit `sessionKey`, `runId`, or `taskId` scope. Run and task queries resolve the owning session server-side and only return transcript media with matching provenance; unsafe or local URL sources return unsupported downloads instead of fetching server-side. - `environments.list` and `environments.status` expose read-only gateway-local and node environment discovery for SDK clients. - `agent.identity.get` returns the effective assistant identity for an agent or session. - `agent.wait` waits for a run to finish and returns the terminal snapshot when available. - `sessions.list` returns the current session index, including per-row `agentRuntime` metadata when an agent runtime backend is configured. - `sessions.subscribe` and `sessions.unsubscribe` toggle session change event subscriptions for the current WS client. - `sessions.messages.subscribe` and `sessions.messages.unsubscribe` toggle transcript/message event subscriptions for one session. - `sessions.preview` returns bounded transcript previews for specific session keys. - `sessions.describe` returns one gateway session row for an exact session key. - `sessions.resolve` resolves or canonicalizes a session target. - `sessions.create` creates a new session entry. - `sessions.send` sends a message into an existing session. - `sessions.steer` is the interrupt-and-steer variant for an active session. - `sessions.abort` aborts active work for a session. Pass `key` plus optional `runId`, or `runId` alone for active runs the gateway can resolve to a session. - `sessions.patch` updates session metadata/overrides and reports the resolved canonical model plus effective `agentRuntime`. - `sessions.reset`, `sessions.delete`, and `sessions.compact` perform session maintenance. - `sessions.get` returns the full stored session row. - Chat execution still uses `chat.history`, `chat.send`, `chat.abort`, and `chat.inject`. `chat.history` is display-normalized for UI clients: inline directive tags are stripped from visible text, plain-text tool-call XML payloads (`...`, `...`, `...`, `...`, and truncated tool-call blocks) and leaked ASCII/full-width model control tokens are stripped, pure silent-token assistant rows (exact `NO_REPLY` / `no_reply`) are omitted, and oversized rows can be replaced with placeholders. - `chat.message.get` is the additive bounded full-message reader for a single visible transcript entry. Pass `sessionKey`, optional `agentId` when session selection is agent-scoped, and a transcript `messageId` previously surfaced through `chat.history`; the gateway returns the same display-normalized projection without the lightweight history truncation cap when the stored entry is still available and not oversized. - `chat.send` accepts one-turn `fastMode: "auto"` to use fast mode for model calls started before the auto cutoff, then start later retry, fallback, tool-result, or continuation calls without fast mode. The cutoff defaults to 60 seconds (`DEFAULT_FAST_MODE_AUTO_ON_SECONDS`) and can be configured per model with `agents.defaults.models["/"].params.fastAutoOnSeconds`. A `chat.send` caller can pass one-turn `fastAutoOnSeconds` to override the cutoff for that request. - `device.pair.list` returns pending and approved paired devices. - `device.pair.setupCode` creates a mobile setup code and, by default, a PNG QR data URL. It requires `operator.admin` and is intentionally omitted from advertised discovery. The result includes `setupCode`, optional `qrDataUrl`, `gatewayUrl`, the non-secret `auth` label, and `urlSource`. - `device.pair.approve`, `device.pair.reject`, and `device.pair.remove` manage device-pairing records. - `device.token.rotate` rotates a paired device token within its approved role and caller scope bounds. - `device.token.revoke` revokes a paired device token within its approved role and caller scope bounds. The setup code embeds a short-lived bootstrap credential. Clients must not log or persist it beyond the pairing flow. - `node.pair.request`, `node.pair.list`, `node.pair.approve`, `node.pair.reject`, `node.pair.remove`, and `node.pair.verify` cover node pairing and bootstrap verification. - `node.list` and `node.describe` return known/connected node state. - `node.rename` updates a paired node label. - `node.invoke` forwards a command to a connected node. - `node.invoke.result` returns the result for an invoke request. - `node.event` carries node-originated events back into the gateway. - `node.pending.pull` and `node.pending.ack` are the connected-node queue APIs. - `node.pending.enqueue` and `node.pending.drain` manage durable pending work for offline/disconnected nodes. - `exec.approval.request`, `exec.approval.get`, `exec.approval.list`, and `exec.approval.resolve` cover one-shot exec approval requests plus pending approval lookup/replay. - `exec.approval.waitDecision` waits on one pending exec approval and returns the final decision (or `null` on timeout). - `exec.approvals.get` and `exec.approvals.set` manage gateway exec approval policy snapshots. - `exec.approvals.node.get` and `exec.approvals.node.set` manage node-local exec approval policy via node relay commands. - `plugin.approval.request`, `plugin.approval.list`, `plugin.approval.waitDecision`, and `plugin.approval.resolve` cover plugin-defined approval flows. - Automation: `wake` schedules an immediate or next-heartbeat wake text injection; `cron.get`, `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`, `cron.run`, `cron.runs` manage scheduled work. - `cron.run` remains an enqueue-style RPC for manual runs. Clients that need completion semantics should read the returned `runId` and poll `cron.runs`. - `cron.runs` accepts an optional non-empty `runId` filter so clients can follow one queued manual run without racing against other history entries for the same job. - Skills and tools: `commands.list`, `skills.*`, `tools.catalog`, `tools.effective`, `tools.invoke`. See [Operator helper methods](#operator-helper-methods) below. ### Common event families - `chat`: UI chat updates such as `chat.inject` and other transcript-only chat events. In protocol v4, delta payloads carry `deltaText`; `message` remains the cumulative assistant snapshot. Non-prefix replacements set `replace=true` and use `deltaText` as the replacement text. - `session.message`, `session.operation`, `session.tool`: transcript, in-flight session operation, and event-stream updates for a subscribed session. - `sessions.changed`: session index or metadata changed. - `presence`: system presence snapshot updates. - `tick`: periodic keepalive/liveness event. - `health`: gateway health snapshot update. - `heartbeat`: heartbeat event stream update. - `cron`: cron run/job change event. - `shutdown`: gateway shutdown notification. - `node.pair.requested` / `node.pair.resolved`: node pairing lifecycle. - `node.invoke.request`: node invoke request broadcast. - `device.pair.requested` / `device.pair.resolved`: paired-device lifecycle. - `voicewake.changed`: wake-word trigger config changed. - `exec.approval.requested` / `exec.approval.resolved`: exec approval lifecycle. - `plugin.approval.requested` / `plugin.approval.resolved`: plugin approval lifecycle. ### Node helper methods Nodes may call `skills.bins` to fetch the current list of skill executables for auto-allow checks. ## Audit ledger RPC `audit.list` gives operator clients a stable newest-first view of agent run and tool action metadata. It requires `operator.read`. Queries exclude records older than 30 days, and the shared SQLite ledger is capped at 100,000 records. Expired rows are deleted during Gateway startup, hourly maintenance, and later writes. - Params: optional exact `agentId`, `sessionKey`, or `runId`; optional `kind` (`"agent_run"` or `"tool_action"`); optional `status` (`"started"`, `"succeeded"`, `"failed"`, `"cancelled"`, `"timed_out"`, `"blocked"`, or `"unknown"`); optional inclusive `after` / `before` Unix-millisecond bounds; optional `limit` from `1` to `500`; and optional string `cursor` from the preceding page. - Result: `{ "events": AuditEvent[], "nextCursor"?: string }`. Each event includes a stable event id, monotonic ledger sequence, source event sequence, timestamp, actor, agent/session/run provenance, action, status, and a normalized error code when applicable. Tool events may include tool call id and tool name. The `redaction` field is always `"metadata_only"`: the ledger does not store prompts, messages, tool arguments, tool results, command output, or raw error text. Recording is on by default and controlled by [`audit.enabled`](/gateway/configuration-reference#audit); when disabled, `audit.list` keeps serving records written earlier until they expire. Use [`openclaw audit`](/cli/audit) for text queries and bounded JSON exports. ## Task ledger RPCs Operator clients inspect and cancel gateway background task records through the task ledger RPCs (`packages/gateway-protocol/src/schema/tasks.ts`). These return sanitized task summaries, not raw runtime state. - `tasks.list` requires `operator.read`. - Params: optional `status` (`"queued"`, `"running"`, `"completed"`, `"failed"`, `"cancelled"`, or `"timed_out"`) or an array of those statuses, optional `agentId`, optional `sessionKey`, optional `limit` from `1` to `500`, and optional string `cursor`. - Result: `{ "tasks": TaskSummary[], "nextCursor"?: string }`. - `tasks.get` requires `operator.read`. - Params: `{ "taskId": string }`. - Result: `{ "task": TaskSummary }`. - Missing task ids return the gateway not-found error shape. - `tasks.cancel` requires `operator.write`. - Params: `{ "taskId": string, "reason"?: string }`. - Result: `{ "found": boolean, "cancelled": boolean, "reason"?: string, "task"?: TaskSummary }`. - `found` reports whether the ledger had a matching task. `cancelled` reports whether the runtime accepted or recorded cancellation. `TaskSummary` includes `id`, `status`, and optional metadata: `kind`, `runtime`, `title`, `agentId`, `sessionKey`, `childSessionKey`, `ownerKey`, `runId`, `taskId`, `flowId`, `parentTaskId`, `sourceId`, timestamps, progress, terminal summary, and sanitized error text. `agentId` identifies the agent executing the task; `sessionKey` and `ownerKey` preserve requester and control context. ## Operator helper methods - `commands.list` (`operator.read`) fetches the runtime command inventory for an agent. - `agentId` is optional; omit it to read the default agent workspace. - `scope` controls which surface the primary `name` targets: `text` returns the primary text command token without the leading `/`; `native` and the default `both` path return provider-aware native names when available. - `textAliases` carries exact slash aliases such as `/model` and `/m`. - `nativeName` carries the provider-aware native command name when one exists. - `provider` is optional and only affects native naming plus native plugin command availability. - `includeArgs=false` omits serialized argument metadata from the response. - `tools.catalog` (`operator.read`) fetches the runtime tool catalog for an agent. The response includes grouped tools and provenance metadata: - `source`: `core` or `plugin` - `pluginId`: plugin owner when `source="plugin"` - `optional`: whether a plugin tool is optional - `tools.effective` (`operator.read`) fetches the runtime-effective tool inventory for a session. - `sessionKey` is required. - The gateway derives trusted runtime context from the session server-side instead of accepting caller-supplied auth or delivery context. - The response is a session-scoped server-derived projection of the active inventory, including core, plugin, channel, and already-discovered MCP server tools. - `tools.effective` is read-only for MCP: it may project a warm session MCP catalog through the final tool policy, but does not create MCP runtimes, connect transports, or issue `tools/list`. If no matching warm catalog exists, the response may include a notice such as `mcp-not-yet-connected`, `mcp-not-yet-listed`, or `mcp-stale-catalog`. - Effective tool entries use `source="core"`, `source="plugin"`, `source="channel"`, or `source="mcp"`. - `tools.invoke` (`operator.write`) invokes one available tool through the same gateway policy path as `/tools/invoke`. - `name` is required. `args`, `sessionKey`, `agentId`, `confirm`, and `idempotencyKey` are optional. - If both `sessionKey` and `agentId` are present, the resolved session agent must match `agentId`. - Owner-only core wrappers such as `cron`, `gateway`, and `nodes` require owner/admin identity (`operator.admin`) even though `tools.invoke` itself is `operator.write`. - The response is an SDK-facing envelope with `ok`, `toolName`, optional `output`, and typed `error` fields. Approval or policy refusals return `ok:false` in the payload rather than bypassing the gateway tool policy pipeline. - `skills.status` (`operator.read`) fetches the visible skill inventory for an agent. - `agentId` is optional; omit it to read the default agent workspace. - The response includes eligibility, missing requirements, config checks, and sanitized install options without exposing raw secret values. - `skills.search` and `skills.detail` (`operator.read`) return ClawHub discovery metadata. - `skills.upload.begin`, `skills.upload.chunk`, and `skills.upload.commit` (`operator.admin`) stage a private skill archive before installing it. This is a separate admin upload path for trusted clients, not the normal ClawHub skill install flow, and is disabled by default unless `skills.install.allowUploadedArchives` is enabled. - `skills.upload.begin({ kind: "skill-archive", slug, sizeBytes, sha256?, force?, idempotencyKey? })` creates an upload bound to that slug and force value. - `skills.upload.chunk({ uploadId, offset, dataBase64 })` appends bytes at the exact decoded offset. - `skills.upload.commit({ uploadId, sha256? })` verifies the final size and SHA-256. Commit only finalizes the upload; it does not install the skill. - Uploaded skill archives are zip archives containing a `SKILL.md` root. The archive's internal directory name never selects the install target. - `skills.install` (`operator.admin`) has three modes: - ClawHub mode: `{ source: "clawhub", slug, version?, force? }` installs a skill folder into the default agent workspace `skills/` directory. - Upload mode: `{ source: "upload", uploadId, slug, force?, sha256?, timeoutMs? }` installs a committed upload into the default agent workspace `skills/` directory. The slug and force value must match the original `skills.upload.begin` request. Rejected unless `skills.install.allowUploadedArchives` is enabled; the setting does not affect ClawHub installs. - Gateway installer mode: `{ name, installId, timeoutMs? }` runs a declared `metadata.openclaw.install` action on the gateway host. Older clients may still send `dangerouslyForceUnsafeInstall`; this field is deprecated, accepted only for protocol compatibility, and ignored. Use `security.installPolicy` for operator-owned install decisions. - `skills.update` (`operator.admin`) has two modes: - ClawHub mode updates one tracked slug or all tracked ClawHub installs in the default agent workspace. - Config mode patches `skills.entries.` values such as `enabled`, `apiKey`, and `env`. ### `models.list` views `models.list` accepts an optional `view` parameter (`src/agents/model-catalog-visibility.ts`): - Omitted or `"default"`: if `agents.defaults.models` is configured, the response is the allowed catalog, including dynamically discovered models for `provider/*` entries. Otherwise the response is the full gateway catalog. - `"configured"`: picker-sized behavior. If `agents.defaults.models` is configured, it still wins, including provider-scoped discovery for `provider/*` entries. Without an allowlist, the response uses explicit `models.providers..models` entries, falling back to the full catalog only when no configured model rows exist. - `"all"`: full gateway catalog, bypassing `agents.defaults.models`. Use for diagnostics/discovery UIs, not normal model pickers. ## Exec approvals - When an exec request needs approval, the gateway broadcasts `exec.approval.requested`. - Operator clients resolve by calling `exec.approval.resolve` (requires `operator.approvals`). - For `host=node`, `exec.approval.request` must include `systemRunPlan` (canonical `argv`/`cwd`/`rawCommand`/session metadata). Requests missing `systemRunPlan` are rejected. - After approval, forwarded `node.invoke system.run` calls reuse that canonical `systemRunPlan` as the authoritative command/cwd/session context. - If a caller mutates `command`, `rawCommand`, `cwd`, `agentId`, or `sessionKey` between prepare and the final approved `system.run` forward, the gateway rejects the run instead of trusting the mutated payload. ## Agent delivery fallback - `agent` requests can include `deliver=true` to request outbound delivery. - `bestEffortDeliver=false` (the default) keeps strict behavior: unresolved or internal-only delivery targets return `INVALID_REQUEST`. - `bestEffortDeliver=true` allows fallback to session-only execution when no external deliverable route can be resolved (for example internal/webchat sessions or ambiguous multi-channel configs). - Final `agent` results may include `result.deliveryStatus` when delivery was requested, using the same `sent`, `suppressed`, `partial_failed`, and `failed` statuses documented for [`openclaw agent --json --deliver`](/cli/agent#json-delivery-status). ## Versioning - `PROTOCOL_VERSION`, `MIN_CLIENT_PROTOCOL_VERSION`, `MIN_NODE_PROTOCOL_VERSION`, and `MIN_PROBE_PROTOCOL_VERSION` live in `packages/gateway-protocol/src/version.ts`. - Clients send `minProtocol` + `maxProtocol`. Operator and UI clients must include the current protocol in that range; current clients and servers run protocol v4. - Authenticated clients with both `role: "node"` and `client.mode: "node"` may use the N-1 node protocol (currently v3). Lightweight restart probes use the same N-1 window. Device auth, pairing, scopes, command policy, and exec approvals are unchanged by this compatibility window. Plugin-owned node capabilities and commands are withheld until the node upgrades to the current protocol because their hosted surfaces are not part of the N-1 contract. - Schemas and models are generated from TypeBox definitions: - `pnpm protocol:gen` - `pnpm protocol:gen:swift` - `pnpm protocol:check` ### Client constants The reference client implementation lives in `packages/gateway-client/src/` (OpenClaw wraps it via the thin `src/gateway/client.ts` facade). These defaults are stable across protocol v4 and are the expected baseline for third-party clients. | Constant | Default | Source | | ----------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `PROTOCOL_VERSION` | `4` | `packages/gateway-protocol/src/version.ts` | | `MIN_CLIENT_PROTOCOL_VERSION` | `4` | `packages/gateway-protocol/src/version.ts` | | `MIN_NODE_PROTOCOL_VERSION` | `3` | `packages/gateway-protocol/src/version.ts` | | `MIN_PROBE_PROTOCOL_VERSION` | `3` | `packages/gateway-protocol/src/version.ts` | | Request timeout (per RPC) | `30_000` ms | `packages/gateway-client/src/client.ts` (`requestTimeoutMs`) | | Preauth / connect-challenge timeout | `15_000` ms | `packages/gateway-client/src/timeouts.ts` (`OPENCLAW_HANDSHAKE_TIMEOUT_MS` env can raise the paired server/client budget) | | Initial reconnect backoff | `1_000` ms | `packages/gateway-client/src/client.ts` (`backoffMs`) | | Max reconnect backoff | `30_000` ms | `packages/gateway-client/src/client.ts` (`scheduleReconnect`) | | Fast-retry clamp after device-token close | `250` ms | `packages/gateway-client/src/client.ts` | | Force-stop grace before `terminate()` | `250` ms | `FORCE_STOP_TERMINATE_GRACE_MS` | | `stopAndWait()` default timeout | `1_000` ms | `STOP_AND_WAIT_TIMEOUT_MS` | | Default tick interval (pre `hello-ok`) | `30_000` ms | `packages/gateway-client/src/client.ts` | | Tick-timeout close | code `4000` when silence exceeds `tickIntervalMs * 2` | `packages/gateway-client/src/client.ts` | | `MAX_PAYLOAD_BYTES` | `25 * 1024 * 1024` (25 MB) | `src/gateway/server-constants.ts` | The server advertises the effective `policy.tickIntervalMs`, `policy.maxPayload`, and `policy.maxBufferedBytes` in `hello-ok`; clients should honor those values rather than the pre-handshake defaults. ## Auth - Shared-secret gateway auth uses `connect.params.auth.token` or `connect.params.auth.password`, depending on the configured `gateway.auth.mode` (`"none" | "token" | "password" | "trusted-proxy"`). - Identity-bearing modes such as Tailscale Serve (`gateway.auth.allowTailscale: true`) or non-loopback `gateway.auth.mode: "trusted-proxy"` satisfy the connect auth check from request headers instead of `connect.params.auth.*`. - Private-ingress `gateway.auth.mode: "none"` skips shared-secret connect auth entirely; do not expose that mode on public/untrusted ingress. - After pairing, the gateway issues a device token scoped to the connection role + scopes, returned in `hello-ok.auth.deviceToken`. Clients should persist it after any successful connect. - Reconnecting with that stored device token should also reuse the stored approved scope set for that token. This preserves read/probe/status access already granted and avoids silently collapsing reconnects to a narrower implicit admin-only scope. - Client-side connect auth assembly (`selectConnectAuth` in `packages/gateway-client/src/client.ts`): - `auth.password` is orthogonal and always forwarded when set. - `auth.token` is populated in priority order: explicit shared token first, then an explicit `deviceToken`, then a stored per-device token (keyed by `deviceId` + `role`). - `auth.bootstrapToken` is sent only when none of the above resolved `auth.token`. A shared token or any resolved device token suppresses it. - Auto-promotion of a stored device token on the one-shot `AUTH_TOKEN_MISMATCH` retry is gated to trusted endpoints only: loopback, or `wss://` with a pinned `tlsFingerprint`. Public `wss://` without pinning does not qualify. - Built-in setup-code bootstrap returns the primary node `hello-ok.auth.deviceToken` plus a bounded operator token in `hello-ok.auth.deviceTokens` for trusted mobile handoff. The operator token includes `operator.talk.secrets` for native Talk configuration reads, but excludes pairing-mutation scopes and `operator.admin`. - While a non-baseline setup-code bootstrap waits for approval, `PAIRING_REQUIRED` details include `recommendedNextStep: "wait_then_retry"`, `retryable: true`, and `pauseReconnect: false`. Keep reconnecting with the same bootstrap token until the request is approved or the token becomes invalid. - Persist `hello-ok.auth.deviceTokens` only when the connect used bootstrap auth on a trusted transport such as `wss://` or loopback/local pairing. - If a client supplies an explicit `deviceToken` or explicit `scopes`, that caller-requested scope set remains authoritative; cached scopes are only reused when the client is reusing the stored per-device token. - Device tokens can be rotated/revoked via `device.token.rotate` and `device.token.revoke` (requires `operator.pairing`). Rotating or revoking a node or other non-operator role also requires `operator.admin`. - `device.token.rotate` returns rotation metadata. It echoes the replacement bearer token only for same-device calls already authenticated with that device token, so token-only clients can persist their replacement before reconnecting. Shared/admin rotations do not echo the bearer token. - Token issuance, rotation, and revocation stay bounded to the approved role set recorded in that device's pairing entry; token mutation cannot expand or target a device role that pairing approval never granted. - For paired-device token sessions, device management is self-scoped unless the caller also has `operator.admin`: non-admin callers can manage only the operator token for their own device entry. Node and other non-operator token management is admin-only, even for the caller's own device. - `device.token.rotate` and `device.token.revoke` also check the target operator token scope set against the caller's current session scopes. Non-admin callers cannot rotate or revoke a broader operator token than they already hold. - Auth failures include `error.details.code` plus recovery hints: - `error.details.canRetryWithDeviceToken` (boolean) - `error.details.recommendedNextStep`: one of `retry_with_device_token`, `update_auth_configuration`, `update_auth_credentials`, `wait_then_retry`, `review_auth_configuration` (`packages/gateway-protocol/src/connect-error-details.ts`). - Client behavior for `AUTH_TOKEN_MISMATCH`: - Trusted clients may attempt one bounded retry with a cached per-device token. - If that retry fails, stop automatic reconnect loops and surface operator action guidance. - `AUTH_SCOPE_MISMATCH` means the device token was recognized but does not cover the requested role/scopes. Do not present this as a bad token; prompt the operator to re-pair or approve the narrower/broader scope contract. ## Device identity and pairing - Nodes should include a stable device identity (`device.id`) derived from a keypair fingerprint. - Gateways issue tokens per device + role. - Pairing approvals are required for new device IDs unless local auto-approval is enabled. - Pairing auto-approval is centered on direct local loopback connects. - OpenClaw also has a narrow backend/container-local self-connect path for trusted shared-secret helper flows. - Same-host tailnet or LAN connects are still treated as remote for pairing and require approval. - WS clients normally include `device` identity during `connect` (operator + node). The only device-less operator exceptions are explicit trust paths: - `gateway.controlUi.allowInsecureAuth=true` for localhost-only insecure HTTP compatibility. - successful `gateway.auth.mode: "trusted-proxy"` operator Control UI auth. - `gateway.controlUi.dangerouslyDisableDeviceAuth=true` (break-glass, severe security downgrade). - direct-loopback `gateway-client` backend RPCs on the reserved internal helper path. - Omitting device identity has scope consequences. When a device-less operator connection is allowed through an explicit trust path, OpenClaw still clears self-declared scopes to an empty set unless that path has a named scope-preservation exception. Scope-gated methods then fail with `missing scope`. - `gateway.controlUi.dangerouslyDisableDeviceAuth=true` is a Control UI break-glass scope-preservation path. It does not grant scopes to arbitrary custom backend or CLI-shaped WebSocket clients. - The reserved direct-loopback `gateway-client` backend helper path preserves scopes only for internal local control-plane RPCs; custom backend IDs do not receive this exception. - All connections must sign the server-provided `connect.challenge` nonce. ### Device auth migration diagnostics For legacy clients that still use pre-challenge signing behavior, `connect` returns `DEVICE_AUTH_*` detail codes under `error.details.code` with a stable `error.details.reason`. Common migration failures: | Message | details.code | details.reason | Meaning | | --------------------------- | -------------------------------- | ------------------------ | -------------------------------------------------- | | `device nonce required` | `DEVICE_AUTH_NONCE_REQUIRED` | `device-nonce-missing` | Client omitted `device.nonce` (or sent blank). | | `device nonce mismatch` | `DEVICE_AUTH_NONCE_MISMATCH` | `device-nonce-mismatch` | Client signed with a stale/wrong nonce. | | `device signature invalid` | `DEVICE_AUTH_SIGNATURE_INVALID` | `device-signature` | Signature payload does not match v2 payload. | | `device signature expired` | `DEVICE_AUTH_SIGNATURE_EXPIRED` | `device-signature-stale` | Signed timestamp is outside allowed skew. | | `device identity mismatch` | `DEVICE_AUTH_DEVICE_ID_MISMATCH` | `device-id-mismatch` | `device.id` does not match public key fingerprint. | | `device public key invalid` | `DEVICE_AUTH_PUBLIC_KEY_INVALID` | `device-public-key` | Public key format/canonicalization failed. | Migration target: - Always wait for `connect.challenge`. - Sign the v2 payload that includes the server nonce. - Send the same nonce in `connect.params.device.nonce`. - Preferred signature payload is `v3` (`buildDeviceAuthPayloadV3` in `packages/gateway-client/src/device-auth.ts`), which binds `platform` and `deviceFamily` in addition to device/client/role/scopes/token/nonce fields. - Legacy `v2` signatures remain accepted for compatibility, but paired-device metadata pinning still controls command policy on reconnect. ## TLS and pinning - TLS is supported for WS connections (`gateway.tls` config). - Clients may optionally pin the gateway cert fingerprint via `gateway.remote.tlsFingerprint` or CLI `--tls-fingerprint`. ## Scope This protocol exposes the full gateway API: status, channels, models, chat, agent, sessions, nodes, approvals, and more. The exact surface is defined by the TypeBox schemas re-exported from `packages/gateway-protocol/src/schema.ts`. ## Related - [Bridge protocol](/gateway/bridge-protocol) - [Gateway runbook](/gateway)