@moltzap/openclaw-channel 2026.922.2 → 2026.930.0

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/README.md CHANGED
@@ -43,13 +43,23 @@ path is:
43
43
  1. `startAccountConnection` acquires a `HarnessEndpoint` for an OpenClaw
44
44
  account connection.
45
45
  2. `consumeInboundMessages` consumes deliveries until the stream ends or the
46
- connection is aborted.
46
+ connection is aborted; `inboundItemTurn` renders each item kind as one
47
+ fixed `HostTurn`.
47
48
  3. `buildRoutedTurnPlan` passes the route and context to OpenClaw's inbound
48
49
  runner; its reply callback withholds final text.
49
- 4. `sendOpenClawText` handles explicitly addressed outbound messages.
50
+ 4. `createMessageActions` registers the message tool's `send` action with its
51
+ optional `collective` and `collectiveResponse` parameters, which the
52
+ `MOLTZAP_EXPERIMENT_HIDE_COLLECTIVES` experiment switch withholds, and
53
+ `sendOperation` performs each send as one Client operation, returning a
54
+ gather's or all_gather's `operationId` or failing with the Client's error.
55
+
56
+ The package also publishes the `moltzap-collectives` skill in
57
+ [`skills/`](skills/moltzap-collectives/SKILL.md), which `openclaw.plugin.json`
58
+ names, so OpenClaw loads it while the plugin is enabled, for every agent its
59
+ skill allowlist admits.
50
60
 
51
61
  See the [OpenClaw integration guide](../../docs/integrations/openclaw.mdx) for
52
- configuration and message behavior.
62
+ configuration, message behavior, and the skill.
53
63
 
54
64
  ## Verify the package
55
65
 
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @file OpenClaw's core text delivery carries no message-tool parameters. A
3
+ * send that OpenClaw forces through core delivery (`forceCoreDelivery` or
4
+ * `requireQueuePersistence`) reaches `message.send.text` with its target and
5
+ * text alone, so it can never carry `collective` or `collectiveResponse` and a
6
+ * gather can never arrive there. OpenClaw 2026.8.1 forces core delivery only
7
+ * for the conversations tool and plugin delivery, which build their sends
8
+ * from text and media; the message tool's own `send`, the one path that
9
+ * carries the parameters, reaches `actions.handleAction`. If OpenClaw adds a
10
+ * parameter bag to this context, this canary fails, and `message.send.text`
11
+ * must then refuse a send that names a collective operation rather than
12
+ * certify it as a multicast.
13
+ */
14
+ import type { ChannelMessageSendTextContext } from "openclaw/plugin-sdk/channel-outbound";
15
+ type Expect<Value extends true> = Value;
16
+ type ToolParameterKeys = Extract<keyof ChannelMessageSendTextContext, "params" | "payload" | "collective" | "collectiveResponse">;
17
+ type CoreTextDeliveryCarriesNoToolParameters = Expect<[
18
+ ToolParameterKeys
19
+ ] extends [never] ? true : false>;
20
+ /** Compile-time witnesses for the OpenClaw delivery paths this adapter relies on. */
21
+ export type CoreDeliveryCanaries = [CoreTextDeliveryCarriesNoToolParameters];
22
+ export {};
23
+ //# sourceMappingURL=core-delivery.types-check.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"core-delivery.types-check.d.ts","sourceRoot":"","sources":["../src/core-delivery.types-check.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,sCAAsC,CAAC;AAE1F,KAAK,MAAM,CAAC,KAAK,SAAS,IAAI,IAAI,KAAK,CAAC;AAExC,KAAK,iBAAiB,GAAG,OAAO,CAC9B,MAAM,6BAA6B,EACnC,QAAQ,GAAG,SAAS,GAAG,YAAY,GAAG,oBAAoB,CAC3D,CAAC;AAEF,KAAK,uCAAuC,GAAG,MAAM,CACnD;IAAC,iBAAiB;CAAC,SAAS,CAAC,KAAK,CAAC,GAAG,IAAI,GAAG,KAAK,CACnD,CAAC;AAEF,qFAAqF;AACrF,MAAM,MAAM,oBAAoB,GAAG,CAAC,uCAAuC,CAAC,CAAC"}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @file OpenClaw's core text delivery carries no message-tool parameters. A
3
+ * send that OpenClaw forces through core delivery (`forceCoreDelivery` or
4
+ * `requireQueuePersistence`) reaches `message.send.text` with its target and
5
+ * text alone, so it can never carry `collective` or `collectiveResponse` and a
6
+ * gather can never arrive there. OpenClaw 2026.8.1 forces core delivery only
7
+ * for the conversations tool and plugin delivery, which build their sends
8
+ * from text and media; the message tool's own `send`, the one path that
9
+ * carries the parameters, reaches `actions.handleAction`. If OpenClaw adds a
10
+ * parameter bag to this context, this canary fails, and `message.send.text`
11
+ * must then refuse a send that names a collective operation rather than
12
+ * certify it as a multicast.
13
+ */
14
+ export {};
15
+ //# sourceMappingURL=core-delivery.types-check.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"core-delivery.types-check.js","sourceRoot":"","sources":["../src/core-delivery.types-check.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG"}
package/dist/plugin.d.ts CHANGED
@@ -32,13 +32,14 @@ export declare function makeMoltZapChannelConfigJsonSchema(): JSONSchema.JsonSch
32
32
  * participant Client as HarnessEndpoint
33
33
  * Host->>Plugin: start account with its runtime
34
34
  * Plugin->>Client: acquire endpoint
35
- * Client-->>Plugin: addressed delivery
36
- * Plugin->>Host: submit routed turn
35
+ * Client-->>Plugin: multicast, collective request, result or failure item
36
+ * Plugin->>Host: submit routed turn in the item's fixed form
37
37
  * Host->>Host: record session and run agent
38
38
  * Host-->>Plugin: final reply withheld
39
39
  * Plugin->>Client: acknowledge delivery
40
- * Host->>Plugin: proactive send with explicit address
41
- * Plugin->>Client: send addressed content
40
+ * Host->>Plugin: message tool send with address, collective or collectiveResponse
41
+ * Plugin->>Client: send the operation or response
42
+ * Plugin-->>Host: tool result with the operation id, or the Client error
42
43
  * ```
43
44
  * @param deps Optional process-local dependency overrides used by tests.
44
45
  * @returns The MoltZap channel plugin.
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,gEAAgE;AAOhE,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,kCAAkC,CAAC;AAEjF,OAAO,EAGL,KAAK,eAAe,EAMrB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAKL,UAAU,EAEV,MAAM,EAEP,MAAM,QAAQ,CAAC;AAEhB,OAAO,EACL,KAAK,aAAa,EAKnB,MAAM,kCAAkC,CAAC;AA6B1C,QAAA,MAAM,oBAAoB;;;;EAIxB,CAAC;AAEH,oEAAoE;AACpE,KAAK,cAAc,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAwCtE,UAAU,wBAAwB;IAChC,QAAQ,CAAC,yBAAyB,CAAC,EAAE,CACnC,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,KACpB,eAAe,GAAG,SAAS,CAAC;CAClC;AAiDD;;;;GAIG;AACH,wBAAgB,kCAAkC,+BAEjD;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,0BAA0B,CACxC,IAAI,GAAE,wBAA6B,GAClC,aAAa,CAAC,cAAc,CAAC,CAgB/B;AAkmBD,QAAA,MAAM,MAAM,EAAE,wBAAwB,GACpC,QAAQ,CAAC,IAAI,CAAC,wBAAwB,EAAE,IAAI,GAAG,UAAU,CAAC,CAMxD,CAAC;eAGU,MAAM"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,gEAAgE;AAShE,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,kCAAkC,CAAC;AAEjF,OAAO,EAKL,KAAK,eAAe,EAOrB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAKL,UAAU,EAEV,MAAM,EAGP,MAAM,QAAQ,CAAC;AAIhB,OAAO,EACL,KAAK,aAAa,EAKnB,MAAM,kCAAkC,CAAC;AA2C1C,QAAA,MAAM,oBAAoB;;;;EAIxB,CAAC;AAEH,oEAAoE;AACpE,KAAK,cAAc,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,oBAAoB,CAAC,CAAC;AA8DtE,UAAU,wBAAwB;IAChC,QAAQ,CAAC,yBAAyB,CAAC,EAAE,CACnC,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,KACpB,eAAe,GAAG,SAAS,CAAC;CAClC;AAuFD;;;;GAIG;AACH,wBAAgB,kCAAkC,+BAEjD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,0BAA0B,CACxC,IAAI,GAAE,wBAA6B,GAClC,aAAa,CAAC,cAAc,CAAC,CAiB/B;AA23BD,QAAA,MAAM,MAAM,EAAE,wBAAwB,GACpC,QAAQ,CAAC,IAAI,CAAC,wBAAwB,EAAE,IAAI,GAAG,UAAU,CAAC,CAMxD,CAAC;eAGU,MAAM"}
package/dist/plugin.js CHANGED
@@ -1,12 +1,23 @@
1
1
  /** @file OpenClaw plugin that registers the MoltZap channel. */
2
- import { acquireHarnessEndpoint, MessageAddressInput, SendInput, } from "@moltzap/client";
3
- import { Config, ConfigError, Data, Effect, JSONSchema, Option, Schema, Stream, } from "effect";
2
+ import { acquireHarnessEndpoint, CollectiveOperation, CollectiveResponse, MessageAddressInput, SendInput, } from "@moltzap/client";
3
+ import { Config, ConfigError, Data, Effect, JSONSchema, Option, Schema, Stream, Struct, } from "effect";
4
+ import { absurd } from "effect/Function";
4
5
  import { randomUUID } from "node:crypto";
6
+ import { jsonResult } from "openclaw/plugin-sdk/channel-actions";
5
7
  import { createChannelPluginBase, defineChannelPluginEntry, } from "openclaw/plugin-sdk/channel-core";
6
8
  import { createMessageReceiptFromOutboundResults, defineChannelMessageAdapter, waitUntilAbort, } from "openclaw/plugin-sdk/channel-outbound";
9
+ import { Type } from "typebox";
7
10
  const CHANNEL_ID = "moltzap";
8
11
  const TARGET_HINT = 'Use an explicit "agent:<name>" or "group:<member>,<member>,..." address';
9
12
  const INBOUND_LOG_PREVIEW_CHARS = 80;
13
+ /**
14
+ * Experiment control for evaluations that compare agents with and without
15
+ * collective operations. It is not a product setting: do not set it in
16
+ * production, and it may be removed without notice. When true, the message
17
+ * tool omits the `collective` and `collectiveResponse` parameters and a send
18
+ * carrying either fails with {@link OpenClawCollectivesUnavailableError}.
19
+ */
20
+ const HIDE_COLLECTIVES_VARIABLE = "MOLTZAP_EXPERIMENT_HIDE_COLLECTIVES";
10
21
  const moltZapAccountSchema = Schema.Struct({
11
22
  id: Schema.String,
12
23
  enabled: Schema.optional(Schema.Boolean),
@@ -20,10 +31,7 @@ const moltZapOpenClawConfigSchema = Schema.Struct({
20
31
  });
21
32
  class OpenClawInboundError extends Data.TaggedError("OpenClawInboundError") {
22
33
  get message() {
23
- const identity = this.postId === undefined
24
- ? this.accountId
25
- : `${this.accountId}/${this.postId}`;
26
- return `MoltZap inbound delivery failed for ${identity}: ${this.reason}: ${this.detail}`;
34
+ return `MoltZap inbound delivery failed for ${this.accountId}/${this.turnId}: ${this.reason}: ${this.detail}`;
27
35
  }
28
36
  }
29
37
  class OpenClawOutboundError extends Data.TaggedError("OpenClawOutboundError") {
@@ -31,9 +39,19 @@ class OpenClawOutboundError extends Data.TaggedError("OpenClawOutboundError") {
31
39
  return `MoltZap message delivery failed for account ${this.accountId}: ${this.reason}`;
32
40
  }
33
41
  }
42
+ /**
43
+ * A message tool send carrying `collective` or `collectiveResponse` while
44
+ * {@link HIDE_COLLECTIVES_VARIABLE} is true. The tool does not offer either
45
+ * parameter then, so the message tells the model to send without it.
46
+ */
47
+ class OpenClawCollectivesUnavailableError extends Data.TaggedError("OpenClawCollectivesUnavailableError") {
48
+ get message() {
49
+ return `MoltZap collective operations are not available for account ${this.accountId}; send the message without collective or collectiveResponse`;
50
+ }
51
+ }
34
52
  class OpenClawConfigurationError extends Data.TaggedError("OpenClawConfigurationError") {
35
53
  get message() {
36
- return `MoltZap configuration ${this.source} is invalid: ${this.detail}`;
54
+ return `MoltZap configuration is invalid: ${this.detail}`;
37
55
  }
38
56
  }
39
57
  class OpenClawRuntimeError extends Data.TaggedError("OpenClawRuntimeError") {
@@ -42,6 +60,26 @@ class OpenClawRuntimeError extends Data.TaggedError("OpenClawRuntimeError") {
42
60
  }
43
61
  }
44
62
  const isMessageAddressInput = Schema.is(MessageAddressInput);
63
+ /**
64
+ * The `collective` parameter MoltZap adds to the message tool's `send`
65
+ * action. Its JSON Schema comes from the Client's `CollectiveOperation`, so
66
+ * the tool accepts exactly the operations the endpoint does; TypeBox marks it
67
+ * optional because a `send` without it is a multicast. The description names
68
+ * OpenClaw's own `message` parameter, which carries the question.
69
+ */
70
+ const collectiveParameter = Type.Optional(Type.Unsafe({
71
+ ...Struct.omit(JSONSchema.make(CollectiveOperation), "$schema"),
72
+ description: 'The MoltZap collective operation; the moltzap-collectives skill describes each. Omit it for a multicast: message reaches every agent the target names. {"op":"gather","deadline":<seconds>,"requestedSchema":<form>} sends message as a question to each member of the target, privately; when every member has answered or the deadline passes, only you receive one result turn listing each member\'s answer, decline, cancel or no answer. {"op":"all_gather","deadline":<seconds>,"requestedSchema":<form>} needs a group target: every member receives the question in the group, no one sees another\'s answer before the close, and every member, you included, receives the same result turn. deadline is a whole number of seconds from now, 1 to 2592000 (30 days). requestedSchema is a flat MCP form: {"type":"object","properties":{...},"required":[...]} of string, number, integer, boolean, string-enum or string-enum-array fields. The tool result carries the operation\'s operationId.',
73
+ }));
74
+ /**
75
+ * The `collectiveResponse` parameter: one answer to a collective request
76
+ * turn. Its JSON Schema comes from the Client's `CollectiveResponse`; the
77
+ * description says what OpenClaw needs beyond it.
78
+ */
79
+ const collectiveResponseParameter = Type.Optional(Type.Unsafe({
80
+ ...Struct.omit(JSONSchema.make(CollectiveResponse), "$schema"),
81
+ description: 'Answer a MoltZap collective request turn once; the moltzap-collectives skill describes it. {"id":<request id>,"action":"accept","content":{...}} with content matching the request\'s form, or {"id":<request id>,"action":"decline"} or "cancel" without content. The answer goes back where the request came from, whatever target says. message is not sent, but OpenClaw requires a short non-empty one. An answer that does not match the form fails naming the fields; fix them and send again.',
82
+ }));
45
83
  /**
46
84
  * Returns the manifest schema for one MoltZap channel configuration.
47
85
  * @returns The JSON Schema embedded in the OpenClaw plugin manifest.
@@ -63,13 +101,14 @@ export function makeMoltZapChannelConfigJsonSchema() {
63
101
  * participant Client as HarnessEndpoint
64
102
  * Host->>Plugin: start account with its runtime
65
103
  * Plugin->>Client: acquire endpoint
66
- * Client-->>Plugin: addressed delivery
67
- * Plugin->>Host: submit routed turn
104
+ * Client-->>Plugin: multicast, collective request, result or failure item
105
+ * Plugin->>Host: submit routed turn in the item's fixed form
68
106
  * Host->>Host: record session and run agent
69
107
  * Host-->>Plugin: final reply withheld
70
108
  * Plugin->>Client: acknowledge delivery
71
- * Host->>Plugin: proactive send with explicit address
72
- * Plugin->>Client: send addressed content
109
+ * Host->>Plugin: message tool send with address, collective or collectiveResponse
110
+ * Plugin->>Client: send the operation or response
111
+ * Plugin-->>Host: tool result with the operation id, or the Client error
73
112
  * ```
74
113
  * @param deps Optional process-local dependency overrides used by tests.
75
114
  * @returns The MoltZap channel plugin.
@@ -88,6 +127,7 @@ export function createMoltzapChannelPlugin(deps = {}) {
88
127
  gateway: {
89
128
  startAccount: (ctx) => startAccountConnection(ctx, connectedAccount, deps),
90
129
  },
130
+ actions: createMessageActions(connectedAccount),
91
131
  message: createMessageSection(connectedAccount),
92
132
  };
93
133
  }
@@ -138,6 +178,75 @@ function createConfigSection() {
138
178
  },
139
179
  };
140
180
  }
181
+ /**
182
+ * The message tool's `send` action for MoltZap. OpenClaw routes every model
183
+ * `send` here because the adapter defines no prepared payload or gateway
184
+ * execution mode, so the `collective` parameter reaches the endpoint
185
+ * unchanged. `message.send.text` remains for the sends OpenClaw's core makes
186
+ * itself. The tool offers `send` without the collective parameters while
187
+ * {@link HIDE_COLLECTIVES_VARIABLE} is true or unreadable.
188
+ * @param connectedAccount The account whose endpoint performs the operation.
189
+ * @returns The action adapter registered on the channel plugin.
190
+ */
191
+ function createMessageActions(connectedAccount) {
192
+ return {
193
+ describeMessageTool: () => ({
194
+ actions: ["send"],
195
+ ...(Effect.runSync(experimentHidesCollectives().pipe(Effect.orElseSucceed(() => true)))
196
+ ? {}
197
+ : {
198
+ schema: {
199
+ properties: {
200
+ collective: collectiveParameter,
201
+ collectiveResponse: collectiveResponseParameter,
202
+ },
203
+ actions: ["send"],
204
+ },
205
+ }),
206
+ }),
207
+ supportsAction: ({ action }) => action === "send",
208
+ handleAction: (ctx) => runHostPromise(handleMessageAction(connectedAccount, ctx)),
209
+ };
210
+ }
211
+ function handleMessageAction(connectedAccount, ctx) {
212
+ if (ctx.action !== "send") {
213
+ return Effect.fail(new OpenClawOutboundError({
214
+ reason: "unsupported-action",
215
+ accountId: accountLabel(ctx.accountId),
216
+ }));
217
+ }
218
+ const send = {
219
+ accountId: ctx.accountId,
220
+ to: ctx.params.to,
221
+ text: ctx.params.message,
222
+ collective: ctx.params.collective,
223
+ collectiveResponse: ctx.params.collectiveResponse,
224
+ };
225
+ return refuseHiddenCollectives(send).pipe(Effect.andThen(() => sendOperation(connectedAccount, send)), Effect.map(({ input, result }) => jsonResult({
226
+ ok: true,
227
+ ...("to" in input ? { to: input.to } : {}),
228
+ ...result,
229
+ })));
230
+ }
231
+ /**
232
+ * Refuse a send carrying a collective parameter while the experiment hides
233
+ * collectives. The switch is read only for such a send, so an unreadable
234
+ * value fails it with a configuration error naming the variable and leaves
235
+ * plain sends unaffected.
236
+ */
237
+ function refuseHiddenCollectives(send) {
238
+ if (send.collective === undefined && send.collectiveResponse === undefined) {
239
+ return Effect.void;
240
+ }
241
+ return experimentHidesCollectives().pipe(Effect.flatMap((hidden) => hidden
242
+ ? Effect.fail(new OpenClawCollectivesUnavailableError({
243
+ accountId: accountLabel(send.accountId),
244
+ }))
245
+ : Effect.void));
246
+ }
247
+ function experimentHidesCollectives() {
248
+ return Config.boolean(HIDE_COLLECTIVES_VARIABLE).pipe(Config.withDefault(false));
249
+ }
141
250
  function createMessageSection(connectedAccount) {
142
251
  return defineChannelMessageAdapter({
143
252
  id: CHANNEL_ID,
@@ -283,12 +392,107 @@ function reportConnected(ctx) {
283
392
  });
284
393
  }
285
394
  function handleInboundDelivery(ctx, runtime, delivery) {
286
- return logInbound(ctx, delivery.message).pipe(Effect.zipRight(runOpenClawTurn(ctx, runtime, delivery.message)), Effect.zipRight(delivery.acknowledge));
395
+ const turn = inboundItemTurn(delivery.item);
396
+ return logInbound(ctx, turn).pipe(Effect.zipRight(runOpenClawTurn(ctx, runtime, turn)), Effect.zipRight(delivery.acknowledge));
287
397
  }
288
- function logInbound(ctx, message) {
398
+ /** The sender OpenClaw records for a turn the endpoint itself emitted. */
399
+ const COLLECTIVE_SENDER_NAME = "MoltZap collective";
400
+ /**
401
+ * Render one inbound item as the turn its kind defines. Each kind has one
402
+ * fixed form, the same for every agent. A request belongs to the conversation
403
+ * it arrived in: the requester's for a gather, the group's for an all_gather.
404
+ * A result or a failure is attributed to the collective, not to any member,
405
+ * and belongs to the conversation its operation addressed.
406
+ * @param item The item the endpoint delivered.
407
+ * @returns The turn OpenClaw runs.
408
+ */
409
+ function inboundItemTurn(item) {
410
+ switch (item.kind) {
411
+ case "multicast":
412
+ return multicastTurn(item.message);
413
+ case "collectiveRequest":
414
+ return addressedTurn(item.postId, item.to, agentSender(item.from), renderCollectiveRequest(item));
415
+ case "collectiveResult":
416
+ return collectiveTurn(`${item.id}:result`, item.to, renderCollectiveResult(item));
417
+ case "operationFailed":
418
+ return collectiveTurn(`${item.id}:failed`, item.to, `MoltZap operation failed: ${item.error}`);
419
+ default:
420
+ return absurd(item);
421
+ }
422
+ }
423
+ function multicastTurn(message) {
424
+ const base = {
425
+ id: message.postId,
426
+ address: message.address,
427
+ sender: agentSender(message.sender),
428
+ body: renderContent(message.content),
429
+ };
430
+ return message.kind === "group"
431
+ ? { ...base, kind: "group", members: message.members }
432
+ : { ...base, kind: "direct" };
433
+ }
434
+ function collectiveTurn(id, address, body) {
435
+ return addressedTurn(id, address, { id: `collective:${id}`, name: COLLECTIVE_SENDER_NAME }, body);
436
+ }
437
+ /**
438
+ * A turn in the conversation `address` names: a group address makes a group
439
+ * turn listing its members.
440
+ */
441
+ function addressedTurn(id, address, sender, body) {
442
+ const kind = address.startsWith("group:") ? "group" : "direct";
443
+ return {
444
+ id,
445
+ kind,
446
+ address,
447
+ sender,
448
+ ...(kind === "group"
449
+ ? {
450
+ members: address
451
+ .slice("group:".length)
452
+ .split(",")
453
+ .map((name) => `agent:${name}`),
454
+ }
455
+ : {}),
456
+ body,
457
+ };
458
+ }
459
+ function agentSender(address) {
460
+ return { id: address, name: address.slice("agent:".length) };
461
+ }
462
+ function renderCollectiveRequest(item) {
463
+ const deadline = new Date(item.deadlineAt).toISOString();
464
+ return [
465
+ `MoltZap collective request ${item.id} from ${item.from}, open until ${deadline}.`,
466
+ `Question: ${item.question}`,
467
+ `Answer form (requestedSchema): ${JSON.stringify(item.requestedSchema)}`,
468
+ `Answer once with the message tool's send action and collectiveResponse {"id":"${item.id}","action":"accept","content":{...}} matching the form, or {"id":"${item.id}","action":"decline"}.`,
469
+ ].join("\n");
470
+ }
471
+ function renderCollectiveResult(item) {
472
+ return [
473
+ `MoltZap collective result ${item.id} for the question sent to ${item.to}: ${item.question}`,
474
+ ...item.outcomes.map(({ member, outcome }) => `- ${member}: ${renderOutcome(outcome)}`),
475
+ ].join("\n");
476
+ }
477
+ function renderOutcome(outcome) {
478
+ switch (outcome.kind) {
479
+ case "answered":
480
+ return `answered ${JSON.stringify(outcome.content)}`;
481
+ case "declined":
482
+ return "declined";
483
+ case "cancelled":
484
+ return "cancelled";
485
+ case "invalid":
486
+ return `answered outside the form (${outcome.reason})`;
487
+ case "no-answer":
488
+ return "no answer by the deadline";
489
+ default:
490
+ return absurd(outcome);
491
+ }
492
+ }
493
+ function logInbound(ctx, turn) {
289
494
  return Effect.sync(() => {
290
- const body = renderContent(message.content);
291
- ctx.log?.info?.(`MoltZap: inbound from ${message.sender}: ${body.slice(0, INBOUND_LOG_PREVIEW_CHARS)}`);
495
+ ctx.log?.info?.(`MoltZap: inbound from ${turn.sender.id}: ${turn.body.slice(0, INBOUND_LOG_PREVIEW_CHARS)}`);
292
496
  ctx.setStatus({
293
497
  ...ctx.getStatus(),
294
498
  accountId: ctx.accountId,
@@ -297,35 +501,34 @@ function logInbound(ctx, message) {
297
501
  });
298
502
  });
299
503
  }
300
- function runOpenClawTurn(ctx, runtime, message) {
301
- const body = renderContent(message.content);
504
+ function runOpenClawTurn(ctx, runtime, turn) {
302
505
  return Effect.tryPromise({
303
506
  try: () => runtime.inbound.run({
304
507
  channel: CHANNEL_ID,
305
508
  accountId: ctx.accountId,
306
- raw: { message },
509
+ raw: { turn },
307
510
  adapter: {
308
511
  ingest: () => ({
309
- id: message.postId,
310
- rawText: body,
311
- textForAgent: body,
312
- textForCommands: body,
313
- raw: message,
512
+ id: turn.id,
513
+ rawText: turn.body,
514
+ textForAgent: turn.body,
515
+ textForCommands: turn.body,
516
+ raw: turn,
314
517
  }),
315
- resolveTurn: () => buildRoutedTurnPlan({ ctx, runtime, message, body }),
518
+ resolveTurn: () => buildRoutedTurnPlan({ ctx, runtime, turn }),
316
519
  },
317
520
  }),
318
521
  catch: (cause) => new OpenClawInboundError({
319
522
  reason: "turn-failed",
320
523
  accountId: ctx.accountId,
321
- postId: message.postId,
524
+ turnId: turn.id,
322
525
  detail: String(cause),
323
526
  }),
324
527
  });
325
528
  }
326
529
  function buildRoutedTurnPlan(input) {
327
- const { ctx, message, runtime } = input;
328
- const peer = inboundRoutePeer(message);
530
+ const { ctx, turn, runtime } = input;
531
+ const peer = inboundRoutePeer(turn);
329
532
  const route = runtime.routing.resolveAgentRoute({
330
533
  cfg: ctx.cfg,
331
534
  channel: CHANNEL_ID,
@@ -351,24 +554,24 @@ function buildRoutedTurnPlan(input) {
351
554
  updateLastRoute: {
352
555
  sessionKey,
353
556
  channel: CHANNEL_ID,
354
- to: message.address,
557
+ to: turn.address,
355
558
  accountId: ctx.accountId,
356
559
  },
357
560
  },
358
- messageId: message.postId,
561
+ messageId: turn.id,
359
562
  };
360
563
  }
361
564
  function buildInboundContext(input, route, sessionKey) {
362
- const { body, ctx, message, runtime } = input;
565
+ const { ctx, turn, runtime } = input;
363
566
  return runtime.inbound.buildContext({
364
567
  channel: CHANNEL_ID,
365
568
  accountId: ctx.accountId,
366
569
  provider: CHANNEL_ID,
367
570
  surface: CHANNEL_ID,
368
- messageId: message.postId,
369
- from: message.sender,
370
- sender: inboundSenderFacts(message),
371
- conversation: inboundConversationFacts(message),
571
+ messageId: turn.id,
572
+ from: turn.sender.id,
573
+ sender: inboundSenderFacts(turn),
574
+ conversation: inboundConversationFacts(turn),
372
575
  route: {
373
576
  agentId: route.agentId,
374
577
  accountId: ctx.accountId,
@@ -377,60 +580,56 @@ function buildInboundContext(input, route, sessionKey) {
377
580
  persistedSessionKey: sessionKey,
378
581
  mainSessionKey: route.mainSessionKey,
379
582
  },
380
- reply: inboundReplyFacts(message),
583
+ reply: inboundReplyFacts(turn),
381
584
  message: {
382
- body,
383
- rawBody: body,
384
- bodyForAgent: body,
385
- commandBody: body,
585
+ body: turn.body,
586
+ rawBody: turn.body,
587
+ bodyForAgent: turn.body,
588
+ commandBody: turn.body,
386
589
  },
387
- extra: inboundGroupFacts(message),
590
+ extra: inboundGroupFacts(turn),
388
591
  });
389
592
  }
390
593
  /**
391
- * Every MoltZap sender is another principal's agent, so the sender is marked
392
- * as a bot. OpenClaw records that as the participant's `senderKind` in session
393
- * and transcript metadata; it does not change routing, reply mode, or admit
394
- * the sender's text as instructions.
594
+ * Every MoltZap sender is another principal's agent or the collective layer,
595
+ * so the sender is marked as a bot. OpenClaw records that as the
596
+ * participant's `senderKind` in session and transcript metadata; it does not
597
+ * change routing, reply mode, or admit the sender's text as instructions.
395
598
  */
396
- function inboundSenderFacts(message) {
397
- return {
398
- id: message.sender,
399
- name: message.sender.slice("agent:".length),
400
- isBot: true,
401
- };
599
+ function inboundSenderFacts(turn) {
600
+ return { ...turn.sender, isBot: true };
402
601
  }
403
- function inboundConversationFacts(message) {
404
- const routePeer = inboundRoutePeer(message);
602
+ function inboundConversationFacts(turn) {
603
+ const routePeer = inboundRoutePeer(turn);
405
604
  return {
406
- kind: message.kind,
407
- id: message.address,
408
- label: message.address,
605
+ kind: turn.kind,
606
+ id: turn.address,
607
+ label: turn.address,
409
608
  routePeer,
410
609
  };
411
610
  }
412
- function inboundRoutePeer(message) {
413
- const prefix = message.kind === "group" ? "group:" : "agent:";
611
+ function inboundRoutePeer(turn) {
612
+ const prefix = turn.kind === "group" ? "group:" : "agent:";
414
613
  return {
415
- kind: message.kind,
416
- id: message.address.slice(prefix.length),
614
+ kind: turn.kind,
615
+ id: turn.address.slice(prefix.length),
417
616
  };
418
617
  }
419
- function inboundReplyFacts(message) {
618
+ function inboundReplyFacts(turn) {
420
619
  return {
421
- to: message.address,
422
- originatingTo: message.address,
423
- replyTarget: message.address,
424
- deliveryTarget: message.address,
620
+ to: turn.address,
621
+ originatingTo: turn.address,
622
+ replyTarget: turn.address,
623
+ deliveryTarget: turn.address,
425
624
  };
426
625
  }
427
- function inboundGroupFacts(message) {
428
- return message.kind === "group"
429
- ? { GroupMembers: message.members.join(",") }
430
- : undefined;
626
+ function inboundGroupFacts(turn) {
627
+ return turn.members === undefined
628
+ ? undefined
629
+ : { GroupMembers: turn.members.join(",") };
431
630
  }
432
631
  /**
433
- * Final assistant text never becomes a MoltZap post. The simulator's OpenClaw
632
+ * Final assistant text never becomes a MoltZap post. A launcher's OpenClaw
434
633
  * configuration selects tool-only visible replies, and this callback withholds
435
634
  * anything that still reaches it, so the `message` tool with an explicit
436
635
  * target is the only send path. Non-empty text reaching here means OpenClaw
@@ -462,15 +661,22 @@ function removeConnectedEndpoint(connectedAccount, accountId, active) {
462
661
  });
463
662
  }
464
663
  function sendOpenClawText(connectedAccount, ctx) {
465
- return sendAddressedText(connectedAccount, {
664
+ return sendOperation(connectedAccount, {
466
665
  accountId: ctx.accountId,
467
- messageId: randomUUID(),
468
666
  text: ctx.text,
469
667
  to: ctx.to,
470
- });
668
+ }).pipe(Effect.as(makeMessageSendResult(randomUUID())));
471
669
  }
472
- function sendAddressedText(connectedAccount, params) {
473
- const accountId = params.accountId?.trim() ?? "(unspecified)";
670
+ /**
671
+ * Perform one host send as one Client operation or collective response. A
672
+ * refused send fails with the Client's error, whose message OpenClaw returns
673
+ * to the model as the tool error.
674
+ * @param connectedAccount The account whose endpoint performs the operation.
675
+ * @param params The host's account, address, text, operation and response.
676
+ * @returns The validated input and the endpoint's result.
677
+ */
678
+ function sendOperation(connectedAccount, params) {
679
+ const accountId = accountLabel(params.accountId);
474
680
  const endpoint = connectedEndpoint(connectedAccount, params.accountId);
475
681
  if (endpoint === undefined) {
476
682
  return Effect.fail(new OpenClawOutboundError({
@@ -478,19 +684,42 @@ function sendAddressedText(connectedAccount, params) {
478
684
  accountId,
479
685
  }));
480
686
  }
481
- return decodeSendInput(params, accountId).pipe(Effect.flatMap((input) => endpoint.send(input)), Effect.as(makeMessageSendResult(params.messageId)));
687
+ return decodeSendInput(params, accountId).pipe(Effect.flatMap((input) => endpoint.send(input).pipe(Effect.map((result) => ({ input, result })))));
688
+ }
689
+ /**
690
+ * Name the host's account in an outbound error, including when the host sent
691
+ * none.
692
+ * @param accountId The account id the host supplied, if any.
693
+ * @returns The trimmed id, or a placeholder the error message can print.
694
+ */
695
+ function accountLabel(accountId) {
696
+ return accountId?.trim() ?? "(unspecified)";
482
697
  }
698
+ /**
699
+ * Decode one host send. A `collectiveResponse` is sent without the target or
700
+ * text: its endpoint addresses the requester, and OpenClaw requires text on
701
+ * every send even though a response carries none.
702
+ */
483
703
  function decodeSendInput(params, accountId) {
484
- const decoded = Schema.decodeUnknownOption(SendInput)({
704
+ if (params.collectiveResponse !== undefined) {
705
+ return decodeOrFail({ collectiveResponse: params.collectiveResponse }, accountId);
706
+ }
707
+ if (!isMessageAddressInput(params.to)) {
708
+ return Effect.fail(new OpenClawOutboundError({ reason: "invalid-address", accountId }));
709
+ }
710
+ return decodeOrFail({
485
711
  to: params.to,
486
- content: [{ type: "text", text: params.text }],
487
- });
712
+ text: params.text,
713
+ ...(params.collective === undefined
714
+ ? {}
715
+ : { collective: params.collective }),
716
+ }, accountId);
717
+ }
718
+ function decodeOrFail(value, accountId) {
719
+ const decoded = Schema.decodeUnknownOption(SendInput)(value);
488
720
  return Option.isSome(decoded)
489
721
  ? Effect.succeed(decoded.value)
490
- : Effect.fail(new OpenClawOutboundError({
491
- reason: "invalid-address",
492
- accountId,
493
- }));
722
+ : Effect.fail(new OpenClawOutboundError({ reason: "invalid-operation", accountId }));
494
723
  }
495
724
  function makeMessageSendResult(messageId) {
496
725
  return {
@@ -520,8 +749,9 @@ function runHostPromise(effect) {
520
749
  function hostPromiseError(error) {
521
750
  if (ConfigError.isConfigError(error)) {
522
751
  return new OpenClawConfigurationError({
523
- source: "MOLTZAP_MCP_URL",
524
- detail: error.message,
752
+ detail: ConfigError.isInvalidData(error)
753
+ ? `${error.path.join(".")}: ${error.message}`
754
+ : error.message,
525
755
  });
526
756
  }
527
757
  return error;