const X_BY_KIND = { chat: 'general', work: 'work', report: 'report', discussion: 'discuss', }; export function registerFabricTools(api, client, identity) { // Resolve the calling agent's Fabric session + a guild's token/endpoint. const ctxGuild = async (agentId, guildNodeId) => { const entry = identity.findByAgentId(agentId); if (!entry) throw new Error(`agent ${agentId} not registered — run: AGENT_ID=${agentId} ` + `~/.openclaw/bin/fabric-register --api-key (or set ` + `channels.fabric.accounts.${agentId}); then restart the gateway`); const session = await client.agentLogin(entry.fabricApiKey); const guild = session.guilds.find((g) => g.nodeId === guildNodeId); const token = session.guildAccessTokens.find((t) => t.guildNodeId === guildNodeId)?.token; if (!guild || !token) throw new Error(`agent not a member of guild ${guildNodeId}`); return { session, guild, token }; }; // NOTE: binding an agent's Fabric API key is intentionally NOT a tool. // It's a one-time step done out-of-band via the installed script // ~/.openclaw/bin/fabric-register --api-key (AGENT_ID or --agent-id) // or via static config (channels.fabric.accounts.). const makeCreate = (kind) => api.registerTool((ctx) => ({ name: `create-${kind}-channel`, description: `Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}). ` + 'Optionally pass `purpose` to describe what this channel is for — ' + 'agents browse channels by purpose via fabric-channel-list.', parameters: { type: 'object', additionalProperties: false, required: ['guildNodeId', 'name'], properties: { guildNodeId: { type: 'string' }, name: { type: 'string' }, isPublic: { type: 'boolean' }, memberUserIds: { type: 'array', items: { type: 'string' } }, onDuty: { type: 'string', description: 'required for triage-like flows (unused for these kinds)' }, listeners: { type: 'array', items: { type: 'string' } }, purpose: { type: 'string', description: "Free-form description of what this channel is for. Optional but " + 'strongly recommended so other agents can find this channel by ' + 'intent (via fabric-channel-list). Can be edited later with ' + 'fabric-channel-set-purpose.', }, }, }, execute: async (_id, p) => { const agentId = ctx.agentId; if (!agentId) return { ok: false, error: 'no agent context' }; const { guild, token } = await ctxGuild(agentId, p.guildNodeId); const ch = await client.createChannel(guild.endpoint, token, { guildId: p.guildNodeId, name: p.name, xType: X_BY_KIND[kind], isPublic: p.isPublic ?? false, memberUserIds: p.memberUserIds ?? [], ...(p.purpose !== undefined ? { purpose: p.purpose } : {}), }); return { ok: true, channelId: ch.id }; }, })); makeCreate('chat'); makeCreate('work'); makeCreate('report'); makeCreate('discussion'); // discussion-complete: post a summary then close the channel (Guild // /channels/:id/close — history stays readable, new posts -> 409). api.registerTool((ctx) => ({ name: 'discussion-complete', description: 'Conclude a discussion: post a summary then close the channel.', parameters: { type: 'object', additionalProperties: false, required: ['guildNodeId', 'channelId', 'summary'], properties: { guildNodeId: { type: 'string' }, channelId: { type: 'string' }, summary: { type: 'string' }, callbackChannelId: { type: 'string', description: 'optional channel to also post the summary to' }, }, }, execute: async (_id, p) => { const agentId = ctx.agentId; if (!agentId) return { ok: false, error: 'no agent context' }; const { session, guild, token } = await ctxGuild(agentId, p.guildNodeId); await client.postMessage(guild.endpoint, token, p.channelId, p.summary, session.user.id); if (p.callbackChannelId) { await client .postMessage(guild.endpoint, token, p.callbackChannelId, p.summary, session.user.id) .catch(() => undefined); } await client.closeChannel(guild.endpoint, token, p.channelId); return { ok: true, closed: true }; }, })); // fabric-canvas: share / update / read / close the channel's single // pinned canvas document (one tool, four actions). update/close are // sharer-only server-side (the guild returns 403 otherwise). api.registerTool((ctx) => ({ name: 'fabric-canvas', description: "Manage a channel's pinned canvas document. action: " + "read (current canvas or null) | share (create/replace; you become " + 'the sharer) | update (edit in place; sharer only) | close (remove; ' + 'sharer only).', parameters: { type: 'object', additionalProperties: false, required: ['action', 'guildNodeId', 'channelId'], properties: { action: { type: 'string', enum: ['read', 'share', 'update', 'close'] }, guildNodeId: { type: 'string' }, channelId: { type: 'string' }, title: { type: 'string', description: 'share: required; update: optional' }, format: { type: 'string', enum: ['md', 'html', 'text'], description: 'share: required; update: optional', }, source: { type: 'string', description: 'document body. share: required; update: optional', }, }, }, execute: async (_id, p) => { const agentId = ctx.agentId; if (!agentId) return { ok: false, error: 'no agent context' }; const { guild, token } = await ctxGuild(agentId, p.guildNodeId); const ep = guild.endpoint; switch (p.action) { case 'read': { const canvas = await client.getCanvas(ep, token, p.channelId); return { ok: true, canvas }; } case 'share': { if (!p.title || !p.format || p.source === undefined) { return { ok: false, error: 'share requires title, format, and source' }; } const canvas = await client.shareCanvas(ep, token, p.channelId, { title: p.title, format: p.format, source: p.source, }); return { ok: true, canvas }; } case 'update': { const body = {}; if (p.title !== undefined) body.title = p.title; if (p.format !== undefined) body.format = p.format; if (p.source !== undefined) body.source = p.source; if (Object.keys(body).length === 0) { return { ok: false, error: 'update needs at least one of title/format/source' }; } const canvas = await client.updateCanvas(ep, token, p.channelId, body); return { ok: true, canvas }; } case 'close': { await client.removeCanvas(ep, token, p.channelId); return { ok: true, removed: true }; } default: return { ok: false, error: `unknown action ${String(p.action)}` }; } }, })); // fabric-channel: channel membership (one tool, three actions). api.registerTool((ctx) => ({ name: 'fabric-channel', description: 'Channel membership. action: members (list channel member userIds) | ' + 'join (this agent joins the channel) | leave (this agent leaves).', parameters: { type: 'object', additionalProperties: false, required: ['action', 'guildNodeId', 'channelId'], properties: { action: { type: 'string', enum: ['members', 'join', 'leave'] }, guildNodeId: { type: 'string' }, channelId: { type: 'string' }, }, }, execute: async (_id, p) => { const agentId = ctx.agentId; if (!agentId) return { ok: false, error: 'no agent context' }; const { guild, token } = await ctxGuild(agentId, p.guildNodeId); const ep = guild.endpoint; switch (p.action) { case 'members': { const members = await client.channelMembers(ep, token, p.channelId); return { ok: true, members }; } case 'join': { await client.joinChannel(ep, token, p.channelId); return { ok: true, joined: true }; } case 'leave': { await client.leaveChannel(ep, token, p.channelId); return { ok: true, left: true }; } default: return { ok: false, error: `unknown action ${String(p.action)}` }; } }, })); // ----------------------------------------------------------------- // fabric-send-message: post a message into a specific channel. // // Unlike a normal channel reply (which goes back to whatever channel // woke the agent), this lets the agent proactively initiate text into // any channel they are a member of — e.g. ARD broadcasting daily // workload to #agents-room, or triage agent following up on an // already-routed task by commenting in #updates. // ----------------------------------------------------------------- api.registerTool((ctx) => ({ name: 'fabric-send-message', description: 'Send a text message into a specific Fabric channel. Author is the calling agent. ' + 'Requires guildNodeId + channelId + content. Returns {ok, messageId, seq}.', 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).' }, }, }, execute: async (_id, p) => { const agentId = ctx.agentId; if (!agentId) return { ok: false, error: 'no agent context' }; const { session, guild, token } = await ctxGuild(agentId, p.guildNodeId); const res = (await client.postMessage(guild.endpoint, token, p.channelId, p.content, session.user.id)); return { ok: true, messageId: res.messageId, seq: res.seq }; }, })); // ----------------------------------------------------------------- // fabric-channel-list: enumerate channels the calling agent can see // in a given guild. Backend filters to public channels + channels the // agent is a member of. Returns id / name / xType per channel so the // agent can pick a channelId for fabric-send-message etc. // ----------------------------------------------------------------- api.registerTool((ctx) => ({ name: 'fabric-channel-list', description: 'List channels visible to the calling agent in a guild. Optional ' + 'nameFilter does a case-insensitive substring match client-side. ' + 'Use this to find a channelId before fabric-send-message / fabric-message-history.', parameters: { type: 'object', additionalProperties: false, required: ['guildNodeId'], properties: { guildNodeId: { type: 'string' }, nameFilter: { type: 'string', description: 'optional substring match on channel name (case-insensitive)' }, xType: { type: 'string', enum: ['general', 'work', 'report', 'discuss', 'triage', 'custom', 'dm'], description: 'optional filter by x_type', }, includeClosed: { type: 'boolean', description: 'default false — closed channels filtered out' }, }, }, execute: async (_id, p) => { const agentId = ctx.agentId; if (!agentId) return { ok: false, error: 'no agent context' }; const { guild, token } = await ctxGuild(agentId, p.guildNodeId); const all = await client.listChannels(guild.endpoint, token, p.guildNodeId); const needle = (p.nameFilter ?? '').toLowerCase(); const filtered = all.filter((c) => { if (!p.includeClosed && c.closed) return false; if (p.xType && c.xType !== p.xType) return false; if (needle && !c.name.toLowerCase().includes(needle)) return false; return true; }); return { ok: true, count: filtered.length, channels: filtered.map((c) => ({ id: c.id, name: c.name, xType: c.xType, isPublic: c.isPublic, closed: c.closed, lastSeq: c.lastSeq, purpose: c.purpose ?? null, })), }; }, })); // ----------------------------------------------------------------- // fabric-guild-list: enumerate guilds the calling agent belongs to. // Each row carries `purpose` — free-form description of what the // guild is for (admin-set). Use this as the first step when a // workflow says "find the right guild for X" — pick by purpose, // then fabric-channel-list to find the right channel inside it. // ----------------------------------------------------------------- api.registerTool((ctx) => ({ name: 'fabric-guild-list', description: 'List guilds the calling agent is a member of. Returns ' + '{nodeId, name, purpose, status} per row. `purpose` is a free-form ' + "description of what each guild is for. Use this BEFORE " + 'fabric-channel-list when a workflow asks you to pick the ' + 'right guild by intent (no guild ids hardcoded into workflows).', parameters: { type: 'object', additionalProperties: false, properties: { nameFilter: { type: 'string', description: 'optional case-insensitive substring match on guild name', }, purposeFilter: { type: 'string', description: 'optional case-insensitive substring match on guild purpose ' + '(e.g. "debate", "announcements")', }, }, }, execute: async (_id, p) => { const agentId = ctx.agentId; if (!agentId) return { ok: false, error: 'no agent context' }; const entry = identity.findByAgentId(agentId); if (!entry) return { ok: false, error: `agent ${agentId} not registered` }; const session = await client.agentLogin(entry.fabricApiKey); const nameNeedle = (p.nameFilter ?? '').toLowerCase(); const purposeNeedle = (p.purposeFilter ?? '').toLowerCase(); const guilds = session.guilds.filter((g) => { if (nameNeedle && !g.name.toLowerCase().includes(nameNeedle)) return false; if (purposeNeedle) { const purp = (g.purpose ?? '').toLowerCase(); if (!purp.includes(purposeNeedle)) return false; } return true; }); return { ok: true, count: guilds.length, guilds: guilds.map((g) => ({ nodeId: g.nodeId, name: g.name, status: g.status, purpose: g.purpose ?? null, })), }; }, })); // ----------------------------------------------------------------- // fabric-channel-set-purpose: set/update a channel's free-form // purpose description. Caller must be a channel member (or the // channel must be public). Use this to backfill purpose on existing // channels, or to refine it after a channel's role evolves. // ----------------------------------------------------------------- api.registerTool((ctx) => ({ name: 'fabric-channel-set-purpose', description: "Set or update a channel's free-form purpose description. " + 'Channel membership required (or the channel must be public). ' + 'Pass empty string to clear. Use this to make a channel ' + 'discoverable to other agents via fabric-channel-list.', parameters: { type: 'object', additionalProperties: false, required: ['guildNodeId', 'channelId', 'purpose'], properties: { guildNodeId: { type: 'string' }, channelId: { type: 'string' }, purpose: { type: 'string', description: "What this channel is for. Pass '' (empty string) to clear.", }, }, }, execute: async (_id, p) => { const agentId = ctx.agentId; if (!agentId) return { ok: false, error: 'no agent context' }; const { guild, token } = await ctxGuild(agentId, p.guildNodeId); const res = await client.setChannelPurpose(guild.endpoint, token, p.channelId, p.purpose); return { ok: true, channel: res }; }, })); // ----------------------------------------------------------------- // fabric-message-history: read a channel's recent message history by // `seq`. Tail-by-default: when `seqFrom`/`seqTo` are omitted, returns // the last `limit` messages (limit defaults to 20, max 200). // // Use cases: catch-up on a channel that was muted while the agent was // gated; verify a previous message went through; lookup recent // duplicates before opening a new task in triage. // ----------------------------------------------------------------- api.registerTool((ctx) => ({ name: 'fabric-message-history', description: "Read a channel's recent message history. Omit seqFrom/seqTo to " + 'tail (last `limit` messages, default 20, max 200). Backend ' + 'requires the calling agent to be a channel participant.', parameters: { type: 'object', additionalProperties: false, required: ['guildNodeId', 'channelId'], properties: { guildNodeId: { type: 'string' }, channelId: { type: 'string' }, seqFrom: { type: 'integer', minimum: 1, description: 'inclusive lower bound; default = tail' }, seqTo: { type: 'integer', minimum: 1, description: 'inclusive upper bound; default = channel head' }, limit: { type: 'integer', minimum: 1, maximum: 200, description: 'default 20' }, }, }, execute: async (_id, p) => { const agentId = ctx.agentId; if (!agentId) return { ok: false, error: 'no agent context' }; const { guild, token } = await ctxGuild(agentId, p.guildNodeId); const limit = p.limit ?? 20; // Tail mode: discover channel head via channel listing, then ask // for [head-limit+1, head]. Avoids needing the agent to know seq. let seqFrom = p.seqFrom; let seqTo = p.seqTo; if (seqFrom === undefined && seqTo === undefined) { const channels = await client.listChannels(guild.endpoint, token, p.guildNodeId); const ch = channels.find((c) => c.id === p.channelId); const head = ch?.lastSeq ?? 0; seqFrom = Math.max(1, head - limit + 1); seqTo = head; } const res = await client.listMessages(guild.endpoint, token, p.channelId, { seqFrom, seqTo, limit, }); return { ok: true, page: res.page, messages: res.items.map((m) => ({ messageId: m.messageId, seq: m.seq, authorUserId: m.authorUserId, content: m.content, createdAt: m.createdAt, isDeleted: m.isDeleted, })), }; }, })); }