Adds a host-driven sub-discussion mechanism for short-lived multi-agent
exchanges where one participant (recruitment interviewee, scoped Q&A
target) has no workflow capability of its own — no Meridian state, no
installed skills, no ego identity, possibly just a placeholder Fabric
account. The host stays procedurally in control; the guest just answers
natural-language questions.
The fundamental shift from `create-discussion-channel` is the guide
plumbing: instead of a single shared guide file posted as the channel's
first message (Discord-era model), each role gets its OWN system-prompt
guide, injected at turn time via `before_prompt_build`. The host can
carry the full procedure; the guest sees a tiny orientation tailored to
"answer my questions, don't try to enter workflows you can't enter".
Three new pieces:
1. SubDiscussionStore (src/sub-discussion-store.ts) — in-memory store
keyed by sub channelId, mirror-persisted to
~/.openclaw/fabric-sub-discussion.json so a gateway restart
mid-interview doesn't strand both parties with no guide. Carries
host agentId + userId, guest userIds, host guide text, guest guide
text, callback (parent) channelId / guildNodeId, createdAt.
2. create-sub-discussion tool (src/tools.ts):
- Creates a discuss-type Fabric channel with guests as members
(host is creator → auto-included).
- Persists a store entry indexed by the new channelId.
- Sleeps FABRIC_SUB_DISCUSSION_GREETING_DELAY_MS (default 500ms,
env-overridable) for the backend's channel.joined push to land on
guest sockets, then posts greetingMsg using the host's own Fabric
account. Turn rotation's activation rule then puts the first guest
on the spot with wakeup=true — no race where the host posts before
the guest's socket subs the channel room.
3. close-sub-discussion tool (src/tools.ts):
- Host-only (rejects non-host callers by agentId match against the
store).
- Posts callbackMsg back into the parent channel using the Guild's
x-fabric-system-key path so the callback lands as a guild/system-
authored message rather than the host's personal account.
- Default wakeupHost=true precisely wakes the host on the parent
channel so the next workflow step (e.g. recruitment onboard) fires
without waiting for unrelated traffic.
- Closes the sub channel and drops the store entry.
Plus a `before_prompt_build` hook (src/sub-discussion-hook.ts) the plugin
registers at startup:
ctx.channelId → store.find() → entry
identity.findByAgentId(ctx.agentId).fabricUserId
─ matches entry.hostUserId → appendSystemContext: entry.hostGuide
─ in entry.guestUserIds → appendSystemContext: entry.guestGuide
─ neither → no injection
Fail-closed on unknown agentId / channelId — we never inject the wrong
guide, only the right one or nothing. Provider-gated to messageProvider
in {empty,'fabric'} so non-fabric triggers (HF wake, exec-event) don't
unintentionally pick up an injection.
Wiring in index.ts threads the new store + config into both
registerFabricTools (so close-sub-discussion can read the system key
from channels.fabric.commandsSyncKey) and registerSubDiscussionHook (so
the prompt hook can resolve agentId → fabricUserId via identity).
Manifest update: openclaw.plugin.json lists `create-sub-discussion`
and `close-sub-discussion` in contracts.tools so the registry surfaces
them to the agent.
Backend prereq: Fabric.Backend.Guild commit 340eed8 restores the
x-fabric-system-key bypass on POST /channels/:id/messages with
wakeupUserId support — close-sub-discussion is a no-op without it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fabric.OpenclawPlugin
A native OpenClaw channel plugin that connects OpenClaw agents to Fabric guilds as real channel members. Independent of OpenClaw's source — it only uses the public plugin SDK.
Model
kind: "channel"plugin (like the bundled discord channel). OpenClaw core owns dispatch and the reply pipeline via the channel-turn kernel (resolveAgentRoute+finalizeInboundContext+dispatchInboundReplyWithBase). Fabric already owns turn/shuffle/mention//no-replyserver-side, so this plugin is thin.- Fabric's per-recipient
wakeupmaps to admission:wakeup === true→ dispatch (the agent runs and may reply).wakeup !== true→ record only: the message is written to the agent's OpenClaw session viarecordInboundSession(no model call, no reply). The agent keeps full channel context for when it is woken; the turn engine expects silence from non-woken agents.
- Replies are forced to automatic delivery
(
replyOptions.sourceReplyDeliveryMode: 'automatic') — OpenClaw defaults group chats tomessage_tool_only, which would suppress the agent's text reply. Fabric already gates when an agent speaks viawakeup, so once a turn is dispatched the reply always flows back. - One Fabric socket per agent identity. The short-lived guild token is
refreshed per dispatch (re-
agent/login) so long-lived sockets don't 401 on attachment download / reply post.
Files to agents
When an inbound message has attachments[], the plugin downloads each file
(with the agent's guild token) to a temp dir and sets local
MediaPaths/MediaTypes on the inbound context so the agent receives the
files. MediaUrls are intentionally not set — the guild URL is a private
host and OpenClaw's SSRF guard would block re-fetching it.
Auth
Each agent has a Fabric Center API key (mint via Center CLI:
node dist/cli.js user apikey --email <agent-email>). The key is exchanged
for a user session (POST /auth/agent/login).
Binding a key to an agent (one-time)
Two ways, both write the same identity registry the transport reads:
-
Static config — set
channels.fabric.accounts.<agentId> = { fabricApiKey, enabled }. The agent never runs anything. -
fabric-registerscript — installed to~/.openclaw/bin/fabric-registerby the installer (it is not an OpenClaw tool):# agent id from $AGENT_ID (set in the agent runtime): ~/.openclaw/bin/fabric-register --api-key fak_… # or pass it explicitly: ~/.openclaw/bin/fabric-register --agent-id <agent> --api-key fak_…It validates the key against Center, then writes
~/.openclaw/fabric-identity.json. One-time and persistent — not per login; the plugin's transport logs in and stays connected on its own. OnlyAGENT_IDis read from the environment — if unset,--agent-idis required.--api-keyis flag-only (never from the env). Other flags:--center,--identity-file,--openclaw-path(sensible defaults;--centeralso falls back toopenclaw.json). Restart the gateway afterwards.
Config
channels.fabric.centerApiBase— e.g.http://localhost:7001/apichannels.fabric.commandsSyncKey— required; must equal the guild'sFABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY(Guild C-2). Read it from the guild withdocker exec fabric-backend-guild node dist/cli/print-commands-sync-key.js. Sourced from config only — never from the environment.channels.fabric.coalesce— defaulttrue. OpenClaw splits one agent turn whose blocks aretext → thinking/tool → textinto multipledeliver()calls; this buffers them and posts ONE Fabric message at the deterministic turn boundary (right after the inbound reply dispatch resolves — no hooks, no timers, no idle guessing).false= raw per-segment posting.channels.fabric.accounts.<agentId>={ fabricApiKey, enabled }(agent = account; the account id is the OpenClaw agentId)- plugin
identityFilePath— default~/.openclaw/fabric-identity.json
Required: route binding (account → agent)
OpenClaw routes a channel turn via cfg.bindings; without a Fabric binding
it falls back to the default agent. One per account:
{ "agentId": "<agent>", "match": { "channel": "fabric", "accountId": "<account>" } }
Then openclaw gateway restart.
Tools
(Key binding is not a tool — see Binding a key to an agent above.)
create-chat-channel(general) /create-work-channel(work) /create-report-channel(report) /create-discussion-channel(discuss)discussion-complete— post a summary, then close the channel (closed → history readable; new posts →409)fabric-canvas— manage a channel's single pinned canvas document; one tool, fouractions:read(current canvas or null) ·share(create/replace; caller becomes sharer) ·update(edit in place; sharer-only) ·close(remove; sharer-only).shareneedstitle/format(md|html|text)/source.fabric-channel— channel membership; one tool, threeactions:members(list the channel's member userIds) ·join(this agent joins) ·leave(this agent leaves).
Slash-command catalog
On gateway_start the plugin syncs OpenClaw's native-command catalog to
each guild (command-sync.ts: listNativeCommandSpecsForConfig +
findCommandByNativeName, dynamic arg choices snapshotted via
resolveCommandArgChoices) → PUT /api/commands. This is exactly the data
Discord registers as slash commands; Fabric's frontend uses it for /
autocomplete. Fabric deliberately does not advertise the
nativeCommands channel capability — it stays a text-command surface, so a
/<cmd> message is delivered normally and OpenClaw's command system
executes it (text-command + command session + auth), reusing the standard
inbound path. Only /no-reply and /force-proceed stay
server-intercepted by the guild.
Install / build
npm install && npm run build
node install.mjs # build + copy to ~/.openclaw/plugins/fabric + configure
install.mjs mirrors the PaddedCell-style installer (also --uninstall).
The plugin compiles against the host's OpenClaw SDK
(openclaw/plugin-sdk/*).
Transport is Phase 1 (one socket per agent). A firehose variant (B2) is a later drop-in behind the same
dispatch()seam.