从 0 到 1:用 Node 打通 OpenClaw WebSocket 通信全流程

0 阅读14分钟

引言

书接上回, 我们在 OpenClaw 上手实践: 使用 Docker 从构建到可用全流程指南 介绍了, 如果通过 Docker 来快速部署 OpenClaw

其实呢, 这边想要借助 OpenClaw昆仑虚 搭一个个人的 AI 应用, 这里希望整体架构如下:

image

这边 Node 服务端就是做了中间层的转发, 但是这么做有什么好处呢?

  • 权限: 可以进行很好的权限管理, OpenClaw 仅运行 Node 服务进行访问, 不对外开放
  • 多用户: 可以将 sessionagentmessage 等内容按用户进行隔离, 甚至可以一个用户分配一个独立隔离的 OpenClaw(容器)
  • 定制化: 要想做应用, 必然会有很对定制信息, 比如设置 Agent 的头像等。这边我们只需要 OpenClaw 调度大模型的能力, 其他的就希望完全定制。

所以接下来最重要的就是, 在 Node 服务端要如何和 OpenClaw 进行协作(通信), 这也正是接下来我们要聊的....

一、OpenClaw 架构

如图, 是 OpenClaw 的整体架构

image

1.1 智能体运行时环境

这里是整个核心, 是真正干活的核心引擎, 也是我想要的核心能力, 这边主要就是:

  1. 负责拼装 promptcontext
  2. 调度各种大模型
  3. 协调各种 AgentSkillTools 的执行
  4. 保存各种配置、回话记录

当然这边其实没这么简单, 只是想说明这边主要就是核心干活的地方

1.2 网关层

外界各个应用、服务、IM 如何通知引擎部分让 Agent 开始干活? 而引擎部分又如何告知外界 Agent 处理的结果? 而它们之间又是怎么鉴权的? 怎么通信的? 这都是网关层进行控制的。

image

OpenClaw 通过 WebSocket 并定义了一套协议, 来链接 "外界" 和 "引擎"

如下所示, 是外界通过 ws 连接到 OpenClaw 网关, 并约定好的参数(协议)来调用 "引擎" 干活:

import WebSocket from 'ws';

const ws = new WebSocket('ws://127.0.0.1:18789');

ws.send(JSON.stringify({
  type: 'req',  // 请求类型,固定为 req
  id: '任意唯一ID', // 请求 ID
  method: 'chat.send', // 请求内容
  params: {}, // 请求参数,根据不同 method 定义不同的参数结构
}));

同时, 外界也是通过 ws 来监听网关发来的消息, 来获取 "引擎" 广播的消息:

// 监听 OpenClaw 广播的消息
ws.on('message', (data) => {
  
});

// 可能数据如下
{
  "type": "event",
  "event": "chat",
  "payload": {
    "runId": "同一个 runId",
    "sessionKey": "main",
    "seq": 1,
    "state": "delta",
    "message": {
      "role": "assistant",
      "content": [
        { "type": "text", "text": "正在生成中的文本" }
      ],
      "timestamp": 1710000000000
    }
  }
}

我们可能习惯性通过 REST API 来调用第三方服务提供的接口来获取数据、修改数据, 但这边则全部走 WebSocket 并通过约定好的协议来完成所有事情

// 拉历史
ws.send(JSON.stringify({
  type: "req",
  id: "history-1", // 自定义请求 AI
  method: "chat.history", // 具体请求方法
  params: {} // 参数
}));

// 拉 agent 列表
ws.send(JSON.stringify({
  type: "req",
  id: "agents-1", // 自定义请求 AI
  method: "agents.list", // 具体请求方法
  params: {} // 参数
}));

1.3 其他层

  1. 工具与能力层: 本质上大模型是不具备各种调用工具的能力的, 所有工具的调用都是在本地完成, 并将调用结果告诉大模型。大模型再进行决策, 而这边工具与能力层就是提供各种工具能力, 来供 OpenClaw 来调度, 在需要时 OpenClaw 会调用相关工具来完成各类工作, 并将工具调用结果返回给大模型

  2. 接口控制层、消息通讯渠道: 这边其实就是针对各种场景、IM, 来做一些兼容处理, 使得能够顺利接入网关。

二、握手流程

参考文档: Gateway 网关协议 - 握手

如下图所示:

  1. 当客户端与 OpenClaw 网关连接建立后
  2. 网关会立刻发送 connect.challenge 事件(消息)
  3. 客户端需要紧接着发送 connect 请求(含鉴权信息)
  4. 网关层鉴权成功则返回 hello-ok 响应, 否则则关闭连接

image

如下代码所示, 是一个最简化的 DEMO:

import WebSocket from 'ws';
// 1. 建立连接
const ws = new WebSocket('ws://127.0.0.1:18789'); 

ws.on('message', (data) => {
  const msg = JSON.parse(data.toString());

  // 2. 网关发送 connect.challenge 事件(消息)
  if (msg.type === 'event' && msg.event === 'connect.challenge') {
    console.log('🔐 receive challenge');

    // 3. 客户端紧接着发送 connect 请求(含鉴权信息)
    ws.send(JSON.stringify({
      id: '1', // 唯一 ID 客户端自己随便写即可
      type: 'req', 
      method: 'connect',
      params: {
        minProtocol: 3,
        maxProtocol: 3,
        client: {
          id: 'cli', 
          version: '1.0.0',
          platform: 'node',
          mode: 'node',
        },
        role: 'operator',
        scopes: [
          'operator.read',
          'operator.write',
          'operator.admin',
          'operator.approvals',
          'operator.pairing',
        ],
        auth: { token: '9e1a21f5555asdsads555666666666df3f81' }, // 换成你自己的 OpenClaw 登陆 Token
      },
    }));
  }

  // 4. 网关层鉴权成功则返回 hello-ok 响应
  if msg.payload?.type === 'hello-ok') {
    console.log('🎉 connected success');
  }
});

// 其他事件
ws.on('open', () => console.log('✅ connected'));
ws.on('close', () => console.log('✅ connected'));

使用 Node 运行结果如下:

image

三、简单通信

上面我们简单演示了和 OpenClaw 网关建立握手连接, 但是实际上还缺了设备鉴权、授权这部分内容, 如果想要调用一些操作就需要把这部分补全...

3.1 设备身份鉴权

这边其实就是:

  • 根据 OpenClaw 自己的一套加密方式, 在客户端生成唯一设备 ID、公钥、私钥
  • 在握手阶段认证阶段, 需要按 OpenClaw 定义的规则, 生成相关的签名、设备信息, 一同传给网关层
  • 并且在首次设备连接时, 需要在 OpenClaw 进行设备的授权
  • 需要注意的是: 我们生成的设备 ID、公钥、私钥, 应该是固定不变的, 不应该每次都动态生成(实际场景中, 我们需要进行缓存, 或者加到服务配置中)

下面是一份完整的设备信息、签名生成代码:

import crypto from 'node:crypto';
import fs from 'fs';

const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');

// base64url 编码
const base64UrlEncode = (buf) => buf.toString('base64').replaceAll('+', '-')
  .replaceAll('/', '_')
  .replace(/=+$/g, '');

// 从 PEM 格式的公钥中提取原始公钥数据,并进行 base64url 编码
const derivePublicKeyRaw = (publicKeyPem) => {
  const key = crypto.createPublicKey(publicKeyPem);
  const spki = key.export({ type: 'spki', format: 'der' });

  if (
    spki.length === ED25519_SPKI_PREFIX.length + 32 &&
    spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
  ) {
    return base64UrlEncode(spki.subarray(ED25519_SPKI_PREFIX.length));
  }

  return base64UrlEncode(spki);
};

// 从原始公钥数据派生设备 ID,通常是公钥的 SHA-256 哈希值
const deriveDeviceIdFromPublicKey = (publicKeyRawBase64Url) => crypto
  .createHash('sha256')
  .update(Buffer.from(publicKeyRawBase64Url, 'base64url'))
  .digest('hex');

// 创建网关设备身份,包括生成密钥对和设备 ID
const createGatewayDeviceIdentity = () => {
  // 如果已经存在设备身份文件,则直接读取并返回
  if (fs.existsSync('./device_identity.json')) {
    const content = fs.readFileSync('./device_identity.json', 'utf-8');
    return JSON.parse(content);
  }

  const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');

  const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
  const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
  const publicKeyRaw = derivePublicKeyRaw(publicKeyPem);
  const deviceId = deriveDeviceIdFromPublicKey(publicKeyRaw);

  const identity = {
    deviceId,
    privateKeyPem,
    publicKeyPem,
    publicKeyRaw,
  };

  // 将生成的设备身份信息保存到文件中,供后续使用
  fs.writeFileSync('./device_identity.json', JSON.stringify(identity, null, 2), 'utf-8');

  return identity;
};

// 构建设备认证信息,包括生成签名等
export const buildDeviceAuthPayloadV3 = (params) => [
  'v3',
  params.deviceId,
  params.clientId,
  params.clientMode,
  params.role,
  params.scopes.join(','),
  String(params.signedAtMs),
  params.token ?? '',
  params.nonce,
  params.platform ?? '',
  params.deviceFamily ?? '',
].join('|');

// 使用设备的私钥对认证负载进行签名,生成 base64url 编码的签名字符串
export const signDevicePayload = (privateKeyPem, payload) => crypto.sign(null, Buffer.from(payload, 'utf8'), privateKeyPem).toString('base64url');

// 构建网关设备认证信息,供连接网关时使用
export const buildGatewayDeviceAuth = (params) => {
  const signedAt = Date.now();
  const identity = createGatewayDeviceIdentity();

  const payload = buildDeviceAuthPayloadV3({
    deviceId: identity.deviceId,
    clientId: params.clientId,
    clientMode: params.clientMode,
    role: params.role,
    scopes: params.scopes,
    signedAtMs: signedAt,
    token: params.token,
    nonce: params.nonce,
    platform: params.platform,
    deviceFamily: params.deviceFamily,
  });

  const signature = signDevicePayload(identity.privateKeyPem, payload);

  return {
    signedAt,
    signature,
    nonce: params.nonce,
    id: identity.deviceId,
    publicKey: identity.publicKeyRaw,
  };
};

下面是完整连接 OpenClaw 网关代码, 这边调用 buildGatewayDeviceAuth 来生成设备签名等信息:

import WebSocket from 'ws';
import { buildGatewayDeviceAuth } from './device.mjs';

const REQUESTED_SCOPES = ['operator.admin'];
const CLIENT = {
  id: 'cli',
  version: '1.0.0',
  platform: 'node',
  mode: 'node',
  deviceFamily: 'desktop',
};

const GATEWAY_TOKEN = 'your-real-token';
const ws = new WebSocket('ws://127.0.0.1:18789');

ws.on('message', (data) => {
  const msg = JSON.parse(data.toString());

  // 1️⃣ 先接 challenge
  if (msg.type === 'event' && msg.event === 'connect.challenge') {
    console.log('🔐 receive challenge', msg.payload);
    const device = buildGatewayDeviceAuth({
      role: 'operator',
      nonce: msg.payload?.nonce ?? '',
      token: GATEWAY_TOKEN,
      clientId: CLIENT.id,
      clientMode: CLIENT.mode,
      scopes: REQUESTED_SCOPES,
      platform: CLIENT.platform,
      deviceFamily: CLIENT.deviceFamily,
    });

    ws.send(JSON.stringify({
      type: 'req',
      id: '1',
      method: 'connect',
      params: {
        device,
        minProtocol: 3,
        maxProtocol: 3,
        client: CLIENT,
        role: 'operator',
        scopes: REQUESTED_SCOPES,
        auth: { token: GATEWAY_TOKEN },
      },
    }));
  }

  // 2️⃣ connect 成功
  if (msg.payload?.type === 'hello-ok') {
    console.log('🎉 connected success');
  }

  console.log('👀 receive message', msg);
});


// 其他事件
ws.on('open', () => console.log('✅ connected'));
ws.on('close', () => console.log('✅ connected'));

执行上面连接 OpenClaw 脚本, 连接能够成功, 同时还会提示需要配对:

image

进入 OpenClaw 容器内部, 进行设备授权:

docker exec -it openclaw bash # 进入 openclaw 容器
openclaw devices list # 查看当前设备连接情况
openclaw devices approve b5950461-e541-4114-9165-413fb3e7afe2 # 授权设备 b5950461-e541-4114-9165-413fb3e7afe2

image

3.1 发起对话

如下代码所示:

  • 在连接 OpenClaw 网关成功之后, 1秒 后我们立马发起一轮对话
  • 发送对话本质上其实就是调用 webSocket.send 方法, 并定义合适的 methodparams 等参数
  • 最后我们再通过监听 message 类型, 来获取大模型输出内容
// connect 成功
if (msg.payload?.type === 'hello-ok') {
  console.log('🎉 connected success');

  // 发 chat
  setTimeout(() => {
    ws.send(JSON.stringify({
      type: 'req',
      id: Math.random().toString(16),
      method: 'chat.send',
      params: {
        sessionKey: 'agent:main:main',
        message: '你好,世界!',
        idempotencyKey: Math.random().toString(16), // 确保消息幂等, 避免重复发送
      },
    }));
  }, 1000);
}

// 接收消息
if (msg.type === 'event' && msg.event === 'agent') {
  console.log('💬 receive message', msg.payload);
}

最终执行代码结果如下:

image

3.2 查询可用模型列表

开始前我们写一个通用的工具函数 sendRpc, 在 OpenClaw 都是同 webSocket 来发起各种请求, 那么要如何去监听到每次请求的响应呢? 如下代码所示, 其实我们在使用 send 来模拟发起一个请求时会给一个唯一的请求 ID, OpenClaw 处理完请求后, 将响应接口加请求 ID 一起推送给我们, 通过该唯一请求 ID 我们就可以精准获取到我们需要的响应结果。

const sendRpc  = (ws, method, params = {}) => {
  // 每次发送请求都生成一个唯一的 ID,方便后续匹配响应
  const id = crypto.randomUUID();
  console.log('📤 send request', { id, method, params });

  ws.send(
    JSON.stringify({
      type: 'req',
      id,
      method,
      params,
    }),
  );

  const handleResponse = (data) => {
    const msg = JSON.parse(data.toString());

    // 匹配指定请求 ID 的响应
    if (msg.type === 'res' && msg.id === id) {
      console.log('📩 receive response', JSON.stringify(msg, null, 4));
      ws.off('message', handleResponse); // 收到对应 ID 的响应后取消监听
    }
  };

  ws.on('message', handleResponse); // 监听响应消息, 收到对应 ID 的响应后会取消监听
};

上面方法调用也很简单,

sendRpc(ws, 'models.list', {});

如果需要我们也可以将工具函数改为 Promise 形式

const sendRpc  = (ws, method, params = {}) => new Promise((resolve) => {
  // 每次发送请求都生成一个唯一的 ID,方便后续匹配响应
  const id = crypto.randomUUID();
  console.log('📤 send request', { id, method, params });

  ws.send(
    JSON.stringify({
      type: 'req',
      id,
      method,
      params,
    }),
  );

  const handleResponse = (data) => {
    const msg = JSON.parse(data.toString());

    // 匹配指定请求 ID 的响应
    if (msg.type === 'res' && msg.id === id) {
      console.log('📩 receive response', JSON.stringify(msg, null, 4));
      ws.off('message', handleResponse); // 收到对应 ID 的响应后取消监听
      resolve(msg); // 将响应结果通过 Promise 返回
    }
  };

  ws.on('message', handleResponse); // 监听响应消息, 收到对应 ID 的响应后会取消监听
});

这样就可以使用 await 来等待每次请求响应结果:

await sendRpc(ws, 'models.list', {});

最后在上文的 Demo 基础上, 在连接 OpenClaw 网关后 1秒 尝试调用 sendRpc 来查询下当前可用模型列表:

// connect 成功
if (msg.payload?.type === 'hello-ok') {
  console.log('🎉 connected success');

  // 发 chat
  setTimeout(() => {
    sendRpc(ws, 'models.list', {});
  }, 1000);
}

最后执行结果:

node demo/index.mjs
✅ connected
🔐 receive challenge { nonce: '7f083827-7e61-4b97-84d5-7c075fd191b2', ts: 1775445486797 }
🎉 connected success
📩 receive response {
    "type": "res",
    "id": "b829d02e-fcfb-45ee-b70c-25e2808bed29",
    "ok": true,
    "payload": {
        "models": [
            {
                "id": "gpt-5.1",
                "name": "GPT-5.1",
                "provider": "openai-codex",
                "contextWindow": 272000,
                "reasoning": true,
                "input": [
                    "text",
                    "image"
                ]
            }
        ]
    }
}

四、OpenClaw 所有协议

所有 WebSocket 消息定义在 src/gateway/protocol/schema/frames.ts 中, 总的来说有三个大类:

类型type用途
Request"req"客户端发起请求(含 id, method, params)
Response"res"服务端对请求的响应(含 id, ok, payload/error)
Event"event"服务端主动推送事件(含 event, payload, seq)

4.1 常见 RPC 方法

OpenClaw 通过 WebSocketextensions/whatsapp/src/shared.ts 实现了 100 多个可用的 RPC 方法:

#方法名分类说明
1health系统获取网关健康状态
2doctor.memory.status系统内存诊断状态
3logs.tail系统获取日志尾部
4channels.status频道获取所有频道状态
5channels.logout频道登出频道
6status系统获取完整网关状态
7usage.status用量获取使用状态
8usage.cost用量获取使用费用
9tts.statusTTSTTS 状态
10tts.providersTTS列出 TTS 提供商
11tts.enableTTS启用 TTS
12tts.disableTTS禁用 TTS
13tts.convertTTS文本转语音
14tts.setProviderTTS设置 TTS 提供商
15config.get配置获取配置
16config.set配置设置配置
17config.apply配置应用配置
18config.patch配置补丁更新配置
19config.schema配置获取配置 Schema
20config.schema.lookup配置查找配置 Schema
21exec.approvals.get执行批准获取执行批准列表
22exec.approvals.set执行批准设置执行批准列表
23exec.approvals.node.get执行批准获取节点执行批准
24exec.approvals.node.set执行批准设置节点执行批准
25exec.approval.request执行批准请求执行批准
26exec.approval.waitDecision执行批准等待批准决定
27exec.approval.resolve执行批准解决执行批准
28plugin.approval.request插件批准请求插件批准
29plugin.approval.waitDecision插件批准等待插件批准决定
30plugin.approval.resolve插件批准解决插件批准
31wizard.start向导启动配置向导
32wizard.next向导向导下一步
33wizard.cancel向导取消向导
34wizard.status向导获取向导状态
35talk.configTalk获取 Talk 配置
36talk.speakTalkTalk 说话
37talk.modeTalk设置 Talk 模式
38models.list模型列出可用模型
39tools.catalog工具获取工具目录
40tools.effective工具获取有效工具
41agents.list代理列出代理
42agents.create代理创建代理
43agents.update代理更新代理
44agents.delete代理删除代理
45agents.files.list代理列出代理文件
46agents.files.get代理获取代理文件
47agents.files.set代理设置代理文件
48skills.status技能获取技能状态
49skills.bins技能获取技能二进制
50skills.install技能安装技能
51skills.update技能更新技能
52update.run更新运行网关更新
53voicewake.get语音唤醒获取唤醒配置
54voicewake.set语音唤醒设置唤醒配置
55secrets.reload密钥重新加载密钥
56secrets.resolve密钥解析密钥引用
57sessions.list会话列出会话
58sessions.subscribe会话订阅会话变化
59sessions.unsubscribe会话取消订阅会话变化
60sessions.messages.subscribe会话订阅会话消息
61sessions.messages.unsubscribe会话取消订阅会话消息
62sessions.preview会话预览会话
63sessions.create会话创建会话
64sessions.send会话发送消息到会话
65sessions.abort会话中止会话
66sessions.patch会话修补会话
67sessions.reset会话重置会话
68sessions.delete会话删除会话
69sessions.compact会话压缩会话
70last-heartbeat心跳获取最后心跳
71set-heartbeats心跳设置心跳
72wake系统唤醒网关
73node.pair.request节点配对请求节点配对
74node.pair.list节点配对列出配对请求
75node.pair.approve节点配对批准配对
76node.pair.reject节点配对拒绝配对
77node.pair.verify节点配对验证配对
78device.pair.list设备配对列出设备配对
79device.pair.approve设备配对批准设备配对
80device.pair.reject设备配对拒绝设备配对
81device.pair.remove设备配对移除设备配对
82device.token.rotate设备令牌轮换设备令牌
83device.token.revoke设备令牌撤销设备令牌
84node.rename节点重命名节点
85node.list节点列出节点
86node.describe节点描述节点信息
87node.pending.drain节点队列排空待处理队列
88node.pending.enqueue节点队列入队待处理工作
89node.invoke节点调用节点命令
90node.pending.pull节点队列拉取待处理工作
91node.pending.ack节点队列确认待处理工作
92node.invoke.result节点节点调用结果
93node.event节点节点事件
94node.canvas.capability.refresh节点刷新画布能力
95cron.listCron列出定时任务
96cron.statusCron获取定时任务状态
97cron.addCron添加定时任务
98cron.updateCron更新定时任务
99cron.removeCron移除定时任务
100cron.runCron立即运行定时任务
101cron.runsCron获取运行历史
102gateway.identity.get网关获取网关身份
103system-presence系统获取系统存在
104system-event系统系统事件
105send消息发送消息到频道
106agent代理调用代理
107agent.identity.get代理获取代理身份
108agent.wait代理等待代理完成
109chat.historyWebChat获取聊天历史
110chat.abortWebChat中止聊天
111chat.sendWebChat发送聊天消息
112web.login.startWhatsApp启动 Web 登录流程
113web.login.waitWhatsApp等待 Web 登录完成

4.2 常见推送事件类型

OpenClaw 通过 WebSocketsrc/gateway/server-broadcast.ts 定义了 20 多个可用的事件推送类型:

事件名说明权限范围
connect.challenge连接握手挑战-
tick心跳 (含时间戳)-
heartbeat保活-
shutdown网关关闭 (含 reason)-
health健康状态更新-
presence系统存在更新-
session.message会话消息推送operator.read
session.tool工具调用事件operator.read
sessions.changed会话列表变化operator.read
chat聊天流式响应 (含 state: delta/final/aborted/error)-
chat.side_result聊天副作用结果-
agent代理流式输出-
node.pair.requested节点配对请求operator.pairing
node.pair.resolved节点配对已解决operator.pairing
node.invoke.request节点调用请求-
device.pair.requested设备配对请求operator.pairing
device.pair.resolved设备配对已解决operator.pairing
exec.approval.requested执行批准请求operator.approvals
exec.approval.resolved执行批准已解决operator.approvals
plugin.approval.requested插件批准请求operator.approvals
plugin.approval.resolved插件批准已解决operator.approvals
voicewake.changed语音唤醒变化-
talk.modeTalk 模式变化-
cronCron 任务事件-
update.available更新可用通知-

五、参考