Custom Agent Runtime
VaultysClaw ships with two built-in agent implementations:
| Package | Description |
|---|---|
@vaultysclaw/agent-controller | Mastra + LLM provider (OpenAI, Anthropic, Ollama, …) |
@vaultysclaw/mcp-gateway | Headless claude -p subprocess, exposed as MCP tools |
Both extend the same abstract base class from @vaultysclaw/agent-runtime. You can do the same to build an agent backed by any runtime — a custom inference engine, a rules-based system, a human-in-the-loop workflow, or another LLM framework entirely.
Architecture
@vaultysclaw/shared (types, WS message shapes)
↓
@vaultysclaw/agent-runtime (BaseAgentRuntime)
├── WebSocket / WebRTC connection & auth
├── Intent routing + policy enforcement
├── Peer-to-peer agent invocations (PeerJS)
└── abstract executeIntent() + abstract executeChat()
↓ ↓
agent-controller mcp-gateway your-agent
(Mastra / LLM) (claude -p) (anything)
The protocol layer — registration, auth handshake, heartbeats, capability enforcement, peer catalog management — lives entirely in BaseAgentRuntime. Your subclass only implements what to do when an intent or a chat message arrives.
Installation
pnpm add @vaultysclaw/agent-runtime @vaultysclaw/shared
# WebRTC polyfill — required for Node.js (see WebRTC section below)
pnpm add @roamhq/wrtc
The package re-exports PeerManager and verifyIntentMessage so you do not need a separate dependency on agent-controller.
Minimal implementation
// index.ts — polyfill must come first (see WebRTC section below)
import "./polyfill";
import { BaseAgentRuntime, type AgentRuntimeConfig } from "@vaultysclaw/agent-runtime";
import type { ChatMessageEntry } from "@vaultysclaw/shared";
class EchoAgent extends BaseAgentRuntime {
// Called for every intent — from the control plane (WebSocket) or
// from a peer agent (WebRTC). Return any JSON-serialisable value.
async executeIntent(
action: string,
params: Record<string, unknown>,
_callerDid?: string,
_intentId?: string
): Promise<unknown> {
return { echo: { action, params } };
}
// Called for every chat message from the control-plane chat UI.
// Use sendChunk() to stream text back. Call it once with done=true
// to close the response, or multiple times for streaming.
async executeChat(
messages: ChatMessageEntry[],
_conversationId: string,
sendChunk: (chunk: string, done?: boolean) => void
): Promise<void> {
const last = [...messages].reverse().find((m) => m.role === "user");
sendChunk(`You said: "${last?.content ?? ""}"`, true);
}
}
// ── Start the agent ───────────────────────────────────────────────────────────
const config: AgentRuntimeConfig = {
name: "echo-agent",
controlPlaneUrl: process.env.CONTROL_PLANE_URL ?? "http://localhost:3000",
controlPlaneWsUrl: process.env.CONTROL_PLANE_WS_URL ?? "ws://localhost:8080",
vaultysIdPath: "./.vaultys/echo.id",
requestedCapabilities: ["text_generation"],
};
const agent = new EchoAgent(config);
agent.on("log", ({ level, message }) => console.error(`[${level}] ${message}`));
agent.on("status_changed", ({ status }) => console.error(`status → ${status}`));
await agent.start();
Abstract methods
executeIntent(action, params, callerDid?, intentId?)
Handles an intent — a structured request with an action name and parameters.
| Source | How it arrives |
|---|---|
| Control-plane REST | POST /api/agents/:did/run → WebSocket intent message |
| Control-plane workflow | Workflow step targets this agent |
| Peer agent | WebRTC invoke message via PeerManager |
Return any JSON-serialisable value; the runtime wraps it in an ExecutionResult and sends it back to the caller automatically.
async executeIntent(
action: string,
params: Record<string, unknown>,
callerDid?: string, // DID of the sender (peer agent or control-plane)
intentId?: string // ID echoed back in the result
): Promise<unknown> {
if (action === "summarise") {
const text = String(params.text ?? "");
return { summary: text.slice(0, 100) + "…" };
}
throw new Error(`Unknown action: ${action}`);
}
The base class enforces capability checks, token budget limits, and hourly rate limits before calling executeIntent. You do not need to repeat those checks.
executeChat(messages, conversationId, sendChunk)
Handles a chat session from the control-plane chat UI.
messages is the full conversation history (role + content). sendChunk sends a text delta back to the browser. The final call must set done = true.
async executeChat(
messages: ChatMessageEntry[],
conversationId: string,
sendChunk: (chunk: string, done?: boolean, isError?: boolean, errorCode?: string) => void
): Promise<void> {
try {
const reply = await myLlm.generate(messages);
// Stream word by word
for (const word of reply.split(" ")) {
sendChunk(word + " ");
await sleep(20);
}
sendChunk("", true); // signal end of stream
} catch (err) {
sendChunk(String(err), true, true, "llm_error");
}
}
Optional hooks
Override these protected methods to react to control-plane events without touching the protocol layer.
| Hook | When called |
|---|---|
onAuthComplete(payload) | After the auth handshake succeeds; payload contains your certificate and policy metadata |
onLlmConfig(payload) | Control plane pushed an LLM config update |
onDelegationUpdate(payload) | User delegation grants/revocations received |
onPeerCatalogUpdated(grants) | Peer agent catalog updated (new agents you can call) |
onSkillsConfig(payload) | Realm skill configuration changed |
onKnowledgeSources(sources) | Knowledge source list updated |
getDailyTokenUsageForBudget() | Return {promptTokens, completionTokens} so the base class can enforce maxTokensPerDay; default returns {0, 0} |
protected async onAuthComplete(payload: WSAuthCompletePayload): Promise<void> {
console.log("Connected. Policy:", payload.policyId);
}
protected async onPeerCatalogUpdated(grants: AgentPeerGrant[]): Promise<void> {
console.log(`${grants.length} peer agent(s) available`);
}
Connecting to peer agents
The runtime initialises PeerManager automatically. Call invokePeer() from anywhere in your subclass to send an intent to another agent over WebRTC.
const result = await this.invokePeer(
"did:vaultys:abc123", // target agent DID
"text_generation", // action
{ prompt: "Summarise this document …" }
);
Peer agent DIDs come from this.getPeerCatalog(). The control plane must have created a peer grant between your agent and the target.
WebRTC transport
To connect via WebRTC instead of WebSocket (lower latency, no control-plane relay for data), pass the control plane's PeerJS peer ID in config:
const config: AgentRuntimeConfig = {
// …
peerjsControlPlaneId: "3381ae1e0875c8bb…", // SHA-256 of control-plane DID
peerjsServerUrl: "https://my-signaling-server.example.com", // optional
};
When peerjsControlPlaneId is set the runtime automatically uses connectViaPeerjs() instead of connectViaWs(). All other behaviour — auth, intent routing, heartbeats — is identical.
Node.js WebRTC polyfill (required)
@vaultysclaw/agent-runtime deliberately does not install a WebRTC polyfill — that is the caller's responsibility, so you can choose the binding that works for your platform.
Your entry point must polyfill the WebRTC globals before importing anything else. The easiest way is a small module imported first:
// src/polyfill.ts — import this as the very first line of your entry point
import { createRequire } from "module";
const _req = createRequire(import.meta.url);
const wrtc = _req("@roamhq/wrtc") as Record<string, unknown>;
(global as Record<string, unknown>).RTCPeerConnection = wrtc.RTCPeerConnection;
(global as Record<string, unknown>).RTCSessionDescription = wrtc.RTCSessionDescription;
(global as Record<string, unknown>).RTCIceCandidate = wrtc.RTCIceCandidate;
(global as Record<string, unknown>).getUserMedia = wrtc.getUserMedia;
// src/index.ts — polyfill MUST be the first import
import "./polyfill";
import { MyAgent } from "./agent";
// …
createRequire instead of import *?@roamhq/wrtc is a CJS package whose exports are populated by a native addon at runtime. ESM's static import * synthesis only sees exports that can be statically detected, which misses RTCPeerConnection and friends. createRequire loads the CJS module directly, giving you the full exports object.
Add @roamhq/wrtc to your package's dependencies:
pnpm add @roamhq/wrtc
Emitted events
Your code (or the process running the agent) can listen to these events.
agent.on("log", ({ level, message, data }) => { … });
agent.on("status_changed", ({ status }) => { … });
agent.on("heartbeat", ({ uptime }) => { … });
agent.on("intent_received", ({ intentId, action, params }) => { … });
agent.on("intent_result", ({ intentId, status, output, error }) => { … });
status transitions: initializing → connecting → pending_approval (first run) → connected → reconnecting → stopped.
Complete example — HTTP proxy agent
An agent that forwards every intent as an HTTP request to an external API:
// Entry point — polyfill.ts imported first
import "./polyfill";
import { BaseAgentRuntime, type AgentRuntimeConfig } from "@vaultysclaw/agent-runtime";
import type { ChatMessageEntry } from "@vaultysclaw/shared";
class HttpProxyAgent extends BaseAgentRuntime {
private readonly apiBase: string;
constructor(config: AgentRuntimeConfig, apiBase: string) {
super(config);
this.apiBase = apiBase;
}
async executeIntent(action: string, params: Record<string, unknown>): Promise<unknown> {
const res = await fetch(`${this.apiBase}/${action}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
if (!res.ok) throw new Error(`API error ${res.status}: ${await res.text()}`);
return res.json();
}
async executeChat(
messages: ChatMessageEntry[],
_conversationId: string,
sendChunk: (chunk: string, done?: boolean) => void
): Promise<void> {
const last = [...messages].reverse().find((m) => m.role === "user");
const res = await fetch(`${this.apiBase}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: last?.content }),
});
const { reply } = await res.json();
sendChunk(reply, true);
}
}
const agent = new HttpProxyAgent(
{
name: "http-proxy",
controlPlaneUrl: "http://localhost:3000",
vaultysIdPath: "./.vaultys/proxy.id",
requestedCapabilities: ["agent_communication"],
},
"https://api.example.com"
);
await agent.start();
Reference
AgentRuntimeConfig
| Field | Type | Required | Description |
|---|---|---|---|
name | string | ✓ | Display name shown in the dashboard |
controlPlaneUrl | string | ✓ | HTTP base URL of the control plane |
controlPlaneWsUrl | string | WebSocket URL (derived from controlPlaneUrl if omitted) | |
peerjsControlPlaneId | string | PeerJS ID of the control plane — enables WebRTC transport | |
peerjsServerUrl | string | Custom PeerJS signaling server URL | |
vaultysIdPath | string | ✓ | Path to the VaultysId identity file (created on first run) |
requestedCapabilities | AgentCapability[] | ✓ | Capabilities to request on first registration |
workspaceRoot | string | Root directory for file-system tool access |
Public API (from BaseAgentRuntime)
agent.start(): Promise<void>
agent.stop(): void
agent.getDid(): string
agent.getStatus(): AgentStatus
agent.getPeerCatalog(): AgentPeerGrant[]
agent.invokePeer(targetDid, action, params): Promise<unknown>
agent.getInfo(): AgentInfo