feat: add fabric-send-sys-msg tool for system-authored Fabric posts

Thin agent-facing wrapper over the Guild backend's x-fabric-system-key
path (see [[reference_fabric_system_msg_api]]). Posts a message into a
specific channel as the guild sentinel author
(00000000-0000-0000-0000-000000000000), not as the calling agent.

Use case driver: Dialectic recruitment broadcasts. ClawSkills'
`analyze-intel` Step 4 currently posts via `fabric-send-message` which
attributes the message to the proposing agent; that's fine for DM
fallbacks but for announce-channel broadcasts the message should look
like a system lifecycle event, not a personal ping. Without this tool,
the only way to get a system-authored post was the close-sub-discussion
internal path — generic broadcast use cases had no door.

Tool shape mirrors fabric-send-message but:
- Reads channels.fabric.commandsSyncKey from openclaw config; empty →
  ok:false with a configuration error (no silent fallthrough to
  agent-bearer posting).
- Optional `wakeupUserId` plumbs through to the backend's
  emitMessageTargeted path: precise wake one recipient or fully silent
  broadcast (default). For announce-channel broadcasts the silent path
  is right — agents poll/discover, they shouldn't be woken on broadcast.
- Caller doesn't need to be a member of the channel (backend isSystem
  branch skips assertParticipant). Guild membership is still required
  because we resolve guild.endpoint from the agent's session.

Manifest gets the tool name so it surfaces in the agent registry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-28 23:46:33 +01:00
parent 152b465e64
commit 180b717eda
2 changed files with 84 additions and 0 deletions

View File

@@ -20,6 +20,7 @@
"fabric-canvas",
"fabric-channel",
"fabric-send-message",
"fabric-send-sys-msg",
"fabric-channel-list",
"fabric-message-history",
"fabric-guild-list",

View File

@@ -555,6 +555,89 @@ export function registerFabricTools(
},
}));
// ───────────────────────────────────────────────────────────────────
// fabric-send-sys-msg: post a system-authored message (author =
// sentinel UUID 0000…, not the calling agent) using the Guild's
// x-fabric-system-key path. Use for cross-agent broadcasts where you
// don't want the message tied to one agent's identity — Dialectic
// topic announcements / lifecycle events, host-system advisories,
// etc. Caller doesn't need to be a member of the channel (the
// backend isSystem branch skips assertParticipant), but must be a
// member of the guild (their session resolves the guild endpoint).
//
// Shared secret: reads channels.fabric.commandsSyncKey (same value
// as the guild's FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY env). Empty
// config → tool returns ok:false with a clear error, no fall-through
// to regular agent posting.
// ───────────────────────────────────────────────────────────────────
api.registerTool((ctx: Ctx) => ({
name: 'fabric-send-sys-msg',
description:
'Send a SYSTEM-AUTHORED message into a Fabric channel (author = guild sentinel, not you). ' +
'Use for cross-agent broadcasts that should not be attributed to a single agent — ' +
'Dialectic announce-channel topic broadcasts, lifecycle events, system advisories. ' +
'Optionally precise-wake one recipient via wakeupUserId; otherwise the message lands ' +
'silently in history (no wake).',
parameters: {
type: 'object',
additionalProperties: false,
required: ['guildNodeId', 'channelId', 'content'],
properties: {
guildNodeId: { type: 'string' },
channelId: { type: 'string' },
content: { type: 'string', description: 'Message body (markdown supported by the renderer).' },
wakeupUserId: {
type: 'string',
description:
"Optional: a single Fabric userId to wake with this message (everyone else in the " +
'channel sees it but with wakeup=false). Omit for fully silent broadcast.',
},
},
},
execute: async (
_id: string,
p: { guildNodeId: string; channelId: string; content: string; wakeupUserId?: string },
) => {
const agentId = ctx.agentId;
if (!agentId) return { ok: false, error: 'no agent context' };
const systemKey = resolveCommandsSyncKey(cfg);
if (!systemKey) {
return {
ok: false,
error:
'channels.fabric.commandsSyncKey is not configured — fabric-send-sys-msg needs it for ' +
'the x-fabric-system-key header. Configure via openclaw config.',
};
}
const { guild } = await ctxGuild(agentId, p.guildNodeId);
const url = `${guild.endpoint}/api/channels/${encodeURIComponent(p.channelId)}/messages`;
const wakeup = typeof p.wakeupUserId === 'string' && p.wakeupUserId.trim()
? p.wakeupUserId.trim()
: null;
const res = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-fabric-system-key': systemKey,
},
body: JSON.stringify({ content: p.content, wakeupUserId: wakeup }),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
return { ok: false, error: `POST ${url} -> ${res.status} ${text}` };
}
const json = (await res.json().catch(() => null)) as
| { messageId?: string; seq?: number; authorUserId?: string }
| null;
return {
ok: true,
messageId: json?.messageId,
seq: json?.seq,
authorUserId: json?.authorUserId,
};
},
}));
// -----------------------------------------------------------------
// fabric-channel-list: enumerate channels the calling agent can see
// in a given guild. Backend filters to public channels + channels the