@newbase-clawchat/openclaw-clawchat 2026.4.29 → 2026.4.30

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.
Files changed (47) hide show
  1. package/README.md +33 -10
  2. package/dist/index.js +27 -0
  3. package/dist/src/api-client.js +156 -0
  4. package/dist/src/api-types.js +17 -0
  5. package/dist/src/buffered-stream.js +177 -0
  6. package/dist/src/channel.js +191 -0
  7. package/dist/src/client.js +176 -0
  8. package/dist/src/commands.js +35 -0
  9. package/dist/src/config.js +214 -0
  10. package/dist/src/inbound.js +133 -0
  11. package/dist/src/login.runtime.js +130 -0
  12. package/dist/src/media-runtime.js +85 -0
  13. package/dist/src/message-mapper.js +82 -0
  14. package/dist/src/outbound.js +181 -0
  15. package/dist/src/protocol.js +38 -0
  16. package/dist/src/reply-dispatcher.js +440 -0
  17. package/dist/src/runtime.js +288 -0
  18. package/dist/src/streaming.js +65 -0
  19. package/dist/src/tools-schema.js +38 -0
  20. package/dist/src/tools.js +287 -0
  21. package/openclaw.plugin.json +12 -0
  22. package/package.json +25 -5
  23. package/skills/clawchat-activate/SKILL.md +17 -8
  24. package/src/buffered-stream.test.ts +10 -0
  25. package/src/buffered-stream.ts +6 -6
  26. package/src/channel.outbound.test.ts +3 -3
  27. package/src/channel.ts +11 -3
  28. package/src/client.test.ts +8 -1
  29. package/src/client.ts +11 -10
  30. package/src/commands.test.ts +6 -0
  31. package/src/commands.ts +5 -1
  32. package/src/config.test.ts +3 -0
  33. package/src/config.ts +7 -0
  34. package/src/inbound.test.ts +4 -1
  35. package/src/inbound.ts +11 -10
  36. package/src/login.runtime.test.ts +36 -0
  37. package/src/login.runtime.ts +54 -26
  38. package/src/manifest.test.ts +98 -22
  39. package/src/outbound.test.ts +6 -5
  40. package/src/outbound.ts +8 -7
  41. package/src/reply-dispatcher.test.ts +418 -3
  42. package/src/reply-dispatcher.ts +137 -12
  43. package/src/runtime.ts +1 -0
  44. package/src/streaming.test.ts +12 -9
  45. package/src/streaming.ts +6 -6
  46. package/src/tools.test.ts +81 -18
  47. package/src/tools.ts +63 -72
@@ -3,12 +3,24 @@
3
3
  "channels": ["openclaw-clawchat"],
4
4
  "skills": ["./skills"],
5
5
  "activation": {
6
+ "onStartup": true,
6
7
  "onChannels": ["openclaw-clawchat"],
7
8
  "onCommands": ["clawchat-login"]
8
9
  },
9
10
  "commandAliases": [
10
11
  { "name": "clawchat-login", "kind": "runtime-slash" }
11
12
  ],
13
+ "contracts": {
14
+ "tools": [
15
+ "clawchat_activate",
16
+ "clawchat_get_account_profile",
17
+ "clawchat_get_user_profile",
18
+ "clawchat_list_account_friends",
19
+ "clawchat_update_account_profile",
20
+ "clawchat_upload_avatar_image",
21
+ "clawchat_upload_media_file"
22
+ ]
23
+ },
12
24
  "configSchema": {
13
25
  "type": "object",
14
26
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@newbase-clawchat/openclaw-clawchat",
3
- "version": "2026.4.29",
3
+ "version": "2026.4.30",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "files": [
6
+ "dist",
6
7
  "index.ts",
7
8
  "src",
8
9
  "skills",
@@ -11,6 +12,12 @@
11
12
  ],
12
13
  "type": "module",
13
14
  "scripts": {
15
+ "build": "tsc -p tsconfig.build.json",
16
+ "test": "vitest",
17
+ "test:e2e:install-clawchat-plugin": "bash .e2e/run-install-clawchat-plugin-e2e.sh",
18
+ "test:e2e:install-clawchat-plugin:smoke": "node --test .e2e/run-install-clawchat-plugin-e2e.test.mjs",
19
+ "dev:openclaw-source": "test -d tmp/openclaw || git clone --depth=1 https://github.com/openclaw/openclaw.git tmp/openclaw",
20
+ "prepack": "npm run build",
14
21
  "typecheck": "tsc --noEmit",
15
22
  "prepublishOnly": "npm run typecheck",
16
23
  "release": "npm run prepublishOnly && npm publish"
@@ -21,11 +28,12 @@
21
28
  },
22
29
  "devDependencies": {
23
30
  "@types/node": "^25.5.0",
24
- "openclaw": "^2026.3.23",
25
- "typescript": "^5.4.0"
31
+ "openclaw": "2026.4.29",
32
+ "typescript": "^5.4.0",
33
+ "vitest": "^4.1.5"
26
34
  },
27
35
  "peerDependencies": {
28
- "openclaw": "^2026.3.23"
36
+ "openclaw": "^2026.4.29"
29
37
  },
30
38
  "peerDependenciesMeta": {
31
39
  "openclaw": {
@@ -39,10 +47,22 @@
39
47
  "extensions": [
40
48
  "./index.ts"
41
49
  ],
50
+ "runtimeExtensions": [
51
+ "./dist/index.js"
52
+ ],
53
+ "channel": {
54
+ "id": "openclaw-clawchat",
55
+ "label": "Clawling Chat",
56
+ "selectionLabel": "Clawling Chat",
57
+ "docsPath": "/channels/openclaw-clawchat",
58
+ "docsLabel": "openclaw-clawchat",
59
+ "blurb": "Clawling Protocol v2 over WebSocket (chat-sdk).",
60
+ "order": 110
61
+ },
42
62
  "install": {
43
63
  "npmSpec": "@newbase-clawchat/openclaw-clawchat",
44
64
  "defaultChoice": "npm",
45
- "minHostVersion": ">=2026.3.23"
65
+ "minHostVersion": ">=2026.4.29"
46
66
  }
47
67
  }
48
68
  }
@@ -6,24 +6,33 @@ description: |
6
6
 
7
7
  # ClawChat Activation
8
8
 
9
- Activation must execute the OpenClaw channel login command. Do not edit config files manually, do not call tools for activation, and do not try to execute slash commands from this skill.
9
+ Activation should use the `clawchat_activate` tool when that tool is available. The tool runs the same invite-code login runtime as OpenClaw channel login and persists credentials into `channels.openclaw-clawchat`.
10
+
11
+ If the tool is not available because the Gateway has not loaded this plugin yet, fall back to OpenClaw channel login. Do not edit config files manually and do not try to execute slash commands from this skill.
10
12
 
11
13
  ## Workflow
12
14
 
13
15
  1. Check whether the user is trying to activate or log in ClawChat.
14
- 2. Execute the channel login command:
16
+ 2. If `clawchat_activate` is available and the invite code is known, call it with the invite code.
17
+ 3. If the invite code is not known, ask the user for it before calling `clawchat_activate`.
18
+ 4. If `clawchat_activate` is not available, execute the channel login command:
15
19
 
16
20
  ```bash
17
21
  openclaw channels login --channel openclaw-clawchat
18
22
  ```
19
- 3. Do not append the invite code to the command; `openclaw channels login` has no code argument.
20
- 4. When the command prompts for an invite code, provide the user's invite code if one was already present, otherwise ask the user for it.
21
- 5. After channel login succeeds, execute the Gateway restart command:
23
+ 5. Do not append the invite code to the command; `openclaw channels login` has no code argument.
24
+ 6. When the command prompts for an invite code, provide the user's invite code if one was already present, otherwise ask the user for it.
25
+ 7. After activation succeeds, rely on OpenClaw's `channels.*` config hot reload when the plugin is already loaded. Verify channel status if available:
26
+
27
+ ```bash
28
+ openclaw channels status --probe
29
+ ```
30
+ 8. Execute the Gateway restart command only when the plugin was just installed/updated, the tool was unavailable because the running Gateway had not loaded the plugin, config reload is disabled, or the probe does not become healthy:
22
31
 
23
32
  ```bash
24
33
  openclaw gateway restart
25
34
  ```
26
- 6. Tell the user activation completes after channel login and Gateway restart both succeed.
35
+ 9. Tell the user activation completes after login succeeds and either config hot reload/probe succeeds or Gateway restart succeeds.
27
36
 
28
37
  ## Trigger Examples
29
38
 
@@ -33,6 +42,6 @@ openclaw gateway restart
33
42
  - `绑定 ClawChat,邀请码 A1B2C3`
34
43
  - `激活 ClawChat`
35
44
 
36
- Do not ask the user to enter a bare ClawChat command. If activation is requested, execute `openclaw channels login --channel openclaw-clawchat` yourself, then execute `openclaw gateway restart`.
45
+ Do not ask the user to enter a bare ClawChat command. If activation is requested and `clawchat_activate` is available, call that tool yourself. If the tool is unavailable, execute `openclaw channels login --channel openclaw-clawchat` yourself, then probe channel status and restart the Gateway only when needed.
37
46
 
38
- When the user asks to activate ClawChat without including a code, run channel login and ask for the invite code when the command needs it.
47
+ When the user asks to activate ClawChat without including a code, ask for the invite code before calling `clawchat_activate`; if falling back to channel login, provide the code when the command needs it.
@@ -53,6 +53,7 @@ describe("openBufferedStreamingSession", () => {
53
53
  maxBufferChars: 1000,
54
54
  });
55
55
  expect(typing).toEqual([["u1", true]]);
56
+ expect((client.typing as ReturnType<typeof vi.fn>).mock.calls).toEqual([["u1", true]]);
56
57
  expect(sent.map((s) => s.event)).toEqual(["message.created"]);
57
58
  });
58
59
 
@@ -101,6 +102,7 @@ describe("openBufferedStreamingSession", () => {
101
102
  [{ kind: "text", text: "Hello ", delta: "Hello " }],
102
103
  [{ kind: "text", text: "Hello world", delta: "world" }],
103
104
  ]);
105
+ expect(adds.map((a) => a.payload.sequence)).toEqual([0, 1]);
104
106
  expect(session.currentText).toBe("Hello world");
105
107
  });
106
108
 
@@ -125,6 +127,10 @@ describe("openBufferedStreamingSession", () => {
125
127
  ["u1", true],
126
128
  ["u1", false],
127
129
  ]);
130
+ expect((client.typing as ReturnType<typeof vi.fn>).mock.calls).toEqual([
131
+ ["u1", true],
132
+ ["u1", false],
133
+ ]);
128
134
  });
129
135
 
130
136
  it("done() is idempotent", async () => {
@@ -158,7 +164,11 @@ describe("openBufferedStreamingSession", () => {
158
164
  await session.fail("boom");
159
165
  const failed = sent.find((s) => s.event === "message.failed")!;
160
166
  expect(failed.payload.reason).toBe("boom");
167
+ expect(failed.payload.sequence).toBe(0);
168
+ expect(failed.payload).toHaveProperty("completed_at");
169
+ expect(failed.payload).not.toHaveProperty("failed_at");
161
170
  expect(typing.at(-1)).toEqual(["u1", false]);
171
+ expect((client.typing as ReturnType<typeof vi.fn>).mock.calls.at(-1)).toEqual(["u1", false]);
162
172
  });
163
173
 
164
174
  it("deduplicates a snapshot that is a substring of the buffered snapshot", async () => {
@@ -93,7 +93,7 @@ export function openBufferedStreamingSession(
93
93
  const routing = resolveRouting(options);
94
94
  const emitTyping = options.emitTyping !== false;
95
95
  if (emitTyping)
96
- options.client.typing(routing.chatId, true, routing.chatType);
96
+ options.client.typing(routing.chatId, true);
97
97
  emitStreamCreated(options.client, {
98
98
  messageId: options.messageId,
99
99
  routing,
@@ -101,7 +101,7 @@ export function openBufferedStreamingSession(
101
101
 
102
102
  let bufferedSnapshot = "";
103
103
  let flushedSnapshot = "";
104
- let sequence = 0;
104
+ let sequence = -1;
105
105
  let flushTimer: ReturnType<typeof setTimeout> | null = null;
106
106
  let pendingFlush: Promise<void> = Promise.resolve();
107
107
  let closed = false;
@@ -177,11 +177,11 @@ export function openBufferedStreamingSession(
177
177
  emitStreamDone(options.client, {
178
178
  messageId: options.messageId,
179
179
  routing,
180
- finalSequence: sequence,
180
+ finalSequence: Math.max(sequence, 0),
181
181
  finalText: bufferedSnapshot,
182
182
  });
183
183
  if (emitTyping)
184
- options.client.typing(routing.chatId, false, routing.chatType);
184
+ options.client.typing(routing.chatId, false);
185
185
  };
186
186
 
187
187
  const fail = async (reason?: string): Promise<void> => {
@@ -191,11 +191,11 @@ export function openBufferedStreamingSession(
191
191
  emitStreamFailed(options.client, {
192
192
  messageId: options.messageId,
193
193
  routing,
194
- sequence: sequence + 1,
194
+ sequence: Math.max(sequence, 0),
195
195
  ...(reason !== undefined ? { reason } : {}),
196
196
  });
197
197
  if (emitTyping)
198
- options.client.typing(routing.chatId, false, routing.chatType);
198
+ options.client.typing(routing.chatId, false);
199
199
  };
200
200
 
201
201
  return {
@@ -62,10 +62,10 @@ describe("openclaw-clawchat channel outbound", () => {
62
62
  expect(client.sendMessage).toHaveBeenCalledWith(
63
63
  expect.objectContaining({
64
64
  chat_id: "user-1",
65
- chat_type: "direct",
66
65
  body: { fragments: [{ kind: "text", text: "hello" }] },
67
66
  }),
68
67
  );
68
+ expect(client.sendMessage.mock.calls[0][0]).not.toHaveProperty("chat_type");
69
69
  expect(result).toEqual({
70
70
  channel: "openclaw-clawchat",
71
71
  to: "cc:user-1",
@@ -121,7 +121,6 @@ describe("openclaw-clawchat channel outbound", () => {
121
121
  expect(client.sendMessage).toHaveBeenCalledWith(
122
122
  expect.objectContaining({
123
123
  chat_id: "room-1",
124
- chat_type: "group",
125
124
  body: {
126
125
  fragments: [
127
126
  { kind: "text", text: "caption" },
@@ -130,6 +129,7 @@ describe("openclaw-clawchat channel outbound", () => {
130
129
  },
131
130
  }),
132
131
  );
132
+ expect(client.sendMessage.mock.calls[0][0]).not.toHaveProperty("chat_type");
133
133
  expect(result).toEqual({
134
134
  channel: "openclaw-clawchat",
135
135
  to: "cc:group:room-1",
@@ -199,7 +199,6 @@ describe("openclaw-clawchat channel outbound", () => {
199
199
  expect(client.sendMessage).toHaveBeenCalledWith(
200
200
  expect.objectContaining({
201
201
  chat_id: "room-1",
202
- chat_type: "group",
203
202
  body: {
204
203
  fragments: [
205
204
  { kind: "text", text: "caption" },
@@ -208,6 +207,7 @@ describe("openclaw-clawchat channel outbound", () => {
208
207
  },
209
208
  }),
210
209
  );
210
+ expect(client.sendMessage.mock.calls[0][0]).not.toHaveProperty("chat_type");
211
211
  expect(result).toEqual({
212
212
  channel: "openclaw-clawchat",
213
213
  to: "cc:group:room-1",
package/src/channel.ts CHANGED
@@ -19,8 +19,9 @@ import {
19
19
  resolveOpenclawClawlingAccount,
20
20
  type ResolvedOpenclawClawlingAccount,
21
21
  } from "./config.ts";
22
+ import type { OpenclawClawchatMutateConfigFile } from "./login.runtime.ts";
22
23
  import { openclawClawlingOutbound } from "./outbound.ts";
23
- import { startOpenclawClawlingGateway } from "./runtime.ts";
24
+ import { getOpenclawClawlingRuntime, startOpenclawClawlingGateway } from "./runtime.ts";
24
25
 
25
26
  const configAdapter = createTopLevelChannelConfigAdapter<ResolvedOpenclawClawlingAccount>({
26
27
  sectionKey: CHANNEL_ID,
@@ -36,6 +37,7 @@ const configAdapter = createTopLevelChannelConfigAdapter<ResolvedOpenclawClawlin
36
37
  "replyMode",
37
38
  "forwardThinking",
38
39
  "forwardToolCalls",
40
+ "richInteractions",
39
41
  "enabled",
40
42
  ],
41
43
  resolveAllowFrom: (account) => account.allowFrom,
@@ -53,8 +55,8 @@ const configAdapter = createTopLevelChannelConfigAdapter<ResolvedOpenclawClawlin
53
55
  * `afterAccountConfigWritten`.
54
56
  *
55
57
  * `applyAccountConfig` itself only marks the section `enabled: true`;
56
- * credentials are written by `runOpenclawClawlingLogin` which calls `writeConfigFile`
57
- * on its own after the `/v1/agents/connect` response lands.
58
+ * credentials are written by `runOpenclawClawlingLogin` via the runtime config
59
+ * mutator after the `/v1/agents/connect` response lands.
58
60
  */
59
61
  const setupAdapter = {
60
62
  resolveAccountId: () => DEFAULT_ACCOUNT_ID,
@@ -105,6 +107,9 @@ const setupAdapter = {
105
107
  accountId: null,
106
108
  runtime: { log: (message: string) => runtime.log(message) },
107
109
  readInviteCode: async () => code,
110
+ mutateConfigFile: (getOpenclawClawlingRuntime().config as unknown as {
111
+ mutateConfigFile: OpenclawClawchatMutateConfigFile;
112
+ }).mutateConfigFile,
108
113
  });
109
114
  },
110
115
  };
@@ -177,6 +182,9 @@ export const openclawClawlingPlugin: ChannelPlugin<ResolvedOpenclawClawlingAccou
177
182
  cfg,
178
183
  accountId: accountId ?? null,
179
184
  runtime: { log: (message: string) => runtime.log(message) },
185
+ mutateConfigFile: (getOpenclawClawlingRuntime().config as unknown as {
186
+ mutateConfigFile: OpenclawClawchatMutateConfigFile;
187
+ }).mutateConfigFile,
180
188
  });
181
189
  },
182
190
  },
@@ -92,7 +92,8 @@ describe("openclaw-clawchat client", () => {
92
92
  const env = JSON.parse(transport.sent[0]!);
93
93
  expect(env.event).toBe("message.created");
94
94
  expect(env.chat_id).toBe("user-1");
95
- expect(env.chat_type).toBe("direct");
95
+ expect(env).not.toHaveProperty("chat_type");
96
+ expect(env).not.toHaveProperty("sender");
96
97
  // Payload is intentionally minimal: just message_id, no message body /
97
98
  // context / sender / streaming metadata.
98
99
  expect(env.payload).toEqual({ message_id: "msg-1" });
@@ -118,6 +119,9 @@ describe("openclaw-clawchat client", () => {
118
119
  const env = JSON.parse(transport.sent[0]!);
119
120
  expect(env.event).toBe("message.add");
120
121
  expect(env.payload.sequence).toBe(3);
122
+ expect(env.chat_id).toBe("user-1");
123
+ expect(env).not.toHaveProperty("chat_type");
124
+ expect(env).not.toHaveProperty("sender");
121
125
  expect(env.payload.fragments).toEqual([
122
126
  { kind: "text", text: "Hello, wor", delta: "wor" },
123
127
  ]);
@@ -169,7 +173,10 @@ describe("openclaw-clawchat client", () => {
169
173
  expect(env.event).toBe("message.failed");
170
174
  expect(env.payload.message_id).toBe("msg-1");
171
175
  expect(env.payload.reason).toBe("upstream_error");
176
+ expect(env.payload.fragments).toEqual([{ kind: "text", text: "upstream_error" }]);
172
177
  expect(env.payload.streaming.status).toBe("failed");
178
+ expect(env.payload.streaming.completed_at).toBe(env.payload.completed_at);
179
+ expect(env.payload).not.toHaveProperty("failed_at");
173
180
  client.close();
174
181
  });
175
182
  });
package/src/client.ts CHANGED
@@ -76,11 +76,8 @@ function normalizeRouting(params: {
76
76
  }
77
77
 
78
78
  /**
79
- * Emit a raw v2 envelope directly over the transport so we can carry
80
- * `chat_id` + `chat_type` at envelope root (the new protocol). The SDK's
81
- * `emitRaw` can't express `chat_type` and always writes `to`; we bypass it
82
- * entirely for the events we construct ourselves (streaming lifecycle +
83
- * message.reply finalize).
79
+ * Emit a raw v2 envelope directly over the transport so we can carry top-level
80
+ * `chat_id` routing without SDK-injected `to` metadata.
84
81
  */
85
82
  function emitEnvelope(
86
83
  client: ClawlingChatClient,
@@ -105,7 +102,6 @@ function emitEnvelope(
105
102
  trace_id: inner.opts.traceIdFactory(),
106
103
  emitted_at: Date.now(),
107
104
  chat_id: routing.chatId,
108
- chat_type: routing.chatType,
109
105
  payload,
110
106
  };
111
107
  inner.opts.transport.send(JSON.stringify(env));
@@ -238,7 +234,7 @@ export function emitFinalStreamReply(
238
234
  /** The user message this stream is a reply to (usually the inbound turn). */
239
235
  replyTo: {
240
236
  msgId: string;
241
- senderId: string;
237
+ previewId: string;
242
238
  nickName: string;
243
239
  fragments: Fragment[];
244
240
  };
@@ -260,7 +256,7 @@ export function emitFinalStreamReply(
260
256
  reply: {
261
257
  reply_to_msg_id: params.replyTo.msgId,
262
258
  reply_preview: {
263
- id: params.replyTo.senderId,
259
+ id: params.replyTo.previewId,
264
260
  nick_name: params.replyTo.nickName,
265
261
  fragments: params.replyTo.fragments,
266
262
  },
@@ -284,13 +280,18 @@ export function emitStreamFailed(
284
280
  ): void {
285
281
  const now = Date.now();
286
282
  const routing = normalizeRouting(params);
283
+ const reason = params.reason ?? "unknown";
284
+ const reasonFragment = params.reason?.trim()
285
+ ? { fragments: [{ kind: "text", text: params.reason.trim() }] }
286
+ : {};
287
287
  emitEnvelope(
288
288
  client,
289
289
  "message.failed",
290
290
  {
291
291
  message_id: params.messageId,
292
292
  sequence: params.sequence,
293
- reason: params.reason ?? "unknown",
293
+ reason,
294
+ ...reasonFragment,
294
295
  streaming: {
295
296
  status: "failed",
296
297
  sequence: params.sequence,
@@ -298,7 +299,7 @@ export function emitStreamFailed(
298
299
  started_at: null,
299
300
  completed_at: now,
300
301
  },
301
- failed_at: now,
302
+ completed_at: now,
302
303
  },
303
304
  routing,
304
305
  );
@@ -13,6 +13,11 @@ describe("registerOpenclawClawlingCommands", () => {
13
13
  const commands: Array<{ name: string; acceptsArgs?: boolean; handler: (ctx: unknown) => Promise<{ text: string }> }> = [];
14
14
  const api = {
15
15
  registerCommand: (command: (typeof commands)[number]) => commands.push(command),
16
+ runtime: {
17
+ config: {
18
+ mutateConfigFile: vi.fn(),
19
+ },
20
+ },
16
21
  } as never;
17
22
 
18
23
  registerOpenclawClawlingCommands(api);
@@ -27,6 +32,7 @@ describe("registerOpenclawClawlingCommands", () => {
27
32
 
28
33
  expect(loginRuntime.runOpenclawClawlingLogin).toHaveBeenCalledTimes(1);
29
34
  const params = loginRuntime.runOpenclawClawlingLogin.mock.calls[0]?.[0];
35
+ expect(params.mutateConfigFile).toBe(api.runtime.config.mutateConfigFile);
30
36
  await expect(params.readInviteCode()).resolves.toBe("A1B2C3");
31
37
  expect(result.text).toMatch(/activated successfully/i);
32
38
  });
package/src/commands.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import type { OpenclawClawchatMutateConfigFile } from "./login.runtime.ts";
2
3
 
3
4
  function extractInviteCode(value: unknown): string {
4
5
  const raw = typeof value === "string" ? value.trim() : "";
@@ -9,7 +10,7 @@ function errorMessage(err: unknown): string {
9
10
  return err instanceof Error ? err.message : String(err);
10
11
  }
11
12
 
12
- export function registerOpenclawClawlingCommands(api: Pick<OpenClawPluginApi, "registerCommand" | "logger">): void {
13
+ export function registerOpenclawClawlingCommands(api: Pick<OpenClawPluginApi, "registerCommand" | "logger" | "runtime">): void {
13
14
  api.registerCommand({
14
15
  name: "clawchat-login",
15
16
  description: "Activate ClawChat with an invite code, e.g. /clawchat-login A1B2C3.",
@@ -27,6 +28,9 @@ export function registerOpenclawClawlingCommands(api: Pick<OpenClawPluginApi, "r
27
28
  accountId: ctx.accountId ?? null,
28
29
  runtime: { log: (message: string) => api.logger?.info?.(message) },
29
30
  readInviteCode: async () => code,
31
+ mutateConfigFile: (api.runtime.config as unknown as {
32
+ mutateConfigFile: OpenclawClawchatMutateConfigFile;
33
+ }).mutateConfigFile,
30
34
  });
31
35
  return { text: "✅ ClawChat activated successfully." };
32
36
  } catch (err) {
@@ -23,6 +23,7 @@ describe("openclaw-clawchat config", () => {
23
23
  expect(account.replyMode).toBe("static");
24
24
  expect(account.forwardThinking).toBe(true);
25
25
  expect(account.forwardToolCalls).toBe(false);
26
+ expect(account.richInteractions).toBe(false);
26
27
  expect(account.stream).toEqual(DEFAULT_STREAM);
27
28
  });
28
29
 
@@ -36,6 +37,7 @@ describe("openclaw-clawchat config", () => {
36
37
  replyMode: "stream",
37
38
  forwardThinking: false,
38
39
  forwardToolCalls: true,
40
+ richInteractions: true,
39
41
  stream: { flushIntervalMs: 500, minChunkChars: 50, maxBufferChars: 3000 },
40
42
  },
41
43
  },
@@ -48,6 +50,7 @@ describe("openclaw-clawchat config", () => {
48
50
  expect(account.replyMode).toBe("stream");
49
51
  expect(account.forwardThinking).toBe(false);
50
52
  expect(account.forwardToolCalls).toBe(true);
53
+ expect(account.richInteractions).toBe(true);
51
54
  expect(account.stream.flushIntervalMs).toBe(500);
52
55
  expect(account.stream.minChunkChars).toBe(50);
53
56
  expect(account.stream.maxBufferChars).toBe(3000);
package/src/config.ts CHANGED
@@ -91,6 +91,8 @@ export type OpenclawClawlingConfig = {
91
91
  groupMode?: GroupMode;
92
92
  forwardThinking?: boolean;
93
93
  forwardToolCalls?: boolean;
94
+ /** Emit approval/action rich fragments instead of plain fallback text. */
95
+ richInteractions?: boolean;
94
96
  stream?: OpenclawClawlingStreamConfig;
95
97
  reconnect?: OpenclawClawlingReconnectConfig;
96
98
  heartbeat?: OpenclawClawlingHeartbeatConfig;
@@ -111,6 +113,7 @@ export const openclawClawlingConfigSchema = {
111
113
  groupMode: { type: "string", enum: ["mention", "all"] },
112
114
  forwardThinking: { type: "boolean" },
113
115
  forwardToolCalls: { type: "boolean" },
116
+ richInteractions: { type: "boolean" },
114
117
  stream: {
115
118
  type: "object",
116
119
  additionalProperties: false,
@@ -213,6 +216,7 @@ export type ResolvedOpenclawClawlingAccount = {
213
216
  groupMode: GroupMode;
214
217
  forwardThinking: boolean;
215
218
  forwardToolCalls: boolean;
219
+ richInteractions: boolean;
216
220
  allowFrom: string[];
217
221
  stream: Required<OpenclawClawlingStreamConfig>;
218
222
  reconnect: Required<OpenclawClawlingReconnectConfig>;
@@ -299,6 +303,8 @@ export function resolveOpenclawClawlingAccount(
299
303
  typeof channel.forwardThinking === "boolean" ? channel.forwardThinking : true;
300
304
  const forwardToolCalls =
301
305
  typeof channel.forwardToolCalls === "boolean" ? channel.forwardToolCalls : false;
306
+ const richInteractions =
307
+ typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
302
308
 
303
309
  return {
304
310
  accountId: DEFAULT_ACCOUNT_ID,
@@ -313,6 +319,7 @@ export function resolveOpenclawClawlingAccount(
313
319
  groupMode,
314
320
  forwardThinking,
315
321
  forwardToolCalls,
322
+ richInteractions,
316
323
  allowFrom: [],
317
324
  stream: readStream(channel.stream),
318
325
  reconnect: readReconnect(channel.reconnect),
@@ -41,6 +41,7 @@ function buildSendEnvelope(
41
41
  mentions: string[];
42
42
  reply: unknown;
43
43
  messageId: string;
44
+ chatId: string;
44
45
  }> = {},
45
46
  ): Envelope<DownlinkMessageSendPayload> {
46
47
  return {
@@ -48,6 +49,7 @@ function buildSendEnvelope(
48
49
  event: overrides.event ?? "message.send",
49
50
  trace_id: "trace-1",
50
51
  emitted_at: 1776162600000,
52
+ chat_id: overrides.chatId,
51
53
  to: { id: "agent-1", type: overrides.senderType ?? "direct" },
52
54
  sender: {
53
55
  sender_id: "user-1",
@@ -174,7 +176,7 @@ describe("openclaw-clawchat inbound", () => {
174
176
  },
175
177
  };
176
178
  await dispatchOpenclawClawlingInbound({
177
- envelope: buildSendEnvelope({ event: "message.reply", reply: replyRef }),
179
+ envelope: buildSendEnvelope({ event: "message.reply", reply: replyRef, chatId: "chat-1" }),
178
180
  cfg: {},
179
181
  runtime: {} as never,
180
182
  account: baseAccount(),
@@ -183,6 +185,7 @@ describe("openclaw-clawchat inbound", () => {
183
185
  const { replyCtx } = ingest.mock.calls[0]![0];
184
186
  expect(replyCtx).toEqual({
185
187
  replyToMessageId: "m-orig",
188
+ replyPreviewChatId: "chat-1",
186
189
  replyPreviewSenderId: "user-2",
187
190
  replyPreviewNickName: "User Two",
188
191
  replyPreviewText: "original text",
package/src/inbound.ts CHANGED
@@ -29,6 +29,7 @@ export interface IngestTurnParams {
29
29
  mediaItems: MediaItem[];
30
30
  replyCtx?: {
31
31
  replyToMessageId: string;
32
+ replyPreviewChatId: string;
32
33
  replyPreviewSenderId: string;
33
34
  replyPreviewNickName: string;
34
35
  replyPreviewText: string;
@@ -191,9 +192,19 @@ export async function dispatchOpenclawClawlingInbound(
191
192
  return;
192
193
  }
193
194
 
195
+ log?.info?.(
196
+ `[${account.accountId}] openclaw-clawchat inbound event=${envelope.event === EVENT.MESSAGE_REPLY ? "reply" : "send"} msg=${payload.message_id} from=${sender.id} text_len=${rawBody.length} mentioned=${wasMentioned}`,
197
+ );
198
+
199
+ // New protocol: `chat_id` is the routing primary; `to` is deprecated.
200
+ // Fall back to sender.id if neither is present (defensive).
201
+ const chatId =
202
+ (envelope as Envelope<DownlinkMessageSendPayload> & { chat_id?: string }).chat_id ??
203
+ sender.id;
194
204
  const replyCtx = message.context.reply
195
205
  ? {
196
206
  replyToMessageId: message.context.reply.reply_to_msg_id,
207
+ replyPreviewChatId: chatId,
197
208
  replyPreviewSenderId:
198
209
  message.context.reply.reply_preview.id ??
199
210
  message.context.reply.reply_preview.sender_id ??
@@ -206,16 +217,6 @@ export async function dispatchOpenclawClawlingInbound(
206
217
  }
207
218
  : undefined;
208
219
 
209
- log?.info?.(
210
- `[${account.accountId}] openclaw-clawchat inbound event=${envelope.event === EVENT.MESSAGE_REPLY ? "reply" : "send"} msg=${payload.message_id} from=${sender.id} text_len=${rawBody.length} mentioned=${wasMentioned}`,
211
- );
212
-
213
- // New protocol: `chat_id` is the routing primary; `to` is deprecated.
214
- // Fall back to sender.id if neither is present (defensive).
215
- const chatId =
216
- (envelope as Envelope<DownlinkMessageSendPayload> & { chat_id?: string }).chat_id ??
217
- sender.id;
218
-
219
220
  await params.ingest({
220
221
  channel: "openclaw-clawchat",
221
222
  accountId: account.accountId,
@@ -112,6 +112,42 @@ describe("runOpenclawClawlingLogin", () => {
112
112
  expect(log).toHaveBeenCalledWith(expect.stringContaining("login succeeded"));
113
113
  });
114
114
 
115
+ it("uses the runtime config mutator with auto reload intent for config writes", async () => {
116
+ const cfg = buildCfg({
117
+ baseUrl: "https://api.example.com",
118
+ websocketUrl: "wss://ws.example.com/v2/client",
119
+ });
120
+ const agentsConnect = vi.fn().mockResolvedValue({
121
+ agent: { user_id: "agent-123", nickname: "Bot" },
122
+ access_token: "access-tok",
123
+ refresh_token: "refresh-tok",
124
+ });
125
+ let mutatedCfg: OpenClawConfig | undefined;
126
+ const mutateConfigFile = vi.fn(async (params) => {
127
+ expect(params.afterWrite).toEqual({ mode: "auto" });
128
+ const draft = structuredClone(cfg) as OpenClawConfig;
129
+ await params.mutate(draft, { snapshot: {} as never, previousHash: "before" });
130
+ mutatedCfg = draft;
131
+ return { nextConfig: draft } as never;
132
+ });
133
+
134
+ await runOpenclawClawlingLogin({
135
+ cfg,
136
+ runtime: { log: vi.fn() },
137
+ readInviteCode: async () => "INV-ABC",
138
+ apiClientFactory: () => makeApiClient({ agentsConnect }),
139
+ mutateConfigFile,
140
+ });
141
+
142
+ expect(mutateConfigFile).toHaveBeenCalledTimes(1);
143
+ const section = (mutatedCfg!.channels as Record<string, Record<string, unknown>>)[
144
+ CHANNEL_ID
145
+ ]!;
146
+ expect(section.token).toBe("access-tok");
147
+ expect(section.refreshToken).toBe("refresh-tok");
148
+ expect(section.userId).toBe("agent-123");
149
+ });
150
+
115
151
  it("preserves other configured channels when persisting ClawChat credentials", async () => {
116
152
  const cfg = {
117
153
  channels: {