clawmatrix 0.1.5 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/BOOTSTRAP.md ADDED
@@ -0,0 +1,260 @@
1
+ # ClawMatrix — OpenClaw 集群插件安装指南
2
+
3
+ > 本文档供 OpenClaw Agent 参考,用于帮助用户安装和配置 ClawMatrix 插件。
4
+
5
+ ## 这个插件是什么
6
+
7
+ ClawMatrix 让多个 OpenClaw Gateway 组成去中心化 mesh 网络。安装后,节点之间可以:
8
+
9
+ - **共享模型**:家庭节点借用内网/云端节点的 LLM(如 Claude、DeepSeek)
10
+ - **委托任务**:将复杂任务 handoff 给远程 Agent(如有代码仓库访问权限的内网 Agent)
11
+ - **远程工具调用**:在远程节点执行命令、读写文件,无需委托整个任务
12
+ - **自动发现和故障转移**:gossip 协议自动发现节点,宕机时自动路由到备份
13
+
14
+ ## 安装步骤
15
+
16
+ ### 第 1 步:安装插件
17
+
18
+ ```bash
19
+ openclaw plugins install clawmatrix
20
+ ```
21
+
22
+ ### 第 2 步:配置
23
+
24
+ 编辑 `openclaw.json`,在 `plugins.entries.clawmatrix` 中添加配置。**所有要组网的节点必须使用相同的 `secret`**。
25
+
26
+ 用户需要告知你:
27
+ 1. **这个节点的角色**:公网服务器?内网办公机?家庭电脑?
28
+ 2. **是否需要接受入站连接**(公网节点通常需要,内网/家庭节点不需要)
29
+ 3. **要连接的 peer**(通常是公网节点的 WebSocket 地址)
30
+ 4. **本节点提供什么**:有哪些 Agent?有哪些可共享的模型?
31
+ 5. **是否允许远程工具执行**(toolProxy)
32
+
33
+ ### 第 3 步:重启 Gateway
34
+
35
+ ```bash
36
+ openclaw gateway restart
37
+ ```
38
+
39
+ ### 第 4 步:验证
40
+
41
+ ```bash
42
+ openclaw clawmatrix status
43
+ ```
44
+
45
+ ## 配置模板
46
+
47
+ 根据用户的节点角色,选择对应模板并替换占位值。
48
+
49
+ ### 公网节点(中继 + 可选 Agent)
50
+
51
+ 适用于:有公网 IP 或域名的云服务器。作为 mesh 的中继枢纽,内网和家庭节点都连它。
52
+
53
+ ```json
54
+ {
55
+ "nodeId": "<唯一节点名,如 cloud-01>",
56
+ "secret": "<所有节点共用的密钥,至少 16 个字符>",
57
+ "listen": true,
58
+ "listenPort": 19000,
59
+ "peers": [],
60
+ "agents": [
61
+ { "id": "<agent名>", "description": "<agent描述>", "tags": ["<标签>"] }
62
+ ],
63
+ "models": [],
64
+ "tags": ["cloud"]
65
+ }
66
+ ```
67
+
68
+ 注意事项:
69
+ - `listenPort` 需要在防火墙/安全组中放行
70
+ - 生产环境建议配置 TLS,让 peer 使用 `wss://` 连接
71
+ - 如果不跑 Agent 可以把 `agents` 设为 `[]`
72
+
73
+ ### 内网/办公节点(有模型或代码仓库)
74
+
75
+ 适用于:有 GPU、有 API Key、有内网资源的机器。不需要公网 IP,主动连接公网节点。
76
+
77
+ ```json
78
+ {
79
+ "nodeId": "<唯一节点名,如 office-01>",
80
+ "secret": "<同上>",
81
+ "listen": false,
82
+ "peers": [
83
+ { "nodeId": "<公网节点名>", "url": "wss://<公网节点地址>:19000" }
84
+ ],
85
+ "agents": [
86
+ { "id": "coder", "description": "有代码仓库访问权限,可以读写代码和执行命令", "tags": ["coding"] }
87
+ ],
88
+ "models": [
89
+ { "id": "claude-sonnet", "provider": "anthropic" },
90
+ { "id": "deepseek-coder", "provider": "ollama" }
91
+ ],
92
+ "tags": ["office", "gpu"],
93
+ "toolProxy": {
94
+ "enabled": true,
95
+ "allow": ["exec", "read", "write", "edit", "web_search"],
96
+ "deny": ["browser", "sessions_spawn"]
97
+ }
98
+ }
99
+ ```
100
+
101
+ 注意事项:
102
+ - `models` 里填这台机器上 OpenClaw 已经配置好的模型(必须本地能用才行)
103
+ - **必须开启 OpenClaw 的 chatCompletions HTTP 端点**,否则模型代理无法工作(见下方「前置配置」)
104
+ - `toolProxy.enabled: true` 才会接受远程工具调用
105
+ - `toolProxy.deny` 优先于 `allow`,建议禁用高风险工具
106
+
107
+ ### 家庭/轻量节点(借用集群资源)
108
+
109
+ 适用于:个人电脑、轻量设备。没有模型和特殊资源,借用集群。
110
+
111
+ ```json
112
+ {
113
+ "nodeId": "<唯一节点名,如 home-01>",
114
+ "secret": "<同上>",
115
+ "listen": false,
116
+ "peers": [
117
+ { "nodeId": "<公网节点名>", "url": "wss://<公网节点地址>:19000" }
118
+ ],
119
+ "agents": [
120
+ { "id": "assistant", "description": "个人助手", "tags": ["general"] }
121
+ ],
122
+ "models": [],
123
+ "proxyModels": [
124
+ { "id": "claude-sonnet", "nodeId": "office-01" }
125
+ ],
126
+ "tags": ["home"]
127
+ }
128
+ ```
129
+
130
+ 如果要用集群中的模型,还需要修改 agent 的模型配置:
131
+
132
+ ```json
133
+ {
134
+ "agents": {
135
+ "defaults": {
136
+ "model": "clawmatrix/<模型ID>"
137
+ }
138
+ }
139
+ }
140
+ ```
141
+
142
+ 注意事项:
143
+ - `proxyModels` 中的 `nodeId` 可选,指定后精确路由到该节点,不指定则自动查找
144
+ - `proxyModels` 中的模型 ID 必须与远程节点 `models` 中声明的 ID 一致
145
+
146
+ ## 完整配置字段参考
147
+
148
+ | 字段 | 类型 | 默认值 | 说明 |
149
+ |------|------|--------|------|
150
+ | `nodeId` | string | *必填* | 节点唯一标识 |
151
+ | `secret` | string | *必填* | 集群共享密钥,最少 16 字符 |
152
+ | `listen` | boolean | `false` | 是否接受入站 WebSocket 连接 |
153
+ | `listenHost` | string | `"0.0.0.0"` | WebSocket 监听地址 |
154
+ | `listenPort` | number | `19000` | 入站 WebSocket 端口 |
155
+ | `peers` | array | `[]` | 要连接的 peer:`{ nodeId, url }` |
156
+ | `agents` | array | `[]` | 本节点提供的 Agent:`{ id, description, tags }` |
157
+ | `models` | array | `[]` | 本节点共享给集群的模型:`{ id, provider, description? }` |
158
+ | `proxyModels` | array | `[]` | 从集群消费的远程模型:`{ id, nodeId?, description? }` |
159
+ | `tags` | array | `[]` | 自由标签,用于能力路由 |
160
+ | `proxyPort` | number | `19001` | 本地模型代理 HTTP 端口 |
161
+ | `handoffTimeout` | number | `600000` | Handoff 超时(毫秒,默认 10 分钟) |
162
+ | `toolProxy.enabled` | boolean | `false` | 允许远程工具执行 |
163
+ | `toolProxy.allow` | array | `[]` | 允许的工具名(`[]` 或 `["*"]` = 全部允许) |
164
+ | `toolProxy.deny` | array | `[]` | 禁止的工具名(优先于 allow) |
165
+ | `toolProxy.maxOutputBytes` | number | `1048576` | 单次响应最大字节数 |
166
+
167
+ ## 安装后 Agent 获得的工具
168
+
169
+ 安装后,本节点的 Agent 自动获得 7 个集群工具:
170
+
171
+ | 工具 | 用途 | 关键参数 |
172
+ |------|------|----------|
173
+ | `cluster_peers` | 查看集群拓扑和连接状态 | 无 |
174
+ | `cluster_handoff` | 委托任务给远程 Agent | `target`, `task`, `context?` |
175
+ | `cluster_send` | 向远程 Agent 发单向消息 | `target`, `message` |
176
+ | `cluster_exec` | 在远程节点执行命令 | `node`, `command`, `workdir?`, `timeout?` |
177
+ | `cluster_read` | 读取远程节点文件 | `node`, `path` |
178
+ | `cluster_write` | 写入远程节点文件 | `node`, `path`, `content` |
179
+ | `cluster_tool` | 调用远程节点任意 OpenClaw 工具 | `node`, `tool`, `args` |
180
+
181
+ `target` 参数支持 Agent ID(如 `"coder"`)或标签查询(如 `"tags:coding"`)。
182
+ `node` 参数支持 nodeId(如 `"office-01"`)或标签查询(如 `"tags:gpu"`)。
183
+
184
+ ## 前置配置:开启 chatCompletions 端点
185
+
186
+ **所有共享模型的节点都必须配置此项**。ClawMatrix 的模型代理通过 OpenClaw Gateway 的 `/v1/chat/completions` HTTP 端点转发请求,该端点默认关闭。
187
+
188
+ 在 `openclaw.json` 的 `gateway` 字段下添加:
189
+
190
+ ```json
191
+ {
192
+ "gateway": {
193
+ "http": {
194
+ "endpoints": {
195
+ "chatCompletions": { "enabled": true }
196
+ }
197
+ }
198
+ }
199
+ }
200
+ ```
201
+
202
+ 如果不开启,模型代理请求会返回 404 错误。
203
+
204
+ ## 前置配置:注册集群模型到 OpenClaw
205
+
206
+ 消费远程模型的节点需要在 `models.providers` 中注册,否则 `/models` 命令看不到集群模型。
207
+
208
+ 以 nodeId 作为 provider key(推荐),baseUrl 指向本地模型代理端口:
209
+
210
+ ```json
211
+ {
212
+ "models": {
213
+ "providers": {
214
+ "<远程nodeId>": {
215
+ "baseUrl": "http://127.0.0.1:19001",
216
+ "apiKey": "cluster-internal",
217
+ "auth": "api-key",
218
+ "api": "openai-completions",
219
+ "models": [
220
+ {
221
+ "id": "<模型ID>",
222
+ "name": "<显示名>",
223
+ "reasoning": false,
224
+ "input": ["text"],
225
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
226
+ "contextWindow": 200000,
227
+ "maxTokens": 32000
228
+ }
229
+ ]
230
+ }
231
+ }
232
+ }
233
+ }
234
+ ```
235
+
236
+ 配置后使用 `/model <nodeId>/<模型ID>` 切换。
237
+
238
+ ## 常见问题排查
239
+
240
+ **节点连不上**:
241
+ - 检查 `secret` 是否所有节点一致
242
+ - 检查公网节点的 `listenPort` 是否在防火墙中放行
243
+ - 检查 `peers` 中的 URL 格式:`wss://host:port` 或 `ws://host:port`
244
+
245
+ **远程工具调用失败**:
246
+ - 确认目标节点的 `toolProxy.enabled` 为 `true`
247
+ - 确认目标工具不在 `toolProxy.deny` 列表中
248
+ - 如果 `toolProxy.allow` 非空,确认目标工具在列表中
249
+
250
+ **模型代理不工作**:
251
+ - 确认远程节点的 `models` 中声明了该模型
252
+ - 确认该模型在远程节点上本地可用(OpenClaw 已配置对应 provider)
253
+ - 确认本节点使用 `clawmatrix/<模型ID>` 格式引用模型
254
+ - 如果用了 `proxyModels.nodeId`,确认该节点在线
255
+
256
+ **查看状态**:
257
+ ```bash
258
+ openclaw clawmatrix status # 表格形式
259
+ openclaw clawmatrix peers # JSON 格式
260
+ ```
package/README.md CHANGED
@@ -1,15 +1,176 @@
1
- # clawmatrix
1
+ # ClawMatrix
2
2
 
3
- To install dependencies:
3
+ 去中心化 mesh 集群插件,让多个 [OpenClaw](https://github.com/nicepkg/openclaw) Gateway 组成 peer-to-peer 网络,跨节点共享 Agent、模型和工具。
4
+
5
+ ## 功能
6
+
7
+ **模型代理** — 家庭节点没有 API Key?通过集群使用内网节点的 LLM,就像本地模型一样。
8
+
9
+ **任务 Handoff** — 需要内网资源?将任务委托给有仓库访问权限的远程 Agent,流式返回结果。
10
+
11
+ **工具代理** — 想在远程节点跑个命令或读个文件?直接调用,不用委托整个任务。
12
+
13
+ **自动发现 & 故障转移** — Gossip 协议自动发现节点,宕机时请求自动路由到备份。
14
+
15
+ ## 快速开始
16
+
17
+ > **推荐**:将 [BOOTSTRAP.md](BOOTSTRAP.md) 链接发给 OpenClaw Agent,它会引导你完成安装和配置。
18
+
19
+ ### 安装
20
+
21
+ ```bash
22
+ openclaw plugins install clawmatrix
23
+ ```
24
+
25
+ ### 配置
26
+
27
+ 编辑 `openclaw.json`,添加插件配置。所有节点共享同一 `secret`。
28
+
29
+ **公网节点**(中继枢纽):
30
+
31
+ ```json
32
+ {
33
+ "plugins": {
34
+ "entries": {
35
+ "clawmatrix": {
36
+ "enabled": true,
37
+ "config": {
38
+ "nodeId": "cloud-01",
39
+ "secret": "your-shared-secret-min-16-chars",
40
+ "listen": true,
41
+ "listenPort": 19000,
42
+ "agents": [
43
+ { "id": "reviewer", "description": "代码审查", "tags": ["review"] }
44
+ ]
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ **内网节点**(有模型和仓库):
53
+
54
+ ```json
55
+ {
56
+ "nodeId": "office-01",
57
+ "secret": "your-shared-secret-min-16-chars",
58
+ "peers": [{ "nodeId": "cloud-01", "url": "wss://cloud-01.example.com:19000" }],
59
+ "agents": [{ "id": "coder", "description": "代码开发", "tags": ["coding"] }],
60
+ "models": [
61
+ { "id": "claude-sonnet", "provider": "anthropic" },
62
+ { "id": "deepseek-coder", "provider": "ollama" }
63
+ ],
64
+ "toolProxy": { "enabled": true, "allow": ["exec", "read", "write", "edit"] }
65
+ }
66
+ ```
67
+
68
+ **家庭节点**(借用集群资源):
69
+
70
+ ```json
71
+ {
72
+ "nodeId": "home-01",
73
+ "secret": "your-shared-secret-min-16-chars",
74
+ "peers": [{ "nodeId": "cloud-01", "url": "wss://cloud-01.example.com:19000" }],
75
+ "agents": [{ "id": "assistant", "description": "个人助手", "tags": ["general"] }],
76
+ "proxyModels": [{ "id": "claude-sonnet", "nodeId": "office-01" }]
77
+ }
78
+ ```
79
+
80
+ **重要**:共享模型的节点必须开启 chatCompletions 端点:
81
+
82
+ ```json
83
+ {
84
+ "gateway": {
85
+ "http": {
86
+ "endpoints": {
87
+ "chatCompletions": { "enabled": true }
88
+ }
89
+ }
90
+ }
91
+ }
92
+ ```
93
+
94
+ 消费远程模型的节点需要在 `models.providers` 中注册(以 nodeId 为 key,baseUrl 指向 `http://127.0.0.1:19001`),详见 [BOOTSTRAP.md](BOOTSTRAP.md)。
95
+
96
+ 使用集群模型:`/model <nodeId>/<模型ID>`。
97
+
98
+ ### 启动
4
99
 
5
100
  ```bash
6
- bun install
101
+ openclaw gateway restart
102
+ openclaw clawmatrix status
7
103
  ```
8
104
 
9
- To run:
105
+ ## Agent 工具
106
+
107
+ 安装后 Agent 自动获得以下工具:
108
+
109
+ | 工具 | 说明 |
110
+ |------|------|
111
+ | `cluster_handoff` | 委托任务给远程 Agent,等待结果 |
112
+ | `cluster_send` | 向远程 Agent 发送单向消息 |
113
+ | `cluster_peers` | 查看集群拓扑和连接状态 |
114
+ | `cluster_exec` | 在远程节点执行命令 |
115
+ | `cluster_read` | 读取远程节点文件 |
116
+ | `cluster_write` | 写入远程节点文件 |
117
+ | `cluster_tool` | 调用远程节点任意 OpenClaw 工具 |
118
+
119
+ 目标参数支持 nodeId(`"office-01"`)或标签查询(`"tags:coding"`)。
120
+
121
+ ## 配置参考
122
+
123
+ | 字段 | 类型 | 默认值 | 说明 |
124
+ |------|------|--------|------|
125
+ | `nodeId` | string | 必填 | 节点唯一标识 |
126
+ | `secret` | string | 必填 | 集群共享密钥(>= 16 字符) |
127
+ | `listen` | boolean | `false` | 接受入站 WS 连接 |
128
+ | `listenHost` | string | `"0.0.0.0"` | 监听地址 |
129
+ | `listenPort` | number | `19000` | 入站 WS 端口 |
130
+ | `peers` | array | `[]` | 要连接的 peer:`{ nodeId, url }` |
131
+ | `agents` | array | `[]` | 本节点 Agent:`{ id, description, tags }` |
132
+ | `models` | array | `[]` | 共享给集群的模型:`{ id, provider }` |
133
+ | `proxyModels` | array | `[]` | 要消费的远程模型:`{ id, nodeId? }` |
134
+ | `tags` | array | `[]` | 自由标签 |
135
+ | `proxyPort` | number | `19001` | 本地模型代理端口 |
136
+ | `handoffTimeout` | number | `600000` | Handoff 超时(ms) |
137
+ | `toolProxy.enabled` | boolean | `false` | 允许远程工具执行 |
138
+ | `toolProxy.allow` | array | `[]` | 允许的工具(`[]` = 全部) |
139
+ | `toolProxy.deny` | array | `[]` | 禁止的工具(优先) |
140
+
141
+ ## 架构
142
+
143
+ ```
144
+ ┌──────────────────────────┐
145
+ │ 公网 Gateway │
146
+ │ listen: true │
147
+ │ listenPort: 19000 │
148
+ └──┬──────────────────┬────┘
149
+ inbound │ │ inbound
150
+ WS conn │ │ WS conn
151
+ ┌──┴────┐ ┌────┴──────┐
152
+ │ 家庭 │ │ 内网办公室 │
153
+ └───────┘ └──────────┘
154
+ ```
155
+
156
+ - 去中心化 mesh,无 leader,无共识协议
157
+ - HMAC-SHA256 challenge-response 认证
158
+ - 消息中继 + TTL 防环 + ID 去重
159
+ - 心跳检测 + 指数退避重连 + 自动故障转移
160
+ - 生产环境建议 `wss://`(TLS)
161
+
162
+ ## 开发
10
163
 
11
164
  ```bash
12
- bun run index.ts
165
+ bun install # 安装依赖
166
+ bun test # 运行测试
13
167
  ```
14
168
 
15
- This project was created using `bun init` in bun v1.3.6. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
169
+ ## 文档
170
+
171
+ - [技术规格](docs/SPEC.md) — 完整协议、消息类型、安全设计
172
+ - [安装指南](BOOTSTRAP.md) — AI Agent 辅助安装配置
173
+
174
+ ## License
175
+
176
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmatrix",
3
- "version": "0.1.5",
3
+ "version": "0.1.8",
4
4
  "description": "Decentralized mesh cluster plugin for OpenClaw — inter-gateway communication, model proxy, task handoff, and tool proxy.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,7 +18,7 @@
18
18
  "src/",
19
19
  "!src/**/*.test.ts",
20
20
  "openclaw.plugin.json",
21
- "llms.txt",
21
+ "BOOTSTRAP.md",
22
22
  "README.md"
23
23
  ],
24
24
  "openclaw": {
package/src/cli.ts CHANGED
@@ -1,5 +1,46 @@
1
1
  import type { Command } from "commander";
2
- import { getClusterRuntime } from "./cluster-service.ts";
2
+ import { spawnProcess } from "./compat.ts";
3
+
4
+ async function callGateway(method: string): Promise<unknown> {
5
+ const proc = spawnProcess(["openclaw", "gateway", "call", method, "--json"], {
6
+ stdout: "pipe",
7
+ stderr: "pipe",
8
+ });
9
+
10
+ const stdoutChunks: Uint8Array[] = [];
11
+ const stderrChunks: Uint8Array[] = [];
12
+
13
+ const readStream = async (stream: ReadableStream | null, target: Uint8Array[]) => {
14
+ if (!stream) return;
15
+ const reader = stream.getReader();
16
+ while (true) {
17
+ const { done, value } = await reader.read();
18
+ if (done) break;
19
+ target.push(value);
20
+ }
21
+ };
22
+
23
+ await Promise.all([
24
+ readStream(proc.stdout, stdoutChunks),
25
+ readStream(proc.stderr, stderrChunks),
26
+ ]);
27
+
28
+ const code = await proc.exited;
29
+ const stdout = Buffer.concat(stdoutChunks).toString("utf-8").trim();
30
+ const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
31
+
32
+ if (code !== 0) {
33
+ // Extract meaningful error from stderr
34
+ const errLine = stderr.split("\n").find((l) => l.includes("Error:") || l.includes("error"));
35
+ throw new Error(errLine || stderr || "Gateway call failed (exit code " + code + ")");
36
+ }
37
+
38
+ if (!stdout) {
39
+ throw new Error("Empty response from gateway");
40
+ }
41
+
42
+ return JSON.parse(stdout);
43
+ }
3
44
 
4
45
  export const registerClusterCli = ({ program }: { program: Command }) => {
5
46
  const cmd = program.command("clawmatrix").description("ClawMatrix cluster management");
@@ -7,48 +48,71 @@ export const registerClusterCli = ({ program }: { program: Command }) => {
7
48
  cmd
8
49
  .command("status")
9
50
  .description("Show cluster topology and peer status")
10
- .action(() => {
11
- let runtime: ReturnType<typeof getClusterRuntime>;
51
+ .action(async () => {
52
+ let data: Record<string, unknown>;
12
53
  try {
13
- runtime = getClusterRuntime();
54
+ data = (await callGateway("clawmatrix.status")) as Record<string, unknown>;
14
55
  } catch {
15
- console.log("ClawMatrix service not running.");
56
+ console.log("Could not reach gateway. Is it running?");
16
57
  return;
17
58
  }
18
59
 
19
- const config = runtime.config;
20
- const peers = runtime.peerManager.router.getAllPeers();
21
-
22
- // Header
23
- console.log(`\nNode: ${config.nodeId}`);
24
- console.log(
25
- `Listen: ${config.listen ? `port ${config.listenPort}` : "no"}`,
26
- );
27
- console.log(`Model proxy: port ${config.proxyPort}`);
28
- console.log(`Agents: [${config.agents.map((a) => a.id).join(", ")}]`);
29
- console.log(
30
- `Models: [${config.models.map((m) => m.id).join(", ")}]`,
31
- );
60
+ if (data.error) {
61
+ console.log(String(data.error));
62
+ return;
63
+ }
64
+
65
+ const agents = data.agents as Array<{ id: string }>;
66
+ const models = data.models as Array<{ id: string }>;
67
+ const tags = data.tags as string[];
68
+
69
+ console.log("");
70
+ console.log("ClawMatrix Cluster");
32
71
  console.log("");
72
+ console.log(` Node: ${data.nodeId}`);
73
+ if (tags.length > 0) {
74
+ console.log(` Tags: ${tags.join(", ")}`);
75
+ }
76
+ console.log(` Listen: ${data.listen !== false ? `port ${data.listen}` : "disabled"}`);
77
+ console.log(` Model proxy: port ${data.proxyPort}`);
78
+ console.log(` Agents: ${agents.map((a) => a.id).join(", ") || "-"}`);
79
+ console.log(` Models: ${models.map((m) => m.id).join(", ") || "-"}`);
80
+
81
+ const peers = data.peers as Array<{
82
+ nodeId: string;
83
+ agents: Array<{ id: string }>;
84
+ models: Array<{ id: string }>;
85
+ tags: string[];
86
+ connected: boolean;
87
+ latencyMs: number;
88
+ }>;
33
89
 
34
- if (peers.length === 0) {
35
- console.log("No peers connected.");
90
+ const connected = peers.filter((p) => p.connected).length;
91
+ console.log("");
92
+ if (!peers || peers.length === 0) {
93
+ console.log(" No peers discovered.");
94
+ console.log("");
36
95
  return;
37
96
  }
38
97
 
39
- // Peer table
40
- const header = padRow("NODE", "AGENTS", "MODELS", "STATUS", "LATENCY");
41
- console.log(header);
42
- console.log("-".repeat(header.length));
98
+ console.log(`Peers (${connected}/${peers.length} connected)`);
99
+ console.log("");
43
100
 
44
101
  for (const peer of peers) {
45
- const agents = peer.agents.map((a) => a.id).join(", ") || "-";
46
- const models = peer.models.map((m) => m.id).join(", ") || "-";
47
- const status = peer.connection?.isOpen ? "connected" : "unreachable";
48
- const latency = peer.latencyMs > 0 ? `${peer.latencyMs}ms` : "-";
49
- console.log(
50
- padRow(peer.nodeId, `[${agents}]`, `[${models}]`, status, latency),
51
- );
102
+ const status = peer.connected ? "connected" : "unreachable";
103
+ const latency = peer.latencyMs > 0 ? `, ${peer.latencyMs}ms` : "";
104
+ console.log(` ${peer.nodeId} (${status}${latency})`);
105
+ if (peer.tags.length > 0) {
106
+ console.log(` Tags: ${peer.tags.join(", ")}`);
107
+ }
108
+ const peerAgents = peer.agents.map((a) => a.id).join(", ");
109
+ if (peerAgents) {
110
+ console.log(` Agents: ${peerAgents}`);
111
+ }
112
+ const peerModels = peer.models.map((m) => m.id).join(", ");
113
+ if (peerModels) {
114
+ console.log(` Models: ${peerModels}`);
115
+ }
52
116
  }
53
117
  console.log("");
54
118
  });
@@ -56,29 +120,14 @@ export const registerClusterCli = ({ program }: { program: Command }) => {
56
120
  cmd
57
121
  .command("peers")
58
122
  .description("List known peers (JSON)")
59
- .action(() => {
60
- let runtime: ReturnType<typeof getClusterRuntime>;
123
+ .action(async () => {
124
+ let peers: unknown;
61
125
  try {
62
- runtime = getClusterRuntime();
126
+ peers = await callGateway("clawmatrix.peers");
63
127
  } catch {
64
128
  console.log("[]");
65
129
  return;
66
130
  }
67
-
68
- const peers = runtime.peerManager.router.getAllPeers().map((p) => ({
69
- nodeId: p.nodeId,
70
- agents: p.agents,
71
- models: p.models,
72
- tags: p.tags,
73
- connected: !!p.connection?.isOpen,
74
- reachableVia: p.reachableVia,
75
- latencyMs: p.latencyMs,
76
- }));
77
131
  console.log(JSON.stringify(peers, null, 2));
78
132
  });
79
133
  };
80
-
81
- function padRow(...cols: string[]): string {
82
- const widths = [16, 24, 30, 14, 8];
83
- return cols.map((c, i) => c.padEnd(widths[i] ?? 12)).join(" ");
84
- }
@@ -14,6 +14,7 @@ import type {
14
14
  AnyClusterFrame,
15
15
  HandoffRequest,
16
16
  HandoffResponse,
17
+ HandoffStreamChunk,
17
18
  ModelRequest,
18
19
  ModelResponse,
19
20
  ModelStreamChunk,
@@ -93,6 +94,9 @@ export class ClusterRuntime {
93
94
  this.logger.error(`[clawmatrix] Handoff request error: ${err}`);
94
95
  });
95
96
  break;
97
+ case "handoff_stream":
98
+ this.handoffManager.handleStream(frame as HandoffStreamChunk);
99
+ break;
96
100
  case "handoff_res":
97
101
  this.handoffManager.handleResponse(frame as HandoffResponse);
98
102
  break;
package/src/config.ts CHANGED
@@ -63,6 +63,7 @@ export const ClawMatrixConfigSchema = z.object({
63
63
  tags: z.array(z.string()).default([]),
64
64
  proxyPort: z.number().default(19001),
65
65
  toolProxy: ToolProxyConfigSchema.optional(),
66
+ handoffTimeout: z.number().default(600_000),
66
67
  });
67
68
 
68
69
  export type ClawMatrixConfig = z.infer<typeof ClawMatrixConfigSchema>;
package/src/handoff.ts CHANGED
@@ -4,10 +4,10 @@ import { spawnProcess } from "./compat.ts";
4
4
  import type {
5
5
  HandoffRequest,
6
6
  HandoffResponse,
7
- AnyClusterFrame,
7
+ HandoffStreamChunk,
8
8
  } from "./types.ts";
9
9
 
10
- const HANDOFF_TIMEOUT = 120_000; // 2 minutes
10
+ const DEFAULT_HANDOFF_TIMEOUT = 600_000; // 10 minutes (resets on each stream chunk)
11
11
  const MAX_RETRIES = 2;
12
12
 
13
13
  interface PendingHandoff {
@@ -18,6 +18,7 @@ interface PendingHandoff {
18
18
  retriesLeft: number;
19
19
  task: string;
20
20
  context?: string;
21
+ accumulated: string;
21
22
  }
22
23
 
23
24
  export class HandoffManager {
@@ -54,24 +55,9 @@ export class HandoffManager {
54
55
  const id = crypto.randomUUID();
55
56
 
56
57
  return new Promise<HandoffResponse["payload"]>((resolve, reject) => {
57
- const timer = setTimeout(() => {
58
- this.pending.delete(id);
59
- this.peerManager.router.markFailed(id);
60
-
61
- // Retry with failover
62
- if (retriesLeft > 0) {
63
- const nextRoute = this.peerManager.router.resolveAgent(target);
64
- if (nextRoute && nextRoute.nodeId !== targetNodeId) {
65
- this.sendHandoff(nextRoute.nodeId, target, task, context, retriesLeft - 1)
66
- .then(resolve)
67
- .catch(reject);
68
- return;
69
- }
70
- }
71
- reject(new Error(`Handoff to "${target}" timed out`));
72
- }, HANDOFF_TIMEOUT);
58
+ const timer = this.createTimeout(id, targetNodeId, target, task, context, retriesLeft, resolve, reject);
73
59
 
74
- this.pending.set(id, { resolve, reject, timer, target, retriesLeft, task, context });
60
+ this.pending.set(id, { resolve, reject, timer, target, retriesLeft, task, context, accumulated: "" });
75
61
 
76
62
  const frame: HandoffRequest = {
77
63
  type: "handoff_req",
@@ -102,6 +88,57 @@ export class HandoffManager {
102
88
  });
103
89
  }
104
90
 
91
+ private createTimeout(
92
+ id: string,
93
+ targetNodeId: string,
94
+ target: string,
95
+ task: string,
96
+ context: string | undefined,
97
+ retriesLeft: number,
98
+ resolve: (result: HandoffResponse["payload"]) => void,
99
+ reject: (error: Error) => void,
100
+ ): ReturnType<typeof setTimeout> {
101
+ return setTimeout(() => {
102
+ this.pending.delete(id);
103
+ this.peerManager.router.markFailed(id);
104
+
105
+ // Retry with failover
106
+ if (retriesLeft > 0) {
107
+ const nextRoute = this.peerManager.router.resolveAgent(target);
108
+ if (nextRoute && nextRoute.nodeId !== targetNodeId) {
109
+ this.sendHandoff(nextRoute.nodeId, target, task, context, retriesLeft - 1)
110
+ .then(resolve)
111
+ .catch(reject);
112
+ return;
113
+ }
114
+ }
115
+ reject(new Error(`Handoff to "${target}" timed out`));
116
+ }, this.config.handoffTimeout ?? DEFAULT_HANDOFF_TIMEOUT);
117
+ }
118
+
119
+ /** Handle incoming stream chunk — reset timeout & accumulate. */
120
+ handleStream(frame: HandoffStreamChunk) {
121
+ if (this.peerManager.router.isFailed(frame.id)) return;
122
+
123
+ const pending = this.pending.get(frame.id);
124
+ if (!pending) return;
125
+
126
+ pending.accumulated += frame.payload.delta;
127
+
128
+ // Reset timeout — the remote agent is still working
129
+ clearTimeout(pending.timer);
130
+ pending.timer = this.createTimeout(
131
+ frame.id,
132
+ frame.from,
133
+ pending.target,
134
+ pending.task,
135
+ pending.context,
136
+ pending.retriesLeft,
137
+ pending.resolve,
138
+ pending.reject,
139
+ );
140
+ }
141
+
105
142
  /** Handle incoming handoff response. */
106
143
  handleResponse(frame: HandoffResponse) {
107
144
  if (this.peerManager.router.isFailed(frame.id)) return;
@@ -111,6 +148,12 @@ export class HandoffManager {
111
148
 
112
149
  clearTimeout(pending.timer);
113
150
  this.pending.delete(frame.id);
151
+
152
+ // If the response has no result but we accumulated stream data, use that
153
+ if (frame.payload.success && !frame.payload.result && pending.accumulated) {
154
+ frame.payload.result = pending.accumulated;
155
+ }
156
+
114
157
  pending.resolve(frame.payload);
115
158
  }
116
159
 
@@ -153,7 +196,8 @@ export class HandoffManager {
153
196
  { stdout: "pipe", stderr: "pipe" },
154
197
  );
155
198
 
156
- const stdout = proc.stdout ? await new Response(proc.stdout).text() : "";
199
+ // Stream stdout chunks back to the caller
200
+ const fullOutput = await this.streamStdout(proc.stdout, id, from);
157
201
  const exitCode = await proc.exited;
158
202
 
159
203
  if (exitCode !== 0) {
@@ -171,7 +215,7 @@ export class HandoffManager {
171
215
  success: true,
172
216
  nodeId: this.config.nodeId,
173
217
  agent: agent.id,
174
- result: stdout.trim(),
218
+ result: fullOutput.trim(),
175
219
  },
176
220
  } satisfies HandoffResponse);
177
221
  } catch (err) {
@@ -191,9 +235,55 @@ export class HandoffManager {
191
235
  }
192
236
  }
193
237
 
238
+ /** Read stdout incrementally, sending handoff_stream chunks to the caller. */
239
+ private async streamStdout(
240
+ stdout: ReadableStream | null,
241
+ handoffId: string,
242
+ to: string,
243
+ ): Promise<string> {
244
+ if (!stdout) return "";
245
+
246
+ const reader = stdout.getReader();
247
+ const decoder = new TextDecoder();
248
+ let full = "";
249
+
250
+ try {
251
+ while (true) {
252
+ const { done, value } = await reader.read();
253
+ if (done) break;
254
+
255
+ const chunk = decoder.decode(value, { stream: true });
256
+ full += chunk;
257
+
258
+ this.peerManager.sendTo(to, {
259
+ type: "handoff_stream",
260
+ id: handoffId,
261
+ from: this.config.nodeId,
262
+ to,
263
+ timestamp: Date.now(),
264
+ payload: { delta: chunk, done: false },
265
+ } satisfies HandoffStreamChunk);
266
+ }
267
+ } finally {
268
+ reader.releaseLock();
269
+ }
270
+
271
+ // Send final done marker
272
+ this.peerManager.sendTo(to, {
273
+ type: "handoff_stream",
274
+ id: handoffId,
275
+ from: this.config.nodeId,
276
+ to,
277
+ timestamp: Date.now(),
278
+ payload: { delta: "", done: true },
279
+ } satisfies HandoffStreamChunk);
280
+
281
+ return full;
282
+ }
283
+
194
284
  /** Clean up on shutdown. */
195
285
  destroy() {
196
- for (const [id, pending] of this.pending) {
286
+ for (const [, pending] of this.pending) {
197
287
  clearTimeout(pending.timer);
198
288
  pending.reject(new Error("Shutting down"));
199
289
  }
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
1
+ import type { OpenClawPluginApi, GatewayRequestHandlerOptions } from "openclaw/plugin-sdk";
2
2
  import { ClawMatrixConfigSchema, parseConfig } from "./config.ts";
3
3
  import { createClusterService, getClusterRuntime } from "./cluster-service.ts";
4
4
  import { createClusterHandoffTool } from "./tools/cluster-handoff.ts";
@@ -41,18 +41,36 @@ const plugin = {
41
41
  // Background service: manages mesh connections, WS listener, heartbeat
42
42
  api.registerService(createClusterService(config, api.config));
43
43
 
44
- // Model provider: register clawmatrix as a provider pointing to local HTTP proxy
45
- api.registerProvider({
46
- id: "clawmatrix",
47
- label: "ClawMatrix Cluster",
48
- docsPath: "/plugins/clawmatrix",
49
- auth: [],
50
- models: {
51
- baseUrl: `http://127.0.0.1:${config.proxyPort}`,
52
- api: "openai-completions",
53
- models: getAllClusterModels(config),
54
- },
55
- });
44
+ // Model providers: register per-node providers so models are accessed as nodeId/modelId
45
+ const baseUrl = `http://127.0.0.1:${config.proxyPort}/v1`;
46
+ const modelsByNode = groupModelsByNode(config);
47
+
48
+ // Patch openclaw config so auth resolution can find a dummy API key
49
+ // (resolveApiKeyForProvider checks cfg.models.providers, not plugin registry)
50
+ const ocModels = ((api.config as Record<string, unknown>).models ??= {}) as Record<string, unknown>;
51
+ const ocProviders = (ocModels.providers ??= {}) as Record<string, unknown>;
52
+
53
+ for (const [nodeId, models] of Object.entries(modelsByNode)) {
54
+ if (!ocProviders[nodeId]) {
55
+ ocProviders[nodeId] = {
56
+ baseUrl,
57
+ apiKey: "sk-clawmatrix-proxy",
58
+ models,
59
+ };
60
+ }
61
+
62
+ api.registerProvider({
63
+ id: nodeId,
64
+ label: `ClawMatrix: ${nodeId}`,
65
+ docsPath: "/plugins/clawmatrix",
66
+ auth: [],
67
+ models: {
68
+ baseUrl,
69
+ apiKey: "sk-clawmatrix-proxy",
70
+ models,
71
+ },
72
+ });
73
+ }
56
74
 
57
75
  // Agent tools
58
76
  api.registerTool(createClusterHandoffTool(), { optional: true });
@@ -63,6 +81,57 @@ const plugin = {
63
81
  api.registerTool(createClusterWriteTool(), { optional: true });
64
82
  api.registerTool(createClusterToolTool(), { optional: true });
65
83
 
84
+ // Gateway methods (queried by CLI via `openclaw gateway call`)
85
+ api.registerGatewayMethod(
86
+ "clawmatrix.status",
87
+ ({ respond }: GatewayRequestHandlerOptions) => {
88
+ try {
89
+ const runtime = getClusterRuntime();
90
+ const peers = runtime.peerManager.router.getAllPeers();
91
+ respond(true, {
92
+ nodeId: config.nodeId,
93
+ listen: config.listen ? config.listenPort : false,
94
+ proxyPort: config.proxyPort,
95
+ agents: config.agents.map((a) => ({ id: a.id, description: a.description })),
96
+ models: config.models.map((m) => ({ id: m.id })),
97
+ tags: config.tags,
98
+ peers: peers.map((p) => ({
99
+ nodeId: p.nodeId,
100
+ agents: p.agents,
101
+ models: p.models,
102
+ tags: p.tags,
103
+ connected: !!p.connection?.isOpen,
104
+ reachableVia: p.reachableVia,
105
+ latencyMs: p.latencyMs,
106
+ })),
107
+ });
108
+ } catch {
109
+ respond(false, { error: "ClawMatrix service not running" });
110
+ }
111
+ },
112
+ );
113
+
114
+ api.registerGatewayMethod(
115
+ "clawmatrix.peers",
116
+ ({ respond }: GatewayRequestHandlerOptions) => {
117
+ try {
118
+ const runtime = getClusterRuntime();
119
+ const peers = runtime.peerManager.router.getAllPeers().map((p) => ({
120
+ nodeId: p.nodeId,
121
+ agents: p.agents,
122
+ models: p.models,
123
+ tags: p.tags,
124
+ connected: !!p.connection?.isOpen,
125
+ reachableVia: p.reachableVia,
126
+ latencyMs: p.latencyMs,
127
+ }));
128
+ respond(true, peers);
129
+ } catch {
130
+ respond(true, []);
131
+ }
132
+ },
133
+ );
134
+
66
135
  // CLI subcommand
67
136
  api.registerCli(registerClusterCli, { commands: ["clawmatrix"] });
68
137
 
@@ -117,17 +186,8 @@ const plugin = {
117
186
  },
118
187
  };
119
188
 
120
- function getAllClusterModels(config: ReturnType<typeof parseConfig>) {
121
- // models = models this node serves to the cluster
122
- // proxyModels = remote models this node wants to consume from the cluster
123
- // Both need to be registered with the OpenClaw provider so it routes requests to our local proxy.
124
- const seen = new Set<string>();
125
- const all = [...config.models, ...config.proxyModels].filter((m) => {
126
- if (seen.has(m.id)) return false;
127
- seen.add(m.id);
128
- return true;
129
- });
130
- return all.map((m) => ({
189
+ function formatModel(m: { id: string; description?: string; api?: string; reasoning?: boolean; input?: string[]; cost?: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow?: number; maxTokens?: number }) {
190
+ return {
131
191
  id: m.id,
132
192
  name: m.description ?? m.id,
133
193
  api: m.api ?? ("openai-completions" as const),
@@ -136,7 +196,25 @@ function getAllClusterModels(config: ReturnType<typeof parseConfig>) {
136
196
  cost: m.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
137
197
  contextWindow: m.contextWindow ?? 128_000,
138
198
  maxTokens: m.maxTokens ?? 4096,
139
- }));
199
+ };
200
+ }
201
+
202
+ function groupModelsByNode(config: ReturnType<typeof parseConfig>): Record<string, ReturnType<typeof formatModel>[]> {
203
+ const result: Record<string, ReturnType<typeof formatModel>[]> = {};
204
+
205
+ // Local models served by this node
206
+ for (const m of config.models) {
207
+ const nodeId = config.nodeId;
208
+ (result[nodeId] ??= []).push(formatModel(m));
209
+ }
210
+
211
+ // Remote models consumed from peers (proxyModels have nodeId)
212
+ for (const m of config.proxyModels) {
213
+ const nodeId = m.nodeId;
214
+ (result[nodeId] ??= []).push(formatModel(m));
215
+ }
216
+
217
+ return result;
140
218
  }
141
219
 
142
220
  export default plugin;
@@ -129,6 +129,17 @@ export class ModelProxy {
129
129
  };
130
130
  }
131
131
 
132
+ // Inject model identity so the LLM knows what it is
133
+ const messages = body.messages;
134
+ if (proxyModel?.description) {
135
+ const first = messages[0] as { role?: string; content?: string } | undefined;
136
+ if (first?.role === "system" && typeof first.content === "string") {
137
+ first.content = `[Model: ${proxyModel.description}]\n${first.content}`;
138
+ } else {
139
+ messages.unshift({ role: "system", content: `[Model: ${proxyModel.description}]` });
140
+ }
141
+ }
142
+
132
143
  const stream = body.stream ?? false;
133
144
  const requestId = crypto.randomUUID();
134
145
 
@@ -140,7 +151,7 @@ export class ModelProxy {
140
151
  timestamp: Date.now(),
141
152
  payload: {
142
153
  model: modelId,
143
- messages: body.messages,
154
+ messages,
144
155
  temperature: body.temperature,
145
156
  maxTokens: body.max_tokens,
146
157
  stream,
@@ -427,7 +438,8 @@ export class ModelProxy {
427
438
 
428
439
  try {
429
440
  const parsed = JSON.parse(data);
430
- const delta = parsed.choices?.[0]?.delta?.content ?? "";
441
+ const d = parsed.choices?.[0]?.delta;
442
+ const delta = d?.content || d?.reasoning_content || "";
431
443
  if (delta) {
432
444
  this.peerManager.sendTo(from, {
433
445
  type: "model_stream",
@@ -445,10 +457,11 @@ export class ModelProxy {
445
457
  }
446
458
  } else {
447
459
  const result = (await response.json()) as {
448
- choices?: { message?: { content?: string } }[];
460
+ choices?: { message?: { content?: string; reasoning_content?: string } }[];
449
461
  usage?: { prompt_tokens: number; completion_tokens: number };
450
462
  };
451
- const content = result.choices?.[0]?.message?.content ?? "";
463
+ const msg = result.choices?.[0]?.message;
464
+ const content = msg?.content || msg?.reasoning_content || "";
452
465
  const usage = result.usage;
453
466
 
454
467
  this.peerManager.sendTo(from, {
package/src/router.ts CHANGED
@@ -120,6 +120,12 @@ export class Router {
120
120
  }
121
121
  }
122
122
 
123
+ // Fallback: if no agent ID or tag matched, try matching by nodeId
124
+ if (candidates.length === 0 && !isTagQuery) {
125
+ const byNode = this.routes.get(target);
126
+ if (byNode) candidates.push(byNode);
127
+ }
128
+
123
129
  if (candidates.length === 0) return undefined;
124
130
 
125
131
  // Sort: direct connections first, then by latency
package/src/types.ts CHANGED
@@ -113,6 +113,15 @@ export interface HandoffRequest extends ClusterFrame {
113
113
  };
114
114
  }
115
115
 
116
+ export interface HandoffStreamChunk extends ClusterFrame {
117
+ type: "handoff_stream";
118
+ id: string;
119
+ payload: {
120
+ delta: string;
121
+ done: boolean;
122
+ };
123
+ }
124
+
116
125
  export interface HandoffResponse extends ClusterFrame {
117
126
  type: "handoff_res";
118
127
  id: string;
@@ -197,6 +206,7 @@ export type AnyClusterFrame =
197
206
  | ModelResponse
198
207
  | ModelStreamChunk
199
208
  | HandoffRequest
209
+ | HandoffStreamChunk
200
210
  | HandoffResponse
201
211
  | SendMessage
202
212
  | ToolProxyRequest
package/llms.txt DELETED
@@ -1,187 +0,0 @@
1
- # ClawMatrix — OpenClaw Mesh Cluster Plugin
2
-
3
- > Decentralized mesh network plugin for OpenClaw. Multiple Gateways install the same plugin, form a peer-to-peer mesh via WebSocket, and share agents, models, and tools across nodes.
4
-
5
- ## Install
6
-
7
- ```bash
8
- openclaw plugins install clawmatrix
9
- ```
10
-
11
- Then restart the Gateway:
12
-
13
- ```bash
14
- openclaw gateway restart
15
- ```
16
-
17
- ## What it does
18
-
19
- - **Model Proxy** — Use LLMs hosted on remote nodes as if they were local. A local HTTP proxy bridges cluster WebSocket to OpenAI-compatible API.
20
- - **Task Handoff** — Delegate complex tasks to agents running on other nodes (e.g. hand off to an internal "coder" agent that has repo access).
21
- - **Tool Proxy** — Invoke any OpenClaw tool (exec, read, write, edit, web_search, etc.) on remote nodes without delegating the entire task.
22
- - **Gossip Discovery** — Nodes discover each other via gossip protocol; no central registry needed.
23
- - **Auto Failover** — If a node goes down, requests route to backup nodes automatically.
24
-
25
- ## Configuration
26
-
27
- Add to `plugins.entries.clawmatrix.config` in your `openclaw.json`:
28
-
29
- ### Public node (relay + own agents)
30
-
31
- ```json
32
- {
33
- "nodeId": "cloud-01",
34
- "secret": "your-shared-secret",
35
- "listen": true,
36
- "listenPort": 19000,
37
- "peers": [],
38
- "agents": [
39
- { "id": "reviewer", "description": "Reviews code and PRs", "tags": ["review"] }
40
- ],
41
- "models": [],
42
- "tags": ["cloud"]
43
- }
44
- ```
45
-
46
- ### Internal node (has models + code repos)
47
-
48
- ```json
49
- {
50
- "nodeId": "office-01",
51
- "secret": "your-shared-secret",
52
- "listen": false,
53
- "peers": [
54
- { "nodeId": "cloud-01", "url": "wss://cloud-01.example.com:19000" }
55
- ],
56
- "agents": [
57
- { "id": "coder", "description": "Writes and debugs code, has repo access", "tags": ["coding"] }
58
- ],
59
- "models": [
60
- { "id": "claude-sonnet", "provider": "anthropic" },
61
- { "id": "deepseek-coder", "provider": "ollama" }
62
- ],
63
- "tags": ["office", "gpu"],
64
- "toolProxy": {
65
- "enabled": true,
66
- "allow": ["exec", "read", "edit", "web_search"],
67
- "deny": ["write", "browser", "sessions_spawn"]
68
- }
69
- }
70
- ```
71
-
72
- ### Home node (lightweight, borrows cluster resources)
73
-
74
- ```json
75
- {
76
- "nodeId": "home-01",
77
- "secret": "your-shared-secret",
78
- "listen": false,
79
- "peers": [
80
- { "nodeId": "cloud-01", "url": "wss://cloud-01.example.com:19000" }
81
- ],
82
- "agents": [
83
- { "id": "assistant", "description": "Personal assistant", "tags": ["general"] }
84
- ],
85
- "models": [],
86
- "tags": ["home"]
87
- }
88
- ```
89
-
90
- To use cluster models, set your agent's model to a cluster-proxied model:
91
-
92
- ```json
93
- {
94
- "agents": {
95
- "defaults": {
96
- "model": "clawmatrix/claude-sonnet"
97
- }
98
- }
99
- }
100
- ```
101
-
102
- ## Config reference
103
-
104
- | Field | Type | Default | Description |
105
- |-------|------|---------|-------------|
106
- | `nodeId` | string | *required* | Unique identifier for this node |
107
- | `secret` | string | *required* | Shared HMAC secret for cluster authentication |
108
- | `listen` | boolean | `false` | Accept inbound WebSocket connections |
109
- | `listenHost` | string | `"0.0.0.0"` | Bind address for WebSocket listener |
110
- | `listenPort` | number | `19000` | Port for inbound WebSocket connections |
111
- | `peers` | array | `[]` | List of `{ nodeId, url }` peers to connect to |
112
- | `agents` | array | `[]` | Agents this node provides: `{ id, description, tags }` |
113
- | `models` | array | `[]` | Models this node shares: `{ id, provider, description? }` |
114
- | `tags` | array | `[]` | Free-form tags for capability routing |
115
- | `proxyPort` | number | `19001` | Local HTTP proxy port for model requests |
116
- | `toolProxy` | object | — | Tool proxy settings (see below) |
117
-
118
- ### toolProxy
119
-
120
- | Field | Type | Default | Description |
121
- |-------|------|---------|-------------|
122
- | `enabled` | boolean | `false` | Allow remote tool execution on this node |
123
- | `allow` | array | `[]` | Allowed OpenClaw tool names. `["*"]` or `[]` = all allowed |
124
- | `deny` | array | `[]` | Denied OpenClaw tool names (takes precedence over allow) |
125
- | `maxOutputBytes` | number | `1048576` | Max output size per tool response (1 MB) |
126
-
127
- ## Agent tools
128
-
129
- ClawMatrix registers 7 tools available to agents (tool proxy tools correspond to OpenClaw's built-in tools):
130
-
131
- ### cluster_peers
132
- List all reachable peers, their agents, and available models.
133
-
134
- ### cluster_handoff
135
- Hand off a task to another agent in the cluster and wait for the result.
136
- - `target`: Agent ID or `"tags:<tag>"` expression
137
- - `task`: Task description
138
- - `context`: Optional additional context
139
-
140
- ### cluster_send
141
- Send a one-way message to another agent (injected into their session).
142
- - `target`: Agent ID or `"tags:<tag>"` expression
143
- - `message`: Message content
144
-
145
- ### cluster_exec
146
- Execute a shell command on a remote node (invokes OpenClaw `exec` tool).
147
- - `node`: Target nodeId or `"tags:<tag>"`
148
- - `command`: Shell command
149
- - `workdir`: Optional working directory
150
- - `timeout`: Optional timeout in seconds (default 1800)
151
-
152
- ### cluster_read
153
- Read a file from a remote node (invokes OpenClaw `read` tool).
154
- - `node`: Target nodeId or `"tags:<tag>"`
155
- - `path`: File path
156
-
157
- ### cluster_write
158
- Write content to a file on a remote node (invokes OpenClaw `write` tool).
159
- - `node`: Target nodeId or `"tags:<tag>"`
160
- - `path`: File path
161
- - `content`: File content
162
-
163
- ### cluster_tool
164
- Invoke any OpenClaw tool on a remote node (exec, read, write, edit, web_search, web_fetch, browser, process, etc.).
165
- - `node`: Target nodeId or `"tags:<tag>"`
166
- - `tool`: OpenClaw tool name
167
- - `args`: Tool arguments (tool-specific)
168
-
169
- ## Verify cluster status
170
-
171
- ```bash
172
- openclaw clawmatrix status
173
- ```
174
-
175
- ## Architecture
176
-
177
- - Nodes form a decentralized mesh over WebSocket (no leader, no consensus protocol)
178
- - Authentication via HMAC-SHA256 challenge-response (secret never sent in plaintext)
179
- - Messages relay through intermediate nodes with TTL-based loop prevention
180
- - Heartbeat every 15s; 3 missed pings = disconnect + peer_leave broadcast
181
- - Reconnection with exponential backoff (1s to 60s max)
182
- - Production deployments should use `wss://` (TLS)
183
-
184
- ## Source
185
-
186
- - GitHub: https://github.com/nicepkg/clawmatrix
187
- - npm: https://www.npmjs.com/package/clawmatrix