Openclaw · EN · Part 2
Ch 04 Gateway Protocol Deep Dive
04-ch-04-Gateway-Protocol-Deep-Dive.md
第4章 Gateway Protocol Deep Dive
Understanding the Gateway protocol is essential for anyone working with OpenClaw beyond casual usage. Every CLI command, mobile app connection, and channel integration ultimately speaks this protocol. It's the wire contract that binds the system together—a typed WebSocket protocol with ~40 RPC methods, strict validation, and bidirectional streaming.
Let's examine what happens on the wire when you run openclaw agent "hello" or when your iOS app connects to the Gateway.
Protocol Schema and TypeBox
OpenClaw doesn't treat protocol as an afterthought. The Gateway wire protocol is defined first-class using TypeBox schemas in src/gateway/protocol/schema/. Every frame, parameter, and response has a corresponding TypeScript type derived from these schemas.
// src/gateway/protocol/schema/frames.ts:139-169
export const RequestFrameSchema = Type.Object(
{
type: Type.Literal("req"),
id: NonEmptyString,
method: NonEmptyString,
params: Type.Optional(Type.Unknown()),
},
{ additionalProperties: false },
);
export const ResponseFrameSchema = Type.Object(
{
type: Type.Literal("res"),
id: NonEmptyString,
ok: Type.Boolean(),
payload: Type.Optional(Type.Unknown()),
error: Type.Optional(ErrorShapeSchema),
},
{ additionalProperties: false },
);
export const EventFrameSchema = Type.Object(
{
type: Type.Literal("event"),
event: NonEmptyString,
payload: Type.Optional(Type.Unknown()),
seq: Type.Optional(Type.Integer({ minimum: 0 })),
stateVersion: Type.Optional(StateVersionSchema),
},
{ additionalProperties: false },
);
The protocol defines three frame types that flow over the WebSocket connection:
| Frame Type | Direction | Purpose |
|---|---|---|
RequestFrame | Client → Gateway | RPC method calls with parameters |
ResponseFrame | Gateway → Client | Correlated responses or errors |
EventFrame | Gateway → Client | Server-pushed events (agent output, status) |
The type field serves as a discriminator for TypeScript's discriminated unions. This means:
type GatewayFrame = RequestFrame | ResponseFrame | EventFrame;
function handleFrame(frame: GatewayFrame) {
switch (frame.type) {
case "req":
// TypeScript knows frame.method exists here
console.log(`Calling ${frame.method}`);
break;
case "res":
// TypeScript knows frame.ok and frame.payload exist here
console.log(`Response: ${frame.ok ? 'success' : 'error'}`);
break;
case "event":
// TypeScript knows frame.event and frame.seq exist here
console.log(`Event: ${frame.event}`);
break;
}
}
🤿 深入底层:TypeBox was chosen over Zod for protocol schemas because it generates JSON Schema compatible with code generation tools (quicktype) and validates efficiently at runtime without compile-time dependencies. The schemas live in
protocol-schemas.tsand are exported asProtocolSchemas—a const object mapping schema names to their TypeBox definitions.
40+ RPC Methods
The protocol exposes methods across several domains:
- Sessions:
sessions.create,sessions.send,sessions.list,messages.subscribe,messages.unsubscribe - Config:
config.get,config.set,config.apply,config.patch,config.schema - Channels:
channels.status,channels.logout,webLogin.start,webLogin.wait - Agents:
agents.create,agents.update,agents.delete,agents.files.list,agents.files.set - Nodes:
node.list,node.describe,node.pair.request,node.pair.approve - Tools:
tools.catalog,tools.effective - Wizard:
wizard.start,wizard.next,wizard.cancel,wizard.status
Each method has corresponding parameter and result schemas. For example, sessions.create uses SessionsCreateParamsSchema:
// src/gateway/protocol/schema/sessions.ts:11-28
export const SessionsCreateParamsSchema = Type.Object(
{
to: Type.Optional(NonEmptyString),
agentId: Type.Optional(NonEmptyString),
label: Type.Optional(SessionLabelString),
activationMode: Type.Optional(
Type.String({ enum: ["auto", "manual", "foreground"] }),
),
queueMode: Type.Optional(
Type.String({ enum: ["live", "drain", "off"] }),
),
dryRun: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
Schema Validation
Frames are validated on receipt using the validateRequestFrame, validateResponseFrame, and validateEventFrame functions. Invalid frames are rejected before processing—this prevents malformed data from reaching business logic.
⚠️ 避坑指南:When extending the protocol, always add schemas first. The TypeBox definitions are the source of truth. Runtime validators, TypeScript types, and documentation are all derived from them. Skipping schema validation leads to "works on my machine" bugs that only surface in production with unexpected payloads.
Connection Lifecycle
A Gateway connection follows a defined sequence: WebSocket connect → challenge-response handshake → device authorization → session establishment. Let's trace this flow end-to-end.
Rendering diagram...
Step 1: WebSocket Connection
The client initiates a WebSocket connection to ws://127.0.0.1:18789 (default) or a configured URL:
const ws = new WebSocket("ws://127.0.0.1:18789", {
maxPayload: 25 * 1024 * 1024, // 25MB for snapshots
});
☕ 茶水间时间:Why 25MB? Early OpenClaw versions used smaller payloads (1MB), which caused failures when mobile apps requested node screen snapshots. The connection would close mid-transfer with "frame too large" errors. Raising the limit to 25MB accommodated full-resolution screenshots while staying well under WebSocket's 256MB theoretical maximum.
Step 2: Challenge-Response Handshake
On connection, the Gateway sends a challenge frame with a cryptographic nonce:
// Client receives: {"type":"challenge","nonce":"random-nonce-here"}
The client must sign this nonce with its device identity (if available) and include the signature in the connect request:
// src/gateway/client.ts:436-460
const payload = buildDeviceAuthPayloadV3({
deviceId: this.opts.deviceIdentity.deviceId,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
role,
scopes,
signedAtMs,
token: signatureToken ?? null,
nonce,
platform,
deviceFamily: this.opts.deviceFamily,
});
const signature = signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload);
Step 3: Connect Request
The client sends a connect request with authentication:
// src/gateway/protocol/schema/protocol-schemas.ts:20-70
export const ConnectParamsSchema = Type.Object({
minProtocol: Type.Integer({ minimum: 1 }),
maxProtocol: Type.Integer({ minimum: 1 }),
client: Type.Object({
id: GatewayClientIdSchema,
displayName: Type.Optional(NonEmptyString),
version: NonEmptyString,
platform: NonEmptyString,
deviceFamily: Type.Optional(NonEmptyString),
mode: GatewayClientModeSchema,
instanceId: Type.Optional(NonEmptyString),
}),
caps: Type.Optional(Type.Array(NonEmptyString, { default: [] })),
commands: Type.Optional(Type.Array(NonEmptyString)),
permissions: Type.Optional(Type.Record(NonEmptyString, Type.Boolean())),
role: Type.Optional(NonEmptyString),
scopes: Type.Optional(Type.Array(NonEmptyString)),
device: Type.Optional(Type.Object({
id: NonEmptyString,
publicKey: NonEmptyString,
signature: NonEmptyString,
signedAt: Type.Integer({ minimum: 0 }),
nonce: NonEmptyString,
})),
auth: Type.Optional(Type.Object({
token: Type.Optional(Type.String()),
bootstrapToken: Type.Optional(Type.String()),
deviceToken: Type.Optional(Type.String()),
password: Type.Optional(Type.String()),
})),
});
Actual connect frame example:
{
"type": "req",
"id": "connect-1",
"method": "connect",
"params": {
"minProtocol": 1,
"maxProtocol": 1,
"client": {
"id": "gateway-client",
"version": "2026.4.4",
"platform": "darwin",
"mode": "backend"
},
"role": "operator",
"scopes": ["operator.admin"],
"device": {
"id": "device-abc123",
"publicKey": "base64-encoded-public-key",
"signature": "base64-encoded-signature",
"signedAt": 1723456789000,
"nonce": "challenge-nonce-from-gateway"
}
}
}
Step 4: HelloOk Response
The Gateway responds with HelloOk containing server info and issued tokens:
// src/gateway/protocol/schema/protocol-schemas.ts:72-126
export const HelloOkSchema = Type.Object({
type: Type.Literal("hello-ok"),
protocol: Type.Integer({ minimum: 1 }),
server: Type.Object({
version: NonEmptyString,
connId: NonEmptyString,
}),
features: Type.Object({
methods: Type.Array(NonEmptyString),
events: Type.Array(NonEmptyString),
}),
snapshot: SnapshotSchema,
canvasHostUrl: Type.Optional(NonEmptyString),
auth: Type.Optional(Type.Object({
deviceToken: NonEmptyString,
role: NonEmptyString,
scopes: Type.Array(NonEmptyString),
issuedAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
})),
policy: Type.Object({
maxPayload: Type.Integer({ minimum: 1 }),
maxBufferedBytes: Type.Integer({ minimum: 1 }),
tickIntervalMs: Type.Integer({ minimum: 1 }),
}),
});
Example HelloOk:
{
"type": "res",
"id": "connect-1",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 1,
"server": {
"version": "2026.4.4",
"connId": "conn-xyz789"
},
"features": {
"methods": ["sessions.create", "config.get", ...],
"events": ["agent.event", "tick", ...]
},
"auth": {
"deviceToken": "issued-device-token",
"role": "operator",
"scopes": ["operator.admin"],
"issuedAtMs": 1723456789000
},
"policy": {
"maxPayload": 26214400,
"maxBufferedBytes": 1048576,
"tickIntervalMs": 30000
}
}
}
⚠️ 避坑指南:The Gateway has a connect challenge timeout (default ~10 seconds). If the client doesn't send
connectwithin this window, the connection closes with reason "connect challenge timeout". This prevents zombie connections from accumulating. UseconnectChallengeTimeoutMsoption to adjust for slow networks.
Step 5: Reconnection Logic
The client implements exponential backoff reconnection:
// src/gateway/client.ts:159-160
private backoffMs = 1000; // # [1]
After a failed connection, the client waits backoffMs before reconnecting. The backoff doubles on each failure up to ~30 seconds. On successful connection, backoffMs resets to 1000ms.
Request/Response Pattern
Once connected, clients make RPC calls using the request/response pattern. Each request gets a unique ID for correlation.
// src/gateway/client.ts:620-670
async request<T = unknown>(method: string, params?: unknown): Promise<T> {
const id = randomUUID();
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`gateway request timeout: ${method}`));
}, this.requestTimeoutMs);
this.pending.set(id, { resolve, reject, expectFinal: true, timeout });
this.ws?.send(JSON.stringify({
type: "req",
id,
method,
params,
}));
});
}
The client tracks pending requests in a Map<string, Pending>. When a ResponseFrame arrives:
// src/gateway/client.ts:750-780
private handleMessage(data: string) {
const frame = JSON.parse(data);
if (frame.type === "res") {
const pending = this.pending.get(frame.id);
if (pending) {
clearTimeout(pending.timeout);
this.pending.delete(frame.id);
if (frame.ok) {
pending.resolve(frame.payload);
} else {
pending.reject(new GatewayClientRequestError(frame.error ?? {}));
}
}
} else if (frame.type === "event") {
this.opts.onEvent?.(frame);
}
}
Request ID Generation
Request IDs are UUID v4 strings. This ensures uniqueness across concurrent requests and allows multiple pending calls for the same method:
// Multiple concurrent sessions.create calls
const promise1 = client.request("sessions.create", { to: "user1" });
const promise2 = client.request("sessions.create", { to: "user2" });
const promise3 = client.request("sessions.create", { to: "user3" });
// All three requests are in flight simultaneously
const [session1, session2, session3] = await Promise.all([promise1, promise2, promise3]);
Timeout Handling
Default request timeout is 30 seconds (requestTimeoutMs option). Requests exceeding this timeout fail with GatewayClientRequestError:
class GatewayClientRequestError extends Error {
readonly gatewayCode: string;
readonly details?: unknown;
constructor(error: GatewayClientErrorShape) {
super(error.message ?? "gateway request failed");
this.name = "GatewayClientRequestError";
this.gatewayCode = error.code ?? "UNAVAILABLE";
this.details = error.details;
}
}
Error Responses
Gateway errors follow a structured format:
// src/gateway/protocol/schema/frames.ts:128-137
export const ErrorShapeSchema = Type.Object({
code: NonEmptyString,
message: NonEmptyString,
details: Type.Optional(Type.Unknown()),
retryable: Type.Optional(Type.Boolean()),
retryAfterMs: Type.Optional(Type.Integer({ minimum: 0 })),
});
Example error response:
{
"type": "res",
"id": "req-123",
"ok": false,
"error": {
"code": "SESSIONS_CREATE_TO_REQUIRED",
"message": "'to' is required when creating a session",
"details": { "field": "to" }
}
}
🤿 深入底层:The
retryableandretryAfterMsfields enable intelligent client-side retry logic. For example, rate limit errors (AUTH_RATE_LIMITED) includeretryAfterMsindicating when to retry. The client respects these hints instead of blind retry loops.
Event Streaming
The Gateway pushes events to connected clients using EventFrame. These are server-initiated messages not tied to any request.
AgentEvent Streaming
The most important event type is AgentEvent—the primary output channel for agent responses:
// src/gateway/protocol/schema/agent.ts:21-30
export const AgentEventSchema = Type.Object({
runId: NonEmptyString,
seq: Type.Integer({ minimum: 0 }),
stream: NonEmptyString,
ts: Type.Integer({ minimum: 0 }),
data: Type.Record(Type.String(), Type.Unknown()),
});
Example agent event:
{
"type": "event",
"event": "agent.event",
"seq": 42,
"stateVersion": { "sessions": "v3", "config": "v12" },
"payload": {
"runId": "run-abc123",
"seq": 5,
"stream": "main",
"ts": 1723456789123,
"data": {
"type": "text",
"content": "Hello! How can I help you today?"
}
}
}
Agent events contain multiple stream types:
| Stream | Purpose |
|---|---|
main | Primary agent output (text, tool calls) |
blocks | Structured content blocks (code, tables) |
debug | Debug and diagnostic information |
tool | Tool execution events |
Tool Events
When an agent invokes a tool, events stream the invocation status:
{
"type": "event",
"event": "agent.event",
"seq": 43,
"payload": {
"runId": "run-abc123",
"seq": 6,
"stream": "tool",
"data": {
"type": "tool_call",
"toolName": "web.search",
"toolArgs": { "query": "OpenClaw documentation" },
"status": "executing"
}
}
}
Followed by the result:
{
"type": "event",
"event": "agent.event",
"seq": 44,
"payload": {
"runId": "run-abc123",
"seq": 7,
"stream": "tool",
"data": {
"type": "tool_result",
"toolName": "web.search",
"status": "success",
"result": { /* search results */ }
}
}
}
Status Updates
The Gateway sends periodic tick events (every 30 seconds by default):
// src/gateway/protocol/schema/protocol-schemas.ts:5-10
export const TickEventSchema = Type.Object({
ts: Type.Integer({ minimum: 0 }),
});
{
"type": "event",
"event": "tick",
"payload": {
"ts": 1723456819000
}
}
Clients use tick events to detect silent stalls (no messages received) and trigger reconnection if the Gateway appears dead.
Sequence Numbers and Gaps
Events include seq (sequence numbers) for ordering and gap detection:
// src/gateway/client.ts:161
private lastSeq: number | null = null;
// In message handler
if (frame.seq !== undefined) {
if (this.lastSeq !== null && frame.seq !== this.lastSeq + 1) {
this.opts.onGap?.({
expected: this.lastSeq + 1,
received: frame.seq
});
}
this.lastSeq = frame.seq;
}
The onGap callback notifies the application when events were missed. This can happen due to network issues or buffer overflows.
State Versioning
Events include stateVersion indicating the Gateway's internal state version:
{
"stateVersion": {
"sessions": "v3",
"config": "v12",
"nodes": "v1"
}
}
Clients can detect when Gateway state changes and refresh their local caches.
☕ 茶水间时间:Event streaming was a major protocol addition. Early OpenClaw used polling for agent output—clients repeatedly called
sessions.messagesto check for new messages. This was inefficient, caused high CPU usage, and had noticeable latency. Switching to server-sent events reduced latency by ~80% and eliminated polling overhead.
Subscription Patterns
Some events require explicit subscriptions. For session messages, use sessions.messages.subscribe:
await client.request("sessions.messages.subscribe", {
sessionKey: "main:[email protected]",
});
After subscribing, the Gateway pushes message events for that session:
{
"type": "event",
"event": "message",
"payload": {
"sessionKey": "main:[email protected]",
"role": "assistant",
"content": "Here's what I found..."
}
}
This chapter covered the Gateway protocol fundamentals: TypeBox schemas, connection lifecycle, request/response patterns, and event streaming. Now you understand the wire contract that powers every OpenClaw interaction.
Try this:
- Implement a basic Gateway client from scratch using the WebSocket API
- Trace a complete
sessions.createrequest and response flow - Document all agent-related events and their stream types
Next, we'll explore how agents actually run and respond to messages through this protocol.