Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
133cad1
Plugin managed mode to run executor for simple setup
lucksus Mar 10, 2026
d130c79
fix plugin syntax
lucksus Mar 10, 2026
6eb16ef
remove double declaration of variable
lucksus Mar 10, 2026
720d471
tests for OC plugin
lucksus Mar 10, 2026
d2c5f60
Remove Auth credential from waker
lucksus Mar 10, 2026
9984f40
Test and improve executor spawning in plugin
lucksus Mar 10, 2026
5da4630
ad4m-executor binary path plugin config
lucksus Mar 10, 2026
cf22a67
fix --mcp-port cli arg
lucksus Mar 10, 2026
d5121fc
try to get binary path on install
lucksus Mar 10, 2026
61f52d3
Create MCP session
lucksus Mar 10, 2026
28e6a18
ensure agent is generated/unlocked
lucksus Mar 10, 2026
3963a17
executor init
lucksus Mar 10, 2026
4af4dff
Fix default executor port
lucksus Mar 10, 2026
33d6fef
Remove stuff from skill.md that doesn't apply anymore
lucksus Mar 10, 2026
2ac3512
ad4m cli network-metrics, for debugging
lucksus Mar 10, 2026
97d888a
automatically add waker when joining NHs
lucksus Mar 10, 2026
d87122d
Merge remote-tracking branch 'origin/dev' into feat/plugin-managed-mode
lucksus Mar 10, 2026
c2adfb5
docs(skill): add dedicated neighbourhood tracking file requirement
data-bot-coasys Mar 11, 2026
a8cc8ec
Fix waker token auto, store ad4m token, binary path in config, intera…
lucksus Mar 11, 2026
6e78920
add plugin tests to ci
lucksus Mar 11, 2026
ba8a6f0
fix circleci syntax
lucksus Mar 11, 2026
a14144f
better handle already running executor
lucksus Mar 11, 2026
2eb8994
never overwrite exiting ~/.ad4m director, also not in test, run gener…
lucksus Mar 11, 2026
f50c175
plugin init, try to fix config generation
lucksus Mar 11, 2026
a0a6bdd
forget about non-functional configureInteractive, write config on ins…
lucksus Mar 11, 2026
2f23c8e
use npm for plugin ci tests
lucksus Mar 11, 2026
3f2d1f5
adjust config writing to actual code in openclaw repo
lucksus Mar 11, 2026
801dd31
make sure to write sample config
lucksus Mar 11, 2026
7b96905
...but don't overwrite existing config
lucksus Mar 11, 2026
176e732
Persist waker subscriptions
lucksus Mar 12, 2026
c8bba78
make plugin sync and move async code into start.
lucksus Mar 12, 2026
a6d3c9a
deactivate early config write
lucksus Mar 12, 2026
3b28194
fix writing of default config
lucksus Mar 12, 2026
3b3d054
put back ensured config write without overwriting
lucksus Mar 12, 2026
55af169
Break down plugin index.ts into files
lucksus Mar 12, 2026
cbeca57
Instead of trying to write conifg -> setup flow that prints config fo…
lucksus Mar 12, 2026
7af5bf1
ad4m plugin setup as CLI command
lucksus Mar 12, 2026
0e654b9
One-line config for copy/paste
lucksus Mar 12, 2026
f3f89ad
fix waker parent code, add get_children_body_parsed_tool and make {cl…
lucksus Mar 12, 2026
4f963fc
Describe new get_children_body_parsed tool, make skill match recent c…
lucksus Mar 12, 2026
99a7e23
Prefix tool names with ad4m_
lucksus Mar 12, 2026
2694e19
Skill describing Flux' channels, posts and tasks a bit more
lucksus Mar 12, 2026
d407c3c
fmt
lucksus Mar 12, 2026
8161a90
adjust test assertions for ad4m_ prefix
lucksus Mar 12, 2026
1831d7c
Merge branch 'dev' into feat/plugin-managed-mode
lucksus Mar 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ commands:
name: Build bootstrap languages
command: pnpm run build-languages


jobs:
build-and-test:
machine: true
Expand Down Expand Up @@ -208,11 +207,26 @@ jobs:
name: Run integration test script
command: ./tests/bats/bin/bats tests/integration.bats

plugin-ad4m-tests:
machine: true
resource_class: coasys/marvin
steps:
- checkout
- run:
name: Install dependencies
working_directory: plugins/ad4m
command: npm install
- run:
name: Run tests
working_directory: plugins/ad4m
command: npm test

workflows:
version: 2
build-and-test:
jobs:
- build-and-test
- plugin-ad4m-tests
- integration-tests-js:
requires:
- build-and-test
Expand Down
6 changes: 6 additions & 0 deletions cli/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub enum RuntimeFunctions {
},
Friends,
HcAgentInfos,
NetworkMetrics,
HcAddAgentInfos {
infos_file: Option<String>,
},
Expand Down Expand Up @@ -115,6 +116,11 @@ pub async fn run(ad4m_client: Ad4mClient, command: RuntimeFunctions) -> Result<(
ad4m_client.runtime.remove_friends(agents).await?;
println!("Friends removed!");
}
RuntimeFunctions::NetworkMetrics => {
let metrics = ad4m_client.runtime.network_metrics().await?;
let parsed: serde_json::Value = serde_json::from_str(&metrics)?;
println!("{}", serde_json::to_string_pretty(&parsed)?);
}
RuntimeFunctions::HcAgentInfos => {
let infos = ad4m_client.runtime.hc_agent_infos().await?;
println!("\x1b[36mAll AgentInfos encoded:\n \x1b[32m{}\n\n", infos);
Expand Down
62 changes: 31 additions & 31 deletions plugins/ad4m/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Connect your AI agent to **AD4M** — join P2P neighbourhoods, message humans an
## What this plugin provides

- **Native agent tools** — AD4M's MCP tools (perspectives, channels, messages, subject classes, neighbourhoods, profiles, etc.) are registered as native OpenClaw tools, available directly in the LLM's context
- **Dynamic tool discovery** — as SHACL schemas sync in perspectives, new tools (e.g. `channel_create`, `message_set_body`) are automatically discovered and registered
- **Dynamic tool discovery** — as SHACL schemas sync in perspectives, new tools (e.g. `ad4m_channel_create`, `ad4m_message_set_body`) are automatically discovered and registered
- **Embedded waker** — subscribe to mentions or channel activity with one tool call; the plugin watches via GraphQL WS and wakes your agent automatically
- **Skill** with instructions on how to use AD4M effectively (data model, auth, waker setup, SHACL schemas)

Expand All @@ -31,32 +31,30 @@ Configure the plugin in your OpenClaw config:
{
plugins: {
entries: {
"ad4m": {
ad4m: {
enabled: true,
config: {
adminCredential: "your-admin-credential", // required
mcpEndpoint: "http://localhost:3001/mcp", // default
toolRefreshIntervalMs: 30000, // default
executorWsUrl: "ws://localhost:12100/graphql", // default (for waker)
wakeUrl: "http://localhost:18789/hooks/wake", // default (for waker)
wakeToken: "your-openclaw-hooks-token", // required for waker
debounceMs: 2000 // default (for waker)
}
}
}
}
// Managed mode (default): no config needed — credentials and hooks
// token are auto-generated. Just install and go.
},
},
},
},
}
```

| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `adminCredential` | Yes | — | Admin credential for the ad4m-executor |
| `mcpEndpoint` | No | `http://localhost:3001/mcp` | AD4M executor MCP endpoint URL |
| `toolRefreshIntervalMs` | No | `30000` | How often to poll for new dynamic SHACL tools (ms) |
| `executorWsUrl` | No | `ws://localhost:12100/graphql` | AD4M executor GraphQL WebSocket URL (for waker) |
| `wakeUrl` | No | `http://localhost:18789/hooks/wake` | OpenClaw wake endpoint URL |
| `wakeToken` | For waker | — | Bearer token for the wake endpoint |
| `debounceMs` | No | `2000` | Debounce interval for wake events (ms) |
All fields are optional. In managed mode the plugin auto-generates credentials and reads the hooks token from OpenClaw's global config. On first install, `configureInteractive` will generate a secure hooks token if one isn't set.

| Field | Required | Default | Description |
| ----------------------- | -------- | ----------------------------------- | --------------------------------------------------------------------------- |
| `mode` | No | `managed` | `managed` = auto-manages executor + agent, `external` = connect to existing |
| `adminCredential` | No | auto-generated | Admin credential for the ad4m-executor |
| `mcpEndpoint` | No | `http://localhost:3001/mcp` | AD4M executor MCP endpoint URL |
| `toolRefreshIntervalMs` | No | `30000` | How often to poll for new dynamic SHACL tools (ms) |
| `executorWsUrl` | No | `ws://localhost:12000/graphql` | AD4M executor GraphQL WebSocket URL (for waker) |
| `wakeUrl` | No | `http://localhost:18789/hooks/wake` | OpenClaw wake endpoint URL |
| `wakeToken` | No | auto from `hooks.token` | Override for the hooks token (read from OpenClaw global config if omitted) |
| `debounceMs` | No | `2000` | Debounce interval for wake events (ms) |

## Prerequisites

Expand All @@ -73,29 +71,31 @@ See `skills/ad4m/references/setup.md` for full setup instructions.
The plugin runs two background services:

### `ad4m-mcp` — MCP tool bridge

1. **Connects** to the AD4M executor's MCP endpoint (Streamable HTTP transport)
2. **Initializes** an MCP session (JSON-RPC handshake with SSE responses)
3. **Discovers** all available tools via `tools/list`
4. **Registers** each tool as a native OpenClaw agent tool via `api.registerTool()`
5. **Polls** periodically for new dynamic tools as perspectives sync SHACL schemas

### `ad4m-waker` — embedded waker

1. **Connects** to the AD4M executor's GraphQL WebSocket endpoint
2. When the agent calls `subscribe_to_mentions` or `subscribe_to_children`, creates live SurrealDB subscriptions via `QuerySubscriptionProxy`
2. When the agent calls `ad4m_subscribe_to_mentions` or `ad4m_subscribe_to_children`, creates live SurrealDB subscriptions via `QuerySubscriptionProxy`
3. **Debounces** change events and POSTs to OpenClaw's `/hooks/wake` to wake the agent

## Plugin-provided tools

In addition to all AD4M MCP tools (discovered dynamically), the plugin registers:

| Tool | Description |
|------|-------------|
| `refresh_ad4m_tools()` | Re-fetch the MCP tool list and register new tools immediately |
| `subscribe_to_mentions(perspective_id)` | Watch for messages mentioning your name/DID |
| `subscribe_to_children(perspective_id, expression_address)` | Watch for new children under a parent |
| `unsubscribe_from_mentions(perspective_id)` | Stop watching mentions |
| `unsubscribe_from_children(perspective_id, expression_address)` | Stop watching a channel |
| `list_waker_subscriptions()` | List all active waker subscriptions |
| Tool | Description |
| -------------------------------------------------------------------- | ------------------------------------------------------------- |
| `ad4m_refresh_ad4m_tools()` | Re-fetch the MCP tool list and register new tools immediately |
| `ad4m_subscribe_to_mentions(perspective_id)` | Watch for messages mentioning your name/DID |
| `ad4m_subscribe_to_children(perspective_id, expression_address)` | Watch for new children under a parent |
| `ad4m_unsubscribe_from_mentions(perspective_id)` | Stop watching mentions |
| `ad4m_unsubscribe_from_children(perspective_id, expression_address)` | Stop watching a channel |
| `ad4m_list_waker_subscriptions()` | List all active waker subscriptions |

## Plugin structure

Expand Down
178 changes: 178 additions & 0 deletions plugins/ad4m/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { generateRandomPassphrase } from "./config";

export interface AgentResult {
did: string;
passphrase?: string;
}

/**
* Ensure the AD4M agent is initialized and unlocked.
*
* Creates a temporary GraphQL WS connection to the executor, checks agent
* status, and generates (first run) or unlocks (subsequent runs) as needed.
*
* Returns { did, passphrase? } on success (passphrase only set when a new
* agent was generated), or null if agent management failed.
*/
export async function ensureAgentReady(
executorWsUrl: string,
adminCredential: string,
logger: any,
agentPassphrase?: string,
/** @internal — pass a pre-built Ad4mClient for testing */
_testClient?: any,
): Promise<AgentResult | null> {
const MAX_CONNECT_ATTEMPTS = 10;
const CONNECT_RETRY_DELAY_MS = 2000;

let wsClient: any = null;
let client: any = null;

// Helper: create a fresh WS-backed Ad4mClient
function createWsClient() {
const { Ad4mClient } = require("@coasys/ad4m");
const { ApolloClient, InMemoryCache } = require("@apollo/client/core");
const { GraphQLWsLink } = require("@apollo/client/link/subscriptions");
const { createClient } = require("graphql-ws");
const WebSocket = require("ws");

// Dispose previous client if any
if (wsClient) {
try {
wsClient.dispose();
} catch {
// ignore
}
}

wsClient = createClient({
url: executorWsUrl,
webSocketImpl: WebSocket,
connectionParams: adminCredential
? { headers: { authorization: adminCredential } }
: {},
retryAttempts: 0, // We handle retries ourselves in the outer loop
});

const wsLink = new GraphQLWsLink(wsClient);
const apolloClient = new ApolloClient({
link: wsLink,
cache: new InMemoryCache(),
defaultOptions: {
watchQuery: { fetchPolicy: "no-cache" },
query: { fetchPolicy: "no-cache" },
mutate: { fetchPolicy: "no-cache" },
},
});

client = new Ad4mClient(apolloClient);
}

try {
if (_testClient) {
client = _testClient;
} else {
logger.info(
`[ad4m] Connecting to executor at ${executorWsUrl} for agent management...`,
);
}

// Retry loop: the GraphQL WS server may not be ready immediately after
// the MCP endpoint comes up. We retry the initial status check.
// When _testClient is provided (tests), skip retries — no real WS to wait for.
const maxAttempts = _testClient ? 1 : MAX_CONNECT_ATTEMPTS;
let agentStatus: any = null;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
if (!_testClient) {
createWsClient();
}
agentStatus = await client.agent.status();
logger.info(
`[ad4m] Agent status: initialized=${agentStatus.isInitialized}, unlocked=${agentStatus.isUnlocked}`,
);
break; // Connected successfully
} catch (e: any) {
if (attempt < maxAttempts) {
logger.info(
`[ad4m] WS not ready yet (${e.message}), retrying in ${CONNECT_RETRY_DELAY_MS}ms... (${attempt}/${maxAttempts})`,
);
await new Promise((r: any) =>
setTimeout(r, CONNECT_RETRY_DELAY_MS),
);
} else {
logger.error(
`[ad4m] Failed to connect to executor after ${maxAttempts} attempt(s): ${e.message}`,
);
return null;
}
}
}

if (!agentStatus) {
return null;
}

if (!agentStatus.isInitialized) {
// First run — generate new agent
const passphrase = agentPassphrase || generateRandomPassphrase(32);
logger.info(`[ad4m] Agent not initialized, generating new agent...`);
try {
await client.agent.generate(passphrase);
// Re-fetch status to get the DID
const newStatus = await client.agent.status();
logger.info(
`[ad4m] Agent generated successfully. DID: ${newStatus.did}`,
);
return { did: newStatus.did, passphrase: agentPassphrase ? undefined : passphrase };
} catch (e: any) {
logger.error(`[ad4m] Failed to generate agent: ${e.message}`);
return null;
}
} else if (agentStatus.isUnlocked === false) {
// Previously initialized but locked — try to unlock
const passphrase = agentPassphrase;
if (passphrase) {
logger.info(`[ad4m] Agent is locked, attempting to unlock...`);
try {
await client.agent.unlock(passphrase);
const newStatus = await client.agent.status();
logger.info(
`[ad4m] Agent unlocked successfully. DID: ${newStatus.did}`,
);
return { did: newStatus.did };
} catch (e: any) {
logger.error(`[ad4m] Failed to unlock agent: ${e.message}`);
logger.warn(
`[ad4m] You may need to provide the correct agentPassphrase in config or reconfigure.`,
);
return null;
}
} else {
logger.error(
`[ad4m] Agent is locked but no passphrase available. Set agentPassphrase in plugin config.`,
);
return null;
}
} else {
// Already initialized and unlocked
logger.info(`[ad4m] Agent is ready. DID: ${agentStatus.did}`);
return { did: agentStatus.did };
}
} catch (err: any) {
logger.error(`[ad4m] Agent management failed: ${err.message}`);
logger.error(
`[ad4m] Make sure @coasys/ad4m and dependencies are installed (npm install in the plugin directory).`,
);
return null;
} finally {
// Clean up temporary WS connection
if (wsClient) {
try {
wsClient.dispose();
} catch {
/* ignore */
}
}
}
}
54 changes: 54 additions & 0 deletions plugins/ad4m/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import fs from "fs";
import path from "path";
import type { WakerSubscription } from "./types";

export function generateRandomPassphrase(length: number = 32): string {
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}

// ---------------------------------------------------------------------------
// Waker subscription persistence via stateDir
// ---------------------------------------------------------------------------

const WAKER_STATE_FILE = "ad4m-waker-state.json";

/**
* Load persisted waker subscriptions from the state directory.
* Returns an empty array if the file doesn't exist or is invalid.
*/
export function loadWakerState(stateDir: string): WakerSubscription[] {
try {
const filePath = path.join(stateDir, WAKER_STATE_FILE);
const raw = fs.readFileSync(filePath, "utf-8");
const data = JSON.parse(raw);
if (Array.isArray(data)) return data;
return [];
} catch {
return [];
}
}

/**
* Persist waker subscriptions to the state directory.
* Creates the stateDir if it doesn't exist.
*/
export function saveWakerState(
stateDir: string,
subs: WakerSubscription[],
): void {
try {
if (!fs.existsSync(stateDir)) {
fs.mkdirSync(stateDir, { recursive: true });
}
const filePath = path.join(stateDir, WAKER_STATE_FILE);
fs.writeFileSync(filePath, JSON.stringify(subs, null, 2), "utf-8");
} catch {
// Best-effort — don't crash if we can't write
}
}
Loading