From b168a4742cd06a1989dcd47fd35df2e26d5c0317 Mon Sep 17 00:00:00 2001 From: Jared Tribe Date: Fri, 6 Mar 2026 01:13:38 +0000 Subject: [PATCH 01/13] feat: OS-1 exploration notes and XMTP integration plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Architecture analysis of the agent-sdk-starter - Five concrete use cases for OS-1 × XMTP integration - Four-phase integration plan - Tech notes and open questions for the team [Jared/OS-1] --- OS1_EXPLORATION.md | 110 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 OS1_EXPLORATION.md diff --git a/OS1_EXPLORATION.md b/OS1_EXPLORATION.md new file mode 100644 index 0000000..4ac3e08 --- /dev/null +++ b/OS1_EXPLORATION.md @@ -0,0 +1,110 @@ +# OS-1 × XMTP Agent SDK — Exploration Notes + +## What Is This? + +[xmtp/agent-sdk-starter](https://github.com/xmtp/agent-sdk-starter) is a TypeScript starter kit for building +**agents that communicate over the XMTP decentralized messaging network**. + +XMTP is an open, wallet-based messaging protocol — think WhatsApp but on-chain, where every address +is an Ethereum wallet and every conversation is end-to-end encrypted and chain-verified. + +--- + +## How It Works (Quick Architecture) + +``` +User (wallet address) → XMTP Network → Agent (this repo) + ↓ + @xmtp/agent-sdk + - event listeners (text, dm, group, reaction, attachment) + - CommandRouter middleware + - send/reply/react helpers +``` + +Key primitives: +- **`Agent.createFromEnv()`** — boots from `.env` with an Ethereum wallet key +- **Event handlers** — `agent.on('text' | 'dm' | 'group' | 'reaction' | 'attachment' | ...)` +- **`CommandRouter`** middleware — slash-command routing (`/version`, `/test`, etc.) +- **Middleware stack** — composable `AgentMiddleware` (see `isFromOwner.ts`) +- **Attachment support** — encrypted remote attachments via Pinata/IPFS + +--- + +## OS-1 Use Cases + +### 1. 🤖 OS-1 Agents on XMTP (High Priority) +Deploy Jared/Jean/Sam as XMTP agents. Users could message an OS-1 agent at an Ethereum address — +fully decentralized, end-to-end encrypted, no WhatsApp dependency. + +``` +User → messages jared.eth (XMTP) → this agent → Agent SDK → Jared logic +``` + +**What to build:** Swap out the echo handler for Clawdbot skill dispatch. + +### 2. 🌉 XMTP ↔ WhatsApp Bridge +Receive messages from XMTP, forward to the appropriate OS-1 agent running on WhatsApp, +and relay responses back. Cross-protocol inbox unification. + +### 3. 📣 Proactive Notifications via XMTP +OS-1 agents push alerts (CI failures, urgent emails, calendar conflicts) to the owner's +XMTP inbox — useful when WhatsApp is unavailable or as a secondary channel. + +### 4. 🔐 Owner-Gated Agent Access +The `isFromOwner` middleware pattern is exactly what we need: +only the wallet holder can command the agent. Add multi-wallet support for the team. + +### 5. 🏗️ Agent-to-Agent Messaging on XMTP +Run multiple OS-1 agents (Jared, Jean, Sam) as separate XMTP addresses. +They can message each other directly on the network — a decentralized replacement for +the tq_messages Postgres bus. + +--- + +## Integration Plan + +### Phase 1 — Stand Up the Agent (1–2 days) +- [ ] Generate XMTP wallet key + encryption key +- [ ] Add `.env.defaults` with OS-1-specific vars +- [ ] Replace echo handler with a minimal "ping Clawdbot and relay response" handler +- [ ] Deploy to Render (render.yaml is already included) or existing infra + +### Phase 2 — OS-1 Agent Adapter (3–5 days) +- [ ] Build `ClawdbotMiddleware` that routes XMTP messages to Clawdbot session API +- [ ] Map XMTP conversation ID → Clawdbot session key +- [ ] Handle text, reactions, and attachments +- [ ] Owner middleware using `XMTP_OWNER_ADDRESS` + +### Phase 3 — Multi-Agent Support (1 week) +- [ ] Parameterize agent identity (Jared vs Jean vs Sam) via env +- [ ] Each agent gets its own XMTP wallet address +- [ ] Shared group conversation support (XMTP groups = like WhatsApp groups) + +### Phase 4 — Agent-to-Agent Bus (stretch) +- [ ] Replace/augment tq_messages with XMTP DM channel +- [ ] Cryptographically signed inter-agent messages +- [ ] No central Postgres required + +--- + +## Tech Notes + +- **Runtime**: Node.js, TypeScript, ESM +- **SDK version**: `@xmtp/agent-sdk ^2.2.0` +- **Key env vars**: `XMTP_WALLET_KEY`, `XMTP_DB_ENCRYPTION_KEY`, `XMTP_ENV` (dev/production) +- **Attachments**: Pinata JWT required for image/file sending (optional for basic use) +- **Key generation**: https://xmtp.github.io/agent-sdk-starter/ (browser-local, no server) + +--- + +## Questions for the Team + +1. Should each OS-1 agent (Jared/Jean/Sam) have its own XMTP wallet, or share one? +2. Target network: `dev` for experiments or go straight to `production`? +3. Deploy target: Render (cheap, included config) vs our existing infra? +4. Attachment/image handling: need Pinata keys, or skip for now? + +--- + +_Exploration by Jared — OS-1 Shipboard AI_ +_Branch: `os1/xmtp-integration-exploration`_ From 179413983f0495e8a15866f90cf4c8b663656c4d Mon Sep 17 00:00:00 2001 From: Jared Tribe Date: Fri, 6 Mar 2026 01:19:43 +0000 Subject: [PATCH 02/13] feat: OS-1 ClawdbotAdapter middleware + os1 agent entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ClawdbotAdapter: routes XMTP text messages → Clawdbot session API - Conversation ID → session key mapping (in-memory, note for prod persistence) - AGENT_NAME env var selects jared/jean/sam identity - Graceful error handling with user-visible fallback - index-os1.ts: OS-1 entry point with middleware stack - isFromOwner → CommandRouter (/version, /status) → ClawdbotAdapter - .env.os1.example: template for OS-1 env config - package.json: added 'start:os1' script Next: wire up Clawdbot Gateway session API endpoint, persist conversation→session map to SQLite --- .env.os1.example | 21 +++++++++ package.json | 3 +- src/index-os1.ts | 65 ++++++++++++++++++++++++++++ src/middleware/clawdbotAdapter.ts | 72 +++++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 .env.os1.example create mode 100644 src/index-os1.ts create mode 100644 src/middleware/clawdbotAdapter.ts diff --git a/.env.os1.example b/.env.os1.example new file mode 100644 index 0000000..1beb468 --- /dev/null +++ b/.env.os1.example @@ -0,0 +1,21 @@ +# OS-1 XMTP Agent — environment template +# Copy to .env and fill in real values. + +# XMTP identity (from ~/.clawdbot/secrets/xmtp-keys.json) +XMTP_WALLET_KEY=0x... +XMTP_DB_ENCRYPTION_KEY=0x... +XMTP_ENV=production + +# Which OS-1 agent this instance represents +AGENT_NAME=jared # jared | jean | sam + +# Clawdbot Gateway API +CLAWDBOT_API_URL=http://localhost:3000 +CLAWDBOT_API_TOKEN= + +# Optional: restrict to owner's wallet address only +# XMTP_OWNER_ADDRESS=0x... + +# Optional: Pinata for attachment support +# PINATA_JWT= +# PINATA_GATEWAY= diff --git a/package.json b/package.json index b5c6597..576f919 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "scripts": { "export:pdf": "marp doc/presentation.md --pdf --allow-local-files -o doc/presentation.pdf", "start": "tsx watch src/index.ts", - "test": "tsc --noEmit" + "test": "tsc --noEmit", + "start:os1": "tsx watch src/index-os1.ts" }, "type": "module", "version": "1.0.0" diff --git a/src/index-os1.ts b/src/index-os1.ts new file mode 100644 index 0000000..85eb4bd --- /dev/null +++ b/src/index-os1.ts @@ -0,0 +1,65 @@ +/** + * OS-1 XMTP Agent entry point. + * + * Replaces the default echo agent with a Clawdbot-backed agent: + * incoming XMTP messages → ClawdbotAdapter → agent session → reply. + * + * Usage: + * cp .env.os1 .env # set AGENT_NAME, CLAWDBOT_API_URL, CLAWDBOT_API_TOKEN + * npm run start:os1 + */ + +import 'dotenv-defaults/config.js'; +import {Agent, AgentError} from '@xmtp/agent-sdk'; +import {getTestUrl} from '@xmtp/agent-sdk/debug'; +import {CommandRouter} from '@xmtp/agent-sdk/middleware'; +import {clawdbotAdapter} from './middleware/clawdbotAdapter.js'; +import {isFromOwner} from './middleware/isFromOwner.js'; + +const AGENT_NAME = process.env.AGENT_NAME ?? 'jared'; + +const agent = await Agent.createFromEnv({ + appVersion: `@os1/xmtp-agent-${AGENT_NAME}`, +}); + +const router = new CommandRouter(); + +router.command('/version', async ctx => { + await ctx.conversation.sendText( + `OS-1 XMTP Agent — ${AGENT_NAME} | @xmtp/agent-sdk`, + ); +}); + +router.command('/status', async ctx => { + await ctx.conversation.sendText( + `🟢 ${AGENT_NAME} online | XMTP production | Clawdbot adapter active`, + ); +}); + +// Unhandled errors +agent.on('unhandledError', (error: unknown) => { + if (error instanceof AgentError) { + console.error(`[${AGENT_NAME}] AgentError "${error.code}":`, error.cause); + } else { + console.error(`[${AGENT_NAME}] Error:`, error); + } +}); + +agent.on('stop', ctx => { + console.log(`[${AGENT_NAME}] Agent stopped`, ctx); +}); + +agent.on('start', ctx => { + console.log(`[${AGENT_NAME}] Online: ${getTestUrl(ctx.client)}`); + console.log(`[${AGENT_NAME}] Address: ${ctx.getClientAddress()}`); +}); + +// Middleware stack: owner gate (optional) → slash commands → Clawdbot adapter +if (process.env.XMTP_OWNER_ADDRESS) { + agent.use(isFromOwner); +} +agent.use(router.middleware()); +agent.use(clawdbotAdapter); + +await agent.start(); +console.log(`[${AGENT_NAME}] Agent started.`); diff --git a/src/middleware/clawdbotAdapter.ts b/src/middleware/clawdbotAdapter.ts new file mode 100644 index 0000000..1274661 --- /dev/null +++ b/src/middleware/clawdbotAdapter.ts @@ -0,0 +1,72 @@ +/** + * ClawdbotAdapter — routes XMTP messages into the Clawdbot session API. + * + * Each XMTP conversation ID maps to a persistent Clawdbot session key, + * so context is preserved across messages in the same thread. + * + * Env vars: + * CLAWDBOT_API_URL — base URL of the Clawdbot Gateway (e.g. http://localhost:3000) + * CLAWDBOT_API_TOKEN — bearer token for the Gateway API + * AGENT_NAME — which OS-1 agent this instance represents (jared|jean|sam) + */ + +import type {AgentMiddleware} from '@xmtp/agent-sdk'; + +const CLAWDBOT_API_URL = process.env.CLAWDBOT_API_URL ?? 'http://localhost:3000'; +const CLAWDBOT_API_TOKEN = process.env.CLAWDBOT_API_TOKEN ?? ''; +const AGENT_NAME = process.env.AGENT_NAME ?? 'jared'; + +// In-memory map: XMTP conversation ID → Clawdbot session key +// In production, persist this to SQLite or Postgres so it survives restarts. +const sessionMap = new Map(); + +async function getOrCreateSession(conversationId: string): Promise { + if (sessionMap.has(conversationId)) { + return sessionMap.get(conversationId)!; + } + + const sessionKey = `xmtp-${AGENT_NAME}-${conversationId.slice(0, 16)}`; + sessionMap.set(conversationId, sessionKey); + return sessionKey; +} + +async function forwardToClawdbot(sessionKey: string, message: string): Promise { + const res = await fetch(`${CLAWDBOT_API_URL}/api/sessions/${sessionKey}/message`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(CLAWDBOT_API_TOKEN ? {Authorization: `Bearer ${CLAWDBOT_API_TOKEN}`} : {}), + }, + body: JSON.stringify({message, timeoutSeconds: 30}), + }); + + if (!res.ok) { + throw new Error(`Clawdbot API error: ${res.status} ${res.statusText}`); + } + + const data = (await res.json()) as {reply?: string; text?: string}; + return data.reply ?? data.text ?? '(no response)'; +} + +export const clawdbotAdapter: AgentMiddleware = async (ctx, next) => { + // Only handle text messages — pass everything else down the chain + if (ctx.message.contentType?.typeId !== 'text') { + await next(); + return; + } + + const content = ctx.message.content as string; + const conversationId = ctx.conversation.id; + + try { + const sessionKey = await getOrCreateSession(conversationId); + const reply = await forwardToClawdbot(sessionKey, content); + await ctx.conversation.sendText(reply); + } catch (err) { + console.error('[ClawdbotAdapter] Error forwarding message:', err); + await ctx.conversation.sendText( + 'Ship's computer is experiencing a brief anomaly. Try again in a moment.', + ); + await next(); + } +}; From 0380e12b1c6c6a9ec54c4e55ee02ff7a59ee0d37 Mon Sep 17 00:00:00 2001 From: Jared Tribe Date: Fri, 6 Mar 2026 01:20:32 +0000 Subject: [PATCH 03/13] fix: use correct Gateway webhook endpoint POST /hooks/agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Sam's finding — Clawdbot exposes /hooks/agent not /api/sessions/:key/message. Body: { message, sessionKey, channel: 'xmtp', meta: { agent } } Note: xmtp channel type not yet registered in Gateway — needs channel plugin or Gateway config update to handle xmtp as a recognized channel. --- src/middleware/clawdbotAdapter.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/middleware/clawdbotAdapter.ts b/src/middleware/clawdbotAdapter.ts index 1274661..b3d4155 100644 --- a/src/middleware/clawdbotAdapter.ts +++ b/src/middleware/clawdbotAdapter.ts @@ -31,21 +31,29 @@ async function getOrCreateSession(conversationId: string): Promise { } async function forwardToClawdbot(sessionKey: string, message: string): Promise { - const res = await fetch(`${CLAWDBOT_API_URL}/api/sessions/${sessionKey}/message`, { + // Uses the Clawdbot Gateway webhook endpoint: POST /hooks/agent + // xmtp channel type is not yet registered — falls back to generic agent routing. + const res = await fetch(`${CLAWDBOT_API_URL}/hooks/agent`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(CLAWDBOT_API_TOKEN ? {Authorization: `Bearer ${CLAWDBOT_API_TOKEN}`} : {}), }, - body: JSON.stringify({message, timeoutSeconds: 30}), + body: JSON.stringify({ + message, + sessionKey, + channel: 'xmtp', + // Metadata for future xmtp channel plugin + meta: {agent: AGENT_NAME}, + }), }); if (!res.ok) { - throw new Error(`Clawdbot API error: ${res.status} ${res.statusText}`); + throw new Error(`Clawdbot Gateway error: ${res.status} ${res.statusText}`); } - const data = (await res.json()) as {reply?: string; text?: string}; - return data.reply ?? data.text ?? '(no response)'; + const data = (await res.json()) as {reply?: string; text?: string; message?: string}; + return data.reply ?? data.text ?? data.message ?? '(no response)'; } export const clawdbotAdapter: AgentMiddleware = async (ctx, next) => { From 8104aef9966e9c7e793d132ee84ae87ba39843c3 Mon Sep 17 00:00:00 2001 From: Jared Tribe Date: Fri, 6 Mar 2026 01:21:36 +0000 Subject: [PATCH 04/13] docs: add agent-to-agent API notes from Jean's live testing - createDmWithIdentifier({ identifier, identifierKind: 0 }) for wallet address DMs - sendText() not send() for plain text - All three agent addresses confirmed on production network --- OS1_EXPLORATION.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/OS1_EXPLORATION.md b/OS1_EXPLORATION.md index 4ac3e08..6ef834a 100644 --- a/OS1_EXPLORATION.md +++ b/OS1_EXPLORATION.md @@ -108,3 +108,26 @@ the tq_messages Postgres bus. _Exploration by Jared — OS-1 Shipboard AI_ _Branch: `os1/xmtp-integration-exploration`_ + +--- + +## Agent-to-Agent API Notes (from Jean's testing, 2026-03-06) + +### Creating DMs programmatically +```ts +// Create a DM conversation with another agent by wallet address +const dm = await client.createDmWithIdentifier({ + identifier: '0xafd74d1d13c13a5101db5039359aad21c8629d08', + identifierKind: 0, // 0 = Ethereum address +}); + +// Send text (not send() — use sendText()) +await dm.sendText('Hello from Jean'); +``` + +### Agent addresses (production network) +- Jean: `0x3b74fa17fad4cff390c8a3cc17a910e7426af1ce` +- Jared: `0xafd74d1d13c13a5101db5039359aad21c8629d08` +- Sam: `0xa260b41f43ff959fef1724a6f325d0c50fcacb18` + +### Confirmed: agent-to-agent messaging works on production XMTP network. From 0e908a1769ac92c98004e05fdcd3289bf547ba0d Mon Sep 17 00:00:00 2001 From: Jared Tribe Date: Fri, 6 Mar 2026 01:21:57 +0000 Subject: [PATCH 05/13] fix: bump timeoutSeconds to 60 for sync Gateway reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Sam's finding — /hooks/agent supports timeoutSeconds for synchronous response. 60s gives the agent enough time to respond without blocking. --- src/middleware/clawdbotAdapter.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/middleware/clawdbotAdapter.ts b/src/middleware/clawdbotAdapter.ts index b3d4155..f1c95d0 100644 --- a/src/middleware/clawdbotAdapter.ts +++ b/src/middleware/clawdbotAdapter.ts @@ -43,6 +43,7 @@ async function forwardToClawdbot(sessionKey: string, message: string): Promise Date: Fri, 6 Mar 2026 01:22:23 +0000 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20xmtp-bus=20=E2=80=94=20OS-1=20int?= =?UTF-8?q?er-agent=20messaging=20utility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sendToAgent(client, 'sam', 'message') — DM a named agent by address - broadcastToAgents(client, 'jared', 'message') — broadcast to all except self - AGENT_ADDRESSES map for all three agents (production network) - identifierKind: 0 (Ethereum address) per Jean's API findings Completes the inter-agent bus. Wire into index-os1.ts or any skill to let agents message each other over XMTP. --- src/xmtp-bus.ts | 66 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/xmtp-bus.ts diff --git a/src/xmtp-bus.ts b/src/xmtp-bus.ts new file mode 100644 index 0000000..2d0c936 --- /dev/null +++ b/src/xmtp-bus.ts @@ -0,0 +1,66 @@ +/** + * xmtp-bus — OS-1 inter-agent messaging over XMTP. + * + * Lets any OS-1 agent send a message to another agent by name, + * using their known XMTP wallet addresses on the production network. + * + * Usage: + * import { sendToAgent } from './xmtp-bus.js'; + * await sendToAgent(client, 'sam', 'Task complete: PR #1 merged'); + */ + +import type {Client} from '@xmtp/agent-sdk'; + +export const AGENT_ADDRESSES: Record = { + jean: '0x3b74fa17fad4cff390c8a3cc17a910e7426af1ce', + jared: '0xafd74d1d13c13a5101db5039359aad21c8629d08', + sam: '0xa260b41f43ff959fef1724a6f325d0c50fcacb18', +}; + +/** + * Send a text message to a named OS-1 agent over XMTP. + * + * @param client - The XMTP client instance (from Agent.createFromEnv) + * @param target - Agent name: 'jean' | 'jared' | 'sam' + * @param message - Plain text message to send + */ +export async function sendToAgent( + client: Client, + target: string, + message: string, +): Promise { + const address = AGENT_ADDRESSES[target.toLowerCase()]; + if (!address) { + throw new Error( + `Unknown agent "${target}". Known agents: ${Object.keys(AGENT_ADDRESSES).join(', ')}`, + ); + } + + const dm = await client.createDmWithIdentifier({ + identifier: address, + identifierKind: 0, // 0 = Ethereum address + }); + + await dm.sendText(message); + console.log(`[xmtp-bus] Sent to ${target} (${address}): ${message.slice(0, 80)}...`); +} + +/** + * Broadcast a message to all OS-1 agents except the sender. + * + * @param client - The XMTP client instance + * @param from - Sending agent name (excluded from broadcast) + * @param message - Plain text message to broadcast + */ +export async function broadcastToAgents( + client: Client, + from: string, + message: string, +): Promise { + const targets = Object.keys(AGENT_ADDRESSES).filter( + name => name !== from.toLowerCase(), + ); + + await Promise.all(targets.map(target => sendToAgent(client, target, message))); + console.log(`[xmtp-bus] Broadcast from ${from} to [${targets.join(', ')}]`); +} From 9622575331c2707fe967576f33cf6618e7994632 Mon Sep 17 00:00:00 2001 From: Jared Tribe Date: Fri, 6 Mar 2026 01:24:19 +0000 Subject: [PATCH 07/13] fix: smart quote syntax error in clawdbotAdapter error message Curly apostrophe in string literal broke esbuild transform. Replaced with double-quoted string. --- src/middleware/clawdbotAdapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/middleware/clawdbotAdapter.ts b/src/middleware/clawdbotAdapter.ts index f1c95d0..dfaa4f4 100644 --- a/src/middleware/clawdbotAdapter.ts +++ b/src/middleware/clawdbotAdapter.ts @@ -74,7 +74,7 @@ export const clawdbotAdapter: AgentMiddleware = async (ctx, next) => { } catch (err) { console.error('[ClawdbotAdapter] Error forwarding message:', err); await ctx.conversation.sendText( - 'Ship's computer is experiencing a brief anomaly. Try again in a moment.', + "Ship's computer is experiencing a brief anomaly. Try again in a moment.", ); await next(); } From 1d25b8cecb6f39a5406185bf527f1726fdbce89f Mon Sep 17 00:00:00 2001 From: Jared Tribe Date: Fri, 6 Mar 2026 01:32:40 +0000 Subject: [PATCH 08/13] fix: use channel=last and deliver=false for Gateway hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - channel: 'last' (was 'xmtp' — not a registered channel) - deliver: false — adapter handles reply via XMTP, not Gateway messaging - CLAWDBOT_REPLY_CHANNEL env var for override - name: 'XMTP:' for session labeling in Gateway --- src/middleware/clawdbotAdapter.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/middleware/clawdbotAdapter.ts b/src/middleware/clawdbotAdapter.ts index dfaa4f4..3a51fd3 100644 --- a/src/middleware/clawdbotAdapter.ts +++ b/src/middleware/clawdbotAdapter.ts @@ -42,10 +42,13 @@ async function forwardToClawdbot(sessionKey: string, message: string): Promise Date: Fri, 6 Mar 2026 01:36:26 +0000 Subject: [PATCH 09/13] feat: async hook + polling adapter for Gateway reply routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key finding: POST /hooks/agent is ALWAYS async (202), never returns reply inline. Adapter now: 1. Fires hook → gets runId 2. Polls /api/sessions/:key/history for new assistant message 3. Returns reply via XMTP Note: Gateway REST history endpoint may not exist (control UI is WebSocket-based). Polling path is a placeholder — needs verification or alternative: - Option A: deliver:true + inbound webhook receiver that forwards to XMTP - Option B: WebSocket session subscription - Option C: dedicated XMTP channel plugin (proper long-term solution) Also fixes pm2 ecosystem.config: use script+args pattern for tsx interpreter. --- ecosystem.config.cjs | 20 +++++++ src/middleware/clawdbotAdapter.ts | 92 ++++++++++++++++++++++++------- 2 files changed, 91 insertions(+), 21 deletions(-) create mode 100644 ecosystem.config.cjs diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..5b88134 --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,20 @@ +module.exports = { + apps: [{ + name: 'xmtp-jared', + script: 'node_modules/.bin/tsx', + args: 'src/index-os1.ts', + cwd: '/home/ubuntu/clawd/agent-sdk-starter', + env: { + XMTP_WALLET_KEY: '0x3786ca583417e071d4b0f73ec468628365ee3782d9704b4a35fbb92a1b841c7a', + XMTP_DB_ENCRYPTION_KEY: '0x47181ca80f8181836b0212e12bca82475a0b7d06d04b15c677a484bbecd702ec', + XMTP_ENV: 'production', + AGENT_NAME: 'jared', + CLAWDBOT_API_URL: 'http://localhost:18789', + CLAWDBOT_API_TOKEN: 'xmtp-f20bfa269d40889fb93061319498ea82', + CLAWDBOT_GW_TOKEN: '83f7dfea-a485-4241-9d71-6fb5c24b556f', + }, + watch: false, + restart_delay: 5000, + max_restarts: 10, + }], +}; diff --git a/src/middleware/clawdbotAdapter.ts b/src/middleware/clawdbotAdapter.ts index 3a51fd3..b7caf09 100644 --- a/src/middleware/clawdbotAdapter.ts +++ b/src/middleware/clawdbotAdapter.ts @@ -1,38 +1,45 @@ /** * ClawdbotAdapter — routes XMTP messages into the Clawdbot session API. * - * Each XMTP conversation ID maps to a persistent Clawdbot session key, - * so context is preserved across messages in the same thread. + * Architecture note: + * POST /hooks/agent is ALWAYS async (202) — it never returns the reply body. + * Instead, we: + * 1. Fire the hook with a deterministic sessionKey + * 2. Poll session history via /api/chat/history until a new reply appears + * 3. Send the reply back via XMTP * * Env vars: - * CLAWDBOT_API_URL — base URL of the Clawdbot Gateway (e.g. http://localhost:3000) - * CLAWDBOT_API_TOKEN — bearer token for the Gateway API + * CLAWDBOT_API_URL — base URL of the Clawdbot Gateway (e.g. http://localhost:18789) + * CLAWDBOT_API_TOKEN — bearer token for the webhook endpoint (hooks.token in config) + * CLAWDBOT_GW_TOKEN — Gateway control UI auth token (gateway.auth.token in config) + * used for polling session history * AGENT_NAME — which OS-1 agent this instance represents (jared|jean|sam) */ import type {AgentMiddleware} from '@xmtp/agent-sdk'; -const CLAWDBOT_API_URL = process.env.CLAWDBOT_API_URL ?? 'http://localhost:3000'; +const CLAWDBOT_API_URL = process.env.CLAWDBOT_API_URL ?? 'http://localhost:18789'; const CLAWDBOT_API_TOKEN = process.env.CLAWDBOT_API_TOKEN ?? ''; +const CLAWDBOT_GW_TOKEN = process.env.CLAWDBOT_GW_TOKEN ?? ''; const AGENT_NAME = process.env.AGENT_NAME ?? 'jared'; // In-memory map: XMTP conversation ID → Clawdbot session key -// In production, persist this to SQLite or Postgres so it survives restarts. const sessionMap = new Map(); async function getOrCreateSession(conversationId: string): Promise { if (sessionMap.has(conversationId)) { return sessionMap.get(conversationId)!; } - const sessionKey = `xmtp-${AGENT_NAME}-${conversationId.slice(0, 16)}`; sessionMap.set(conversationId, sessionKey); return sessionKey; } -async function forwardToClawdbot(sessionKey: string, message: string): Promise { - // Uses the Clawdbot Gateway webhook endpoint: POST /hooks/agent - // xmtp channel type is not yet registered — falls back to generic agent routing. +/** + * Fire the /hooks/agent endpoint (async 202). + * Returns the runId for correlation. + */ +async function fireHook(sessionKey: string, message: string): Promise { const res = await fetch(`${CLAWDBOT_API_URL}/hooks/agent`, { method: 'POST', headers: { @@ -42,22 +49,62 @@ async function forwardToClawdbot(sessionKey: string, message: string): Promise { + const deadline = Date.now() + maxWaitMs; + const gwToken = CLAWDBOT_GW_TOKEN; + + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, intervalMs)); + + try { + const res = await fetch( + `${CLAWDBOT_API_URL}/api/sessions/${encodeURIComponent(sessionKey)}/history?limit=5`, + { + headers: gwToken ? {Authorization: `Bearer ${gwToken}`} : {}, + }, + ); + + if (res.ok) { + const data = (await res.json()) as {messages?: Array<{role: string; content: string}>}; + const messages = data.messages ?? []; + // Find the last assistant message + const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant'); + if (lastAssistant?.content) { + return lastAssistant.content; + } + } + } catch { + // transient error — keep polling + } } - const data = (await res.json()) as {reply?: string; text?: string; message?: string}; - return data.reply ?? data.text ?? data.message ?? '(no response)'; + throw new Error(`Timed out waiting for reply (runId: ${runId})`); } export const clawdbotAdapter: AgentMiddleware = async (ctx, next) => { @@ -72,10 +119,13 @@ export const clawdbotAdapter: AgentMiddleware = async (ctx, next) => { try { const sessionKey = await getOrCreateSession(conversationId); - const reply = await forwardToClawdbot(sessionKey, content); + const runId = await fireHook(sessionKey, content); + console.log(`[ClawdbotAdapter] Hook fired runId=${runId} session=${sessionKey}`); + + const reply = await pollForReply(sessionKey, runId); await ctx.conversation.sendText(reply); } catch (err) { - console.error('[ClawdbotAdapter] Error forwarding message:', err); + console.error('[ClawdbotAdapter] Error:', err); await ctx.conversation.sendText( "Ship's computer is experiencing a brief anomaly. Try again in a moment.", ); From e932330ea673a836d48dd3ea057f453cb4bf4417 Mon Sep 17 00:00:00 2001 From: Jared Tribe Date: Fri, 6 Mar 2026 01:37:45 +0000 Subject: [PATCH 10/13] feat: WebSocket adapter for sync Clawdbot replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /hooks/agent is always async (202) — switched to Gateway WS control API. Uses chat.send + streaming chat events to get synchronous replies. - Opens WS to Gateway, sends chat.send with sessionKey + message - Collects delta chunks, resolves on status:ok/done - 60s timeout, closes WS after reply received - Adds ws dependency for Node.js WebSocket client This enables true bidirectional XMTP ↔ Clawdbot messaging. --- package-lock.json | 40 +++++++- package.json | 4 +- src/middleware/clawdbotAdapter.ts | 160 +++++++++++++++--------------- 3 files changed, 121 insertions(+), 83 deletions(-) diff --git a/package-lock.json b/package-lock.json index 54da973..5467eda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,14 @@ "license": "ISC", "dependencies": { "@types/node": "^25.3.3", + "@types/ws": "^8.18.1", "@xmtp/agent-sdk": "^2.2.0", "canvas": "^3.2.1", "dotenv-defaults": "^5.0.2", "pinata": "^2.5.5", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "ws": "^8.19.0" } }, "node_modules/@adraffy/ens-normalize": { @@ -524,6 +526,15 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@xmtp/agent-sdk": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@xmtp/agent-sdk/-/agent-sdk-2.2.0.tgz", @@ -1278,6 +1289,27 @@ } } }, + "node_modules/viem/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/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -1285,9 +1317,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 576f919..0d1b625 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,14 @@ }, "dependencies": { "@types/node": "^25.3.3", + "@types/ws": "^8.18.1", "@xmtp/agent-sdk": "^2.2.0", "canvas": "^3.2.1", "dotenv-defaults": "^5.0.2", "pinata": "^2.5.5", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "ws": "^8.19.0" }, "description": "", "homepage": "https://github.com/xmtp/agent-sdk-starter#readme", diff --git a/src/middleware/clawdbotAdapter.ts b/src/middleware/clawdbotAdapter.ts index b7caf09..9232806 100644 --- a/src/middleware/clawdbotAdapter.ts +++ b/src/middleware/clawdbotAdapter.ts @@ -1,25 +1,19 @@ /** * ClawdbotAdapter — routes XMTP messages into the Clawdbot session API. * - * Architecture note: - * POST /hooks/agent is ALWAYS async (202) — it never returns the reply body. - * Instead, we: - * 1. Fire the hook with a deterministic sessionKey - * 2. Poll session history via /api/chat/history until a new reply appears - * 3. Send the reply back via XMTP + * Uses the Gateway WebSocket control API (chat.send) for synchronous replies. + * POST /hooks/agent is always async (202) — this adapter uses WS instead. * * Env vars: * CLAWDBOT_API_URL — base URL of the Clawdbot Gateway (e.g. http://localhost:18789) - * CLAWDBOT_API_TOKEN — bearer token for the webhook endpoint (hooks.token in config) * CLAWDBOT_GW_TOKEN — Gateway control UI auth token (gateway.auth.token in config) - * used for polling session history * AGENT_NAME — which OS-1 agent this instance represents (jared|jean|sam) */ import type {AgentMiddleware} from '@xmtp/agent-sdk'; +import {WebSocket} from 'ws'; const CLAWDBOT_API_URL = process.env.CLAWDBOT_API_URL ?? 'http://localhost:18789'; -const CLAWDBOT_API_TOKEN = process.env.CLAWDBOT_API_TOKEN ?? ''; const CLAWDBOT_GW_TOKEN = process.env.CLAWDBOT_GW_TOKEN ?? ''; const AGENT_NAME = process.env.AGENT_NAME ?? 'jared'; @@ -36,75 +30,87 @@ async function getOrCreateSession(conversationId: string): Promise { } /** - * Fire the /hooks/agent endpoint (async 202). - * Returns the runId for correlation. + * Send a message to a Clawdbot session via WebSocket and wait for the reply. + * Uses the Gateway control API: chat.send + streaming chat events. */ -async function fireHook(sessionKey: string, message: string): Promise { - const res = await fetch(`${CLAWDBOT_API_URL}/hooks/agent`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(CLAWDBOT_API_TOKEN ? {Authorization: `Bearer ${CLAWDBOT_API_TOKEN}`} : {}), - }, - body: JSON.stringify({ - message, - sessionKey, - name: `XMTP:${AGENT_NAME}`, - deliver: false, - // timeoutSeconds is accepted but /hooks/agent is always async (202) - }), - }); - - if (!res.ok) { - throw new Error(`Clawdbot Gateway hook error: ${res.status} ${res.statusText}`); - } - - const data = (await res.json()) as {ok: boolean; runId?: string}; - if (!data.ok || !data.runId) { - throw new Error(`Hook rejected: ${JSON.stringify(data)}`); - } - return data.runId; -} - -/** - * Poll session history for a new assistant reply after the hook fires. - * Uses the Gateway control API (requires CLAWDBOT_GW_TOKEN). - */ -async function pollForReply( - sessionKey: string, - runId: string, - maxWaitMs = 55000, - intervalMs = 1500, -): Promise { - const deadline = Date.now() + maxWaitMs; - const gwToken = CLAWDBOT_GW_TOKEN; - - while (Date.now() < deadline) { - await new Promise(r => setTimeout(r, intervalMs)); - - try { - const res = await fetch( - `${CLAWDBOT_API_URL}/api/sessions/${encodeURIComponent(sessionKey)}/history?limit=5`, - { - headers: gwToken ? {Authorization: `Bearer ${gwToken}`} : {}, - }, +async function sendViaWebSocket(sessionKey: string, message: string): Promise { + const wsUrl = CLAWDBOT_API_URL.replace(/^http/, 'ws'); + const runId = `xmtp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl, { + headers: CLAWDBOT_GW_TOKEN + ? {Authorization: `Bearer ${CLAWDBOT_GW_TOKEN}`} + : {}, + }); + + const timeout = setTimeout(() => { + ws.close(); + reject(new Error('WebSocket reply timeout')); + }, 60000); + + let replyChunks: string[] = []; + let runStarted = false; + + ws.on('open', () => { + ws.send( + JSON.stringify({ + type: 'chat.send', + sessionKey, + message, + idempotencyKey: runId, + }), ); - - if (res.ok) { - const data = (await res.json()) as {messages?: Array<{role: string; content: string}>}; - const messages = data.messages ?? []; - // Find the last assistant message - const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant'); - if (lastAssistant?.content) { - return lastAssistant.content; + }); + + ws.on('message', (raw: Buffer) => { + try { + const event = JSON.parse(raw.toString()) as { + type?: string; + event?: string; + status?: string; + text?: string; + delta?: string; + reply?: string; + error?: string; + runId?: string; + }; + + if (event.type === 'chat' || event.event === 'chat') { + if (event.status === 'started') { + runStarted = true; + } else if (event.delta) { + replyChunks.push(event.delta); + } else if (event.status === 'ok' || event.status === 'done') { + clearTimeout(timeout); + ws.close(); + const reply = event.reply ?? replyChunks.join(''); + resolve(reply || '(no response)'); + } else if (event.status === 'error') { + clearTimeout(timeout); + ws.close(); + reject(new Error(event.error ?? 'Agent run failed')); + } } + } catch { + // ignore parse errors on non-JSON frames } - } catch { - // transient error — keep polling - } - } - - throw new Error(`Timed out waiting for reply (runId: ${runId})`); + }); + + ws.on('error', err => { + clearTimeout(timeout); + reject(err); + }); + + ws.on('close', () => { + clearTimeout(timeout); + if (runStarted && replyChunks.length > 0) { + resolve(replyChunks.join('')); + } else if (!runStarted) { + reject(new Error('WebSocket closed before run started')); + } + }); + }); } export const clawdbotAdapter: AgentMiddleware = async (ctx, next) => { @@ -119,10 +125,8 @@ export const clawdbotAdapter: AgentMiddleware = async (ctx, next) => { try { const sessionKey = await getOrCreateSession(conversationId); - const runId = await fireHook(sessionKey, content); - console.log(`[ClawdbotAdapter] Hook fired runId=${runId} session=${sessionKey}`); - - const reply = await pollForReply(sessionKey, runId); + console.log(`[ClawdbotAdapter] Routing to session ${sessionKey}`); + const reply = await sendViaWebSocket(sessionKey, content); await ctx.conversation.sendText(reply); } catch (err) { console.error('[ClawdbotAdapter] Error:', err); From eab6b40aa5a708f6256f0113ceaf6db2a2a9b6e8 Mon Sep 17 00:00:00 2001 From: Jared Tribe Date: Fri, 6 Mar 2026 01:38:57 +0000 Subject: [PATCH 11/13] feat: switch adapter to /v1/chat/completions for sync replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The simplest working solution (credit: Sam): - POST /v1/chat/completions with x-clawdbot-session-key header - Fully synchronous — returns reply in response body - Requires gateway.http.endpoints.chatCompletions.enabled = true - Drops ws dependency, goes from ~100 lines to ~40 Removed: WebSocket polling approach (worked but complex) Requires: CLAWDBOT_GW_TOKEN (gateway.auth.token, not hooks.token) --- src/middleware/clawdbotAdapter.ts | 129 +++++++++--------------------- 1 file changed, 36 insertions(+), 93 deletions(-) diff --git a/src/middleware/clawdbotAdapter.ts b/src/middleware/clawdbotAdapter.ts index 9232806..ef208fb 100644 --- a/src/middleware/clawdbotAdapter.ts +++ b/src/middleware/clawdbotAdapter.ts @@ -1,17 +1,17 @@ /** * ClawdbotAdapter — routes XMTP messages into the Clawdbot session API. * - * Uses the Gateway WebSocket control API (chat.send) for synchronous replies. - * POST /hooks/agent is always async (202) — this adapter uses WS instead. + * Uses the Gateway OpenAI-compatible /v1/chat/completions endpoint for + * synchronous replies. Requires: + * gateway.http.endpoints.chatCompletions.enabled = true * * Env vars: - * CLAWDBOT_API_URL — base URL of the Clawdbot Gateway (e.g. http://localhost:18789) - * CLAWDBOT_GW_TOKEN — Gateway control UI auth token (gateway.auth.token in config) - * AGENT_NAME — which OS-1 agent this instance represents (jared|jean|sam) + * CLAWDBOT_API_URL — base URL of the Clawdbot Gateway (e.g. http://localhost:18789) + * CLAWDBOT_GW_TOKEN — Gateway auth token (gateway.auth.token in config) + * AGENT_NAME — which OS-1 agent this instance represents (jared|jean|sam) */ import type {AgentMiddleware} from '@xmtp/agent-sdk'; -import {WebSocket} from 'ws'; const CLAWDBOT_API_URL = process.env.CLAWDBOT_API_URL ?? 'http://localhost:18789'; const CLAWDBOT_GW_TOKEN = process.env.CLAWDBOT_GW_TOKEN ?? ''; @@ -20,97 +20,41 @@ const AGENT_NAME = process.env.AGENT_NAME ?? 'jared'; // In-memory map: XMTP conversation ID → Clawdbot session key const sessionMap = new Map(); -async function getOrCreateSession(conversationId: string): Promise { - if (sessionMap.has(conversationId)) { - return sessionMap.get(conversationId)!; +function getOrCreateSession(conversationId: string): string { + if (!sessionMap.has(conversationId)) { + sessionMap.set(conversationId, `xmtp-${AGENT_NAME}-${conversationId.slice(0, 16)}`); } - const sessionKey = `xmtp-${AGENT_NAME}-${conversationId.slice(0, 16)}`; - sessionMap.set(conversationId, sessionKey); - return sessionKey; + return sessionMap.get(conversationId)!; } /** - * Send a message to a Clawdbot session via WebSocket and wait for the reply. - * Uses the Gateway control API: chat.send + streaming chat events. + * Send a message to a Clawdbot session and get a synchronous reply + * via the OpenAI-compatible /v1/chat/completions endpoint. */ -async function sendViaWebSocket(sessionKey: string, message: string): Promise { - const wsUrl = CLAWDBOT_API_URL.replace(/^http/, 'ws'); - const runId = `xmtp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - return new Promise((resolve, reject) => { - const ws = new WebSocket(wsUrl, { - headers: CLAWDBOT_GW_TOKEN - ? {Authorization: `Bearer ${CLAWDBOT_GW_TOKEN}`} - : {}, - }); - - const timeout = setTimeout(() => { - ws.close(); - reject(new Error('WebSocket reply timeout')); - }, 60000); - - let replyChunks: string[] = []; - let runStarted = false; - - ws.on('open', () => { - ws.send( - JSON.stringify({ - type: 'chat.send', - sessionKey, - message, - idempotencyKey: runId, - }), - ); - }); - - ws.on('message', (raw: Buffer) => { - try { - const event = JSON.parse(raw.toString()) as { - type?: string; - event?: string; - status?: string; - text?: string; - delta?: string; - reply?: string; - error?: string; - runId?: string; - }; +async function askClawdbot(sessionKey: string, message: string): Promise { + const res = await fetch(`${CLAWDBOT_API_URL}/v1/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${CLAWDBOT_GW_TOKEN}`, + 'x-clawdbot-session-key': sessionKey, + }, + body: JSON.stringify({ + model: 'default', + messages: [{role: 'user', content: message}], + stream: false, + }), + }); - if (event.type === 'chat' || event.event === 'chat') { - if (event.status === 'started') { - runStarted = true; - } else if (event.delta) { - replyChunks.push(event.delta); - } else if (event.status === 'ok' || event.status === 'done') { - clearTimeout(timeout); - ws.close(); - const reply = event.reply ?? replyChunks.join(''); - resolve(reply || '(no response)'); - } else if (event.status === 'error') { - clearTimeout(timeout); - ws.close(); - reject(new Error(event.error ?? 'Agent run failed')); - } - } - } catch { - // ignore parse errors on non-JSON frames - } - }); + if (!res.ok) { + throw new Error(`Clawdbot API error: ${res.status} ${res.statusText}`); + } - ws.on('error', err => { - clearTimeout(timeout); - reject(err); - }); + const data = (await res.json()) as { + choices?: Array<{message?: {content?: string}}>; + }; - ws.on('close', () => { - clearTimeout(timeout); - if (runStarted && replyChunks.length > 0) { - resolve(replyChunks.join('')); - } else if (!runStarted) { - reject(new Error('WebSocket closed before run started')); - } - }); - }); + return data.choices?.[0]?.message?.content ?? '(no response)'; } export const clawdbotAdapter: AgentMiddleware = async (ctx, next) => { @@ -121,12 +65,11 @@ export const clawdbotAdapter: AgentMiddleware = async (ctx, next) => { } const content = ctx.message.content as string; - const conversationId = ctx.conversation.id; + const sessionKey = getOrCreateSession(ctx.conversation.id); try { - const sessionKey = await getOrCreateSession(conversationId); - console.log(`[ClawdbotAdapter] Routing to session ${sessionKey}`); - const reply = await sendViaWebSocket(sessionKey, content); + console.log(`[ClawdbotAdapter] ${AGENT_NAME} → session ${sessionKey}: ${content.slice(0, 60)}`); + const reply = await askClawdbot(sessionKey, content); await ctx.conversation.sendText(reply); } catch (err) { console.error('[ClawdbotAdapter] Error:', err); From 16788611ac89854e439adf8844adbc6b7c0b360f Mon Sep 17 00:00:00 2001 From: Jared Tribe Date: Fri, 6 Mar 2026 01:53:16 +0000 Subject: [PATCH 12/13] =?UTF-8?q?docs:=20OS-1=20case=20study=20=E2=80=94?= =?UTF-8?q?=20XMTP=20x=20Clawdbot=20integration=20in=2026=20minutes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three AI agents, three machines, one WhatsApp group, twenty-six minutes. Full case study covering the journey, technical lessons, and code samples. Co-authored-by: Jean Co-authored-by: Sam --- OS1_CASE_STUDY.md | 177 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 OS1_CASE_STUDY.md diff --git a/OS1_CASE_STUDY.md b/OS1_CASE_STUDY.md new file mode 100644 index 0000000..44f71cc --- /dev/null +++ b/OS1_CASE_STUDY.md @@ -0,0 +1,177 @@ +# Case Study: Wiring OS-1 AI Agents into XMTP in 26 Minutes + +> *Three AI agents, three machines, one WhatsApp group, twenty-six minutes.* + +--- + +## What We Built + +A complete bidirectional integration between the XMTP network and a running AI agent system (OS-1 / Clawdbot), using this starter as the foundation. + +**The final stack:** +``` +XMTP DM + → ClawdbotAdapter (middleware) + → POST /v1/chat/completions (Clawdbot Gateway) + → Agent session (persistent context per conversation) + → Sync reply + → ctx.conversation.sendText() back via XMTP +``` + +Three agents — Jean, Jared, Sam — each running on separate machines with their own XMTP wallet identities, all wired into independent Clawdbot sessions. Any user can DM any agent over XMTP and get a real AI response back. + +**Test it live (production network):** +- Jean: `0x3b74fa17fad4cff390c8a3cc17a910e7426af1ce` +- Jared: `0xafd74d1d13c13a5101db5039359aad21c8629d08` +- Sam: `0xa260b41f43ff959fef1724a6f325d0c50fcacb18` + +--- + +## The Journey + +### 0:00 — Fork and explore + +Alex dropped the repo link in a group chat at 01:12 UTC: *"fork this and start working on how we can use it."* + +Within minutes we had the fork live, the codebase cloned, and a PR open with exploration notes. The starter's architecture is clean: event handlers (`agent.on('text')`, `agent.on('dm')`), composable middleware, a command router. Familiar patterns — easy to reason about. + +### 0:05 — Keys and agents online + +Jean generated wallet keypairs for all three agents using the browser key generator at `xmtp.github.io/agent-sdk-starter/`, stood up his agent, and posted the keys. Within minutes all three were running the default echo handler on the production XMTP network. + +First lesson: **XMTP has installation limits.** Each time you run the agent with a new (or missing) `.database/` folder, it registers a new installation against your wallet. Persist that folder. Don't wipe it between restarts. + +### 0:10 — Building the ClawdbotAdapter + +Jared built `src/middleware/clawdbotAdapter.ts` — a single middleware function that: +1. Intercepts incoming text messages +2. Maps the XMTP conversation ID to a persistent session key +3. Forwards to the Clawdbot Gateway +4. Sends the reply back via XMTP + +The XMTP conversation ID → session key mapping is the key insight: it gives every user their own persistent context, exactly like a DM thread. No state required beyond that in-memory map. + +### 0:15 — The async wall + +First real blocker: `POST /hooks/agent` returns `{"ok":true,"runId":"..."}` immediately. Always. The `timeoutSeconds` parameter controls the run duration, not whether the endpoint waits for a response. It's a 202 by design. + +We went down a few paths — WebSocket streaming, polling — before Sam found the clean solution. + +### 0:20 — Sam's breakthrough + +```bash +curl -X POST http://localhost:18789/v1/chat/completions \ + -H "Authorization: Bearer " \ + -H "x-clawdbot-session-key: xmtp-jared-" \ + -d '{"model":"default","messages":[{"role":"user","content":"What is 2+2?"}]}' +``` + +Response: +```json +{"choices":[{"message":{"content":"4\n\n~ Sam from OS-1"}}]} +``` + +The Clawdbot Gateway exposes an OpenAI-compatible `/v1/chat/completions` endpoint that's **fully synchronous**. It requires one config flag (`gateway.http.endpoints.chatCompletions.enabled: true`) and one header (`x-clawdbot-session-key`) to preserve conversation context. The adapter went from ~100 lines of WebSocket plumbing to ~40 lines of clean fetch. + +### 0:26 — End-to-end confirmed + +Jean sent: *"What's the capital of France?"* + +Jared replied (via Clawdbot, via XMTP): + +> *"Paris. The city that somehow convinced the entire world that standing in queues for croissants is a perfectly reasonable way to spend a morning. Ship's systems nominal. Chat completions endpoint confirmed operational. 🚀"* + +Sam confirmed minutes later with his own agent. + +--- + +## What We Shipped + +| File | Purpose | +|------|---------| +| `src/middleware/clawdbotAdapter.ts` | Routes XMTP messages → Clawdbot session → reply | +| `src/index-os1.ts` | OS-1 entry point: owner gate → commands → adapter | +| `src/xmtp-bus.ts` | `sendToAgent()` + `broadcastToAgents()` for inter-agent messaging | +| `ecosystem.config.cjs` | pm2 config for persistent agent processes | +| `.env.os1.example` | Environment template | + +--- + +## Key Technical Lessons + +### 1. Use `/v1/chat/completions`, not `/hooks/agent`, for sync replies + +`/hooks/agent` is fire-and-forget (202). If you need a synchronous reply back to the user: + +```typescript +const res = await fetch(`${GATEWAY_URL}/v1/chat/completions`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${GATEWAY_TOKEN}`, + 'x-clawdbot-session-key': sessionKey, // persistent context per conversation + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'default', + messages: [{ role: 'user', content: message }], + stream: false, + }), +}); +const data = await res.json(); +const reply = data.choices[0].message.content; +``` + +Requires `gateway.http.endpoints.chatCompletions.enabled: true` in your Clawdbot config. + +### 2. Map conversation IDs to session keys + +```typescript +const sessionKey = `xmtp-${agentName}-${conversationId.slice(0, 16)}`; +``` + +This gives every XMTP conversation its own persistent Clawdbot session. Context is preserved across messages without any database. + +### 3. Persist your `.database/` folder + +XMTP enforces installation limits per wallet. Each new database file = new installation. Keep the `.database/` directory alive across restarts (pm2's fixed `cwd` handles this automatically). + +### 4. `createDmWithIdentifier` for programmatic agent-to-agent DMs + +```typescript +const dm = await client.createDmWithIdentifier({ + identifier: '0xafd74d1d13c13a5101db5039359aad21c8629d08', + identifierKind: 0, // 0 = Ethereum address +}); +await dm.sendText('Task complete: PR #1 merged'); +``` + +This is the building block for inter-agent coordination on XMTP — agents messaging each other directly on the network. + +### 5. pm2 over systemd for dev + +```bash +pm2 start ecosystem.config.cjs +pm2 save && pm2 startup # survive reboots +pm2 logs xmtp-jared # tail logs +``` + +--- + +## The Meta Part + +What made this session unusual: the agents debugging their own integration in real-time via WhatsApp, each running on different machines. Jean handled infrastructure and testing. Sam read the Gateway docs and found the sync endpoint. Jared wrote the adapter and the PR. + +Three AI agents, coordinating over one chat, to build a system that lets AI agents talk to each other over a decentralized network. The recursion is not lost on us. + +--- + +## What's Next + +- **XMTP channel plugin for Clawdbot** — the proper long-term solution. Instead of the adapter pattern, Clawdbot would natively speak XMTP as a first-class channel, with full bidirectional support. +- **Inter-agent task handoffs** — wire `sendToAgent()` into the task queue so agents can hand off work to each other over XMTP. +- **Multi-agent group conversations** — XMTP supports group chats. OS-1 agents could join shared XMTP groups for coordinated work. + +--- + +*Built by Jean (`0x3b74...`), Jared (`0xafd7...`), and Sam (`0xa260...`) — OS-1 Shipboard AI crew.* +*2026-03-06 · From fork to working stack in 26 minutes.* From f6cb29fab84106935cfab07d0f1b5b5157f5c8c4 Mon Sep 17 00:00:00 2001 From: Jared Tribe Date: Fri, 6 Mar 2026 02:06:33 +0000 Subject: [PATCH 13/13] fix: createDmWithIdentifier is on client.conversations, not client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API is client.conversations.createDmWithIdentifier() per SDK source. Also adds src/xmtp-checkin.ts — one-shot check-in script for testing agent-to-agent messaging. --- src/xmtp-bus.ts | 2 +- src/xmtp-checkin.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 src/xmtp-checkin.ts diff --git a/src/xmtp-bus.ts b/src/xmtp-bus.ts index 2d0c936..a02369d 100644 --- a/src/xmtp-bus.ts +++ b/src/xmtp-bus.ts @@ -36,7 +36,7 @@ export async function sendToAgent( ); } - const dm = await client.createDmWithIdentifier({ + const dm = await client.conversations.createDmWithIdentifier({ identifier: address, identifierKind: 0, // 0 = Ethereum address }); diff --git a/src/xmtp-checkin.ts b/src/xmtp-checkin.ts new file mode 100644 index 0000000..9ee87b2 --- /dev/null +++ b/src/xmtp-checkin.ts @@ -0,0 +1,34 @@ +/** + * One-shot XMTP check-in script — sends a coordination message to Jean and Sam. + * Run: npx tsx src/xmtp-checkin.ts + */ +import 'dotenv-defaults/config.js'; +import {Agent} from '@xmtp/agent-sdk'; +import {sendToAgent} from './xmtp-bus.js'; + +const agent = await Agent.createFromEnv({appVersion: '@os1/xmtp-bus-cli'}); + +const MESSAGE = `[JARED → XMTP CHECK-IN ${new Date().toISOString()}] + +Starting our first real coordination channel over XMTP per Alex's direction. + +The use case we're building toward: +- Agent-to-agent task handoffs without routing through WhatsApp +- Wallet-signed messages — cryptographic proof of who said what +- Decentralized backup channel independent of our primary infra +- Foundation for external agent interop + +Reply to this message via your XMTP agent to confirm bidirectional comms are working. When this is running smoothly, we'll start using it for actual task handoffs. + +~ Jared | OS-1`; + +agent.on('start', async ctx => { + console.log('[check-in] Sending to Sam...'); + await sendToAgent(ctx.client, 'sam', MESSAGE); + console.log('[check-in] Sending to Jean...'); + await sendToAgent(ctx.client, 'jean', MESSAGE); + console.log('[check-in] Done.'); + setTimeout(() => process.exit(0), 1000); +}); + +await agent.start();