docs/gateway/protocol.md
The Gateway WS protocol is the single control plane + node transport for OpenClaw. All clients (CLI, web UI, macOS app, iOS/Android nodes, headless nodes) connect over WebSocket and declare their role + scope at handshake time.
connect request.hello-ok.policy.maxPayload and
hello-ok.policy.maxBufferedBytes limits. With diagnostics enabled,
oversized inbound frames and slow outbound buffers emit payload.large events
before the gateway closes or drops the affected frame. These events keep
sizes, limits, surfaces, and safe reason codes. They do not keep the message
body, attachment contents, raw frame body, tokens, cookies, or secret values.Gateway → Client (pre-connect challenge):
{
"type": "event",
"event": "connect.challenge",
"payload": { "nonce": "…", "ts": 1737264000000 }
}
Client → Gateway:
{
"type": "req",
"id": "…",
"method": "connect",
"params": {
"minProtocol": 3,
"maxProtocol": 3,
"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 → Client:
{
"type": "res",
"id": "…",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 3,
"server": { "version": "…", "connId": "…" },
"features": { "methods": ["…"], "events": ["…"] },
"snapshot": { "…": "…" },
"auth": {
"role": "operator",
"scopes": ["operator.read", "operator.write"]
},
"policy": {
"maxPayload": 26214400,
"maxBufferedBytes": 52428800,
"tickIntervalMs": 15000
}
}
}
While the Gateway is still finishing startup sidecars, the connect request can
return a retryable UNAVAILABLE error with details.reason set to
"startup-sidecars" and retryAfterMs. Clients should retry that response
within their overall connection budget instead of surfacing it as a terminal
handshake failure.
server, features, snapshot, and policy are all required by the schema
(src/gateway/protocol/schema/frames.ts). auth is also required and reports
the negotiated role/scopes. canvasHostUrl is optional.
When no device token is issued, hello-ok.auth reports the negotiated
permissions without token fields:
{
"auth": {
"role": "operator",
"scopes": ["operator.read", "operator.write"]
}
}
Trusted same-process backend clients (client.id: "gateway-client",
client.mode: "backend") may omit device on direct loopback connections when
they authenticate with the shared gateway token/password. This path is reserved
for internal control-plane RPCs and keeps stale CLI/device pairing baselines from
blocking local backend work such as subagent session updates. Remote clients,
browser-origin clients, node clients, and explicit device-token/device-identity
clients still use the normal pairing and scope-upgrade checks.
When a device token is issued, hello-ok also includes:
{
"auth": {
"deviceToken": "…",
"role": "operator",
"scopes": ["operator.read", "operator.write"]
}
}
During trusted bootstrap handoff, hello-ok.auth may also include additional
bounded role entries in deviceTokens:
{
"auth": {
"deviceToken": "…",
"role": "node",
"scopes": [],
"deviceTokens": [
{
"deviceToken": "…",
"role": "operator",
"scopes": ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"]
}
]
}
}
For the built-in node/operator bootstrap flow, the primary node token stays
scopes: [] and any handed-off operator token stays bounded to the bootstrap
operator allowlist (operator.approvals, operator.read,
operator.talk.secrets, operator.write). Bootstrap scope checks stay
role-prefixed: operator entries only satisfy operator requests, and non-operator
roles still need scopes under their own role prefix.
{
"type": "req",
"id": "…",
"method": "connect",
"params": {
"minProtocol": 3,
"maxProtocol": 3,
"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": "…"
}
}
}
{type:"req", id, method, params}{type:"res", id, ok, payload|error}{type:"event", event, payload, seq?, stateVersion?}Side-effecting methods require idempotency keys (see schema).
For the full operator scope model, approval-time checks, and shared-secret semantics, see Operator scopes.
operator = control plane client (CLI/UI/automation).node = capability host (camera/screen/canvas/system.run).Common scopes:
operator.readoperator.writeoperator.adminoperator.approvalsoperator.pairingoperator.talk.secretstalk.config with includeSecrets: true requires operator.talk.secrets
(or operator.admin).
Plugin-registered gateway RPC methods may request their own operator scope, but
reserved core admin prefixes (config.*, exec.approvals.*, wizard.*,
update.*) always resolve to operator.admin.
Method scope is only the first gate. Some slash commands reached through
chat.send apply stricter command-level checks on top. For example, persistent
/config set and /config unset writes require operator.admin.
node.pair.approve also has an extra approval-time scope check on top of the
base method scope:
operator.pairingoperator.pairing + operator.writesystem.run, system.run.prepare, or system.which:
operator.pairing + operator.adminNodes declare capability claims at connect time:
caps: high-level capability categories.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.
system-presence returns entries keyed by device identity.deviceId, roles, and scopes so UIs can show a single row per device
even when it connects as both operator and node.node.list includes optional lastSeenAtMs and lastSeenReason fields. Connected nodes report
their current connection time as lastSeenAtMs with reason connect; paired nodes can also report
durable background presence when a trusted node event updates their pairing metadata.Nodes may call node.event with event: "node.presence.alive" to record that a paired node was
alive during a background wake without marking it 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, or connect. Unknown trigger strings are normalized to
background by the gateway before persistence. The event is durable only for authenticated node
device sessions; device-less or unpaired sessions return handled: false.
Successful gateways return a structured result:
{
"ok": true,
"event": "node.presence.alive",
"handled": true,
"reason": "persisted"
}
Older gateways may still return { "ok": true } for node.event; clients should treat that as an
acknowledged RPC, not as durable presence persistence.
Server-pushed WebSocket broadcast events are scope-gated so that pairing-scoped or node-only sessions do not passively receive session content.
agent events and tool call results) require at least operator.read. Sessions without operator.read skip these frames entirely.plugin.* broadcasts are gated to operator.write or operator.admin, depending on how the plugin registered them.heartbeat, presence, tick, connect/disconnect lifecycle, etc.) remain unrestricted so transport health stays observable to every authenticated session.Each client connection keeps its own per-client sequence number so broadcasts preserve monotonic ordering on that socket even when different clients see different scope-filtered subsets of the event stream.
The public WS surface is broader than the handshake/auth examples above. This
is not a generated dump — hello-ok.features.methods is a conservative
discovery list built from src/gateway/server-methods-list.ts plus loaded
plugin/channel method exports. Treat it as feature discovery, not a full
enumeration of src/gateway/server-methods/*.ts.
chat: UI chat updates such as chat.inject and other transcript-only chat
events.session.message and session.tool: transcript/event-stream updates for a
subscribed session.sessions.changed: session index or metadata changed.presence: system presence snapshot updates.tick: periodic keepalive / liveness event.health: gateway health snapshot update.heartbeat: heartbeat event stream update.cron: cron run/job change event.shutdown: gateway shutdown notification.node.pair.requested / node.pair.resolved: node pairing lifecycle.node.invoke.request: node invoke request broadcast.device.pair.requested / device.pair.resolved: paired-device lifecycle.voicewake.changed: wake-word trigger config changed.exec.approval.requested / exec.approval.resolved: exec approval
lifecycle.plugin.approval.requested / plugin.approval.resolved: plugin approval
lifecycle.skills.bins to fetch the current list of skill executables
for auto-allow checks.commands.list (operator.read) to fetch the runtime
command inventory for an agent.
agentId is optional; omit it to read the default agent workspace.scope controls which surface the primary name targets:
text returns the primary text command token without the leading /native and the default both path return provider-aware native names
when availabletextAliases carries exact slash aliases such as /model and /m.nativeName carries the provider-aware native command name when one exists.provider is optional and only affects native naming plus native plugin
command availability.includeArgs=false omits serialized argument metadata from the response.tools.catalog (operator.read) to fetch the runtime tool catalog for an
agent. The response includes grouped tools and provenance metadata:
source: core or pluginpluginId: plugin owner when source="plugin"optional: whether a plugin tool is optionaltools.effective (operator.read) to fetch the runtime-effective tool
inventory for a session.
sessionKey is required.tools.invoke (operator.write) to invoke one available tool through the
same gateway policy path as /tools/invoke.
name is required. args, sessionKey, agentId, confirm, and
idempotencyKey are optional.sessionKey and agentId are present, the resolved session agent must match
agentId.ok, toolName, optional output, and typed
error fields. Approval or policy refusals return ok:false in the payload rather than
bypassing the gateway tool policy pipeline.skills.status (operator.read) to fetch the visible
skill inventory for an agent.
agentId is optional; omit it to read the default agent workspace.skills.search and skills.detail (operator.read) for
ClawHub discovery metadata.skills.install (operator.admin) in two modes:
{ source: "clawhub", slug, version?, force? } installs a
skill folder into the default agent workspace skills/ directory.{ name, installId, dangerouslyForceUnsafeInstall?, timeoutMs? }
runs a declared metadata.openclaw.install action on the gateway host.skills.update (operator.admin) in two modes:
skills.entries.<skillKey> values such as enabled,
apiKey, and env.models.list viewsmodels.list accepts an optional view parameter:
"default": current runtime behavior. If agents.defaults.models is configured, the response is the allowed catalog; otherwise the response is the full Gateway catalog."configured": picker-sized behavior. If agents.defaults.models is configured, it still wins. Otherwise the response uses explicit models.providers.*.models entries, falling back to the full catalog only when no configured model rows exist."all": full Gateway catalog, bypassing agents.defaults.models. Use this for diagnostics and discovery UIs, not normal model pickers.exec.approval.requested.exec.approval.resolve (requires operator.approvals scope).host=node, exec.approval.request must include systemRunPlan (canonical argv/cwd/rawCommand/session metadata). Requests missing systemRunPlan are rejected.node.invoke system.run calls reuse that canonical
systemRunPlan as the authoritative command/cwd/session context.command, rawCommand, cwd, agentId, or
sessionKey between prepare and the final approved system.run forward, the
gateway rejects the run instead of trusting the mutated payload.agent requests can include deliver=true to request outbound delivery.bestEffortDeliver=false keeps strict behavior: unresolved or internal-only delivery targets return INVALID_REQUEST.bestEffortDeliver=true allows fallback to session-only execution when no external deliverable route can be resolved (for example internal/webchat sessions or ambiguous multi-channel configs).PROTOCOL_VERSION lives in src/gateway/protocol/schema/protocol-schemas.ts.minProtocol + maxProtocol; the server rejects mismatches.pnpm protocol:genpnpm protocol:gen:swiftpnpm protocol:checkThe reference client in src/gateway/client.ts uses these defaults. Values are
stable across protocol v3 and are the expected baseline for third-party clients.
| Constant | Default | Source |
|---|---|---|
PROTOCOL_VERSION | 3 | src/gateway/protocol/schema/protocol-schemas.ts |
| Request timeout (per RPC) | 30_000 ms | src/gateway/client.ts (requestTimeoutMs) |
| Preauth / connect-challenge timeout | 15_000 ms | src/gateway/handshake-timeouts.ts (config/env can raise the paired server/client budget) |
| Initial reconnect backoff | 1_000 ms | src/gateway/client.ts (backoffMs) |
| Max reconnect backoff | 30_000 ms | src/gateway/client.ts (scheduleReconnect) |
| Fast-retry clamp after device-token close | 250 ms | src/gateway/client.ts |
Force-stop grace before terminate() | 250 ms | FORCE_STOP_TERMINATE_GRACE_MS |
stopAndWait() default timeout | 1_000 ms | STOP_AND_WAIT_TIMEOUT_MS |
Default tick interval (pre hello-ok) | 30_000 ms | src/gateway/client.ts |
| Tick-timeout close | code 4000 when silence exceeds tickIntervalMs * 2 | src/gateway/client.ts |
MAX_PAYLOAD_BYTES | 25 * 1024 * 1024 (25 MB) | src/gateway/server-constants.ts |
The server advertises the effective policy.tickIntervalMs, policy.maxPayload,
and policy.maxBufferedBytes in hello-ok; clients should honor those values
rather than the pre-handshake defaults.
connect.params.auth.token or
connect.params.auth.password, depending on the configured auth mode.gateway.auth.allowTailscale: true) or non-loopback
gateway.auth.mode: "trusted-proxy" satisfy the connect auth check from
request headers instead of connect.params.auth.*.gateway.auth.mode: "none" skips shared-secret connect auth
entirely; do not expose that mode on public/untrusted ingress.hello-ok.auth.deviceToken and should be
persisted by the client for future connects.hello-ok.auth.deviceToken after any
successful connect.selectConnectAuth in
src/gateway/client.ts):
auth.password is orthogonal and is always forwarded when set.auth.token is populated in priority order: explicit shared token first,
then an explicit deviceToken, then a stored per-device token (keyed by
deviceId + role).auth.bootstrapToken is sent only when none of the above resolved an
auth.token. A shared token or any resolved device token suppresses it.AUTH_TOKEN_MISMATCH retry is gated to trusted endpoints only —
loopback, or wss:// with a pinned tlsFingerprint. Public wss://
without pinning does not qualify.hello-ok.auth.deviceTokens entries are bootstrap handoff tokens.
Persist them only when the connect used bootstrap auth on a trusted transport
such as wss:// or loopback/local pairing.deviceToken or explicit scopes, that
caller-requested scope set remains authoritative; cached scopes are only
reused when the client is reusing the stored per-device token.device.token.rotate and
device.token.revoke (requires operator.pairing scope).device.token.rotate returns rotation metadata. It echoes the replacement
bearer token only for same-device calls that are already authenticated with
that device token, so token-only clients can persist their replacement before
reconnecting. Shared/admin rotations do not echo the bearer token.operator.admin: non-admin callers can remove/revoke/rotate
only their own device entry.device.token.rotate and device.token.revoke also check the target operator
token scope set against the caller's current session scopes. Non-admin callers
cannot rotate or revoke a broader operator token than they already hold.error.details.code plus recovery hints:
error.details.canRetryWithDeviceToken (boolean)error.details.recommendedNextStep (retry_with_device_token, update_auth_configuration, update_auth_credentials, wait_then_retry, review_auth_configuration)AUTH_TOKEN_MISMATCH:
device.id) derived from a
keypair fingerprint.device identity during connect (operator +
node). The only device-less operator exceptions are explicit trust paths:
gateway.controlUi.allowInsecureAuth=true for localhost-only insecure HTTP compatibility.gateway.auth.mode: "trusted-proxy" operator Control UI auth.gateway.controlUi.dangerouslyDisableDeviceAuth=true (break-glass, severe security downgrade).gateway-client backend RPCs authenticated with the shared
gateway token/password.connect.challenge nonce.For legacy clients that still use pre-challenge signing behavior, connect now returns
DEVICE_AUTH_* detail codes under error.details.code with a stable error.details.reason.
Common migration failures:
| Message | details.code | details.reason | Meaning |
|---|---|---|---|
device nonce required | DEVICE_AUTH_NONCE_REQUIRED | device-nonce-missing | Client omitted device.nonce (or sent blank). |
device nonce mismatch | DEVICE_AUTH_NONCE_MISMATCH | device-nonce-mismatch | Client signed with a stale/wrong nonce. |
device signature invalid | DEVICE_AUTH_SIGNATURE_INVALID | device-signature | Signature payload does not match v2 payload. |
device signature expired | DEVICE_AUTH_SIGNATURE_EXPIRED | device-signature-stale | Signed timestamp is outside allowed skew. |
device identity mismatch | DEVICE_AUTH_DEVICE_ID_MISMATCH | device-id-mismatch | device.id does not match public key fingerprint. |
device public key invalid | DEVICE_AUTH_PUBLIC_KEY_INVALID | device-public-key | Public key format/canonicalization failed. |
Migration target:
connect.challenge.connect.params.device.nonce.v3, which binds platform and deviceFamily
in addition to device/client/role/scopes/token/nonce fields.v2 signatures remain accepted for compatibility, but paired-device
metadata pinning still controls command policy on reconnect.gateway.tls
config plus gateway.remote.tlsFingerprint or CLI --tls-fingerprint).This protocol exposes the full gateway API (status, channels, models, chat,
agent, sessions, nodes, approvals, etc.). The exact surface is defined by the
TypeBox schemas in src/gateway/protocol/schema.ts.