npm package: @runloop/agent-axon-client
Description: Axon-backed ACP and Claude agent clients for Runloop broker agents
Author: Runloop, Inc.
Latest version: 0.3.0

# @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](https://runloop.ai) devboxes via the Axon event bus.

This package provides two protocol modules and a shared utilities module:

| Module | Import path | Protocol | Use case |
|--------|-------------|----------|----------|
| **ACP** | `@runloop/agent-axon-client/acp` | [Agent Client Protocol](https://agentclientprotocol.com) (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

```bash
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:

```bash
npm install @anthropic-ai/claude-agent-sdk
```

## Imports

```typescript
// Subpath imports (recommended — tree-shakable)
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";

// Namespaced root import (all modules at once)
import { acp, claude, shared } from "@runloop/agent-axon-client";
```

## Getting Started

### ACP Agent

```typescript
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 });

// Create an Axon channel and a devbox with an ACP broker mount.
// The broker launches the agent binary (e.g. OpenCode) inside the devbox.
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"],
    },
  ],
});

// Wrap the Axon channel in a high-level ACP connection and negotiate capabilities
const conn = new ACPAxonConnection(axon, devbox);
await conn.initialize({
  protocolVersion: PROTOCOL_VERSION,
  clientInfo: { name: "my-app", version: "1.0.0" },
});

// Stream agent responses as they arrive — use type guards to narrow update variants
conn.onSessionUpdate((sessionId, update) => {
  if (isAgentMessageChunk(update)) {
    process.stdout.write(update.message);
  }
});

// Start a session and send a prompt (prompt() resolves when the turn ends,
// but onSessionUpdate may still receive trailing content — see Known Limitations)
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

```typescript
import { ClaudeAxonConnection } from "@runloop/agent-axon-client/claude";
import { RunloopSDK } from "@runloop/api-client";

const sdk = new RunloopSDK({ bearerToken: process.env.RUNLOOP_API_KEY });

// Create an Axon channel and a devbox with a Claude broker mount.
// Uses "claude_json" protocol for native Claude SDK wire format.
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",
  }],
});

// Connect to Claude Code and set the model
const conn = new ClaudeAxonConnection(axon, devbox, { model: "claude-sonnet-4-5" });
await conn.initialize();

// Send a prompt and iterate over response messages until a "result" message arrives
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?)`

| Parameter | Type | Description |
|-----------|------|-------------|
| `axon` | `Axon` | Axon channel from `@runloop/api-client` |
| `devbox` | `Devbox` | Runloop devbox from `@runloop/api-client` |

**Options** (`ACPAxonConnectionOptions`):

| Field | Type | Description |
|-------|------|-------------|
| `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`):

| Method | Description |
|--------|-------------|
| `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**:

| Property / Method | Description |
|---|---|
| `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`:

```typescript
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"],
    },
  ],
});

// Configure connection with lifecycle hooks and a custom permission handler.
// By default, permissions are auto-approved — override requestPermission to prompt the user.
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),
});

// Register listeners before initialize() so no events are missed
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`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `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:

```typescript
import {
  isUserMessageChunk,
  isAgentMessageChunk,
  isToolCall,
  isUsageUpdate,
  // ...
} from "@runloop/agent-axon-client/acp";

// SessionUpdate is a union type — use type guards to narrow and handle each variant
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:

```typescript
import type {
  SessionUpdate,
  SessionNotification,
  ToolCall,
  ContentBlock,
  // ... etc.
} 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:

```typescript
import type {
  SDKMessage,
  SDKAssistantMessage,
  SDKPartialAssistantMessage,
  SDKResultMessage,
  SDKResultSuccess,
  SDKResultError,
  SDKSystemMessage,
  SDKStatusMessage,
  SDKUserMessage,
  SDKControlRequest,
  SDKControlResponse,
  SDKToolProgressMessage,
  PermissionMode,
  // ... etc.
} 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?)`

| Parameter | Type | Description |
|-----------|------|-------------|
| `axon` | `Axon` | Axon channel from `@runloop/api-client` |
| `devbox` | `Devbox` | Runloop devbox from `@runloop/api-client` |

**Options** (`ClaudeAxonConnectionOptions`):

| Field | Type | Description |
|-------|------|-------------|
| `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**:

| Property / Method | Description |
|---|---|
| `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**:

| Method | Description |
|--------|-------------|
| `send(prompt)` | Send a user message. Accepts a `string` or `SDKUserMessage`. |
| `receiveMessages()` | Async iterator yielding all `SDKMessage`s indefinitely |
| `receiveResponse()` | Async iterator yielding messages until (and including) a `result` message |

**Control**:

| Method | Description |
|--------|-------------|
| `interrupt()` | Interrupt the current conversation turn |
| `setPermissionMode(mode)` | Change the permission mode |
| `setModel(model)` | Change the AI model |

**Listeners**:

| Method | Description |
|--------|-------------|
| `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.

```typescript
import { AxonTransport, type Transport } from "@runloop/agent-axon-client/claude";

// AxonTransport gives direct access to the Claude wire protocol over Axon —
// use this if you need custom message handling beyond what ClaudeAxonConnection provides
const transport = new AxonTransport(axon, { verbose: true });
await transport.connect();

// Messages are raw Claude SDK JSON — you manage serialization yourself
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**:

| Method | Description |
|--------|-------------|
| `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)
```

| | ACP Module | Claude Module |
|---|---|---|
| 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`:

| Field | Type | Description |
|-------|------|-------------|
| `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`):

```typescript
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:

```typescript
type AxonEventListener = (event: AxonEventView) => void;
```

### `WireData` (Claude module)

Generic JSON wire format used by the Claude transport:

```typescript
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:

1. `session/prompt` response — resolves the `prompt()` promise (`stopReason: "end_turn"`)
2. `turn.completed` system event
3. `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:

  ```typescript
  // onAxonEvent receives every raw Axon event — filter by origin/type
  // to bracket turns and know when all session updates have been flushed
  conn.onAxonEvent((event) => {
    if (event.origin !== "SYSTEM_EVENT") return;
    if (event.event_type === "turn.started") {
      // Agent turn began — disable input, show cancel button
    }
    if (event.event_type === "turn.completed") {
      // All content for this turn has been delivered
    }
  });
  ```

- **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](../LICENSE)