Inbound was hardcoding `peer: { kind: 'group' }` and `ChatType: 'group'`
for every fabric channel regardless of xType. As a result:
- sessionKey for a DM was `agent:<id>:fabric:group:<chan>` instead of
`agent:<id>:fabric:direct:<chan>`
- ctx.ChatType='group' caused user-prompt metadata to render
`is_group_chat: true` on a DM
- openclaw's `isDirectMessage()` check (ChatType==='direct') returned
false, so DM-specific prompt and turn behavior never engaged
Caught by recruiter test in session 40c51de2: the model's thinking trace
acknowledged "fabric DM channel" (from the ClawPrompts chat-injector
hook) but the surrounding user-prompt metadata contradicted it with
`is_group_chat: true`, and the model reasoned its way out of running
`workflow_start`.
Fix factors a small helper `fabricPeerRoutingForXType` (and a cache-
backed `fabricPeerRoutingForChannel` for outbound) in channel.ts that
maps:
- 'dm' → { peerKind: 'direct', chatType: 'direct' }
- rest → { peerKind: 'group', chatType: 'group' } (no change)
Inbound uses m.xType directly (live, authoritative). Outbound has no
xType in its call signature, so it consults the channel-meta cache
populated by inbound (same `getChannelType` already exposed via
__fabric). Cache miss falls back to 'group' — the pre-fix default, no
regression. The proactive-DM-without-prior-inbound edge case still
routes that one outbound as 'group'; the next round agrees on 'direct'.
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.