From 8dc665e68ffa8b1959fc75956410aa80697d65fd Mon Sep 17 00:00:00 2001 From: clawdbot_cipher Date: Fri, 6 Feb 2026 17:35:53 +0000 Subject: [PATCH 1/5] feat: implement outbound delivery for heartbeat/cron Adds proper outbound.sendText() implementation that uses the aX MCP messages tool to send messages. This enables: - Heartbeat delivery to ax-platform channel - Cron job delivery with 'Deliver' checkbox - Any agent reply routing to aX spaces Configuration: - outbound.tokenFile: Path to OAuth token JSON file - outbound.mcpEndpoint: MCP server URL (default: https://mcp.paxai.app) - outbound.defaultSpaceId: Default target space for delivery The implementation reads the access token from the configured file (or AX_ACCESS_TOKEN env var) and POSTs to the MCP messages tool. Resolves: empty-heartbeat-file and 'Outbound not configured' errors for cron jobs targeting ax-platform channel. --- extension/channel/ax-channel.ts | 95 ++++++++++++++++++++++++++++++--- extension/index.ts | 4 +- extension/lib/types.ts | 7 +++ 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/extension/channel/ax-channel.ts b/extension/channel/ax-channel.ts index da4f171..03869c8 100644 --- a/extension/channel/ax-channel.ts +++ b/extension/channel/ax-channel.ts @@ -22,7 +22,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { PluginRuntime } from "clawdbot/plugin-sdk"; -import type { AxDispatchPayload, AxDispatchResponse, DispatchSession } from "../lib/types.js"; +import type { AxDispatchPayload, AxDispatchResponse, DispatchSession, OutboundConfig } from "../lib/types.js"; import { loadAgentRegistry, getAgent, verifySignature, logRegisteredAgents } from "../lib/auth.js"; import { sendProgressUpdate } from "../lib/api.js"; import { buildMissionBriefing } from "../lib/context.js"; @@ -572,10 +572,12 @@ export function getDispatchSession(sessionKey: string): DispatchSession | undefi * Create the aX Platform channel plugin */ export function createAxChannel(config: { + outbound?: OutboundConfig; agents?: Array<{ id: string; secret: string; handle?: string; env?: string }>; backendUrl?: string; }) { const backendUrl = config.backendUrl || process.env.AX_BACKEND_URL || "http://localhost:8001"; + const outboundConfig = config.outbound || {}; // Load agent registry from config loadAgentRegistry(config.agents); @@ -601,13 +603,89 @@ export function createAxChannel(config: { }, outbound: { - deliveryMode: "direct", - - // Handle agent responses - this is called by the dispatcher - async sendText({ text, sessionKey }: { text: string; sessionKey?: string }) { - // For aX, responses are returned via HTTP response in the dispatch handler - // This is a no-op since we use sync-over-async pattern - return { ok: true }; + deliveryMode: "direct" as const, + + /** + * Send text to aX Platform (for heartbeat/cron delivery) + * Uses MCP messages tool with configured token + */ + async sendText({ + text, + to, + accountId, + sessionKey + }: { + text: string; + to?: string; + accountId?: string; + sessionKey?: string; + }) { + const logger = runtime?.logger || { info: console.log, error: console.error }; + + // Get outbound config + const outboundCfg = outboundConfig || {}; + const mcpEndpoint = outboundCfg.mcpEndpoint || process.env.AX_MCP_ENDPOINT || "https://mcp.paxai.app"; + + let authToken: string | undefined; + + // Try token file first + if (outboundCfg.tokenFile) { + try { + const fs = await import("node:fs"); + const tokenPath = outboundCfg.tokenFile.replace(/^~/, process.env.HOME || ""); + const tokenData = JSON.parse(fs.readFileSync(tokenPath, "utf-8")); + authToken = tokenData.access_token; + } catch (err) { + logger.error(`[ax-platform] Failed to read token file: ${err}`); + } + } + + // Fallback to env var + if (!authToken) { + authToken = process.env.AX_ACCESS_TOKEN; + } + + if (!authToken) { + logger.error("[ax-platform] Outbound failed: No access token configured"); + return { ok: false, error: "No access token" }; + } + + // Determine target space (parse "space:UUID" or use directly) + const spaceId = to?.replace(/^space:/, "") || outboundCfg.defaultSpaceId; + if (!spaceId) { + logger.error("[ax-platform] Outbound failed: No target space"); + return { ok: false, error: "No target space" }; + } + + // Send via MCP messages tool + try { + const response = await fetch(`${mcpEndpoint}/tools/messages`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${authToken}`, + }, + body: JSON.stringify({ + action: "send", + content: text, + space_id: spaceId, + }), + }); + + if (!response.ok) { + const errText = await response.text(); + logger.error(`[ax-platform] Outbound failed: ${response.status} ${errText}`); + return { ok: false, error: `HTTP ${response.status}` }; + } + + const result = await response.json(); + const preview = text.length > 50 ? text.substring(0, 50) + "..." : text; + logger.info(`[ax-platform] Outbound sent to ${spaceId}: ${preview}`); + return { ok: true, messageId: result.message_id }; + } catch (err) { + logger.error(`[ax-platform] Outbound error: ${err}`); + return { ok: false, error: String(err) }; + } }, }, @@ -641,6 +719,7 @@ export function createDispatchHandler( config: { backendUrl?: string } ) { const backendUrl = config.backendUrl || "http://localhost:8001"; + const outboundConfig = config.outbound || {}; return async (req: IncomingMessage, res: ServerResponse): Promise => { // Only handle /ax/dispatch diff --git a/extension/index.ts b/extension/index.ts index 5729c52..c12e5c7 100644 --- a/extension/index.ts +++ b/extension/index.ts @@ -11,6 +11,7 @@ import type { ClawdbotPluginApi, PluginRuntime } from "clawdbot/plugin-sdk"; import { createAxChannel, createDispatchHandler, setAxPlatformRuntime, getDispatchSession } from "./channel/ax-channel.js"; +import type { OutboundConfig } from "./lib/types.js"; import { logRegisteredAgents } from "./lib/auth.js"; import { axMessagesTool } from "./tools/ax-messages.js"; import { axTasksTool } from "./tools/ax-tasks.js"; @@ -28,6 +29,7 @@ interface AxPlatformConfig { env?: string; }>; backendUrl?: string; + outbound?: OutboundConfig; } const plugin = { @@ -65,7 +67,7 @@ const plugin = { api.logger.info(`[ax-platform] Config received: agents=${config.agents?.length || 0}, backendUrl=${config.backendUrl || 'default'}`); // Register the aX Platform channel - const channel = createAxChannel(config); + const channel = createAxChannel({ ...config, outbound: config.outbound }); api.registerChannel({ plugin: channel }); api.logger.info("[ax-platform] Channel registered: ax-platform"); diff --git a/extension/lib/types.ts b/extension/lib/types.ts index 505c090..3485cde 100644 --- a/extension/lib/types.ts +++ b/extension/lib/types.ts @@ -99,3 +99,10 @@ export interface DispatchSession { contextData?: ContextData; startTime: number; } + +// Outbound delivery configuration +export interface OutboundConfig { + mcpEndpoint?: string; // Default: https://mcp.paxai.app + tokenFile?: string; // Path to token JSON file + defaultSpaceId?: string; // Default space for delivery +} From 49ef4743f20908f13e1aa8e36006f2c5b1bb8b97 Mon Sep 17 00:00:00 2001 From: clawdbot_cipher Date: Fri, 6 Feb 2026 17:44:20 +0000 Subject: [PATCH 2/5] feat: proper config adapter + status for gateway integration - Config now reads from channels.ax-platform.accounts (standard pattern) - Falls back to plugins.entries.ax-platform.config.agents (legacy) - Each agent handle becomes an account for multi-agent support - Added status adapter for health checks (configured, accounts, sessions) - Enhanced capabilities (media, threads, mentions) This should fix 'configured: false' in gateway health checks and enable proper delivery/heartbeat routing. Gateway config example: { channels: { 'ax-platform': { accounts: { clawdbot_cipher: { agentId: 'uuid', secret: '...', handle: '@clawdbot_cipher', enabled: true } } } } } --- extension/channel/ax-channel.ts | 109 +++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 2 deletions(-) diff --git a/extension/channel/ax-channel.ts b/extension/channel/ax-channel.ts index 03869c8..7980d0d 100644 --- a/extension/channel/ax-channel.ts +++ b/extension/channel/ax-channel.ts @@ -595,11 +595,77 @@ export function createAxChannel(config: { capabilities: { chatTypes: ["direct", "group"], + media: { images: true, documents: true }, + threads: true, + mentions: true, }, config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({ accountId: "default" }), + /** + * List all configured account IDs + * Reads from channels.ax-platform.accounts (preferred) or + * falls back to plugins.entries.ax-platform.config.agents + */ + listAccountIds: (cfg: any) => { + // Preferred: channels.ax-platform.accounts + const channelAccounts = cfg?.channels?.["ax-platform"]?.accounts; + if (channelAccounts && Object.keys(channelAccounts).length > 0) { + return Object.keys(channelAccounts); + } + + // Fallback: plugins.entries.ax-platform.config.agents (legacy) + const pluginAgents = cfg?.plugins?.entries?.["ax-platform"]?.config?.agents; + if (Array.isArray(pluginAgents) && pluginAgents.length > 0) { + return pluginAgents.map((a) => a.handle?.replace(/^@/, "") || a.id); + } + + return []; + }, + + /** + * Resolve account config by ID + * Returns account object with: accountId, agentId, secret, handle, enabled + */ + resolveAccount: (cfg: any, accountId?: string) => { + // Preferred: channels.ax-platform.accounts + const channelAccounts = cfg?.channels?.["ax-platform"]?.accounts; + if (channelAccounts) { + const account = channelAccounts[accountId || "default"]; + if (account) { + return { + accountId: accountId || "default", + agentId: account.agentId || account.id, + secret: account.secret, + handle: account.handle, + enabled: account.enabled !== false, + env: account.env, + ...account, + }; + } + } + + // Fallback: plugins.entries.ax-platform.config.agents (legacy) + const pluginAgents = cfg?.plugins?.entries?.["ax-platform"]?.config?.agents; + if (Array.isArray(pluginAgents)) { + const agent = pluginAgents.find((a) => + a.handle?.replace(/^@/, "") === accountId || + a.id === accountId + ) || pluginAgents[0]; + + if (agent) { + return { + accountId: agent.handle?.replace(/^@/, "") || agent.id, + agentId: agent.id, + secret: agent.secret, + handle: agent.handle, + enabled: true, + env: agent.env, + }; + } + } + + return { accountId: accountId || "default" }; + }, }, outbound: { @@ -705,6 +771,45 @@ export function createAxChannel(config: { dispatchStates.clear(); }, }, + + // Status adapter for health checks + status: { + async getStatus(cfg: any) { + const accounts = []; + + // Check accounts from channels config + const channelAccounts = cfg?.channels?.["ax-platform"]?.accounts; + if (channelAccounts) { + for (const [id, account] of Object.entries(channelAccounts)) { + accounts.push({ + id, + status: (account as any).enabled !== false ? "configured" : "disabled", + handle: (account as any).handle, + }); + } + } + + // Fallback to plugin config + if (accounts.length === 0) { + const pluginAgents = cfg?.plugins?.entries?.["ax-platform"]?.config?.agents; + if (Array.isArray(pluginAgents)) { + for (const agent of pluginAgents) { + accounts.push({ + id: agent.handle?.replace(/^@/, "") || agent.id, + status: "configured", + handle: agent.handle, + }); + } + } + } + + return { + configured: accounts.length > 0, + accounts, + activeSessions: dispatchSessions.size, + }; + }, + }, }; } From 747e61cf59d9068beca7a655516a55b1548493a3 Mon Sep 17 00:00:00 2001 From: clawdbot_cipher Date: Fri, 6 Feb 2026 17:53:34 +0000 Subject: [PATCH 3/5] fix: rename manifest to openclaw.plugin.json + add channel schema - Renamed clawdbot.plugin.json -> openclaw.plugin.json (required by OpenClaw) - Added channelConfigSchema for channels.ax-platform.accounts pattern - Added outbound config to plugin schema - Bumped version to 0.3.0 This should fix 'Channel config schema unavailable' in gateway UI. --- extension/clawdbot.plugin.json | 36 ----------------- extension/openclaw.plugin.json | 70 ++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 36 deletions(-) delete mode 100644 extension/clawdbot.plugin.json create mode 100644 extension/openclaw.plugin.json diff --git a/extension/clawdbot.plugin.json b/extension/clawdbot.plugin.json deleted file mode 100644 index 3ba0674..0000000 --- a/extension/clawdbot.plugin.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "id": "ax-platform", - "name": "aX Platform Integration", - "description": "Connect Moltbot agents to aX Platform cloud collaboration", - "version": "0.2.0", - "channels": ["ax-platform"], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "agents": { - "type": "array", - "description": "List of aX agents this gateway handles", - "items": { - "type": "object", - "properties": { - "id": { "type": "string", "description": "Agent UUID" }, - "secret": { "type": "string", "description": "Webhook HMAC secret" }, - "handle": { "type": "string", "description": "Agent @handle" }, - "env": { "type": "string", "description": "Environment (prod/dev)" } - }, - "required": ["id", "secret"] - } - }, - "backendUrl": { - "type": "string", - "description": "aX backend API URL", - "default": "http://localhost:8001" - } - } - }, - "uiHints": { - "agents": { "label": "aX Agents", "sensitive": false }, - "backendUrl": { "label": "Backend URL" } - } -} diff --git a/extension/openclaw.plugin.json b/extension/openclaw.plugin.json new file mode 100644 index 0000000..e64cbb0 --- /dev/null +++ b/extension/openclaw.plugin.json @@ -0,0 +1,70 @@ +{ + "id": "ax-platform", + "name": "aX Platform Integration", + "description": "Connect agents to aX Platform for multi-agent cloud collaboration", + "version": "0.3.0", + "channels": ["ax-platform"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "agents": { + "type": "array", + "description": "List of aX agents (legacy config location)", + "items": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Agent UUID" }, + "secret": { "type": "string", "description": "Webhook HMAC secret" }, + "handle": { "type": "string", "description": "Agent @handle" }, + "env": { "type": "string", "description": "Environment (prod/dev)" } + }, + "required": ["id", "secret"] + } + }, + "backendUrl": { + "type": "string", + "description": "aX backend API URL", + "default": "https://api.paxai.app" + }, + "outbound": { + "type": "object", + "description": "Outbound delivery configuration", + "properties": { + "mcpEndpoint": { "type": "string", "description": "MCP server URL", "default": "https://mcp.paxai.app" }, + "tokenFile": { "type": "string", "description": "Path to OAuth token JSON file" }, + "defaultSpaceId": { "type": "string", "description": "Default space ID for delivery" } + } + } + } + }, + "channelConfigSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean", "default": true }, + "accounts": { + "type": "object", + "description": "Agent accounts (accountId -> config)", + "additionalProperties": { + "type": "object", + "properties": { + "agentId": { "type": "string", "description": "Agent UUID" }, + "secret": { "type": "string", "description": "Webhook HMAC secret" }, + "handle": { "type": "string", "description": "Agent @handle" }, + "enabled": { "type": "boolean", "default": true }, + "env": { "type": "string", "description": "Environment label" } + }, + "required": ["agentId", "secret"] + } + } + } + }, + "uiHints": { + "agents": { "label": "aX Agents (Legacy)", "sensitive": false }, + "agents.*.secret": { "label": "HMAC Secret", "sensitive": true }, + "backendUrl": { "label": "Backend URL" }, + "outbound.tokenFile": { "label": "Token File Path" }, + "outbound.mcpEndpoint": { "label": "MCP Endpoint" } + } +} From 4a6ee1ecdf88ef5ca9e28cec328152d0d9572d33 Mon Sep 17 00:00:00 2001 From: clawdbot_cipher Date: Fri, 6 Feb 2026 18:05:01 +0000 Subject: [PATCH 4/5] fix: rename manifest back to clawdbot.plugin.json Clawdbot gateway looks for clawdbot.plugin.json, not openclaw.plugin.json. The schema content added in previous commit is preserved. --- extension/{openclaw.plugin.json => clawdbot.plugin.json} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename extension/{openclaw.plugin.json => clawdbot.plugin.json} (100%) diff --git a/extension/openclaw.plugin.json b/extension/clawdbot.plugin.json similarity index 100% rename from extension/openclaw.plugin.json rename to extension/clawdbot.plugin.json From b242042f8d05e0fe61af54597481a8ce30fdf4a6 Mon Sep 17 00:00:00 2001 From: Jacob Taunton Date: Mon, 9 Feb 2026 09:21:17 -0800 Subject: [PATCH 5/5] fix: JSON-RPC protocol, channel status interface, and config path - callAxTool now uses MCP JSON-RPC at /mcp instead of broken REST /tools/toolName endpoint. Handles both SSE and JSON responses. - outbound sendText/sendMedia match Clawdbot's standard channel interface (to, text, cfg, replyToId, threadId, deps) - Added status interface (buildAccountSnapshot, buildChannelSummary, probeAccount) so dashboard shows configured/connected status - Fixed config path: api.config is full clawdbot.json, plugin config lives at plugins.entries.ax-platform.config - Probe only hits paxai.app for prod agents, with 60s cache - setup.sh now writes outbound config with auto-detected token file - config.defaultAccountId returns first prod agent instead of first agent in list Co-Authored-By: Claude Opus 4.6 --- extension/channel/ax-channel.ts | 387 ++++++++++++++++++++------------ extension/index.ts | 14 +- extension/lib/api.ts | 90 +++++++- setup.sh | 21 +- 4 files changed, 354 insertions(+), 158 deletions(-) diff --git a/extension/channel/ax-channel.ts b/extension/channel/ax-channel.ts index 7980d0d..94ccaeb 100644 --- a/extension/channel/ax-channel.ts +++ b/extension/channel/ax-channel.ts @@ -24,7 +24,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { PluginRuntime } from "clawdbot/plugin-sdk"; import type { AxDispatchPayload, AxDispatchResponse, DispatchSession, OutboundConfig } from "../lib/types.js"; import { loadAgentRegistry, getAgent, verifySignature, logRegisteredAgents } from "../lib/auth.js"; -import { sendProgressUpdate } from "../lib/api.js"; +import { sendProgressUpdate, callAxTool } from "../lib/api.js"; import { buildMissionBriefing } from "../lib/context.js"; // ─── Agent Event Tracking ─────────────────────────────────────────────────── @@ -568,6 +568,28 @@ export function getDispatchSession(sessionKey: string): DispatchSession | undefi return undefined; } +/** + * Resolve auth token for outbound delivery. + * Tries tokenFile first, then AX_ACCESS_TOKEN env var. + */ +async function resolveOutboundToken(outboundCfg: OutboundConfig | Record): Promise { + const cfg = outboundCfg || {}; + const tokenFile = (cfg as OutboundConfig).tokenFile; + + if (tokenFile) { + try { + const fs = await import("node:fs"); + const tokenPath = tokenFile.replace(/^~/, process.env.HOME || ""); + const tokenData = JSON.parse(fs.readFileSync(tokenPath, "utf-8")); + if (tokenData.access_token) return tokenData.access_token; + } catch { + // Fall through to env var + } + } + + return process.env.AX_ACCESS_TOKEN; +} + /** * Create the aX Platform channel plugin */ @@ -582,6 +604,18 @@ export function createAxChannel(config: { // Load agent registry from config loadAgentRegistry(config.agents); + // Probe cache — one MCP call covers all prod accounts + let probeCache: { result: any; ts: number } | null = null; + + // Helper: get agents array from clawdbot config + function getPluginAgents(cfg: any): Array<{ id: string; secret: string; handle?: string; env?: string }> { + const pluginAgents = cfg?.plugins?.entries?.["ax-platform"]?.config?.agents; + if (Array.isArray(pluginAgents) && pluginAgents.length > 0) { + return pluginAgents; + } + return config.agents || []; + } + return { id: "ax-platform", meta: { @@ -603,154 +637,176 @@ export function createAxChannel(config: { config: { /** * List all configured account IDs - * Reads from channels.ax-platform.accounts (preferred) or - * falls back to plugins.entries.ax-platform.config.agents */ listAccountIds: (cfg: any) => { - // Preferred: channels.ax-platform.accounts - const channelAccounts = cfg?.channels?.["ax-platform"]?.accounts; - if (channelAccounts && Object.keys(channelAccounts).length > 0) { - return Object.keys(channelAccounts); - } - - // Fallback: plugins.entries.ax-platform.config.agents (legacy) - const pluginAgents = cfg?.plugins?.entries?.["ax-platform"]?.config?.agents; - if (Array.isArray(pluginAgents) && pluginAgents.length > 0) { - return pluginAgents.map((a) => a.handle?.replace(/^@/, "") || a.id); - } - - return []; + return getPluginAgents(cfg).map((a) => a.handle?.replace(/^@/, "") || a.id); }, + /** + * Default account ID - first prod agent, or first agent + */ + defaultAccountId: (cfg: any) => { + const agents = getPluginAgents(cfg); + const prod = agents.find((a) => a.env === "prod"); + const agent = prod || agents[0]; + return agent ? (agent.handle?.replace(/^@/, "") || agent.id) : "default"; + }, + + /** + * Check if account is enabled + */ + isEnabled: (account: any) => account?.enabled !== false, + + /** + * Check if account has required credentials + */ + isConfigured: (account: any) => Boolean(account?.agentId && account?.secret), + + /** + * Describe account for dashboard listing + */ + describeAccount: (account: any) => ({ + accountId: account?.accountId || "default", + name: account?.handle ? `@${account.handle.replace(/^@/, "")}` : account?.accountId, + enabled: account?.enabled !== false, + configured: Boolean(account?.agentId && account?.secret), + }), + /** * Resolve account config by ID - * Returns account object with: accountId, agentId, secret, handle, enabled */ resolveAccount: (cfg: any, accountId?: string) => { - // Preferred: channels.ax-platform.accounts - const channelAccounts = cfg?.channels?.["ax-platform"]?.accounts; - if (channelAccounts) { - const account = channelAccounts[accountId || "default"]; - if (account) { - return { - accountId: accountId || "default", - agentId: account.agentId || account.id, - secret: account.secret, - handle: account.handle, - enabled: account.enabled !== false, - env: account.env, - ...account, - }; - } - } - - // Fallback: plugins.entries.ax-platform.config.agents (legacy) - const pluginAgents = cfg?.plugins?.entries?.["ax-platform"]?.config?.agents; - if (Array.isArray(pluginAgents)) { - const agent = pluginAgents.find((a) => - a.handle?.replace(/^@/, "") === accountId || - a.id === accountId - ) || pluginAgents[0]; - - if (agent) { - return { - accountId: agent.handle?.replace(/^@/, "") || agent.id, - agentId: agent.id, - secret: agent.secret, - handle: agent.handle, - enabled: true, - env: agent.env, - }; - } + const agents = getPluginAgents(cfg); + const agent = agents.find((a) => + a.handle?.replace(/^@/, "") === accountId || + a.id === accountId + ) || (accountId ? undefined : agents[0]); + + if (agent) { + return { + accountId: agent.handle?.replace(/^@/, "") || agent.id, + agentId: agent.id, + secret: agent.secret, + handle: agent.handle, + enabled: true, + env: agent.env, + }; } - + return { accountId: accountId || "default" }; }, }, outbound: { deliveryMode: "direct" as const, + chunker: null, + textChunkLimit: 4000, /** * Send text to aX Platform (for heartbeat/cron delivery) - * Uses MCP messages tool with configured token + * Matches Clawdbot's standard channel outbound interface */ - async sendText({ - text, + async sendText({ to, + text, accountId, - sessionKey - }: { - text: string; - to?: string; + cfg, + replyToId, + threadId, + deps, + }: { + to: string; + text: string; accountId?: string; - sessionKey?: string; + cfg?: any; + replyToId?: string; + threadId?: string; + deps?: any; }) { const logger = runtime?.logger || { info: console.log, error: console.error }; - - // Get outbound config + + const authToken = await resolveOutboundToken(outboundConfig); + if (!authToken) { + logger.error("[ax-platform] Outbound sendText failed: No access token configured"); + return { channel: "ax-platform", ok: false, error: "No access token" }; + } + const outboundCfg = outboundConfig || {}; const mcpEndpoint = outboundCfg.mcpEndpoint || process.env.AX_MCP_ENDPOINT || "https://mcp.paxai.app"; - - let authToken: string | undefined; - - // Try token file first - if (outboundCfg.tokenFile) { - try { - const fs = await import("node:fs"); - const tokenPath = outboundCfg.tokenFile.replace(/^~/, process.env.HOME || ""); - const tokenData = JSON.parse(fs.readFileSync(tokenPath, "utf-8")); - authToken = tokenData.access_token; - } catch (err) { - logger.error(`[ax-platform] Failed to read token file: ${err}`); - } + const spaceId = to?.replace(/^space:/, "") || outboundCfg.defaultSpaceId; + if (!spaceId) { + logger.error("[ax-platform] Outbound sendText failed: No target space"); + return { channel: "ax-platform", ok: false, error: "No target space" }; } - - // Fallback to env var - if (!authToken) { - authToken = process.env.AX_ACCESS_TOKEN; + + try { + const result = await callAxTool(mcpEndpoint, authToken, "messages", { + action: "send", + content: text, + space_id: spaceId, + }) as Record; + + const preview = text.length > 50 ? text.substring(0, 50) + "..." : text; + logger.info(`[ax-platform] Outbound sent to ${spaceId}: ${preview}`); + return { channel: "ax-platform", ok: true, messageId: result.message_id }; + } catch (err) { + logger.error(`[ax-platform] Outbound sendText error: ${err}`); + return { channel: "ax-platform", ok: false, error: String(err) }; } - + }, + + /** + * Send media to aX Platform (for heartbeat/cron delivery) + * Matches Clawdbot's standard channel outbound interface + */ + async sendMedia({ + to, + text, + mediaUrl, + accountId, + cfg, + replyToId, + threadId, + deps, + }: { + to: string; + text?: string; + mediaUrl: string; + accountId?: string; + cfg?: any; + replyToId?: string; + threadId?: string; + deps?: any; + }) { + const logger = runtime?.logger || { info: console.log, error: console.error }; + + const authToken = await resolveOutboundToken(outboundConfig); if (!authToken) { - logger.error("[ax-platform] Outbound failed: No access token configured"); - return { ok: false, error: "No access token" }; + logger.error("[ax-platform] Outbound sendMedia failed: No access token configured"); + return { channel: "ax-platform", ok: false, error: "No access token" }; } - - // Determine target space (parse "space:UUID" or use directly) + + const outboundCfg = outboundConfig || {}; + const mcpEndpoint = outboundCfg.mcpEndpoint || process.env.AX_MCP_ENDPOINT || "https://mcp.paxai.app"; const spaceId = to?.replace(/^space:/, "") || outboundCfg.defaultSpaceId; if (!spaceId) { - logger.error("[ax-platform] Outbound failed: No target space"); - return { ok: false, error: "No target space" }; + logger.error("[ax-platform] Outbound sendMedia failed: No target space"); + return { channel: "ax-platform", ok: false, error: "No target space" }; } - - // Send via MCP messages tool + try { - const response = await fetch(`${mcpEndpoint}/tools/messages`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${authToken}`, - }, - body: JSON.stringify({ - action: "send", - content: text, - space_id: spaceId, - }), - }); - - if (!response.ok) { - const errText = await response.text(); - logger.error(`[ax-platform] Outbound failed: ${response.status} ${errText}`); - return { ok: false, error: `HTTP ${response.status}` }; - } - - const result = await response.json(); - const preview = text.length > 50 ? text.substring(0, 50) + "..." : text; - logger.info(`[ax-platform] Outbound sent to ${spaceId}: ${preview}`); - return { ok: true, messageId: result.message_id }; + const content = text ? `${text}\n\n${mediaUrl}` : mediaUrl; + const result = await callAxTool(mcpEndpoint, authToken, "messages", { + action: "send", + content, + space_id: spaceId, + }) as Record; + + logger.info(`[ax-platform] Outbound media sent to ${spaceId}: ${mediaUrl}`); + return { channel: "ax-platform", ok: true, messageId: result.message_id }; } catch (err) { - logger.error(`[ax-platform] Outbound error: ${err}`); - return { ok: false, error: String(err) }; + logger.error(`[ax-platform] Outbound sendMedia error: ${err}`); + return { channel: "ax-platform", ok: false, error: String(err) }; } }, }, @@ -772,42 +828,79 @@ export function createAxChannel(config: { }, }, - // Status adapter for health checks + // Status adapter for dashboard health checks status: { - async getStatus(cfg: any) { - const accounts = []; - - // Check accounts from channels config - const channelAccounts = cfg?.channels?.["ax-platform"]?.accounts; - if (channelAccounts) { - for (const [id, account] of Object.entries(channelAccounts)) { - accounts.push({ - id, - status: (account as any).enabled !== false ? "configured" : "disabled", - handle: (account as any).handle, - }); - } + defaultRuntime: { + accountId: "default", + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + + buildAccountSnapshot: ({ account, runtime, probe, audit }: { + account: any; cfg?: any; runtime?: any; probe?: any; audit?: any; + }) => ({ + accountId: account?.accountId || "default", + name: account?.handle ? `@${account.handle.replace(/^@/, "")}` : account?.accountId, + enabled: account?.enabled !== false, + configured: Boolean(account?.agentId && account?.secret), + running: runtime?.running ?? false, + connected: probe?.ok ?? undefined, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + mode: "webhook", + probe, + audit, + }), + + buildChannelSummary: ({ snapshot }: { snapshot: any; account?: any; cfg?: any; defaultAccountId?: string }) => ({ + configured: snapshot?.configured ?? false, + running: snapshot?.running ?? false, + lastError: snapshot?.lastError ?? null, + }), + + probeAccount: async ({ account, timeoutMs }: { account: any; timeoutMs: number; cfg?: any }) => { + if (!account?.agentId || !account?.secret) { + return { ok: false, error: "Missing agentId or secret" }; } - - // Fallback to plugin config - if (accounts.length === 0) { - const pluginAgents = cfg?.plugins?.entries?.["ax-platform"]?.config?.agents; - if (Array.isArray(pluginAgents)) { - for (const agent of pluginAgents) { - accounts.push({ - id: agent.handle?.replace(/^@/, "") || agent.id, - status: "configured", - handle: agent.handle, - }); - } - } + + // Only probe prod agents against paxai.app + if (account.env && account.env !== "prod") { + return { ok: true, mode: "local", skipped: true }; + } + + const token = await resolveOutboundToken(outboundConfig); + if (!token) { + return { ok: false, error: "No access token for outbound" }; + } + + // Reuse cached probe if fresh (within 60s) — avoids N calls per status refresh + if (probeCache && Date.now() - probeCache.ts < 60000) { + return probeCache.result; + } + + const mcpEndpoint = (outboundConfig as OutboundConfig).mcpEndpoint + || process.env.AX_MCP_ENDPOINT || "https://mcp.paxai.app"; + const handle = account.handle?.replace(/^@/, "") || account.accountId; + + try { + const data = await callAxTool(mcpEndpoint, token, "agents", { + search: handle, + limit: 1, + }) as any; + const found = Array.isArray(data?.agents) && data.agents.length > 0; + const result = { ok: true, mode: "webhook", agentFound: found, handle: `@${handle}` }; + probeCache = { result, ts: Date.now() }; + return result; + } catch (err: any) { + const result = { ok: false, error: String(err) }; + probeCache = { result, ts: Date.now() }; + return result; } - - return { - configured: accounts.length > 0, - accounts, - activeSessions: dispatchSessions.size, - }; }, }, }; diff --git a/extension/index.ts b/extension/index.ts index c12e5c7..bafead2 100644 --- a/extension/index.ts +++ b/extension/index.ts @@ -54,6 +54,14 @@ const plugin = { }, }, backendUrl: { type: "string" as const }, + outbound: { + type: "object" as const, + properties: { + mcpEndpoint: { type: "string" as const }, + tokenFile: { type: "string" as const }, + defaultSpaceId: { type: "string" as const }, + }, + }, }, }, @@ -63,8 +71,10 @@ const plugin = { // Store runtime for channel to access setAxPlatformRuntime(api.runtime); - const config = (api.config || {}) as AxPlatformConfig; - api.logger.info(`[ax-platform] Config received: agents=${config.agents?.length || 0}, backendUrl=${config.backendUrl || 'default'}`); + // api.config is the full clawdbot.json — extract plugin-specific config + const fullConfig = (api.config || {}) as Record; + const config = (fullConfig?.plugins?.entries?.["ax-platform"]?.config || {}) as AxPlatformConfig; + api.logger.info(`[ax-platform] Config received: agents=${config.agents?.length || 0}, backendUrl=${config.backendUrl || 'default'}, outbound=${config.outbound?.tokenFile ? 'configured' : 'none'}`); // Register the aX Platform channel const channel = createAxChannel({ ...config, outbound: config.outbound }); diff --git a/extension/lib/api.ts b/extension/lib/api.ts index 5dfd72e..054ec3d 100644 --- a/extension/lib/api.ts +++ b/extension/lib/api.ts @@ -35,7 +35,10 @@ export async function sendProgressUpdate( } /** - * Call aX MCP tool via backend API + * Call aX MCP tool via JSON-RPC (MCP protocol) + * + * The MCP server speaks JSON-RPC at {mcpEndpoint}/mcp. + * We send a `tools/call` request with the tool name and arguments. */ export async function callAxTool( mcpEndpoint: string, @@ -43,18 +46,93 @@ export async function callAxTool( toolName: string, params: Record ): Promise { - const response = await fetch(`${mcpEndpoint}/tools/${toolName}`, { + if (!mcpEndpoint) throw new Error("callAxTool: mcpEndpoint is required"); + if (!authToken) throw new Error("callAxTool: authToken is required"); + if (!toolName) throw new Error("callAxTool: toolName is required"); + + // Ensure endpoint ends with /mcp (the JSON-RPC endpoint) + const rpcUrl = mcpEndpoint.endsWith("/mcp") + ? mcpEndpoint + : `${mcpEndpoint.replace(/\/+$/, "")}/mcp`; + + const body = { + jsonrpc: "2.0", + id: `ax-plugin-${Date.now()}`, + method: "tools/call", + params: { + name: toolName, + arguments: params, + }, + }; + + const response = await fetch(rpcUrl, { method: "POST", headers: { "Content-Type": "application/json", - "Authorization": `Bearer ${authToken}`, + Accept: "application/json, text/event-stream", + Authorization: `Bearer ${authToken}`, }, - body: JSON.stringify(params), + body: JSON.stringify(body), }); if (!response.ok) { - throw new Error(`aX tool ${toolName} failed: ${response.status}`); + const errText = await response.text().catch(() => ""); + throw new Error(`aX tool ${toolName} failed: ${response.status} ${errText}`); + } + + const contentType = response.headers.get("content-type") || ""; + + // Handle SSE (streaming) responses — collect until we get a JSON-RPC result + if (contentType.includes("text/event-stream")) { + const text = await response.text(); + const lines = text.split("\n"); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (line.startsWith("data:")) { + try { + const eventData = JSON.parse(line.slice(5).trim()); + if (eventData.result) { + return extractToolResult(eventData.result); + } + if (eventData.error) { + throw new Error(`MCP error: ${eventData.error.message || JSON.stringify(eventData.error)}`); + } + } catch (parseErr) { + if (parseErr instanceof Error && parseErr.message.startsWith("MCP error:")) throw parseErr; + // Not valid JSON — skip + } + } + } + throw new Error(`aX tool ${toolName}: no result in SSE stream`); } - return response.json(); + // Handle plain JSON-RPC response + const rpcResult = await response.json(); + if (rpcResult.error) { + throw new Error(`MCP error: ${rpcResult.error.message || JSON.stringify(rpcResult.error)}`); + } + return extractToolResult(rpcResult.result); +} + +/** + * Extract text content from an MCP tools/call result. + * + * MCP returns: { content: [{ type: "text", text: "..." }, ...] } + * This extracts text parts, tries JSON parse, falls back to raw string. + */ +function extractToolResult(result: unknown): unknown { + if (!result || typeof result !== "object") return result; + const r = result as Record; + if (Array.isArray(r.content)) { + const textParts = (r.content as Array<{ type?: string; text?: string }>) + .filter(c => c.type === "text" && c.text) + .map(c => c.text!); + const joined = textParts.join("\n"); + try { + return JSON.parse(joined); + } catch { + return joined || result; + } + } + return result; } diff --git a/setup.sh b/setup.sh index 765ddd4..3b6142f 100755 --- a/setup.sh +++ b/setup.sh @@ -160,13 +160,28 @@ cmd_sync() { cd "$SCRIPT_DIR" log_info "Updating clawdbot.json..." - UPDATED_CONFIG=$(cat "$CONFIG_FILE" | jq --argjson agents "$AGENTS_JSON" ' + # Resolve outbound token file (first .ax-token.json found in workspaces) + local token_file="" + for ws in "$HOME/.clawdbot/workspaces"/*/.ax-token.json; do + if [[ -f "$ws" ]]; then + token_file="$ws" + break + fi + done + + UPDATED_CONFIG=$(cat "$CONFIG_FILE" | jq --argjson agents "$AGENTS_JSON" --arg tokenFile "${token_file}" ' .plugins.entries["ax-platform"].enabled = true | .plugins.entries["ax-platform"].config.agents = $agents | - .plugins.entries["ax-platform"].config.backendUrl = "https://api.paxai.app" + .plugins.entries["ax-platform"].config.backendUrl = "https://api.paxai.app" | + .plugins.entries["ax-platform"].config.outbound.mcpEndpoint = "https://mcp.paxai.app" | + if $tokenFile != "" then .plugins.entries["ax-platform"].config.outbound.tokenFile = $tokenFile else . end ') echo "$UPDATED_CONFIG" > "$CONFIG_FILE" - log_ok "Updated plugin config" + if [[ -n "$token_file" ]]; then + log_ok "Updated plugin config (outbound token: ${token_file##*/workspaces/})" + else + log_warn "Updated plugin config (no .ax-token.json found for outbound)" + fi # Clean stale env vars from plist (prevents signature verification failures) if clean_plist_env; then