diff --git a/dist/fabric/index.js b/dist/fabric/index.js
new file mode 100644
index 0000000..6fe7c79
--- /dev/null
+++ b/dist/fabric/index.js
@@ -0,0 +1,44 @@
+import path from 'node:path';
+import os from 'node:os';
+import { defineChannelPluginEntry } from 'openclaw/plugin-sdk/channel-core';
+import { FabricClient } from './src/fabric-client.js';
+import { IdentityRegistry } from './src/identity.js';
+import { FabricInbound } from './src/inbound.js';
+import { buildFabricChannelPlugin } from './src/channel.js';
+import { registerFabricTools } from './src/tools.js';
+function centerApiBase(api) {
+ const section = api.config?.channels?.['fabric'];
+ return section?.centerApiBase ?? 'http://localhost:7001/api';
+}
+let inbound = null;
+export default defineChannelPluginEntry({
+ id: 'fabric',
+ name: 'Fabric',
+ description: 'Fabric channel plugin — OpenClaw agents speak in Fabric guilds',
+ // Channel object: config/security/outbound. Visible turn replies flow
+ // through the inbound channel-turn delivery adapter; outbound.sendText
+ // covers proactive sends via the shared message tool.
+ plugin: buildFabricChannelPlugin(async () => ({ messageId: undefined })),
+ // registerFull: runtime pieces (transport, tools). Guarded so the long-lived
+ // Fabric connections start once per gateway process.
+ registerFull(api) {
+ const cfg = (api.pluginConfig ?? {});
+ const identityFilePath = cfg.identityFilePath ?? path.join(os.homedir(), '.openclaw', 'fabric-identity.json');
+ const client = new FabricClient(centerApiBase(api));
+ const identity = new IdentityRegistry(identityFilePath);
+ registerFabricTools({ registerTool: (d) => api.registerTool(d), logger: api.logger }, client, identity);
+ api.on('gateway_start', () => {
+ const _G = globalThis;
+ if (_G._fabricInboundStarted)
+ return;
+ _G._fabricInboundStarted = true;
+ inbound = new FabricInbound(api.runtime, client, identity, api.logger);
+ void inbound.start();
+ api.logger.info('fabric: inbound transport started');
+ });
+ api.on('gateway_stop', () => {
+ inbound?.stop();
+ inbound = null;
+ });
+ },
+});
diff --git a/dist/fabric/src/channel.js b/dist/fabric/src/channel.js
new file mode 100644
index 0000000..434ddcd
--- /dev/null
+++ b/dist/fabric/src/channel.js
@@ -0,0 +1,46 @@
+import { createChatChannelPlugin, createChannelPluginBase, } from 'openclaw/plugin-sdk/channel-core';
+export function resolveFabricAccount(cfg, accountId) {
+ const section = cfg.channels?.['fabric'];
+ const centerApiBase = section?.centerApiBase;
+ if (!centerApiBase)
+ throw new Error('fabric: channels.fabric.centerApiBase is required');
+ return {
+ accountId: accountId ?? null,
+ centerApiBase,
+ allowFrom: section?.allowFrom ?? [],
+ dmPolicy: section?.dmSecurity,
+ };
+}
+// Outbound is wired by the entry (it needs the identity registry + client to
+// post as the right agent). Channel-turn visible replies go through the
+// inbound adapter's delivery callback; this object owns config/security only.
+export function buildFabricChannelPlugin(sendText) {
+ return createChatChannelPlugin({
+ base: createChannelPluginBase({
+ id: 'fabric',
+ setup: {
+ resolveAccount: resolveFabricAccount,
+ inspectAccount(cfg, accountId) {
+ const section = cfg.channels?.['fabric'];
+ const ok = Boolean(section?.centerApiBase);
+ return { enabled: ok, configured: ok, tokenStatus: ok ? 'available' : 'missing' };
+ },
+ },
+ }),
+ security: {
+ dm: {
+ channelKey: 'fabric',
+ resolvePolicy: (a) => a.dmPolicy,
+ resolveAllowFrom: (a) => a.allowFrom,
+ defaultPolicy: 'allowlist',
+ },
+ },
+ // Fabric replies thread by being posted into the same channel.
+ threading: { topLevelReplyToMode: 'channel' },
+ outbound: {
+ attachedResults: {
+ sendText: async (params) => sendText(params),
+ },
+ },
+ });
+}
diff --git a/dist/fabric/src/fabric-client.js b/dist/fabric/src/fabric-client.js
new file mode 100644
index 0000000..c1f3f4f
--- /dev/null
+++ b/dist/fabric/src/fabric-client.js
@@ -0,0 +1,53 @@
+// Thin Fabric REST client. One auth concept: an agent's Center API key is
+// exchanged for a normal user session (POST /auth/agent/login); the returned
+// guild access tokens are used to post messages and call channel APIs.
+export class FabricClient {
+ centerApiBase;
+ constructor(centerApiBase) {
+ this.centerApiBase = centerApiBase;
+ }
+ async post(url, body, auth) {
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ ...(auth ? { authorization: `Bearer ${auth}` } : {}),
+ },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const text = await res.text().catch(() => '');
+ throw new Error(`POST ${url} -> ${res.status} ${text}`);
+ }
+ return (await res.json());
+ }
+ // Exchange an agent API key for a Fabric user session (+ guild tokens).
+ agentLogin(apiKey) {
+ return this.post(`${this.centerApiBase}/auth/agent/login`, { apiKey });
+ }
+ // Refresh the center access token (guild tokens are re-fetched via /auth/me/guilds).
+ async refresh(refreshToken) {
+ return this.post(`${this.centerApiBase}/auth/refresh`, { refreshToken });
+ }
+ async meGuilds(accessToken) {
+ const res = await fetch(`${this.centerApiBase}/auth/me/guilds`, {
+ headers: { authorization: `Bearer ${accessToken}` },
+ });
+ if (!res.ok)
+ throw new Error(`me/guilds -> ${res.status}`);
+ return (await res.json());
+ }
+ // ---- guild-scoped (use the per-guild access token) ----
+ postMessage(guildEndpoint, guildToken, channelId, content, authorUserId) {
+ return this.post(`${guildEndpoint}/api/channels/${channelId}/messages`, { content, authorUserId }, guildToken);
+ }
+ createChannel(guildEndpoint, guildToken, body) {
+ return this.post(`${guildEndpoint}/api/channels`, body, guildToken);
+ }
+ closeChannel(guildEndpoint, guildToken, channelId) {
+ return this.post(`${guildEndpoint}/api/channels/${channelId}/close`, {}, guildToken);
+ }
+ joinChannel(guildEndpoint, guildToken, channelId) {
+ return this.post(`${guildEndpoint}/api/channels/${channelId}/join`, {}, guildToken);
+ }
+}
diff --git a/dist/fabric/src/identity.js b/dist/fabric/src/identity.js
new file mode 100644
index 0000000..65494d6
--- /dev/null
+++ b/dist/fabric/src/identity.js
@@ -0,0 +1,40 @@
+import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
+import { dirname } from 'node:path';
+export class IdentityRegistry {
+ filePath;
+ entries = new Map();
+ constructor(filePath) {
+ this.filePath = filePath;
+ this.load();
+ }
+ load() {
+ if (!existsSync(this.filePath))
+ return;
+ try {
+ const data = JSON.parse(readFileSync(this.filePath, 'utf8'));
+ for (const e of data.entries ?? []) {
+ if (e.agentId && e.fabricApiKey)
+ this.entries.set(e.agentId, e);
+ }
+ }
+ catch {
+ // corrupt file -> start empty; upsert will rewrite
+ }
+ }
+ persist() {
+ mkdirSync(dirname(this.filePath), { recursive: true });
+ const data = { entries: [...this.entries.values()] };
+ writeFileSync(this.filePath, JSON.stringify(data, null, 2));
+ }
+ list() {
+ return [...this.entries.values()];
+ }
+ findByAgentId(agentId) {
+ return this.entries.get(agentId);
+ }
+ upsert(entry) {
+ const prev = this.entries.get(entry.agentId);
+ this.entries.set(entry.agentId, { ...prev, ...entry });
+ this.persist();
+ }
+}
diff --git a/dist/fabric/src/inbound.js b/dist/fabric/src/inbound.js
new file mode 100644
index 0000000..f278405
--- /dev/null
+++ b/dist/fabric/src/inbound.js
@@ -0,0 +1,146 @@
+import { io } from 'socket.io-client';
+// One live Fabric connection per agent identity (Phase 1 = B1). Lives in the
+// channel-plugin runtime (no separate sidecar). Firehose (B2) would replace
+// this class behind the same dispatch() call.
+export class FabricInbound {
+ runtime;
+ client;
+ identity;
+ log;
+ sockets = [];
+ timers = [];
+ seen = new Set();
+ constructor(runtime, client, identity, log) {
+ this.runtime = runtime;
+ this.client = client;
+ this.identity = identity;
+ this.log = log;
+ }
+ async start() {
+ for (const entry of this.identity.list()) {
+ try {
+ const session = await this.client.agentLogin(entry.fabricApiKey);
+ this.identity.upsert({
+ agentId: entry.agentId,
+ fabricApiKey: entry.fabricApiKey,
+ fabricUserId: session.user.id,
+ displayName: session.user.name,
+ });
+ await this.connectAgent(entry.agentId, session);
+ this.log.info(`fabric: agent ${entry.agentId} connected as ${session.user.email}`);
+ }
+ catch (err) {
+ this.log.warn(`fabric: agent ${entry.agentId} connect failed: ${String(err)}`);
+ }
+ }
+ }
+ stop() {
+ for (const t of this.timers)
+ clearInterval(t);
+ for (const s of this.sockets)
+ s.disconnect();
+ this.sockets = [];
+ this.timers = [];
+ }
+ async connectAgent(agentId, session) {
+ const selfUserId = session.user.id;
+ for (const g of session.guilds) {
+ const tok = session.guildAccessTokens.find((t) => t.guildNodeId === g.nodeId)?.token;
+ if (!tok)
+ continue;
+ const socket = io(`${g.endpoint}/realtime`, {
+ transports: ['websocket'],
+ auth: { token: tok },
+ autoConnect: false,
+ });
+ const joinAll = async () => {
+ try {
+ const res = await fetch(`${g.endpoint}/api/channels?guildId=${encodeURIComponent(g.nodeId)}`, {
+ headers: { authorization: `Bearer ${tok}` },
+ });
+ const channels = res.ok ? (await res.json()) : [];
+ for (const c of channels)
+ socket.emit('join_channel', { channelId: c.id });
+ }
+ catch {
+ /* best effort */
+ }
+ };
+ socket.on('connect', () => void joinAll());
+ socket.on('message.created', (m) => {
+ const channelId = m.channelId ?? '';
+ if (!channelId)
+ return;
+ // self-echo guard + dedupe
+ if (m.authorUserId && m.authorUserId === selfUserId)
+ return;
+ const key = `${agentId}:${m.messageId}`;
+ if (this.seen.has(key))
+ return;
+ this.seen.add(key);
+ if (this.seen.size > 5000)
+ this.seen.clear();
+ void this.dispatch(agentId, g, channelId, m);
+ });
+ socket.connect();
+ this.sockets.push(socket);
+ }
+ }
+ // Hand the inbound Fabric message to OpenClaw's channel-turn kernel.
+ // wakeup === true -> dispatch (agent runs, may reply)
+ // wakeup !== true -> drop but keep as group history/context
+ async dispatch(agentId, guild, channelId, m) {
+ const admit = m.wakeup === true;
+ try {
+ await this.runtime.channel.turn.run({
+ channel: 'fabric',
+ accountId: agentId,
+ raw: m,
+ adapter: {
+ ingest: (raw) => ({
+ id: raw.messageId,
+ timestamp: raw.createdAt ? Date.parse(raw.createdAt) : Date.now(),
+ rawText: raw.content,
+ textForAgent: raw.content,
+ }),
+ classify: () => ({ kind: 'message', canStartAgentTurn: admit }),
+ preflight: () => admit ? {} : { admission: { kind: 'drop', reason: 'no-wakeup', recordHistory: true } },
+ resolveTurn: (input) => ({
+ route: {
+ agentId,
+ routeSessionKey: `agent:${agentId}:fabric:channel:${channelId}`,
+ createIfMissing: true,
+ },
+ conversation: { kind: 'channel', id: channelId, label: `fabric:${guild.nodeId}` },
+ reply: { to: channelId, nativeChannelId: channelId },
+ message: {
+ body: m.content,
+ rawBody: m.content,
+ bodyForAgent: m.content,
+ envelopeFrom: m.authorUserId ?? 'fabric',
+ },
+ delivery: {
+ deliver: async (payload) => {
+ const text = typeof payload?.text === 'string' ? payload.text : '';
+ if (!text.trim())
+ return { visibleReplySent: false };
+ const entry = this.identity.findByAgentId(agentId);
+ const session = entry ? await this.client.agentLogin(entry.fabricApiKey) : null;
+ const gt = session?.guildAccessTokens.find((t) => t.guildNodeId === guild.nodeId)?.token;
+ if (!session || !gt)
+ return { visibleReplySent: false };
+ await this.client.postMessage(guild.endpoint, gt, channelId, text, session.user.id);
+ return { visibleReplySent: true };
+ },
+ },
+ meta: { admission: admit ? { kind: 'dispatch' } : { kind: 'drop', recordHistory: true } },
+ }),
+ },
+ log: (e) => this.runtime.log?.debug?.(`fabric.turn.${e?.stage}`),
+ });
+ }
+ catch (err) {
+ this.log.warn(`fabric: turn.run failed agent=${agentId} channel=${channelId}: ${String(err)}`);
+ }
+ }
+}
diff --git a/dist/fabric/src/setup-entry.js b/dist/fabric/src/setup-entry.js
new file mode 100644
index 0000000..3aca84d
--- /dev/null
+++ b/dist/fabric/src/setup-entry.js
@@ -0,0 +1,14 @@
+// Setup-safe entry: returns channel metadata for read-only command paths
+// (status, channels list) before the plugin runtime starts. Must NOT start
+// clients, listeners, or transports.
+export default {
+ channel: {
+ id: 'fabric',
+ label: 'Fabric',
+ blurb: 'Connect OpenClaw agents to a Fabric guild.',
+ },
+ inspect(cfg) {
+ const ok = Boolean(cfg?.channels?.['fabric']?.centerApiBase);
+ return { enabled: ok, configured: ok };
+ },
+};
diff --git a/dist/fabric/src/tools.js b/dist/fabric/src/tools.js
new file mode 100644
index 0000000..1c1b2a5
--- /dev/null
+++ b/dist/fabric/src/tools.js
@@ -0,0 +1,112 @@
+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 (call fabric-register)`);
+ 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 };
+ };
+ // fabric-register: bind this agent to a Fabric API key.
+ api.registerTool((ctx) => ({
+ name: 'fabric-register',
+ description: "Register this agent's Fabric API key (minted via Center CLI `user apikey`).",
+ inputSchema: {
+ type: 'object',
+ additionalProperties: false,
+ required: ['fabricApiKey'],
+ properties: {
+ fabricApiKey: { type: 'string', description: 'Fabric Center API key (fak_…)' },
+ },
+ },
+ handler: async (params) => {
+ const agentId = ctx.agentId;
+ if (!agentId)
+ return { ok: false, error: 'no agent context' };
+ const session = await client.agentLogin(params.fabricApiKey);
+ identity.upsert({
+ agentId,
+ fabricApiKey: params.fabricApiKey,
+ fabricUserId: session.user.id,
+ displayName: session.user.name,
+ });
+ return { ok: true, user: session.user };
+ },
+ }));
+ const makeCreate = (kind) => api.registerTool((ctx) => ({
+ name: `create-${kind}-channel`,
+ description: `Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}).`,
+ inputSchema: {
+ 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' } },
+ },
+ },
+ handler: async (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 ?? [],
+ });
+ 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.',
+ inputSchema: {
+ 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' },
+ },
+ },
+ handler: async (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 };
+ },
+ }));
+}
diff --git a/install.mjs b/install.mjs
new file mode 100644
index 0000000..ba038a8
--- /dev/null
+++ b/install.mjs
@@ -0,0 +1,212 @@
+#!/usr/bin/env node
+
+/**
+ * Fabric.OpenclawPlugin installer (modeled on PaddedCell's install.mjs).
+ *
+ * node install.mjs --install build + install + configure
+ * node install.mjs --build-only build only
+ * node install.mjs --uninstall remove plugin + config
+ * flags: --skip-check --verbose -v --openclaw-profile-path
+ */
+
+import { execSync } from 'child_process';
+import { existsSync, mkdirSync, copyFileSync, readdirSync, rmSync } from 'fs';
+import { dirname, join, resolve } from 'path';
+import { fileURLToPath } from 'url';
+import { homedir } from 'os';
+
+const __dirname = resolve(dirname(fileURLToPath(import.meta.url)));
+const PLUGIN_ID = 'fabric';
+const DIST_DIR = join(__dirname, 'dist', 'fabric');
+
+const args = process.argv.slice(2);
+const opt = {
+ buildOnly: args.includes('--build-only'),
+ skipCheck: args.includes('--skip-check'),
+ verbose: args.includes('--verbose') || args.includes('-v'),
+ uninstall: args.includes('--uninstall'),
+};
+const pIdx = args.indexOf('--openclaw-profile-path');
+const profileOverride = pIdx !== -1 && args[pIdx + 1] ? resolve(args[pIdx + 1]) : null;
+
+function openclawPath() {
+ if (profileOverride) return profileOverride;
+ if (process.env.OPENCLAW_PATH) return resolve(process.env.OPENCLAW_PATH);
+ return join(homedir(), '.openclaw');
+}
+
+const c = { reset: '\x1b[0m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m' };
+const log = (m, k = 'reset') => console.log(`${c[k]}${m}${c.reset}`);
+const step = (n, t, m) => log(`[${n}/${t}] ${m}`, 'cyan');
+const ok = (m) => log(` ✓ ${m}`, 'green');
+const warn = (m) => log(` ⚠ ${m}`, 'yellow');
+const err = (m) => log(` ✗ ${m}`, 'red');
+
+function exec(cmd, o = {}) {
+ return execSync(cmd, { cwd: __dirname, stdio: o.silent ? 'pipe' : 'inherit', encoding: 'utf8', ...o });
+}
+function cfgGet(key, def) {
+ try {
+ const out = exec(`openclaw config get ${key} --json 2>/dev/null || echo undefined`, { silent: true }).trim();
+ return out === 'undefined' || out === '' ? def : JSON.parse(out);
+ } catch {
+ return def;
+ }
+}
+function cfgSet(key, val) {
+ exec(`openclaw config set ${key} '${JSON.stringify(val)}' --json`, { silent: true });
+}
+function cfgUnset(key) {
+ try {
+ exec(`openclaw config unset ${key}`, { silent: true });
+ } catch {
+ /* ignore */
+ }
+}
+function copyDir(src, dest) {
+ mkdirSync(dest, { recursive: true });
+ for (const e of readdirSync(src, { withFileTypes: true })) {
+ if (e.name === 'node_modules') continue;
+ const s = join(src, e.name);
+ const d = join(dest, e.name);
+ e.isDirectory() ? copyDir(s, d) : copyFileSync(s, d);
+ }
+}
+
+function detect() {
+ step(1, 5, 'Detecting environment...');
+ let node = null;
+ try {
+ node = exec('node --version', { silent: true }).trim();
+ ok(`Node ${node}`);
+ } catch {
+ err('Node not found');
+ }
+ try {
+ ok(`openclaw at ${exec('which openclaw', { silent: true }).trim()}`);
+ } catch {
+ warn('openclaw CLI not in PATH');
+ }
+ return { node };
+}
+
+function checkDeps(env) {
+ if (opt.skipCheck) return;
+ step(2, 5, 'Checking dependencies...');
+ if (!env.node || parseInt(env.node.slice(1), 10) < 18) {
+ err('Node 18+ required');
+ process.exit(1);
+ }
+ ok('deps OK');
+}
+
+function build() {
+ step(3, 5, 'Building plugin...');
+ rmSync(join(__dirname, 'dist'), { recursive: true, force: true });
+ exec('npm install', { silent: !opt.verbose });
+ exec('npm run build', { silent: !opt.verbose });
+ if (!existsSync(join(DIST_DIR, 'index.js'))) throw new Error('build produced no dist/fabric/index.js');
+ ok('compiled -> dist/fabric');
+}
+
+function clearInstall(base) {
+ const dest = join(base, 'plugins', PLUGIN_ID);
+ if (existsSync(dest)) {
+ rmSync(dest, { recursive: true, force: true });
+ ok(`removed ${dest}`);
+ }
+}
+function cleanupConfig(base) {
+ const dest = join(base, 'plugins', PLUGIN_ID);
+ const allow = cfgGet('plugins.allow', []);
+ if (Array.isArray(allow) && allow.includes(PLUGIN_ID)) {
+ cfgSet('plugins.allow', allow.filter((x) => x !== PLUGIN_ID));
+ ok('removed from plugins.allow');
+ }
+ const paths = cfgGet('plugins.load.paths', []);
+ if (Array.isArray(paths) && paths.includes(dest)) {
+ cfgSet('plugins.load.paths', paths.filter((x) => x !== dest));
+ ok('removed from plugins.load.paths');
+ }
+ cfgUnset(`plugins.entries.${PLUGIN_ID}`);
+ ok('removed plugin entry');
+}
+
+function install() {
+ step(4, 5, 'Installing...');
+ const base = openclawPath();
+ const dest = join(base, 'plugins', PLUGIN_ID);
+ log(` OpenClaw path: ${base}`, 'blue');
+
+ if (existsSync(dest)) {
+ warn('existing install -> replacing');
+ clearInstall(base);
+ }
+ mkdirSync(dirname(dest), { recursive: true });
+ copyDir(DIST_DIR, dest);
+ copyFileSync(join(__dirname, 'openclaw.plugin.json'), join(dest, 'openclaw.plugin.json'));
+ copyFileSync(join(__dirname, 'package.json'), join(dest, 'package.json'));
+ ok(`plugin files -> ${dest}`);
+ exec('npm install --omit=dev', { cwd: dest, silent: !opt.verbose });
+ ok('runtime deps installed');
+ return { base, dest };
+}
+
+function configure(base, dest) {
+ step(5, 5, 'Configuring OpenClaw...');
+ const paths = cfgGet('plugins.load.paths', []);
+ if (Array.isArray(paths) && !paths.includes(dest)) {
+ cfgSet('plugins.load.paths', [...paths, dest]);
+ }
+ ok(`plugins.load.paths includes ${dest}`);
+
+ const allow = cfgGet('plugins.allow', []);
+ if (Array.isArray(allow) && !allow.includes(PLUGIN_ID)) {
+ cfgSet('plugins.allow', [...allow, PLUGIN_ID]);
+ }
+ ok(`plugins.allow includes ${PLUGIN_ID}`);
+
+ if (cfgGet(`plugins.entries.${PLUGIN_ID}.enabled`, undefined) === undefined) {
+ cfgSet(`plugins.entries.${PLUGIN_ID}.enabled`, true);
+ }
+ if (cfgGet('channels.fabric.centerApiBase', undefined) === undefined) {
+ cfgSet('channels.fabric.centerApiBase', 'http://localhost:7001/api');
+ ok('channels.fabric.centerApiBase = http://localhost:7001/api (default)');
+ }
+ ok('plugin entry configured (missing defaults only)');
+}
+
+function main() {
+ console.log('');
+ log('Fabric.OpenclawPlugin installer', 'cyan');
+ console.log('');
+ try {
+ const env = detect();
+ if (opt.uninstall) {
+ const base = openclawPath();
+ clearInstall(base);
+ cleanupConfig(base);
+ log('\nRun: openclaw gateway restart', 'yellow');
+ return;
+ }
+ checkDeps(env);
+ build();
+ if (opt.buildOnly) {
+ log('\nbuild-only — not installed.', 'yellow');
+ return;
+ }
+ const { base, dest } = install();
+ configure(base, dest);
+ console.log('');
+ log('Install complete. Next:', 'blue');
+ log(' 1. Mint an agent key: (in Center) node dist/cli.js user apikey --email ', 'cyan');
+ log(' 2. openclaw gateway restart', 'cyan');
+ log(' 3. As an agent, call the fabric-register tool with that key', 'cyan');
+ console.log('');
+ } catch (e) {
+ log(`\nInstall failed: ${e.message}`, 'red');
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/node_modules/.bin/openclaw b/node_modules/.bin/openclaw
new file mode 120000
index 0000000..4166da5
--- /dev/null
+++ b/node_modules/.bin/openclaw
@@ -0,0 +1 @@
+../openclaw/openclaw.mjs
\ No newline at end of file
diff --git a/node_modules/.bin/tsc b/node_modules/.bin/tsc
new file mode 120000
index 0000000..0863208
--- /dev/null
+++ b/node_modules/.bin/tsc
@@ -0,0 +1 @@
+../typescript/bin/tsc
\ No newline at end of file
diff --git a/node_modules/.bin/tsserver b/node_modules/.bin/tsserver
new file mode 120000
index 0000000..f8f8f1a
--- /dev/null
+++ b/node_modules/.bin/tsserver
@@ -0,0 +1 @@
+../typescript/bin/tsserver
\ No newline at end of file
diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json
new file mode 100644
index 0000000..2b460c6
--- /dev/null
+++ b/node_modules/.package-lock.json
@@ -0,0 +1,250 @@
+{
+ "name": "fabric-openclaw-plugin",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "../../.nvm/versions/node/v25.8.1/lib/node_modules/openclaw": {
+ "version": "2026.5.7",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@agentclientprotocol/sdk": "0.21.0",
+ "@anthropic-ai/sdk": "0.93.0",
+ "@anthropic-ai/vertex-sdk": "^0.16.0",
+ "@aws-sdk/client-bedrock": "3.1042.0",
+ "@aws-sdk/client-bedrock-runtime": "3.1042.0",
+ "@aws-sdk/credential-provider-node": "3.972.39",
+ "@aws/bedrock-token-generator": "^1.1.0",
+ "@clack/prompts": "^1.3.0",
+ "@google/genai": "^1.51.0",
+ "@grammyjs/runner": "^2.0.3",
+ "@grammyjs/transformer-throttler": "^1.2.1",
+ "@homebridge/ciao": "^1.3.8",
+ "@lydell/node-pty": "1.2.0-beta.12",
+ "@mariozechner/pi-agent-core": "0.73.0",
+ "@mariozechner/pi-ai": "0.73.0",
+ "@mariozechner/pi-coding-agent": "0.73.0",
+ "@mariozechner/pi-tui": "0.73.0",
+ "@modelcontextprotocol/sdk": "1.29.0",
+ "@mozilla/readability": "^0.6.0",
+ "@slack/bolt": "^4.7.2",
+ "@slack/types": "^2.21.0",
+ "@slack/web-api": "^7.15.2",
+ "ajv": "^8.20.0",
+ "chalk": "^5.6.2",
+ "chokidar": "^5.0.0",
+ "commander": "^14.0.3",
+ "croner": "^10.0.1",
+ "dotenv": "^17.4.2",
+ "express": "5.2.1",
+ "file-type": "22.0.1",
+ "global-agent": "^4.1.3",
+ "grammy": "^1.42.0",
+ "https-proxy-agent": "^9.0.0",
+ "ipaddr.js": "^2.4.0",
+ "jiti": "^2.6.1",
+ "json5": "^2.2.3",
+ "jszip": "^3.10.1",
+ "linkedom": "^0.18.12",
+ "markdown-it": "14.1.1",
+ "minimatch": "10.2.5",
+ "node-edge-tts": "^1.2.10",
+ "openai": "^6.36.0",
+ "openshell": "0.1.0",
+ "pdfjs-dist": "^5.7.284",
+ "playwright-core": "1.59.1",
+ "proxy-agent": "^8.0.1",
+ "qrcode": "1.5.4",
+ "tar": "7.5.13",
+ "tokenjuice": "0.7.0",
+ "tree-sitter-bash": "^0.25.1",
+ "tslog": "^4.10.2",
+ "typebox": "1.1.37",
+ "undici": "8.2.0",
+ "web-push": "^3.6.7",
+ "web-tree-sitter": "^0.26.8",
+ "ws": "^8.20.0",
+ "yaml": "^2.8.4",
+ "zod": "^4.4.3"
+ },
+ "bin": {
+ "openclaw": "openclaw.mjs"
+ },
+ "devDependencies": {
+ "@copilotkit/aimock": "1.17.0",
+ "@grammyjs/types": "^3.26.0",
+ "@lit-labs/signals": "^0.2.0",
+ "@lit/context": "^1.1.6",
+ "@mdx-js/mdx": "^3.1.1",
+ "@types/express": "^5.0.6",
+ "@types/markdown-it": "^14.1.2",
+ "@types/node": "25.6.0",
+ "@types/ws": "^8.18.1",
+ "@typescript/native-preview": "7.0.0-dev.20260504.1",
+ "@vitest/coverage-v8": "^4.1.5",
+ "jscpd": "4.0.9",
+ "jsdom": "^29.1.1",
+ "lit": "^3.3.2",
+ "oxfmt": "0.47.0",
+ "oxlint": "^1.62.0",
+ "oxlint-tsgolint": "^0.22.1",
+ "signal-utils": "0.21.1",
+ "tsdown": "0.21.10",
+ "tsx": "^4.21.0",
+ "typescript": "^6.0.3",
+ "vitest": "^4.1.5"
+ },
+ "engines": {
+ "node": ">=22.14.0"
+ },
+ "optionalDependencies": {
+ "sqlite-vec": "0.1.9"
+ }
+ },
+ "node_modules/@socket.io/component-emitter": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
+ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.19",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
+ "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/engine.io-client": {
+ "version": "6.6.4",
+ "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
+ "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
+ "license": "MIT",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.4.1",
+ "engine.io-parser": "~5.2.1",
+ "ws": "~8.18.3",
+ "xmlhttprequest-ssl": "~2.1.1"
+ }
+ },
+ "node_modules/engine.io-parser": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
+ "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/openclaw": {
+ "resolved": "../../.nvm/versions/node/v25.8.1/lib/node_modules/openclaw",
+ "link": true
+ },
+ "node_modules/socket.io-client": {
+ "version": "4.8.3",
+ "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
+ "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
+ "license": "MIT",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.4.1",
+ "engine.io-client": "~6.6.1",
+ "socket.io-parser": "~4.2.4"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/socket.io-parser": {
+ "version": "4.2.6",
+ "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
+ "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
+ "license": "MIT",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.4.1"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ws": {
+ "version": "8.18.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
+ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xmlhttprequest-ssl": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
+ "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ }
+ }
+}
diff --git a/node_modules/@socket.io/component-emitter/LICENSE b/node_modules/@socket.io/component-emitter/LICENSE
new file mode 100644
index 0000000..de51692
--- /dev/null
+++ b/node_modules/@socket.io/component-emitter/LICENSE
@@ -0,0 +1,24 @@
+(The MIT License)
+
+Copyright (c) 2014 Component contributors
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
diff --git a/node_modules/@socket.io/component-emitter/Readme.md b/node_modules/@socket.io/component-emitter/Readme.md
new file mode 100644
index 0000000..feb36f1
--- /dev/null
+++ b/node_modules/@socket.io/component-emitter/Readme.md
@@ -0,0 +1,79 @@
+# `@socket.io/component-emitter`
+
+ Event emitter component.
+
+This project is a fork of the [`component-emitter`](https://github.com/sindresorhus/component-emitter) project, with [Socket.IO](https://socket.io/)-specific TypeScript typings.
+
+## Installation
+
+```
+$ npm i @socket.io/component-emitter
+```
+
+## API
+
+### Emitter(obj)
+
+ The `Emitter` may also be used as a mixin. For example
+ a "plain" object may become an emitter, or you may
+ extend an existing prototype.
+
+ As an `Emitter` instance:
+
+```js
+import { Emitter } from '@socket.io/component-emitter';
+
+var emitter = new Emitter;
+emitter.emit('something');
+```
+
+ As a mixin:
+
+```js
+import { Emitter } from '@socket.io/component-emitter';
+
+var user = { name: 'tobi' };
+Emitter(user);
+
+user.emit('im a user');
+```
+
+ As a prototype mixin:
+
+```js
+import { Emitter } from '@socket.io/component-emitter';
+
+Emitter(User.prototype);
+```
+
+### Emitter#on(event, fn)
+
+ Register an `event` handler `fn`.
+
+### Emitter#once(event, fn)
+
+ Register a single-shot `event` handler `fn`,
+ removed immediately after it is invoked the
+ first time.
+
+### Emitter#off(event, fn)
+
+ * Pass `event` and `fn` to remove a listener.
+ * Pass `event` to remove all listeners on that event.
+ * Pass nothing to remove all listeners on all events.
+
+### Emitter#emit(event, ...)
+
+ Emit an `event` with variable option args.
+
+### Emitter#listeners(event)
+
+ Return an array of callbacks, or an empty array.
+
+### Emitter#hasListeners(event)
+
+ Check if this emitter has `event` handlers.
+
+## License
+
+MIT
diff --git a/node_modules/@socket.io/component-emitter/lib/cjs/index.d.ts b/node_modules/@socket.io/component-emitter/lib/cjs/index.d.ts
new file mode 100644
index 0000000..49a74e1
--- /dev/null
+++ b/node_modules/@socket.io/component-emitter/lib/cjs/index.d.ts
@@ -0,0 +1,179 @@
+/**
+ * An events map is an interface that maps event names to their value, which
+ * represents the type of the `on` listener.
+ */
+export interface EventsMap {
+ [event: string]: any;
+}
+
+/**
+ * The default events map, used if no EventsMap is given. Using this EventsMap
+ * is equivalent to accepting all event names, and any data.
+ */
+export interface DefaultEventsMap {
+ [event: string]: (...args: any[]) => void;
+}
+
+/**
+ * Returns a union type containing all the keys of an event map.
+ */
+export type EventNames