Gateway WebSocket Protocol: Handshake, Frames, Versioning

Learn the OpenClaw Gateway WebSocket protocol, including handshake, frames, and versioning. Essential for developers building clients or embedding the Gateway.

Read this when

  • Implementing or updating gateway WS clients
  • Debugging protocol mismatches or connect failures
  • Regenerating protocol schema/models

The Gateway WS protocol serves as the single control plane and node transport for OpenClaw. Operator and node clients (CLI, web UI, macOS app, iOS/Android nodes, headless nodes) connect over WebSocket and declare a role and scope at handshake time.

npm packages

These packages ship with OpenClaw release trains. During the initial rollout, npm may return E404 until the first package-bearing release is published.

For application lifecycle guidance, see Building a Gateway client. For apps that supervise the Gateway as a child process, see Embedding OpenClaw.

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, traceparent?}
  • Response: {type:"res", id, ok, payload|error}
  • Event: {type:"event", event, payload, seq?, stateVersion?}

After authentication, a client may include a W3C traceparent string on each request frame. The Gateway continues a valid value as a child trace context for that request. Missing or syntactically malformed values within the 128-character field limit keep the default fresh request trace and do not fail the RPC; longer values make the request frame invalid. The initial connect request never establishes trace context for later frames. Use a separate traceparent for each logical request on a long-lived connection; do not treat the WebSocket itself as one trace.

Response errors use { code, message, details?, retryable?, retryAfterMs? }. Clients should branch on code and details.code; message remains human-readable and can change except where a compatibility note says otherwise. Method-level authorization failures use top-level code: "FORBIDDEN" with structured missing-scope details:

  • Missing scope: { code: "MISSING_SCOPE", missingScope, requiredScopes }. requiredScopes is the complete known scope set for the requested operation. The legacy missing scope: <scope> message is retained for older clients.

Clients should read details first and use the legacy message only as a compatibility fallback. readMissingScopeError and readMissingScopeErrorDetails are exported from @openclaw/gateway-protocol/gateway-error-details; the browser-safe gateway client re-exports them from @openclaw/gateway-client/browser.

The schemas are exported as GatewayErrorDetailsSchema, MissingScopeErrorDetailsSchema from @openclaw/gateway-protocol/schema. HTTP scope failures mirror the MISSING_SCOPE object under error.details and use HTTP status 403.

Side-effecting methods require idempotency keys (see schema).

Gateway-controlled WebRTC Talk

talk.client.create accepts the additive capability gateway-control-v1. It is currently available only for OpenAI GA Realtime sessions with resolvable Platform API-key authentication. A successful result includes clientControl: { owner: "gateway" }, a 60-second single-use Gateway broker token in clientSecret, and the relative offerUrl: "/plugins/openai/realtime/calls".

The client sends only application/sdp to that route with the broker token. It must not create a provider control data channel. The Gateway creates the call, attaches the provider sideband before returning the answer SDP, and owns tool, transcript, steering, cancellation, and close lifecycle. Clients that omit the capability retain the existing browser session behavior. A Gateway or configured authentication path that cannot provide the requested owner returns UNAVAILABLE; it never downgrades the request to client-owned control.

Handshake

Gateway sends a pre-connect challenge:

{
  "type": "event",
  "event": "connect.challenge",
  "payload": { "nonce": "…", "ts": 1737264000000 }
}

Device-auth clients use the challenge ts as connect.params.device.signedAt. For WebSocket challenges, ts must be a non-negative integer. Clients that explicitly support Gateways from before connect.challenge existed may use local time only when no challenge arrives; a received challenge with an absent or malformed ts is invalid.

Client replies with connect:

{
  "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 replies with hello-ok:

{
  "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,
      "attachments": { "maxBytes": 20971520, "maxImageBytes": 6291456 }
    }
  }
}

For HelloOkSchema (packages/gateway-protocol/src/schema/frames.ts), the fields server, features, snapshot, policy, and auth are all mandatory. When no device token gets issued, auth still indicates the agreed-upon role and the effective authorization scopes for the current socket, following the shape shown above. The primary reusable credential for that same device and role, when it exists, is deviceToken. Optional and absent from older gateways, policy.attachments announces the decoded-size limits that chat attachments face on chat.send, sessions.send, and the initial turns of session creation:

FieldMeaning
maxBytesLargest decoded size accepted for a single attachment (agents.defaults.mediaMaxMb, default 20 MB)
maxImageBytesLargest decoded size accepted for a single image: min(maxBytes, 6 MB agent-hydration cap)

Pre-send validation steps:

  1. Compare every file's decoded size against maxImageBytes for images and maxBytes for all other content.
  2. Serialize the complete request and verify its encoded size against policy.maxPayload. That policy.attachments value caps each attachment individually, not the whole frame: since attachments travel as base64, a 20 MB file becomes roughly 26.7 MB on the wire, already exceeding the default 25 MiB frame limit by itself.
  3. Let the server be the final authority on everything else. Advertised MIME types and per-message behavior are intentionally withheld, as they hinge on the entrypoint, the resolved model, and payload sniffing. A typed rejection may come from the gateway, while text-only model runs can drop extra images after their offload cap and still finish the request.
  4. Fetch these values again after every reconnect. They represent a connection-time snapshot, so a live mediaMaxMb edit only affects existing connections once those connections reconnect.

pluginSurfaceUrls is optional, mapping plugin surface names (such as canvas) to scoped hosted URLs; since it can expire, nodes call node.pluginSurface.refresh with { "surface": "canvas" } to obtain a fresh entry. The legacy canvasHostUrl / canvasCapability / node.canvas.capability.refresh route is unsupported; plugin surfaces are the replacement. sessions.observer.ask has been removed in favor of sessions.companion.ask. The optional appliedConfigHash in the snapshot is the resolved source-config revision the active Gateway runtime accepts. Clients can compare it against config.get.configRevisionHash to see if a newer saved config still requires a restart. config.get.hash stays the raw root-file revision used by config write conflict guards.

While startup sidecars are still being finalized, connect may return a retryable UNAVAILABLE error carrying details.reason: "startup-sidecars" and retryAfterMs. Retry within your connection budget rather than treating it as a terminal handshake failure.

When a device token is issued, hello-ok.auth appends it:

{
  "auth": {
    "deviceToken": "…",
    "role": "operator",
    "scopes": ["operator.read"]
  }
}

The built-in QR/setup-code bootstrap serves as a mobile handoff path. A successful baseline setup-code connect yields a primary node token plus one bounded operator token:

{
  "auth": {
    "deviceToken": "…",
    "role": "node",
    "scopes": [],
    "deviceTokens": [
      {
        "deviceToken": "…",
        "role": "operator",
        "scopes": ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"]
      }
    ]
  }
}

This operator handoff is intentionally limited: it suffices to launch the mobile operator loop and native setup, with operator.write covering Talk sessions and operator.talk.secrets handling Talk config reads, but no pairing-mutation scopes and no operator.admin. Broader pairing or admin access demands 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") can skip device on direct loopback connections when authenticating with the shared gateway token/password. This path is reserved for internal control-plane RPCs (for example, subagent session updates) and prevents stale CLI/device pairing baselines from blocking local backend work. Remote, browser-origin, node, and explicit device-token/device-identity clients still undergo normal pairing and scope-upgrade checks.

Worker role and closed protocol

Workers communicate through a restricted protocol, either over the public /__openclaw__/worker WebSocket path on the main TLS endpoint or via the loopback ingress that the gateway-owned, host-key-pinned SSH tunnel exposes. Before any frames are read, the route picks the worker mode, so general authentication, node events, operator RPCs, and plugin methods are never dispatched. Public admission draws from the same per-client pre-auth budget and authentication rate limiter as the main path; its wire errors reduce credential and environment details to admission-rejected, whereas trusted gateway diagnostics keep the internal reason intact. A strict connect validates a short-lived credential stored as a hash-at-rest and tied to the environment, along with the bundle hash, owner epoch, RPC-set version, expiry, and one nullable session; the current version and feature set get checked separately. A successful result yields minimal worker-hello-ok; feature negotiation operates independently of the general protocol version. Frames are capped at 64 KiB, except a negotiated worker.inference.start frame, which can reach 25 MiB. The closed allowlist consists of worker.heartbeat, worker.transcript.commit, worker.live-event, worker.inference.start, and worker.inference.cancel.

For an attached run under identity audit, the live turn capability can log the credential, build, owner-epoch, and placement checks as one enforced admission receipt. That receipt carries none of the credential, build hashes, tokens, environment id, or session id. Worker operation rows and placement state remain the authoritative owners; a successful connection does not count as an action-success receipt.

Transcript commits rely on owner-epoch fencing, a session binding owned by the gateway, base-leaf compare-and-swap, and durable sequence replay; the gateway produces transcript entry and parent IDs through the standard session writer. Each RPC triggers a fresh check of ownership and expiry.

Client capabilities

Operator clients can advertise optional capabilities in connect.params.caps:

  • tool-events: accepts structured tool lifecycle events.
  • inline-widgets: can render hosted inline widget tool results.

Client capabilities describe the connected client, not authorization. Agent tools may declare required capabilities; the Gateway omits those tools unless every requirement appears in the originating client's caps. Channel-originated runs lack Gateway client capabilities, so capability-gated tools stay unavailable even when tool policy explicitly permits them.

Node connect example

{
  "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.

Roles:

  • operator: control-plane client (CLI/UI/automation).
  • node: capability host (camera/screen/canvas/system.run).
  • worker: cloud execution host on the dedicated, closed worker protocol.

Operator scopes (src/gateway/operator-scopes.ts), the full closed set:

  • operator.read
  • operator.write
  • operator.admin
  • operator.approvals
  • operator.pairing
  • operator.talk
  • operator.talk.secrets

operator.write continues to satisfy operator.talk for compatibility with existing clients. Voice-device setup can issue the narrower Talk grant without general Gateway write access.

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.<id>.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 performs an additional scope check at approval time, layered on top of the base method scope (operator.pairing), which draws on the pending request's declared commands (src/infra/node-pairing-authz.ts):

Declared commandsRequired scopes
noneoperator.pairing
ordinary commandsoperator.pairing + operator.write
includes system.run, system.run.prepare, system.which, browser.proxy, browser.proxy.upload.v1, fs.listDir, or system.execApprovals.get/setoperator.pairing + operator.admin

Within this table, fs.listDir refers to the node command that node.invoke relays. For the top-level Gateway fs.listDir RPC, operator.write is needed to browse the host's workspace, and operator.admin becomes necessary when nodeId is present.

Caps/commands/permissions (node)

At connection time, nodes declare their capability claims:

  • caps: broad capability groupings like camera, canvas, screen, location, voice, and talk.
  • commands: the command allowlist used for invoke operations.
  • permissions: fine-grained switches, for instance screen.record, camera.capture.

The Gateway views these as claims and applies server-side allowlists for enforcement. After a successful connect or reconnect, connected nodes may publish optional agent-visible plugin or MCP tool descriptors using node.pluginTools.update. Headless node hosts restart to apply declarative MCP inventory changes. This update method serves as the sole publication route; plugin tool descriptors are not permitted in connect params. Every descriptor must employ a provider-safe tool name and specify a command that exists in the node's current command allowlist. The Gateway trusts descriptor metadata coming from the paired node, filters out descriptors that fall outside the approved command surface, removes them upon node disconnect, and blocks operator attempts to alter another node's catalog. To disregard node-published descriptors, set gateway.nodes.pluginTools.enabled: false.

Connected node hosts publish their full skill replacement catalog via node.skills.update. This node-role method is the only way nodes publish skills; connect params do not accept skills. Each descriptor carries a safe name, a description, and bounded SKILL.md content. The Gateway processes that content with the standard skills loader, incorporates it into agent skill snapshots while the node stays connected, and drops it when the node disconnects. Setting gateway.nodes.allowSkills: false makes the Gateway ignore node-published skills.

Presence

  • system-presence delivers entries keyed by device identity, covering deviceId, roles, and scopes, which lets UIs render a single row per device even when it connects in both operator and node roles.
  • node.list optionally includes lastSeenAtMs and lastSeenReason. Connected nodes report current connection time with reason connect; paired nodes can additionally report durable background presence through a trusted node event.

Native macOS nodes can also transmit authenticated node.presence.activity events with bounded input idle time. The Gateway calculates activity timestamps using its own clock, surfaces the most recent connected Mac via node.list and node.describe, and sends node.presence updates to read-scoped clients. When the user disables activity sharing, the app sends { "action": "clear" }; the Gateway clears timestamps only for that specific authenticated node connection. Gateways that predate this acknowledged action treat it as unhandled, so the Mac node reconnects once and lets disconnect cleanup remove the old connection state. For selection, privacy, model context, and notification-routing details, see Active computer presence.

Node background alive event

Nodes use node.event together with event: "node.presence.alive" to log that a paired node was reachable during a background wake, without setting it to connected:

{
  "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. Any unrecognized value gets normalized to background (src/shared/node-presence.ts). The event is stored only for authenticated node device sessions; sessions without a device or pairing return handled: false.

Gateways that succeed produce a structured result:

{
  "ok": true,
  "event": "node.presence.alive",
  "handled": true,
  "reason": "persisted"
}

Legacy gateways might respond with just { "ok": true } for node.event; interpret that as an acknowledged RPC, not as durable presence storage.

Broadcast event scoping

Broadcast events pushed from the server are filtered by scope, 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) demand at least operator.read. Sessions lacking it get none of these frames.
  • Plugin-defined plugin.* broadcasts default to operator.write or operator.admin; explicit entries like plugin.approval.requested / plugin.approval.resolved rely on operator.approvals instead.
  • Status/transport events (heartbeat, presence, tick, connect/disconnect lifecycle) remain open so every authenticated session can observe transport health.
  • Unknown broadcast event families are scope-gated by default (fail-closed) unless a registered handler explicitly opens them up.

Each client connection tracks 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 assembled from src/gateway/server-methods-list.ts plus loaded plugin/channel method exports, not a generated dump of every method, and some methods (for example push.test, web.login.start, web.login.wait, sessions.usage) are deliberately left out of discovery even though they are real, callable methods. Treat this as feature discovery, not a full enumeration of src/gateway/server-methods/*.ts.

System and identity

  • 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.
  • gateway.restart.preflight is a deprecated, read-only compatibility preview of restart-specific active work. It does not close admission, create a suspension lease, or provide the atomic full-work fence of gateway.suspend.prepare; new restart flows should call gateway.restart.request.
  • gateway.suspend.prepare creates a short cooperative-suspension lease only when tracked Gateway work is idle. While prepared, authenticated WebSocket connects remain available, but only gateway.suspend.* and an exact targeted non-safe gateway.restart.request may run; safe and untargeted restarts remain fenced. gateway.suspend.status checks the lease, and gateway.suspend.resume releases it after thaw or an aborted host operation.

Models and usage

  • models.list provides the model catalog that the runtime permits. Refer to "models.list views" further down.
  • usage.status gives summaries of provider usage windows and remaining quota.
  • usage.cost supplies aggregated cost usage summaries across a date range. Use agentId for a single agent, or agentScope: "all" to combine configured agents.
  • doctor.memory.status reports vector-memory and cached embedding readiness for the active default agent workspace. Pass { "probe": true } or { "deep": true } solely for an explicit live embedding provider ping. Include { "agentId": "agent-id" } to restrict Dreaming store stats to one agent workspace; if omitted, configured Dreaming workspaces are aggregated.
  • doctor.memory.dreamDiary, doctor.memory.backfillDreamDiary, doctor.memory.resetDreamDiary, doctor.memory.resetGroundedShortTerm, doctor.memory.repairDreamingArtifacts, and doctor.memory.dedupeDreamDiary all take an optional { "agentId": "agent-id" }; when left out, they target the configured default agent workspace.
  • sessions.usage delivers per-session usage summaries. Pass agentId for one agent, or agentScope: "all" to present configured agents together. Both usage methods accept mode: "specific" with an IANA timeZone for DST-aware calendar-day boundaries and buckets. utcOffset stays supported for older clients and as a fallback when the Gateway runtime does not recognize the requested zone.
  • sessions.usage.timeseries provides timeseries usage for one session.
  • sessions.usage.logs returns usage log entries for one session.

Channels and login helpers

  • channels.status returns status summaries for built-in and bundled channel/plugin components.
  • channels.logout logs out a specific channel/account where the channel supports it.
  • web.login.start initiates a QR/web login flow for the current QR-capable web channel provider.
  • web.login.wait waits for that flow to finish 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.

Plugin management

  • plugins.list (operator.read) returns the installed plugin inventory plus locally curated official picks, diagnostics, and whether the current install mode allows mutations.
  • plugins.search (operator.read) searches installable ClawHub code-plugin and bundle-plugin families. Pass non-empty query and optional limit from 1 to 100.
  • plugins.install (operator.admin) installs either an official catalog entry with { source: "official", pluginId, acknowledgeInstallPolicyWarning? } or a ClawHub package with { source: "clawhub", packageName, version?, acknowledgeClawHubRisk?, acknowledgeInstallPolicyWarning? }. When install policy returns warn, the error details include installPolicyCode: "install_policy_warning_acknowledgement_required", the target, reason, and optional findings. After review, retrying the same action with acknowledgeInstallPolicyWarning: true approves every warning in that install invocation; each warning is freshly evaluated before installation continues. block and policy failures remain terminal. ClawHub installs preserve Gateway trust and integrity checks. Successful installs require a Gateway restart.
  • plugins.setEnabled (operator.admin) changes one installed plugin's enabled policy with { pluginId, enabled }. The response includes the updated catalog entry, restart metadata, and any slot-selection warnings.
  • plugins.uninstall (operator.admin) removes one externally installed plugin with { pluginId }: config references, the install record, and managed files. Bundled plugins cannot be uninstalled, only disabled. The response lists the removal actions and always requires a Gateway restart.

Messaging and logs

  • 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.

Operator terminal

  • When terminal.open is invoked, a host PTY gets started for either a specified agentId or the default agent, and the response includes the resolved agent, working directory, shell, and confinement state. If you pass sessionKey, the PTY is tied to that exact agent session, and the calling connection becomes its initial viewer; without it, an operator terminal owned by the connection is created.
  • Both terminal.input and terminal.resize target sessions that belong to the calling connection, as well as agent-owned sessions where that connection is listed as an attached viewer. With terminal.close, a connection-owned session is terminated, but for an established agent-owned session, only the calling viewer gets detached. When a Control UI terminal is new and session-bound, closing or disconnecting the initiating viewer discards the PTY until either the browser or the exact-session agent adopts it first through an authorized operation.
  • A single base64 file, capped at 16 MiB, is accepted by terminal.upload, which stages it in a private temporary directory lasting 24 hours on the session's Gateway or paired-node host, then returns the absolute path. That path must still be pasted or otherwise used by the caller; the RPC never writes terminal input or runs a command.
  • Events from terminal.data and terminal.exit are streamed to both the connection owner and any attached viewers. When a task-owned agent terminal's authoritative task reaches a terminal state, that terminal closes, while ordinary conversation-owned agent terminals stay persistent.
  • If a connection-owned session loses its connection, it is detached rather than killed: it remains reattachable for gateway.terminal.detachedSessionTimeoutSeconds (default 300; 0 restores kill-on-disconnect), and recent output is kept in a bounded server-side buffer. Established agent-owned sessions also survive viewer disconnect.
  • Attachable sessions are returned by terminal.list. The replay buffer comes from terminal.attach, which either rebinds a connection-owned session (a tmux-style take-over, where a previous live owner gets terminal.exit with reason detached) or adds the connection as a viewer of an agent-owned session.
  • Every terminal method demands operator.admin; gateway.terminal.enabled defaults to on and rejects every method when set to false. Fully sandboxed agents are refused, and changing an agent policy closes existing and in-flight PTYs, including detached ones.

Talk and TTS

  • talk.catalog provides the read-only catalog of Talk providers for speech, streaming transcription, and realtime voice. It covers 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. Provider secrets are never returned, and global config is left untouched. Current gateways set ready after runtime provider selection is applied; if it is missing, treat the gateway as unverified on older versions.
  • talk.config delivers the effective Talk config payload; includeSecrets needs operator.talk.secrets (or operator.admin).
  • talk.session.create (operator.talk) establishes a gateway-owned Talk session for realtime/gateway-relay, transcription/gateway-relay, or stt-tts/managed-room. For stt-tts/managed-room, non-admin callers supplying sessionKey must also supply spawnedBy to get scoped session-key visibility; unscoped sessionKey creation and brain: "direct-tools" demand operator.admin.
  • talk.session.appendAudio adds base64 PCM input audio to gateway-owned realtime relay and transcription sessions.
  • talk.session.cancelOutput halts assistant audio output, mainly for VAD-gated barge-in in gateway relay sessions. Send the current talk.event.turnId; the outcome is applied, stale, or idle.
  • talk.session.submitToolResult finalizes a provider tool call from a gateway-owned realtime relay session. The request waits for any asynchronous completion signal from the provider bridge; failed submissions keep the linked run active and do not emit a successful tool-result event. Use options: { willContinue: true } for interim tool output or options: { suppressResponse: true } when the provider bridge advertises suppression support and the result should not trigger another response.
  • talk.session.steer pushes active-run voice control into a gateway-owned agent-backed Talk session: { sessionId, text, mode? }, where mode is status, steer, cancel, or followup; if omitted, the mode is inferred from the spoken text.
  • talk.session.close terminates a gateway-owned relay, transcription, or managed-room session and emits terminal Talk events.
  • talk.mode sets or broadcasts the current Talk mode state for WebChat/Control UI clients.
  • talk.client.create creates or resumes a client-owned realtime provider session using webrtc or provider-websocket, while the gateway holds credentials, instructions, tool policy, and the returned voiceSessionId. Clients pass sessionKey and reuse voiceSessionId when swapping the provider transport during a single call. Clients negotiating gateway-control-v1 keep WebRTC media direct but shift the provider control channel and tool lifecycle to the Gateway.
  • talk.client.transcript appends one finalized { role, text } item to the normal agent session. The required entryId is idempotent within voiceSessionId; retries do not duplicate transcript messages.
  • talk.client.close closes the logical voice session after pending transcript writes. Closing is idempotent and may deliver a mutation-only call digest to the session's last non-WebChat channel.
  • talk.client.toolCall allows client-owned realtime transports to forward provider tool calls to gateway policy. The first supported tool is openclaw_agent_consult; clients receive a run id and wait for normal chat lifecycle events before submitting the provider-specific tool result. Voice-bound high-impact actions return VOICE_CONFIRMATION_REQUIRED:<id> until a later finalized user utterance explicitly confirms that exact final execution action and the next consult supplies the confirmationId; policy or hook rewrites require confirmation again.
  • 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 refreshes the preferred TTS provider setting.
  • tts.convert executes a single-pass text-to-speech operation.
  • tts.speak (operator.write) processes any non-empty text through the standard TTS provider chain and delivers a single inline audio clip as audioBase64, along with provider and, when applicable, outputFormat, mimeType, and fileExtension metadata. In contrast to tts.convert, no Gateway-local file path is produced; unlike talk.speak, a Talk provider is not needed. Input exceeding tts.maxTextLength yields INVALID_REQUEST; if synthesis fails, UNAVAILABLE is returned.

Secrets, config, update, and wizard

  • secrets.reload re-evaluates active SecretRefs and publishes owner-aware runtime state atomically. When eligible owners fail, the degradation can be published as cold or stale using warningCount; strict or unmapped failures cause the reload to be rejected, keeping the active snapshot intact.
  • secrets.resolve resolves secret assignments tied to a given command/target combination.
  • secrets.store.list (operator.admin) delivers team-scoped metadata and values exclusively for kind: "env" records. kind: "secret" records come back in a separate result format without a value field, and no reveal operation exists for them.
  • secrets.store.set and secrets.store.delete (operator.admin) either create/update or soft-delete a single team-scoped record. Once the write succeeds, the Gateway refreshes the active secrets runtime only if the name appears in a store SecretRef within the active source config.
  • config.get provides the current on-disk config snapshot, the raw root-file hash, the resolved configRevisionHash, and an optional appliedConfigHash tied to the resolved revision that the active Gateway runtime has accepted.
  • config.set commits a validated config payload.
  • config.patch applies a partial config update. For destructive array replacement, the affected path must be listed in replacePaths; nested arrays under array entries rely on [] paths, for example agents.entries.*.skills.
  • config.apply validates and swaps in the complete config payload.
  • config.schema supplies the live config schema payload for Control UI and CLI tooling, covering schema, uiHints, version, generation metadata, and plugin plus channel schema metadata when those can be loaded. It also includes title / description metadata drawn from the same labels and help text as the UI, including nested object, wildcard, array-item, and anyOf / oneOf / allOf composition branches wherever matching field documentation exists.
  • config.schema.lookup returns a path-scoped lookup payload for one config path, containing the normalized path, a shallow schema node, the matched hint plus hintPath, an 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 preserve user-facing docs and standard 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, and the matched hint / hintPath.
  • update.run triggers the gateway update process and only schedules a restart when the update is successful; session holders may pass continuationMessage so that startup picks up one extra agent turn via the restart continuation queue. Updates from package managers and supervised git-checkout updates directed by the control plane rely on a detached managed-service handoff rather than swapping the package tree or altering checkout/build output within the active gateway. A handoff that has started yields ok: true along with result.reason: "managed-service-handoff-started" and handoff.status: "started". A second simultaneous update.run processed by the same Gateway instance yields ok: false with result.reason: "managed-service-handoff-already-running" and handoff.status: "already-running"; its continuation is rejected, so the caller can retry once the ongoing update finishes. Standalone CLI updaters and replacement Gateway processes fall outside this process-local safeguard. Handoffs that are unavailable or unsuccessful return ok: false with either managed-service-handoff-unavailable or managed-service-handoff-failed, and additionally handoff.command when a manual shell update is needed. Unavailable indicates OpenClaw lacks a secure supervisor boundary or durable service identity, for instance OPENCLAW_SYSTEMD_UNIT for systemd. During a started handoff, the restart sentinel might briefly show stats.reason: "restart-health-pending"; the continuation waits until the CLI confirms the restarted gateway and records the final ok sentinel.
  • update.status refreshes and returns the most recent update restart sentinel, including the post-restart running version when it exists.
  • wizard.start, wizard.next, wizard.status, and wizard.cancel make the onboarding wizard available through WS RPC.

Agent and workspace helpers

  • agents.list returns agent entries visible to the gateway, with effective model/runtime metadata and optional semantic kind (agent or system). Entries that have recorded creation provenance also carry createdVia (operator, agent, or claw), nullable creatorAgentId, and millisecond createdAt; entries lacking provenance omit these fields. Clients announcing the agent-kind handshake capability get the full typed roster; those without it retain the legacy selector-safe roster that excludes system rows. Kind-aware clients filter out system rows from standard selectors but keep them in diagnostic views. Older v4 gateways may return rows without kind.
  • agents.create, agents.update, and agents.delete handle agent record management and workspace configuration.
  • agents.files.list, agents.files.get, and agents.files.set manage the bootstrap workspace files exposed for an agent.
  • audit.activity.list provides the versioned metadata-only activity ledger; audit.run.inspect locates execution ids or inspects a single execution identity context; audit.list stays the compatibility-safe run/tool RPC.
  • agents.workspace.list and agents.workspace.get (operator.read) offer read-only, paginated browsing of an agent's workspace directory for clients within the trusted operator domain described in Operator scopes. Requests accept only workspace-relative paths; reads are confined to the realpathed workspace root (symlink and hardlink escapes are rejected), size-capped, and limited to UTF-8 text plus common image types (base64). Responses never reveal the host workspace path. This namespace contains no write operations.
  • tasks.list, tasks.get, and tasks.cancel expose the gateway task ledger to SDK and operator clients. See 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 (operator.read) stay accessible even without cloud-worker profiles, preserving discovery of gateway-local and node environments. Node environments carry the durable sessionHost identity, which keeps a known offline host visible; however, the current connected inventory takes precedence over that history. When identity is missing, the value is false. The exact bounded { total, available } worker slots exist only during live operation and are omitted when offline. Configured cloud workers and durable records from earlier profiles contribute worker metadata, including providerId, optional leaseId, state, ageMs, optional idleMs, and attachedSessionIds. Worker lifecycle states encompass requested, provisioning, bootstrapping, ready, attached, idle, draining, destroying, destroyed, failed, and orphaned. A connected node might also include workerBundle: { status: "installed", version } or workerBundle: { status: "missing" }. This optional observation is limited to the reconnect scope and confirms validation of a single Gateway-retained bundle; it does not grant launch authority. The public result never reveals the bundle hash, Gateway namespace, node filesystem path, receipt, or protocol-feature specifics.
  • environments.create ({ profileId, idempotencyKey }) creates a worker using a configured plugin provider profile; retrying with the same key reuses the durable operation. environments.destroy ({ environmentId }) asks for idempotent removal of a durable worker environment. Both demand operator.admin, act as control-plane writes, and produce the same environment summary format as status responses.
  • worker.desktop.observe ({ environmentId, control? }, operator.admin) launches or reuses the environment's desktop forward, returning { transport, wsPath, expiresAtMs, control, vncPassword? }. wsPath holds a one-time 60-second token for the Gateway's desktop observer WebSocket; reconnecting requires a new observe call. Environments with an observable desktop advertise worker.desktop: true in environments.list. This method is advertised only when the cloudWorkers.desktop lab is enabled. Refer to Cloud workers.
  • agent.identity.get provides the effective assistant identity for an agent or session.
  • agent.wait blocks until a run completes and returns the terminal snapshot if available.

Session control

  • sessions.list provides the present session index, which includes per-row agentRuntime metadata when an agent runtime backend is set up. The authoritative aggregate fact for direct-session activity is hasActiveRun. When projected, activeRunIds represents the full exact active set; an empty array indicates that the session is idle. If aggregate activity is true but the field is missing, another runtime owner is active, yet its specific identities cannot be determined. A missing field in a snapshot means identities are unavailable. For incremental events, omission signals no change, null acts as the event-only tombstone that resets cached exact IDs to unavailable, and an array replaces the cache. Clients correlate only exact IDs they own locally or that came from requests, history, or events, and they never treat the first list entry as an owner. When cloud-worker placement is enabled or durable recovery state exists, session rows additionally include a closed placement state (local, requested, provisioning, syncing, starting, active, draining, reconciling, reclaimed, or failed) along with state-specific environment, owner-epoch, workspace, bundle, ACK-cursor, or recovery fields. Active placements may carry an advisory diskSpace sample with status (ok, warning, or critical), availableBytes, totalBytes, and observedAtMs. An active paired-device placement also includes runner: { kind: "device", status: "available" | "offline", deviceId? }; deviceId identifies the paired device hosting the placement (the chosen host for autoDevice dispatch), and non-device placements leave this field out. This availability is process-current, derived from the exact active environment binding and reconnect-scoped node-runner proof, and it starts offline after a Gateway restart until that runner reconnects. Inventory changes trigger sessions.changed so clients refresh the canonical row. Rows carry ownership projections: write-once createdActor, the mutable owner (actor plus assignedBy/assignedAt), a bounded participants list (owner excluded, up to 4 actors), and the full participantCount; actor display labels and avatars are resolved from current profiles and agent identities at read time. Pass creatorId to filter by immutable createdActor.id; pass ownerId to filter by the current assignable owner, falling back to createdActor when no owner is assigned. The complete owners facet is independent of pagination and remains unfiltered by either query, so clients can render the full owner picker. Authenticated callers can pass involvingMe: true to keep only sessions the caller owns or has prompted, evaluated against the full participant history (profile-backed human participants only).
  • sessions.subscribe turns on session change events for the current WebSocket client. The subscription ends when that client disconnects.
  • sessions.messages.subscribe and sessions.messages.unsubscribe switch on transcript/message event subscriptions for one session. Pass includeApprovals: true to also receive sanitized session.approval lifecycle events for approvals whose persisted audience includes that exact session and whose reviewer binding authorizes the subscribing client. The subscribe response then includes a bounded pending approvalReplay; it is authoritative when truncated is false. The opt-in is per subscribe call, not sticky: re-subscribing to the same session without includeApprovals: true removes an existing approval subscription. In addition to normal session-read authority, this opt-in requires operator.admin, or operator.approvals on a paired device.
  • 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 by key, raw session ID, label, or Control UI short ID. Ambiguous short IDs return a bounded candidate list as a successful RPC result.
  • sessions.create establishes a fresh session record. When supplied, model, contextWindow, and thinkingLevel persist the starting model, the advertised context-window preference, and reasoning overrides as a single atomic operation; an optional category attaches the session to a custom group, creating that group on its first use. worktree: true sets up a managed worktree, with worktreeBaseRef/worktreeName optionally picking the base reference and branch name, while execNode (operator.admin) ties session execution to a specific node host. In the absence of worktreeName, OpenClaw generates a human-readable name from the session label or the title of the first generated message, defaulting to a crustacean-themed name if neither exists; if another owner, a local branch, or an unmanaged path already claims that name, a numeric suffix is appended. The resulting worktree appears in the response and is stored on the session row via worktree: { id, branch, repoRoot }. Should the entry be created but its nested initial chat.send be refused, the successful response carries runStarted: false and runError; clients can retain the prompt and retry using the returned session key. When a caller provides parentSessionKey along with emitCommandHooks: true, it must also specify the lifecycle outcome for a separate child: succeedsParent: true terminates the parent with session_end, whereas false leaves the parent running and emits only the child's session_start. Leaving out succeedsParent keeps the old parent-rollover behavior for existing clients. This disposition depends on both parent linkage and command hooks; a fork cannot outlive its parent. Reset-in-place for main sessions stays unchanged because no distinct child is generated. New rows get write-once creation provenance (createdVia, createdActor, createdAt) from the trusted creation seam; adopting an existing key never re-stamps it. For human profile actors, createdActor.label is derived from the current user profile at projection time and is never saved on the session entry, so profile renames cause no drift. Session rows additionally carry parentSessionKey (navigation parent, persisted), controlOwnerSessionKey (runtime controller when active), forkSource (exact source key plus transcript generation for forks), and previousSessionId (prior transcript generation under the same key).
  • sessions.dispatch relocates an authorized local OpenClaw session, one with a live, registry-owned managed worktree, to a paired device or a designated cloud profile. Use { key, deviceId, agentId? } for a specific device, { key, autoDevice: true, agentId? } for automatic paired-device selection, { key, profileId, machineClass?, agentId? } for a specific profile, or { key, agentId? } to resolve the managed worktree's normalized origin in cloudWorkers.projectProfiles. These target modes are mutually exclusive, and explicit targets override project-profile lookup. Automatic selection sorts worker-slot runtimes by available slots, then by device ID; runtimes without worker slots fall back to device ID order. If a candidate becomes ineligible during dispatch, up to three ranked candidates are attempted; other errors are not retried. Explicit and automatic device dispatch demand operator.write; explicit-profile and project-profile dispatch demand operator.admin. A missing origin, an unmatched mapping, or a mapping to an unconfigured profile yields a typed INVALID_REQUEST without provisioning or fallback. Malformed parameters use the write scope before schema validation. A missing cloud profile hides only cloud targets; eligible paired-device dispatch stays available. Dispatch closes local turn admission before draining active work and returns only after placement reaches active worker ownership. Arbitrary plain directories cannot be dispatched; after admission, the workspace transport may use manifest mirroring if the managed worktree's Git metadata later becomes unavailable. SSH fallback candidates rotate only for idempotent probes, content-addressed transfers, receipt/lock-guarded artifact installation, convergent managed-worktree mirroring, and tunnel reconnects. Ambiguous unguarded stateful commands fail closed and are not replayed. Dispatch is one-way; worker-to-local pull-back is not part of this RPC.
  • sessions.reclaim (operator.write) safely halts a session placement by key. It waits for an in-flight dispatch, drains admitted work, reconciles active workspace changes, and retries pending failed-environment teardown through the placement owner. Callers never need raw environment-destroy authority.
  • sessions.move shifts an authorized active session to the Gateway, a paired device, or a configured profile. Gateway and device targets require operator.write; profile targets require operator.admin; malformed targets use the write scope before schema validation. The caller provides the exact observed generation, environment, and owner epoch; session authorization and those source facts are revalidated before the move commits. Ordinary moves always reconcile the source. Only a Gateway target may add abandonSource: true, and only when the exact source is a currently offline paired-device placement. That durable decision force-fences and destroys the remote owner, skips remote workspace reconciliation, and continues from the last Gateway-synced state without replay; unsynced files and in-flight work may be lost. Available, unknown, profile, and other-worker sources reject explicit abandonment.
  • sessions.groups.list, sessions.groups.put, sessions.groups.rename, and sessions.groups.delete handle the gateway-owned custom session group catalog (names + display order). The read-scoped list result is intentionally path-free. sessions.groups.defaults and sessions.groups.update require operator.write and read or replace one custom group's optional working-directory and worktree defaults. Non-admin callers can save only directories inside a configured agent workspace; other absolute Gateway paths require operator.admin. Membership stays on each session's category field; rename and delete update member sessions server-side.
  • sessions.send delivers a message into an existing session.
  • sessions.steer is a deprecated alias for chat.send with queueMode: "interrupt"; removal follows the protocol deprecation policy.
  • Calling sessions.abort terminates any in-flight work tied to a session. You can pass key together with the optional runId, or just runId when the gateway can map an active run to a session. Adding runId confines the cancellation to that specific run. For a key-only non-global request, setting clearQueued: true also clears the followup and lane queues that belong to the session. Callers who leave out clearQueued keep those queues intact. Using the literal global key applies the existing agent-qualified chat.abort ownership rules, and no non-global followup or lane cleanup is attempted.
  • Session metadata and overrides are refreshed by sessions.patch, which also reports the resolved canonical model and the effective agentRuntime. Only an id listed in the selected model's contextWindows array is accepted by contextWindow; null brings back contextWindowDefault. To modify session organization fields or the per-session model override, operator.write is necessary, while thinking, fast, verbose, trace, reasoning, and other privileged overrides demand operator.admin. A default agent configuration can only be persisted through an admin model selection. Archive and restore patches need the caller-observed sessionId from sessions.list or sessions.describe as expectedSessionId; if it is missing or altered, the operation fails without creating or modifying a replacement. When archived: true is set, the Gateway shields agent main sessions (including global when global scope is active) and the unknown sentinel; for any other real session, it first blocks new admissions, cancels all active, pending, queued, reply, embedded, and worker work tied to that session, and waits for admission and runtime terminal-persistence drains to finish before applying archivedAt. A failure during cancellation, draining, or persistence yields retryable UNAVAILABLE and leaves the session unarchived. Per target, sessions.patchMany carries expectedSessionId, prepares archive targets in input order within the same batch lifecycle fence, and returns ordered per-target results. Spawn lineage (spawnedBy, spawnedWorkspaceDir, spawnedCwd, spawnDepth, subagentRole, subagentControlScope) can no longer be patched publicly; trusted creation paths write those details once, and any request still including them gets rejected.
  • The session's mutable owner is reassigned by sessions.assignOwner (operator.write) to a person or a configured agent ({ key, owner: { type, id } }). An identified caller is required (an authenticated profile or a trusted agent identity), authorization is based on session visibility, and assignedBy/assignedAt are recorded in the row's owner field. The write-once createdActor and creator-anchored sharing authority remain unchanged; refer to Multi-user mode.
  • Session upkeep is handled by sessions.reset, sessions.delete, and sessions.compact.
  • The complete stored session row is provided by sessions.get.
  • Chat execution continues to rely on chat.history, chat.send, chat.abort, and chat.inject. Its sessionInfo applies the same combined hasActiveRun and optional exact-completion activeRunIds behavior as sessions.list. For UI clients, chat.history is normalized for display: inline directive tags get stripped from the visible text, plain-text tool-call XML payloads (<tool_call>...</tool_call>, <function_call>...</function_call>, <tool_calls>...</tool_calls>, <function_calls>...</function_calls>, and truncated tool-call blocks) plus leaked ASCII or full-width model control tokens are removed, pure silent-token assistant rows (exactly NO_REPLY or no_reply) are left out, and oversized rows may be swapped for placeholders. Tail responses may carry an opaque deltaCursor. Supply it back as cursor to chat.history or chat.startup rather than offset or messageId. A successful catch-up yields { kind: "delta", messages, deltaCursor, sessionInfo }; feed each messages entry through the same reducer used for a live session.message payload. { kind: "reset" } signals that the cursor is invalid, stale, tied to another session, crossed a reset or compaction, or lagging too far; pull a normal tail page instead. Catch-up never delivers a partial page or continuation: exceeding 200 raw events or the 1 MB payload limit falls back to a tail fetch.
  • chat.message.get serves as the additive bounded full-message reader for one visible transcript entry. Provide sessionKey, optional agentId when session selection is agent-scoped, and a transcript messageId previously exposed via chat.history; the gateway returns the same display-normalized projection without the lightweight history truncation cap, provided the stored entry remains available and is not oversized.
  • chat.toolTitles generates short purpose titles for tool calls shown in the Control UI (batched, up to 24 items with bounded inputs). This feature is opt-in through gateway.controlUi.toolTitles (off by default); disabled gateways answer { titles: {}, disabled: true } without a model call so clients stop requesting. When enabled, titles follow standard utility-model routing: an explicitly configured utilityModel (an operator choice that, like all utility tasks, may send bounded task content to the chosen provider), otherwise the session provider's declared small-model default so no new egress destination appears implicitly; an empty utilityModel turns them off entirely. Titles never fall back to the primary model. Results are cached in the per-agent state database keyed by tool name plus input, so repeated views never re-bill the same calls.
  • chat.send accepts a one-turn fastMode: "auto" to use fast mode for model calls initiated 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 set per model via agents.defaults.models["<provider>/<model>"].params.fastAutoOnSeconds. A chat.send caller can pass a one-turn fastAutoOnSeconds to override the cutoff for that request. Pass queueMode (steer, followup, collect, or interrupt) to override the stored queue mode for this request only; explicit Control UI steer actions use queueMode: "steer". Interrupt mode captures and aborts the session's current admitted turn, waits for that exact owner to settle, then starts the new turn; an idle session starts normally. A steer send targets the selected session's current state: the Gateway atomically injects the message into that session's direct active run, or starts a new turn when the session is idle. Activity in descendant subagent sessions never makes the selected session busy for this decision. expectedLeafEntryId is an independent transcript-branch compare-and-swap for non-steer interactive sends: pass the displayed branch leaf (or deliberate null for an authoritative empty transcript) and the send rejects with details.reason: "active-leaf-changed" if another client switched transcript branches first; steer sends ignore it.

Device pairing and device tokens

  • device.pair.list fetches both pending and approved paired devices.
  • device.pair.setupCode generates a mobile setup code and, unless configured otherwise, a PNG QR data URL. It depends on operator.admin and is deliberately excluded from advertised discovery. Modern gateways ship with an opaque non-secret setupId, authoritative expiresAtMs, setupCode, optional qrDataUrl, gatewayUrl, the non-secret auth label, urlSource, and the assigned access tier (full, limited, or node). Legacy protocol-v4 gateways lack setupId and expiresAtMs, so separately shipped clients must treat those lifecycle fields as optional. The setupId operates independently of the bootstrap credential and never appears inside the setup code.
  • device.pair.setupStatus aligns a single setup credential previously issued by the caller ({ setupId }). It needs operator.admin, stays out of advertised discovery, and yields either { completion } once the credential-bearing response completes or { deliveryUncertain } if the bearer was retired but response delivery remained unconfirmed. Both outcomes carry the same non-secret payload as their matching events. With both fields missing, the gateway retains no pending result for that setupId.
  • device.pair.approve, device.pair.reject, and device.pair.remove handle device-pairing records.
  • device.pair.rename sets an operator label ({ deviceId, label }) that takes precedence over the client-reported display name and endures device repair or re-approval.
  • device.token.rotate rotates a paired device token within its approved role and caller scope limits.
  • device.token.revoke invalidates a paired device token within its approved role and caller scope limits.

The setup code carries a short-lived bootstrap credential. Clients must not log or store it beyond the pairing process.

Pairing-scoped clients get device.pair.setup.completed only once the exact setup handoff has delivered its credentials. Its payload is { setupId, deviceId, deviceName?, access, ts }; it never contains the bootstrap credential or token-derived identifiers.

If the response closes before delivery is confirmed, the gateway keeps the bearer retired and emits device.pair.setup.deliveryUncertain instead of success. The presenting client should give the operator a way to inspect or remove the paired device and issue a fresh setup code.

The gateway logs an uncertain outcome when it consumes the bearer, then upgrades it to completion only after response delivery finishes. Operator event frames are best effort and drop for slow subscribers rather than closing their socket. A client that displayed a setup code must therefore call device.pair.setupStatus before presenting the code as expired. Outcomes are retained past the credential's own expiry.

Node pairing, invoke, and pending work

  • node.pair.list, node.pair.approve, node.pair.reject, and node.pair.remove cover node capability approvals. node.pair.request and node.pair.verify were dropped in 2026.7 along with the standalone node pairing store; pending requests are generated by the Gateway during node connects.
  • node.list and node.describe report known/connected node state.
  • node.rename changes a paired node label.
  • node.invoke sends a command to a connected node.
  • node.invoke.result provides the result for an invoke request.
  • mcp.tools.call.v1 is the headless node-host command for invoking a configured node-local MCP tool. It travels through node.invoke, demands the node to declare the command, and stays subject to pairing approval and gateway.nodes.commands.deny.
  • node.event routes node-originated events back into the gateway.
  • node.pluginTools.update is the sole publication path for updating the connected node's agent-visible plugin/MCP tool descriptors; connect params do not carry them.
  • 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.

Approval families

  • approval.history returns the most recent terminal approvals first, keeping 30 days of history for exec, plugin, and system-agent requests (scope operator.approvals). Cursor pagination is supported, along with an optional kind filter; pending approvals do not appear as history rows.
  • approval.get and approval.resolve serve as the kind-agnostic durable approval methods (scope operator.approvals). approval.get provides a sanitized projection of either pending or retained terminal states, featuring a stable urlPath; approval.resolve takes the canonical approval id, an explicit kind, and a decision, applies first-answer-wins resolution, and consistently returns the recorded canonical outcome.
  • exec.approval.request, exec.approval.get, exec.approval.list, and exec.approval.resolve handle one-shot exec approval requests and pending approval lookup/replay. These act as protocol-boundary adapters over the same durable approval registry.
  • exec.approval.waitDecision blocks on a single pending exec approval and yields the final decision (or null if a timeout occurs).
  • exec.approvals.get and exec.approvals.set handle gateway exec approval policy snapshots.
  • exec.approvals.node.get and exec.approvals.node.set manage node-local exec approval policy through node relay commands.
  • plugin.approval.request, plugin.approval.list, plugin.approval.waitDecision, and plugin.approval.resolve cover approval flows defined by plugins.

Control UI commands

  • ui.command enables an operator.write caller to transmit typed layout and navigation commands to connected Control UI clients that declare the ui-commands capability.
  • These commands include pane split/close/focus, sidebar visibility, terminal/browser panel visibility and docking, and session navigation.
  • Protocol v1 deliberately broadcasts to every connected capable Control UI. When no such client is present, the request fails with UNAVAILABLE rather than falsely indicating a layout change.

Automation, skills, and tools

  • 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 stays an enqueue-style RPC for manual runs. Clients requiring completion semantics should read the returned runId and poll cron.runs.
  • cron.runs accepts an optional non-empty runId filter, letting clients track a single queued manual run without conflicting with other history entries for the same job.
  • Skills and tools: commands.list, skills.*, tools.catalog, tools.effective, tools.invoke. See Operator helper methods below.

Common event families

  • chat: UI chat updates, including chat.inject and other transcript-only chat events. In protocol v4, delta payloads contain deltaText; message remains the cumulative assistant snapshot. Non-prefix replacements set replace=true and employ deltaText as the replacement text.
  • session.message, session.operation, session.tool: transcript, in-flight session operation, and event-stream updates for a subscribed session.
  • session.approval: sanitized pending and terminal approval truth for an explicitly opted-in exact-session subscriber. Child approvals use the persisted ancestor audience; events never mutate transcripts or wake agents.
  • session.observer: safe live session headline and status digest. A model-authored preamble can update the headline immediately; utility-model assessments replace it later when available. Web, iOS, and Android use the same run-scoped digest. Clients show its headline or inspector link only while the digest's exact runId is present in activeRunIds.
  • sessions.changed: session index or metadata changed. Active-run fields use the same aggregate and complete-exact semantics as sessions.list; activeRunIds: null clears cached exact identities to unavailable, omission leaves the cache unchanged, and an array replaces it.
  • 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 approval lifecycle.
  • device.pair.setup.completed: exact setup-code handoff completion, scoped to operator.pairing.
  • device.pair.setup.deliveryUncertain: replay-safe setup-code retirement whose credential response delivery could not be confirmed, scoped to operator.pairing.
  • voicewake.changed: wake-word trigger config changed.
  • config.changed: a config write persisted (payload carries the config path, the new snapshot hash, and a timestamp, never config content). Operator-read scoped; clients refresh via config.get.
  • skills.changed: connectivity, the skill catalog, config, or eligibility changed after the gateway invalidated its skills snapshot. The payload's reason is watch, watch-targets, manual, remote-node, config-change, or workshop. Operator-read scoped; clients refresh via skills.status.
  • 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.activity.list gives operator clients a stable newest-first view of agent run, tool action, inbound-message, and terminal outbound-message 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. See Audit history for the data model and privacy semantics.

  • Parameters: either exact agentId, sessionKey, or runId; optionally kind ("agent_run", "tool_action", or "message"); optionally status ("started", "succeeded", "failed", "cancelled", "timed_out", "blocked", or "unknown"); optionally a message direction ("inbound" or "outbound") and exact channel; optionally inclusive after / before Unix-millisecond limits; optionally limit ranging from 1 to 500; and optionally a string cursor taken from the prior page.
  • Output: { "events": AuditActivityEventV1[], "nextCursor"?: string }.

The V1 result union, which is named, splits into separate schemas for agent-run, tool-action, inbound-message, and outbound-message. The eventType discriminator maps to agent_run, tool_action, inbound_message, or outbound_message in that order; kind and message direction stay accessible for filtering and display purposes. Each event carries an integer schemaVersion: 1. References to message identity adopt the exact hmac-sha256:v1:<32 hex key id>:<64 hex digest> format; a channel-sender actor id follows the same format.

Every variant needs eventType, schemaVersion, eventId, sequence, sourceSequence, occurredAt, kind, action, status, actor, and redaction. The variant fields are:

eventTypeMandatory fieldsNon-mandatory fields
agent_runagentId, runId; kind: "agent_run"sessionKey, sessionId, errorCode
tool_actionagentId, runId; kind: "tool_action"sessionKey, sessionId, toolCallId, toolName, errorCode
inbound_messagedirection: "inbound", channel, conversationKind, outcomeagentId, runId, durationMs, resultCount, identity references, reasonCode, errorCode
outbound_messagedirection: "outbound", channel, conversationKind, outcomeagentId, runId, durationMs, resultCount, identity references, reasonCode, deliveryKind, failureStage, errorCode

The enums for closed messages are listed here:

  • conversationKind: direct, group, channel, or unknown.
  • Inbound outcome: completed, skipped, or failed; optional reasonCode: duplicate, reply_operation_active, reply_operation_aborted, fast_abort, plugin_bound_handled, plugin_bound_unavailable, plugin_bound_declined, plugin_bound_error, before_dispatch_handled, acp_dispatch_completed, acp_dispatch_failed, acp_dispatch_empty, or acp_dispatch_aborted.
  • Outbound outcome: sent, suppressed, failed, or unknown; optional reasonCode: cancelled_by_message_sending_hook, cancelled_by_reply_payload_sending_hook, empty_after_message_sending_hook, empty_after_reply_payload_sending_hook, or no_visible_payload. An adapter that returns no platform identity is unknown, because the external side effect cannot be disproved.
  • deliveryKind: text, media, or other; failureStage: platform_send, queue, or unknown.

Terminal fields are correlated, not independently optional:

VariantTerminal mapping
Agent runstarted lacks a errorCode; every non-success finished status must be paired with its corresponding run_* code.
Tool actionstarted and succeeded do not include a errorCode; all other finished statuses need their matching tool_* code.
Inbound messagesucceeded maps to completed; blocked maps to skipped; failed maps to failed plus message_processing_failed. When reasonCode appears, it must be from that terminal family.
Outbound messagesucceeded maps to sent; blocked maps to suppressed plus reasonCode; failed maps to failed plus errorCode and failureStage; unknown maps to unknown plus failureStage.

Every activity event carries a stable event id, a monotonic ledger sequence, a source event sequence, a timestamp, an actor, an action, a status, an integer schemaVersion: 1, and redaction: "metadata_only". Run and tool records need agent and run provenance, and session provenance is optional. Message records can include agent and run ids, but they deliberately omit sessionKey and sessionId; as a result, the sessionKey query filter only affects run and tool rows. Tool events may include a tool call id and a tool name.

The activity ledger returns message.inbound.processed and message.outbound.finished records, adding direction, channel, conversation kind, normalized outcome, and optional delivery kind, failure stage, duration, result count, reason code, and installation-local keyed account, conversation, message, and target pseudonyms. These pseudonyms help with correlation but do not provide anonymization: the state database holds their key, whereas RPC and CLI exports do not. The ledger stores no prompts, message bodies, tool arguments, tool results, command output, or raw error text. Run and tool sessionKey values stay as raw correlation metadata and can embed platform account or peer ids; message records leave out session keys.

For inbound rows, durationMs measures core dispatch through its terminal, and resultCount tallies finalized queued tool, block, and reply payloads. For outbound rows, durationMs covers delivery ownership from acknowledgement through dead letter or reconciliation, including queued wait time, and resultCount counts identified physical platform sends. When deliveryKind is present, it describes the effective payload after hooks and rendering; suppressed or crash-ambiguous rows omit it.

Current message coverage includes accepted inbound messages that reach core dispatch, including core duplicate and terminal outcomes. Outbound coverage writes replay-safe queue and platform-start records to a lazy owner-native companion and one terminal activity row per original logical reply payload that reaches shared durable delivery; run inspection merges those sources. Chunking and adapter fan-out are aggregated in terminal resultCount. Ambiguous sends reach a terminal only after acknowledgement, dead letter, or reconciliation. Plugin-local and direct-send paths that bypass those shared boundaries are not yet covered. The bounded process-owned async queue is best-effort and may drop records on saturation, terminal persistence failure, or shutdown timeout, so this surface is not a lossless compliance archive.

Recording is enabled by default and managed via logging.audit.enabled. Message recording has its own control, logging.audit.messages, and defaults to "off". When recording is off, audit.activity.list continues serving records written earlier until they expire.

audit.run.inspect also demands operator.read. Its closed request chooses exactly one executionId for exact inspection or one runId for bounded execution discovery. A single run match resolves directly; multiple matches yield an explicit ambiguous result with at most 50 candidates and require exact execution selection. Decision pages hold at most 100 receipts. Execution identity collection is separately off by default and needs logging.audit.executionIdentity: true plus an enabled audit ledger after Gateway restart. Missing best-effort evidence never proves that a run did not occur.

For a selected run, decision receipts combine terminal outbound activity with owner-native queued and platform_started progress. Progress is attribution-only, resides in the lazy companion store, and is not part of the audit.activity.list result schema.

The shipped audit.list request, result, and AuditEvent schemas stay unchanged and return only agent-run and tool-action records. New operator clients should call audit.activity.list when the Gateway advertises it. Older Gateways may report either unknown method: audit.activity.list or, because authorization preceded method lookup in shipped versions, missing scope: operator.admin to a read-scoped request. Treat the latter as method absence only when the method was not advertised. A client may then retry audit.list only when its filters do not require message kind, direction, or channel support.

Use openclaw 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.

  • For tasks.list, operator.read is a prerequisite.
    • Params: an optional status (which can be "queued", "running", "completed", "failed", "cancelled", or "timed_out", or an array holding those statuses), an optional agentId, an optional sessionKey, an optional limit spanning 1 through 500, and an optional string cursor.
    • What comes back: { "tasks": TaskSummary[], "nextCursor"?: string }.
  • tasks.get depends on operator.read.
    • Params: { "taskId": string }.
    • What comes back: { "task": TaskSummary }.
    • When task ids are absent, the gateway responds with its not-found error shape.
  • tasks.cancel depends on operator.write.
    • Params: { "taskId": string, "reason"?: string }.
    • What comes back: { "found": boolean, "cancelled": boolean, "reason"?: string, "task"?: TaskSummary }.
    • Whether the ledger held a matching task is indicated by found. Whether cancellation was accepted or recorded by the runtime is indicated by cancelled.

Within TaskSummary you will find 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. The agent responsible for running the task is named by agentId; requester and control context are carried by sessionKey and ownerKey.

Operator helper methods

  • commands.list (operator.read) retrieves the agent's runtime command list.
    • agentId is not required; leave it out to access the default agent workspace.
    • scope determines which surface the main name is aimed at: text gives the primary text command token without the / prefix; native and the standard both path return provider-aware native names when those are available.
    • textAliases holds exact slash aliases like /model and /m.
    • nativeName holds the provider-aware native command name if one is present.
    • provider is optional and impacts only native naming and native plugin command availability.
    • includeArgs=false removes serialized argument metadata from the response.
  • tools.catalog (operator.read) retrieves the agent's runtime tool catalog. The response contains grouped tools and provenance details:
    • source: either core or plugin
    • pluginId: plugin owner when source="plugin"
    • optional: indicates whether a plugin tool is optional
  • tools.effective (operator.read) retrieves the runtime-effective tool inventory for a session.
    • sessionKey is mandatory.
    • The gateway derives trusted runtime context from the session on the server side, rather than using caller-supplied auth or delivery context.
    • The response is a session-scoped, server-derived view of the active inventory, covering core, plugin, channel, and already-discovered MCP server tools.
    • tools.effective is read-only for MCP: it can project a warm session MCP catalog through the final tool policy, but it does not create MCP runtimes, connect transports, or send tools/list. If no matching warm catalog is available, the response may include a notice like 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) calls one available tool through the same gateway policy path as /tools/invoke.
    • name is required. args, sessionKey, agentId, confirm, and idempotencyKey are optional.
    • When both sessionKey and agentId are provided, the resolved session agent must match agentId.
    • Owner-only core wrappers like cron, gateway, and nodes demand 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 instead of skipping the gateway tool policy pipeline.
  • skills.status (operator.read) retrieves the visible skill inventory for an agent.
  • agentId is not required. Leave it out to access the default agent workspace.
  • Eligibility, missing requirements, config checks, and sanitized install options are all part of the response. Raw secret values are never exposed.
  • ClawHub discovery metadata is returned by skills.search and skills.detail (operator.read).
  • Before installation, a private skill archive is staged through skills.upload.begin, skills.upload.chunk, and skills.upload.commit (operator.admin). This admin-only upload path serves trusted clients and is separate from the standard ClawHub skill install process. Unless skills.install.allowUploadedArchives is turned on, it stays disabled by default.
    • An upload tied to that slug and force value is created by skills.upload.begin({ kind: "skill-archive", slug, sizeBytes, sha256?, force?, idempotencyKey? }).
    • Bytes are appended by skills.upload.chunk({ uploadId, offset, dataBase64 }) at the precise decoded offset.
    • Final size and SHA-256 are checked by skills.upload.commit({ uploadId, sha256? }). The upload is only finalized by commit; the skill is not installed at that point.
    • Uploaded skill archives are zips with a SKILL.md root. The internal directory name inside the archive never decides the install target.
  • Three modes are available for skills.install (operator.admin):
    • ClawHub mode: a skill folder gets installed into the skills/ directory of the default agent workspace by { source: "clawhub", slug, version?, force? }.
    • Upload mode: a committed upload is installed into the skills/<slug> directory of the default agent workspace by { source: "upload", uploadId, slug, force?, sha256?, timeoutMs? }. The slug and force value must match those from the original skills.upload.begin request. This is rejected unless skills.install.allowUploadedArchives is enabled; that setting has no effect on ClawHub installs.
    • Gateway installer mode: a declared metadata.openclaw.install action is executed on the gateway host by { name, installId, timeoutMs? }. Older clients might still send dangerouslyForceUnsafeInstall; that field is deprecated, accepted only for protocol compatibility, and otherwise ignored. For operator-owned install decisions, use security.installPolicy.
  • Two modes exist for skills.update (operator.admin):
    • ClawHub mode refreshes one tracked slug or every tracked ClawHub install in the default agent workspace. Updates that would overwrite a skill directory whose installed files no longer match the recorded install digests are refused; the per-skill failure in details.results includes code: "force_required". To replace such a skill regardless, retry with the optional force: true parameter.
    • Config mode patches skills.entries.<skillKey> values like enabled, apiKey, and env.

models.list views

An optional view parameter (src/agents/model-catalog-visibility.ts) is accepted by models.list:

  • Omitted or "default": when agents.defaults.modelPolicy.allow is configured, the response is the allowed catalog, with dynamically discovered models included for provider/* entries. Otherwise the full gateway catalog is returned.
  • "configured": picker-sized behavior. If agents.defaults.modelPolicy.allow is configured, it takes precedence, including provider-scoped discovery for provider/* entries. With no allowlist, explicit models.providers.<provider>.models entries are used in the response, and the full catalog is the fallback only when no configured model rows exist.
  • "provider-config": source-authored models.providers.*.models inventory, unaffected by picker allowlists. Public model capabilities and route-aware availability appear in the rows, but provider endpoints, auth material, and runtime request configuration are left out.
  • "all": the full gateway catalog, with agents.defaults.modelPolicy.allow bypassed. This is meant for diagnostics or discovery UIs, not regular model pickers.

Two optional controls tell automatic reads apart from operator-requested discovery:

  • For that runtime generation, preparedOnly: true reuses the current prepared catalog or a completed catalog without starting provider discovery. Control UI startup and polling rely on this mode.
  • When the selected view calls for discovery, refresh: true swaps in a completed full catalog. Concurrent refreshes share a single build; a failed refresh keeps the previous completed catalog available and sends the failure back to the caller.

Since one forbids discovery while the other asks for it, preparedOnly: true and refresh: true cannot be used together.

Exec approvals

  • When approval is required for an exec request, the gateway emits exec.approval.requested.
  • Operator clients resolve it through exec.approval.resolve, which needs operator.approvals.
  • For host=node, exec.approval.request has to carry systemRunPlan (the canonical argv/cwd/rawCommand/session metadata). Any request lacking systemRunPlan gets turned down.
  • Once approved, forwarded node.invoke system.run calls reuse that same canonical systemRunPlan as the authoritative command/cwd/session context.
  • Should a caller alter command, rawCommand, cwd, agentId, or sessionKey between prepare and the final approved system.run forward, the gateway rejects the run rather than trusting the modified payload.

Agent delivery fallback

  • agent requests may carry deliver=true to ask for outbound delivery.
  • bestEffortDeliver=false (the default) enforces strict behavior: unresolved or internal-only delivery targets yield INVALID_REQUEST.
  • bestEffortDeliver=true permits fallback to session-only execution when no external deliverable route can be found (for instance internal/webchat sessions or ambiguous multi-channel configs).
  • Final agent results may contain result.deliveryStatus when delivery was requested, employing the same sent, suppressed, partial_failed, and failed statuses described for openclaw agent --json --deliver.

Versioning

  • PROTOCOL_VERSION, MIN_CLIENT_PROTOCOL_VERSION, MIN_NODE_PROTOCOL_VERSION, and MIN_PROBE_PROTOCOL_VERSION reside in packages/gateway-protocol/src/version.ts.
  • Clients transmit minProtocol + maxProtocol. Operator and UI clients must include the current protocol in that range; current clients and servers run protocol v4.
  • Authenticated clients holding both role: "node" and client.mode: "node" can 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 unaffected by this compatibility window. Plugin-owned node capabilities and commands stay withheld until the node upgrades to the current protocol because their hosted surfaces fall outside the N-1 contract.
  • Schemas and models derive from TypeBox definitions:
    • pnpm protocol:gen
    • pnpm protocol:gen:swift
    • pnpm protocol:check

Client constants

The reference client implementation sits in packages/gateway-client/src/ (OpenClaw wraps it via the slim src/gateway/client.ts facade). These defaults remain stable across protocol v4 and serve as the expected baseline for third-party clients.

ConstantDefaultSource
PROTOCOL_VERSION4packages/gateway-protocol/src/version.ts
MIN_CLIENT_PROTOCOL_VERSION4packages/gateway-protocol/src/version.ts
MIN_NODE_PROTOCOL_VERSION3packages/gateway-protocol/src/version.ts
MIN_PROBE_PROTOCOL_VERSION3packages/gateway-protocol/src/version.ts
Request timeout (per RPC)30_000 mspackages/gateway-client/src/client.ts (requestTimeoutMs)
Preauth / connect-challenge timeout15_000 mspackages/gateway-client/src/timeouts.ts (OPENCLAW_HANDSHAKE_TIMEOUT_MS env can raise the paired server/client budget)
Initial reconnect backoff1_000 mspackages/gateway-client/src/client.ts (GATEWAY_RECONNECT_POLICY)
Max reconnect backoff30_000 mspackages/gateway-client/src/client.ts (GATEWAY_RECONNECT_POLICY)
Fast-retry clamp after device-token close250 mspackages/gateway-client/src/client.ts
Force-stop grace before terminate()250 msFORCE_STOP_TERMINATE_GRACE_MS
stopAndWait() default timeout1_000 msSTOP_AND_WAIT_TIMEOUT_MS
Default tick interval (pre hello-ok)30_000 mspackages/gateway-client/src/client.ts
Tick-timeout closecode 4000 when silence exceeds tickIntervalMs * 2packages/gateway-client/src/client.ts
MAX_PAYLOAD_BYTES25 * 1024 * 1024 (25 MB)src/gateway/server-constants.ts
Chat attachment ceilingagents.defaults.mediaMaxMb, default 20 MB decodedsrc/gateway/chat-attachment-policy.ts
Chat attachment image ceilingmin(attachment ceiling, 6 MB)src/gateway/chat-attachment-policy.ts, packages/media-core/src/constants.ts

The effective policy.tickIntervalMs, policy.maxPayload, policy.maxBufferedBytes, and policy.attachments are announced by the server through hello-ok; clients should rely on those advertised values instead of the pre-handshake defaults or any hardcoded attachment limits.

When every pending request carries its own deadline, the reference client lets those finite requests govern their configured timeout. The tick watchdog stays engaged if an expectFinal request lacks a finite timeoutMs, if any request sets timeoutMs: null, or when finite and unbounded requests are mixed together. Should no inbound events or responses arrive before the tick-timeout threshold, the client terminates the socket with code 4000, fails all pending requests, and re-establishes the connection. Rejected requests are never retried after the reconnect.

Auth

  • Depending on the configured gateway.auth.mode ("none" | "token" | "password" | "trusted-proxy"), shared-secret gateway authentication relies on either connect.params.auth.token or connect.params.auth.password.
  • Connect auth checks are satisfied by identity-bearing modes like Tailscale Serve (gateway.auth.allowTailscale: true) or non-loopback gateway.auth.mode: "trusted-proxy", which pull from request headers rather than connect.params.auth.*.
  • With private-ingress gateway.auth.mode: "none", shared-secret connect auth is bypassed entirely; that mode should never be exposed on public or untrusted ingress.
  • Once pairing completes, the gateway hands out a device token tied to the connection role and approved grant, delivered in hello-ok.auth.deviceToken. After a successful connect, clients should save it via hello-ok.auth.scopes whenever the token is fresh or differs from what's stored.
  • The live authority for the current socket is hello-ok.auth.scopes, and it aligns with the scopes enforced by RPC dispatch.
  • When hello-ok.auth.deviceToken precisely matches the token already recorded for the same gateway, device, client, and role, keep that record's stored scopes rather than swapping in a narrower live scope set. A token that's newly issued or rotated uses hello-ok.auth.scopes; its approved grant matches that connection at issuance time.
  • Reconnecting with that stored device token should likewise reuse the stored approved scope set for it. This keeps read/probe/status access intact and prevents reconnects from silently dropping to a narrower implicit admin-only scope.
  • Client-side connect auth assembly (selectConnectAuth in packages/gateway-client/src/client.ts):
    • auth.password operates independently and is always forwarded when present.
    • auth.token gets filled by priority: an explicit shared token comes first, then an explicit deviceToken, then a stored per-device token (keyed by deviceId + role).
    • auth.bootstrapToken is only transmitted when none of the above resolved to auth.token. A shared token or any resolved device token keeps it from being sent.
    • Auto-promotion of a stored device token on the one-shot AUTH_TOKEN_MISMATCH retry is limited to trusted endpoints: loopback, or wss:// with a pinned tlsFingerprint. Public wss:// without pinning doesn't qualify.
  • The built-in setup-code bootstrap hands back the primary node hello-ok.auth.deviceToken plus a bounded operator token in hello-ok.auth.deviceTokens for trusted mobile handoff. That operator token carries operator.talk.secrets for native Talk configuration reads, but leaves out pairing-mutation scopes and operator.admin.
  • hello-ok.auth.deviceTokens holds only extra bootstrap-handoff tokens. Don't treat it as metadata for the primary deviceToken reconnect record.
  • While a non-baseline setup-code bootstrap awaits approval, PAIRING_REQUIRED details include recommendedNextStep: "wait_then_retry", retryable: true, and pauseReconnect: false. Keep reconnecting with the same bootstrap token until the request gets approved or the token goes invalid.
  • Persist hello-ok.auth.deviceTokens only when the connect used bootstrap auth over a trusted transport like wss:// or loopback/local pairing.
  • If a client passes an explicit deviceToken or explicit scopes, that caller-requested scope set stays authoritative for the live connection and shows up in hello-ok.auth.scopes; cached token-grant scopes are reused only when the client is reusing the stored per-device token.
  • Rotation or revocation of device tokens happens through device.token.rotate and device.token.revoke (which requires operator.pairing). Rotating or revoking a node or other non-operator role also demands operator.admin.
  • Rotation metadata comes back from device.token.rotate. It echoes the replacement bearer token only for same-device calls already authenticated with that device token, letting token-only clients persist their replacement before reconnecting. Shared/admin rotations don't echo the bearer token.
  • Token issuance, rotation, and revocation stay confined to the approved role set in that device's pairing entry; token mutation can't 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 holds 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 verify the target operator token scope set against the caller's current session scopes. Non-admin callers can't rotate or revoke a broader operator token than they already possess.
  • 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, halt automatic reconnect loops and present operator action guidance.
  • AUTH_SCOPE_MISMATCH indicates the device token was recognized but doesn't cover the requested role/scopes. Don't frame this as a bad token; guide the operator to re-pair or approve the narrower/broader scope contract.

Device identity and pairing

  • Every node should present a stable device identifier (device.id) generated from a keypair fingerprint.
  • Per-device and per-role tokens are issued by gateways.
  • New device IDs demand pairing approval unless local auto-approval is turned on.
  • Pairing auto-approval applies only to direct local loopback connections.
  • OpenClaw additionally provides a restricted backend/container-local self-connect route for trusted shared-secret helper flows.
  • LAN or same-host tailnet connections are still classified as remote for pairing purposes and still require approval.
  • WS clients typically send device identity during connect (operator plus node). The sole device-less operator cases are explicit trust paths:
    • a successful gateway.auth.mode: "trusted-proxy" operator Control UI authentication.
    • direct-loopback gateway-client backend RPCs on the reserved internal helper path.
  • Leaving out device identity carries scope implications. When a device-less operator connection is admitted via an explicit trust path, OpenClaw still resets self-declared scopes to an empty set unless that path carries a named scope-preservation exception. Methods gated by scope then fail with missing scope.
  • The reserved direct-loopback gateway-client backend helper path retains scopes only for internal local control-plane RPCs; custom backend IDs get no such exception.
  • Every connection must sign the server-supplied connect.challenge nonce.

Device auth migration diagnostics

For older clients relying on pre-challenge signing behavior, connect produces DEVICE_AUTH_* detail codes under error.details.code with a consistent error.details.reason.

Typical migration failures:

Messagedetails.codedetails.reasonMeaning
device nonce requiredDEVICE_AUTH_NONCE_REQUIREDdevice-nonce-missingClient left out device.nonce (or sent an empty value).
device nonce mismatchDEVICE_AUTH_NONCE_MISMATCHdevice-nonce-mismatchClient signed using an outdated or incorrect nonce.
device signature invalidDEVICE_AUTH_SIGNATURE_INVALIDdevice-signatureSignature payload fails to match the v2 payload.
device signature expiredDEVICE_AUTH_SIGNATURE_EXPIREDdevice-signature-staleSigned timestamp falls outside the permitted skew.
device identity mismatchDEVICE_AUTH_DEVICE_ID_MISMATCHdevice-id-mismatchdevice.id does not correspond to the public key fingerprint.
device public key invalidDEVICE_AUTH_PUBLIC_KEY_INVALIDdevice-public-keyPublic key format or canonicalization could not be processed.

Migration target:

  • Always wait for connect.challenge.
  • Employ connect.challenge.payload.ts as connect.params.device.signedAt.
  • Sign the v2 payload that incorporates the server nonce.
  • Echo the same nonce in connect.params.device.nonce.
  • The preferred signature payload is v3 (buildDeviceAuthPayloadV3 in packages/gateway-client/src/device-auth.ts), which also binds platform and deviceFamily alongside device/client/role/scopes/token/nonce fields.
  • Legacy v2 signatures remain accepted for compatibility, yet paired-device metadata pinning still governs command policy upon reconnect.

TLS and pinning

  • TLS works for WS connections (gateway.tls config).
  • Clients can optionally pin the gateway cert fingerprint through gateway.remote.tlsFingerprint or CLI --tls-fingerprint.

Scope

This protocol exposes the complete 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.

13,532 words · updated Aug 25, 2026