@runloop/agent-axon-client
Alpha — subject to change. This SDK is in early development. APIs, interfaces, and behavior may change without notice between versions.
TypeScript client for connecting to coding agents running inside Runloop devboxes via the Axon event bus.
This package provides two protocol modules and a shared utilities module:
| ACP | @runloop/agent-axon-client/acp | Agent Client Protocol (JSON-RPC 2.0) | Any ACP-compatible agent (OpenCode, Claude via ACP, etc.) |
| Claude | @runloop/agent-axon-client/claude | Claude Code SDK wire format | Claude Code with native SDK message types |
| Shared | @runloop/agent-axon-client/shared | — | Common types (BaseConnectionOptions, AxonEventView, AxonEventListener) and utilities |
Both protocol modules communicate over Runloop Axon channels. Pick the one that matches your agent's protocol. Shared types are also re-exported from each protocol module for convenience.
Installation
npm install @runloop/agent-axon-client @runloop/api-client
@runloop/api-client is a peer dependency — you provide the Runloop SDK instance.
If using the Claude module, you also need:
npm install @anthropic-ai/claude-agent-sdk
Imports
import { ACPAxonConnection, PROTOCOL_VERSION } from "@runloop/agent-axon-client/acp";
import { ClaudeAxonConnection } from "@runloop/agent-axon-client/claude";
import type { BaseConnectionOptions, AxonEventView } from "@runloop/agent-axon-client/shared";
import { acp, claude, shared } from "@runloop/agent-axon-client";
Getting Started
ACP Agent
import {
ACPAxonConnection,
isAgentMessageChunk,
PROTOCOL_VERSION,
} from "@runloop/agent-axon-client/acp";
import { RunloopSDK } from "@runloop/api-client";
const sdk = new RunloopSDK({ bearerToken: process.env.RUNLOOP_API_KEY });
const axon = await sdk.axon.create({ name: "acp-transport" });
const devbox = await sdk.devbox.create({
mounts: [
{
type: "broker_mount",
axon_id: axon.id,
protocol: "acp",
agent_binary: "opencode",
launch_args: ["acp"],
},
],
});
const conn = new ACPAxonConnection(axon, devbox);
await conn.initialize({
protocolVersion: PROTOCOL_VERSION,
clientInfo: { name: "my-app", version: "1.0.0" },
});
conn.onSessionUpdate((sessionId, update) => {
if (isAgentMessageChunk(update)) {
process.stdout.write(update.message);
}
});
const session = await conn.newSession({ cwd: "/home/user", mcpServers: [] });
await conn.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "Hello!" }],
});
await conn.disconnect();
Claude Code Agent
import { ClaudeAxonConnection } from "@runloop/agent-axon-client/claude";
import { RunloopSDK } from "@runloop/api-client";
const sdk = new RunloopSDK({ bearerToken: process.env.RUNLOOP_API_KEY });
const axon = await sdk.axon.create({ name: "claude-transport" });
const devbox = await sdk.devbox.create({
mounts: [{
type: "broker_mount",
axon_id: axon.id,
protocol: "claude_json",
agent_binary: "claude",
}],
});
const conn = new ClaudeAxonConnection(axon, devbox, { model: "claude-sonnet-4-5" });
await conn.initialize();
await conn.send("What files are in this directory?");
for await (const msg of conn.receiveResponse()) {
console.log(msg.type, msg);
}
await conn.disconnect();
ACP Module
ACPAxonConnection
Higher-level wrapper that manages an axonStream, an AbortController, and the ACP ClientSideConnection.
Constructor: new ACPAxonConnection(axon, devbox, options?)
axon | Axon | Axon channel from @runloop/api-client |
devbox | Devbox | Runloop devbox from @runloop/api-client |
Options (ACPAxonConnectionOptions):
verbose | boolean | Emit verbose logs to stderr |
requestPermission | (params) => Promise<Response> | Custom permission handler (defaults to auto-approve) |
onError | (error: unknown) => void | Error callback (defaults to console.error) |
onDisconnect | () => void | Promise<void> | Teardown callback invoked by disconnect() (e.g. devbox shutdown) |
ACP Methods (proxied from ClientSideConnection):
initialize(params) | Establishes the connection and negotiates capabilities |
newSession(params) | Creates a new conversation session |
loadSession(params) | Loads an existing session |
listSessions(params) | Lists existing sessions |
prompt(params) | Sends a prompt and processes the agent's turn |
cancel(params) | Cancels an ongoing prompt turn |
authenticate(params) | Authenticates using an advertised method |
setSessionMode(params) | Sets session mode (e.g. "ask", "code") |
setSessionConfigOption(params) | Sets a session config option |
extMethod(method, params) | Extension request |
extNotification(method, params) | Extension notification |
Listeners & Lifecycle:
protocol: ClientSideConnection | Escape hatch for experimental/unstable ACP methods |
axonId: string | The Axon channel ID |
devboxId: string | The Runloop devbox ID |
signal: AbortSignal | Fires when the connection closes |
closed: Promise<void> | Resolves when the connection closes |
onSessionUpdate(listener) | Register a session update listener. Returns unsubscribe function. |
onAxonEvent(listener) | Register an Axon event listener. Returns unsubscribe function. |
abortStream() | Abort the SSE stream without clearing listeners (useful for testing / reconnect) |
disconnect() | Abort the stream, clear all listeners, and run the onDisconnect callback |
Provisioning Axon + devbox
Create an Axon channel, attach a devbox broker_mount with protocol: "acp", then pass axon and devboxId into ACPAxonConnection:
import { ACPAxonConnection, PROTOCOL_VERSION } from "@runloop/agent-axon-client/acp";
import { RunloopSDK } from "@runloop/api-client";
const sdk = new RunloopSDK({ bearerToken: process.env.RUNLOOP_API_KEY });
const axon = await sdk.axon.create({ name: "my-channel" });
const devbox = await sdk.devbox.create({
mounts: [
{
type: "broker_mount",
axon_id: axon.id,
protocol: "acp",
agent_binary: "opencode",
launch_args: ["acp"],
},
],
});
const conn = new ACPAxonConnection(axon, devbox, {
onDisconnect: async () => {
await devbox.shutdown();
},
requestPermission: async (params) => {
const option = params.options[0];
return { outcome: { outcome: "selected", optionId: option.optionId } };
},
onError: (err) => console.warn("transport error:", err),
});
conn.onSessionUpdate((sessionId, update) => {
console.log(sessionId, update);
});
await conn.initialize({
protocolVersion: PROTOCOL_VERSION,
clientInfo: { name: "my-app", version: "1.0.0" },
});
axonStream(options): Stream
Low-level function that creates an ACP-compatible duplex stream backed by an Axon channel from @runloop/api-client. Uses axon.subscribeSse() for inbound events and axon.publish() for outbound messages.
Parameters (AxonStreamOptions):
axon | Axon | Yes | Axon channel from @runloop/api-client |
signal | AbortSignal | No | Cancellation signal |
onAxonEvent | (event: AxonEventView) => void | No | Callback for every Axon event |
onError | (error: unknown) => void | No | Callback for swallowed parse errors |
Returns: { readable: ReadableStream<AnyMessage>; writable: WritableStream<AnyMessage> }
The stream handles JSON-RPC ID correlation internally — Axon's wire format doesn't carry IDs, so the transport layer maintains mapping tables to synthesize and restore them.
Session Update Type Guards
Narrowing helpers for discriminating SessionUpdate variants:
import {
isUserMessageChunk,
isAgentMessageChunk,
isToolCall,
isUsageUpdate,
} from "@runloop/agent-axon-client/acp";
conn.onSessionUpdate((sessionId, update) => {
if (isAgentMessageChunk(update)) {
process.stdout.write(update.message);
} else if (isToolCall(update)) {
console.log(`Tool: ${update.toolName}`);
}
});
Available guards: isUserMessageChunk, isAgentMessageChunk, isAgentThoughtChunk, isToolCall, isToolCallProgress, isPlan, isAvailableCommandsUpdate, isCurrentModeUpdate, isConfigOptionUpdate, isSessionInfoUpdate, isUsageUpdate.
Re-exported ACP Types
All types from @agentclientprotocol/sdk are re-exported for convenience:
import type {
SessionUpdate,
SessionNotification,
ToolCall,
ContentBlock,
} from "@runloop/agent-axon-client/acp";
Claude Module
Re-exported Claude SDK Types
All types from @anthropic-ai/claude-agent-sdk are re-exported. The most commonly used message types are explicitly named for discoverability:
import type {
SDKMessage,
SDKAssistantMessage,
SDKPartialAssistantMessage,
SDKResultMessage,
SDKResultSuccess,
SDKResultError,
SDKSystemMessage,
SDKStatusMessage,
SDKUserMessage,
SDKControlRequest,
SDKControlResponse,
SDKToolProgressMessage,
PermissionMode,
} from "@runloop/agent-axon-client/claude";
ClaudeAxonConnection
Bidirectional, interactive client for Claude Code via Axon. Messages are yielded as SDKMessage from @anthropic-ai/claude-agent-sdk — the exact types the Claude Code CLI emits.
Constructor: new ClaudeAxonConnection(axon, devbox, options?)
axon | Axon | Axon channel from @runloop/api-client |
devbox | Devbox | Runloop devbox from @runloop/api-client |
Options (ClaudeAxonConnectionOptions):
verbose | boolean | Emit verbose logs to stderr |
systemPrompt | string | Override the system prompt |
appendSystemPrompt | string | Append to the default system prompt |
model | string | Model ID (e.g. "claude-sonnet-4-5") — set after initialization |
onError | (error: unknown) => void | Error callback (defaults to console.error) |
onDisconnect | () => void | Promise<void> | Teardown callback invoked by disconnect() (e.g. devbox shutdown) |
Listeners & Lifecycle:
axonId: string | The Axon channel ID |
devboxId: string | The Runloop devbox ID |
initialize() | Connect to Claude Code, initialize the control protocol, and set model if configured |
disconnect() | Close the transport, fail pending requests, and run onDisconnect if provided |
Messaging:
send(prompt) | Send a user message. Accepts a string or SDKUserMessage. |
receiveMessages() | Async iterator yielding all SDKMessages indefinitely |
receiveResponse() | Async iterator yielding messages until (and including) a result message |
Control:
interrupt() | Interrupt the current conversation turn |
setPermissionMode(mode) | Change the permission mode |
setModel(model) | Change the AI model |
Listeners:
onAxonEvent(listener) | Register an Axon event listener. Returns unsubscribe function. |
onControlRequest(subtype, handler) | Register a handler for incoming control requests (e.g. "can_use_tool") |
AxonTransport
Lower-level transport that implements the Transport interface using Runloop Axon. Used internally by ClaudeAxonConnection but available for custom integrations.
import { AxonTransport, type Transport } from "@runloop/agent-axon-client/claude";
const transport = new AxonTransport(axon, { verbose: true });
await transport.connect();
await transport.write(JSON.stringify({ type: "user", message: { role: "user", content: "Hello" } }));
for await (const msg of transport.readMessages()) {
console.log(msg);
}
await transport.close();
Transport interface:
connect() | Open the underlying connection |
write(data: string) | Send a JSON message string |
readMessages() | Async iterable of parsed inbound messages |
abortStream() | Abort the SSE stream without closing the transport |
reconnect() | Abort the current SSE stream and re-subscribe |
close() | Close the transport |
isReady() | Whether the transport is connected and not closed |
Architecture
Both modules communicate over Runloop Axon channels but use different wire formats:
ACP Module Claude Module
┌─────────────────┐ ┌─────────────────┐
│ axonStream() │ │ AxonTransport │
│ (Axon SDK) │ │ (Axon SDK) │
│ ↕ │ │ ↕ │
│ JSON-RPC 2.0 │ Axon Bus │ Claude SDK │
│ translation │◄───────────────────────► │ wire format │
│ ↕ │ (SSE + publish) │ ↕ │
│ ACPAxon │ │ ClaudeAxon │
│ Connection │ │ Connection │
└─────────────────┘ └─────────────────┘
↕ ↕
ACP Agent Claude Code
(in devbox) (in devbox)
| Wire format | JSON-RPC 2.0 via Axon events | Claude SDK messages via Axon events |
| Transport | @runloop/api-client Axon SDK | @runloop/api-client Axon SDK |
| Agent protocol | @agentclientprotocol/sdk | @anthropic-ai/claude-agent-sdk |
| ID tracking | Synthetic (transport maps IDs) | Native (SDK handles correlation) |
Shared Types
Shared types are available from @runloop/agent-axon-client/shared or re-exported from each protocol module.
BaseConnectionOptions
Common options accepted by both ACPAxonConnection and ClaudeAxonConnection:
verbose | boolean | Emit verbose logs to stderr |
onError | (error: unknown) => void | Error callback (defaults to console.error) |
onDisconnect | () => void | Promise<void> | Teardown callback invoked by disconnect() |
AxonEventView
Raw event from the Axon event bus (re-exported from @runloop/api-client):
interface AxonEventView {
axon_id: string;
event_type: string;
origin: "EXTERNAL_EVENT" | "AGENT_EVENT" | "USER_EVENT" | "SYSTEM_EVENT";
payload: string;
sequence: number;
source: string;
timestamp_ms: number;
}
AxonEventListener
Callback type for raw Axon event listeners:
type AxonEventListener = (event: AxonEventView) => void;
WireData (Claude module)
Generic JSON wire format used by the Claude transport:
type WireData = Record<string, any>;
Known Limitations
- Eager SSE connection (ACP): The
ACPAxonConnection constructor immediately opens an SSE subscription via axon.subscribeSse(). Connection errors surface on the first awaited method call, not at construction time.
- Automatic reconnection (single retry): If an SSE stream drops unexpectedly, the SDK re-subscribes once and logs a
console.warn. If the retry also fails, the connection is terminal — create a new instance.
- Permission handling (Claude): The
ClaudeAxonConnection auto-approves all tool use by default. Register a "can_use_tool" handler via onControlRequest() to customize.
ACP: prompt() resolves before all session updates arrive
The Axon broker delivers events in this order for a given turn:
session/prompt response — resolves the prompt() promise (stopReason: "end_turn")
turn.completed system event
session/update notifications — thought chunks, message chunks, etc.
This means await conn.prompt(...) returns before the agent's response text has been delivered via onSessionUpdate. If you need to know when all content for a turn has arrived, use one of these strategies:
-
Use onAxonEvent to watch for turn.started / turn.completed system events (recommended). These bracket all content for a turn:
conn.onAxonEvent((event) => {
if (event.origin !== "SYSTEM_EVENT") return;
if (event.event_type === "turn.started") {
}
if (event.event_type === "turn.completed") {
}
});
-
Debounce after prompt() resolves — wait a short period (e.g. 200ms) for trailing session/update events. This is a heuristic and may drop events on slow connections.
License
MIT