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 TypeDirectionPurpose
RequestFrameClient → GatewayRPC method calls with parameters
ResponseFrameGateway → ClientCorrelated responses or errors
EventFrameGateway → ClientServer-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.ts and are exported as ProtocolSchemas—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 connect within this window, the connection closes with reason "connect challenge timeout". This prevents zombie connections from accumulating. Use connectChallengeTimeoutMs option 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 retryable and retryAfterMs fields enable intelligent client-side retry logic. For example, rate limit errors (AUTH_RATE_LIMITED) include retryAfterMs indicating 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:

StreamPurpose
mainPrimary agent output (text, tool calls)
blocksStructured content blocks (code, tables)
debugDebug and diagnostic information
toolTool 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.messages to 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:

  1. Implement a basic Gateway client from scratch using the WebSocket API
  2. Trace a complete sessions.create request and response flow
  3. Document all agent-related events and their stream types

Next, we'll explore how agents actually run and respond to messages through this protocol.