Skip to main content

Overview

The @runloop/agent-axon-client SDK provides client-side integration with Axon-powered agents running in Runloop devboxes. It supports two protocols:
  • ACP — For OpenCode, Goose, and other Agent Client Protocol compatible agents
  • Claude — For Claude Code CLI with the Claude JSON protocol
Use this SDK to build interactive CLIs, full-stack applications, or custom integrations that communicate with agents via Axons. Repository: runloopai/agent-axon-client-ts
For broker configuration and protocol details, see ACP Protocol and Claude Protocol. For a step-by-step tutorial, see Axon + ACP Broker.

Installation

1

Install the SDK

npm install @runloop/agent-axon-client @runloop/api-client
Requirements:
  • Node.js 22+ or Bun
  • RUNLOOP_API_KEY environment variable
  • ANTHROPIC_API_KEY environment variable (for Claude protocol only)
2

Import the protocol module

The SDK exports two protocol-specific modules:
// For ACP agents (OpenCode, Goose, etc.)
import { ACPAxonConnection, PROTOCOL_VERSION } from "@runloop/agent-axon-client/acp";

// For Claude Code CLI
import { ClaudeAxonConnection } from "@runloop/agent-axon-client/claude";

// Core SDK for devbox and axon management
import { RunloopSDK } from "@runloop/api-client";

Quick Comparison: ACP vs Claude

FeatureACPClaude
Use CaseOpenCode, Goose, custom ACP agentsClaude Code CLI
ProtocolAgent Client Protocol (JSON-RPC)Claude JSON streaming protocol
ConnectionACPAxonConnectionClaudeAxonConnection
Session ManagementManual (initialize, newSession)Automatic on connect
StreamingCallback-based (onSessionUpdate)Async iterator (receiveResponse)
FeaturesPlans, slash commands, tool calls, thinkingThinking, tool use, task tracking, control requests
Best ForBuilding agents with structured workflowsBuilding Claude-powered applications
Both protocols support real-time streaming, tool execution, and multi-turn conversations.

ACP Module

The ACP module connects to agents that implement the Agent Client Protocol, including OpenCode and Goose.

Hello World Example

This minimal example creates a devbox with OpenCode, sends a single prompt, and prints the response:
import { RunloopSDK } from "@runloop/api-client";
import {
  ACPAxonConnection,
  PROTOCOL_VERSION,
  isAgentMessageChunk,
  isToolCall,
} from "@runloop/agent-axon-client/acp";

// Setup
const sdk = new RunloopSDK();

console.log('Starting devbox with agent "opencode"...');
const axon = await sdk.axon.create({ name: "acp-transport" });
const devbox = await sdk.devbox.create({
  blueprint_name: "runloop/agents",
  mounts: [
    {
      type: "broker_mount",
      axon_id: axon.id,
      protocol: "acp",
      agent_binary: "opencode",
      launch_args: ["acp"],
    },
  ],
});

const agent = new ACPAxonConnection({
  axon,
  devboxId: devbox.id,
  shutdown: async () => {
    await devbox.shutdown();
  },
});
console.log(`Devbox ready: ${agent.devboxId}`);

// Initialize + create session
await agent.initialize({
  protocolVersion: PROTOCOL_VERSION,
  clientInfo: { name: "acp-hello-world", version: "0.1.0" },
});

const session = await agent.newSession({ cwd: "/home/user", mcpServers: [] });
console.log(`Session ready: ${session.sessionId}\n`);

// Stream session updates
agent.onSessionUpdate((_sid, update) => {
  if (isAgentMessageChunk(update)) {
    if (update.content.type === "text") {
      process.stdout.write(update.content.text);
    }
  } else if (isToolCall(update)) {
    const input = JSON.stringify(update.rawInput ?? {}).slice(0, 120);
    console.log(`\n> ${update.title}(${input})`);
  }
});

// Send a single prompt
console.log("Sending prompt: 'Say hello world'\n");
await agent.prompt({
  sessionId: session.sessionId,
  prompt: [{ type: "text", text: "Say hello world" }],
});
console.log("\n");

// Cleanup
console.log("Shutting down...");
await agent.shutdown();
console.log("Done.");
process.exit(0);
Key concepts:
  • ACPAxonConnection — Wraps the Axon and provides ACP protocol methods
  • initialize() — Establishes protocol version and client info
  • newSession() — Creates a session with working directory and MCP servers
  • onSessionUpdate() — Callback for streaming responses (thinking, tool calls, messages)
  • Type guardsisAgentMessageChunk(), isToolCall(), etc. for discriminating event types
  • prompt() — Sends a user message to start a turn
  • shutdown() — Cleans up resources

Building an Interactive CLI

For a full REPL experience with multi-turn conversations, verbose mode, and cancellation support: Core patterns:
import { createInterface } from "readline";
import {
  ACPAxonConnection,
  isAgentThoughtChunk,
  isToolCallProgress,
  isPlan,
  isUsageUpdate,
} from "@runloop/agent-axon-client/acp";

// REPL loop
const rl = createInterface({ input: process.stdin, output: process.stdout });

function question(): Promise<string> {
  return new Promise<string>((resolve) => rl.question("\n> ", resolve));
}

while (true) {
  const rawInput = await question();
  const input = rawInput.trim();

  if (input.toLowerCase() === "exit") break;

  if (input.toLowerCase() === "cancel") {
    await agent.cancel({ sessionId });
    continue;
  }

  await agent.prompt({
    sessionId,
    prompt: [{ type: "text", text: input }],
  });
}
Verbose mode event handling:
agent.onSessionUpdate((_sid, update) => {
  if (isAgentMessageChunk(update)) {
    // Stream response text
    if (update.content.type === "text") {
      process.stdout.write(update.content.text);
    }
  } else if (isAgentThoughtChunk(update)) {
    // Show thinking in verbose mode
    if (VERBOSE && update.content.type === "text") {
      const thought = update.content.text;
      console.log(`\n[thinking] ${thought.slice(0, 200)}...`);
    }
  } else if (isToolCall(update)) {
    console.log(`\n[tool] ${update.title}`);
  } else if (isToolCallProgress(update)) {
    if (VERBOSE) {
      console.log(`  [${update.status}] ${update.title}`);
    }
  } else if (isPlan(update)) {
    if (VERBOSE) {
      const tasks = update.entries
        .map((e) => `  - [${e.status}] ${e.content}`)
        .join("\n");
      console.log(`\n[plan]\n${tasks}`);
    }
  } else if (isUsageUpdate(update)) {
    if (VERBOSE) {
      const cost = update.cost?.amount != null ? ` $${update.cost.amount.toFixed(4)}` : "";
      console.log(`\n[usage] ${update.used}/${update.size} tokens${cost}`);
    }
  }
});
Graceful shutdown:
process.on("SIGINT", async () => {
  console.log(`\nInterrupted — destroying devbox ${agent.devboxId}...`);
  await agent.shutdown();
  process.exit(0);
});
See the complete working example at acp-cli.ts with support for agent selection, verbose mode, and session management.

Building a Full-Stack App

For production applications with web UIs, the SDK supports streaming events over WebSocket to React frontends. Architecture overview: Key components:
  • Backend: Express server with ACPAxonConnection, WebSocket broadcaster, NodeACPClient for agent callbacks
  • Frontend: React with custom hooks for WebSocket event processing, real-time block rendering
  • Flow: HTTP for control plane, WebSocket for streaming events, SSE from Axon
  • Features: Terminal management, file operations, turn-based rendering (thinking, tool calls, text, plans)
See the complete working example at acp-app with UI modeled after Cursor’s chat interface, supporting streaming blocks, inline tool calls with diffs, and plan views.

ACP API Reference

ACPAxonConnection
MethodDescription
constructor(config)Create connection. Params: { axon, devboxId, shutdown }
initialize(params)Initialize ACP protocol. Params: { protocolVersion, clientInfo, clientCapabilities? }
newSession(params)Create new session. Params: { cwd, mcpServers }
prompt(params)Send prompt to start a turn. Params: { sessionId, prompt }
cancel(params)Cancel current turn. Params: { sessionId }
onSessionUpdate(callback)Register callback for streaming updates. Callback: (sessionId, update) => void
shutdown()Clean up resources and close connection
Type Guards Use these to discriminate session update types:
import {
  isAgentMessageChunk,
  isAgentThoughtChunk,
  isToolCall,
  isToolCallProgress,
  isPlan,
  isUsageUpdate,
} from "@runloop/agent-axon-client/acp";

agent.onSessionUpdate((sessionId, update) => {
  if (isAgentMessageChunk(update)) {
    // Handle agent response text
  } else if (isAgentThoughtChunk(update)) {
    // Handle thinking/reasoning
  } else if (isToolCall(update)) {
    // Handle tool invocation
  } else if (isToolCallProgress(update)) {
    // Handle tool execution progress
  } else if (isPlan(update)) {
    // Handle agent plan with tasks
  } else if (isUsageUpdate(update)) {
    // Handle token usage and cost
  }
});

Claude Module

The Claude module connects to Claude Code CLI running in a devbox via the Claude JSON streaming protocol.

Hello World Example

This minimal example uses the Runloop SDK to create a devbox with Claude Code mounted on it. Claude Code runs inside the devbox and processes your prompt; the SDK sends the prompt and prints the streamed response. This all is orchestrated by an Axon.
import { RunloopSDK } from "@runloop/api-client";
import { ClaudeAxonConnection } from "@runloop/agent-axon-client/claude";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";

// Setup
const runloop = new RunloopSDK();

console.log("Starting devbox...");
const axon = await runloop.axon.create({ name: "hello-world-session" });
const devbox = await runloop.devbox.create({
  mounts: [
    {
      type: "broker_mount",
      axon_id: axon.id,
      protocol: "claude_json",
      launch_args: [],
    },
  ],
  blueprint_name: "runloop/agents",
  environment_variables: {
    ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || "",
  },
});
console.log(`Devbox ready: ${devbox.id}`);

// Connect
const client = new ClaudeAxonConnection(axon, devbox, {
  // Optional: specify model
  // model: "claude-haiku-4.5-20250929"
});

console.log("Connecting to Claude...");
await client.connect();
console.log("Connected.\n");

// Send a single prompt and print the response
console.log("Sending prompt: 'Say hello world'\n");
await client.send("Say hello world");

for await (const msg of client.receiveResponse()) {
  renderMessage(msg);
}

function renderMessage(msg: SDKMessage): void {
  switch (msg.type) {
    case "assistant":
      for (const block of msg.message.content) {
        if (block.type === "text") {
          process.stdout.write(block.text);
        }
      }
      break;
    case "result":
      console.log();
      if (msg.is_error) {
        console.error(`Error: ${msg.subtype}`);
      } else {
        const cost = msg.total_cost_usd;
        const turns = msg.num_turns;
        const duration = (msg.duration_ms / 1000).toFixed(1);
        console.log(`--- ${turns} turn(s), ${duration}s, $${cost.toFixed(4)} ---`);
      }
      break;
  }
}

// Cleanup
console.log("\nDisconnecting...");
await client.disconnect();
console.log("Done.");
process.exit(0);
Key concepts:
  • ClaudeAxonConnection — Wraps the Axon and provides Claude protocol methods
  • connect() — Establishes connection and initializes session automatically
  • send() — Sends a user message to Claude
  • receiveResponse() — Async iterator yielding SDKMessage objects for streaming
  • Message typesassistant, system, result, rate_limit_event for different response types
  • disconnect() — Cleans up resources

Building an Interactive CLI

For a full REPL experience with model selection, system prompts, and verbose mode: Core patterns:
import { createInterface } from "readline";
import { ClaudeAxonConnection } from "@runloop/agent-axon-client/claude";

// Configure connection with options
const client = new ClaudeAxonConnection(axon, devbox, {
  model: "claude-sonnet-4.6-20250929", // Optional: specify model
  systemPrompt: "You are a helpful coding assistant.", // Optional: custom system prompt
  verbose: true, // Optional: enable debug logging
});

await client.connect();

// REPL loop
const rl = createInterface({ input: process.stdin, output: process.stdout });

function prompt(): Promise<string> {
  return new Promise<string>((resolve) => rl.question("\n> ", resolve));
}

console.log("Claude CLI (type 'exit' to quit)\n");

while (true) {
  const rawInput = await prompt();
  const input = rawInput.trim();

  if (!input) continue;
  if (input.toLowerCase() === "exit") break;

  try {
    await client.send(rawInput);

    for await (const msg of client.receiveResponse()) {
      renderMessage(msg);
    }
  } catch (err: unknown) {
    console.error(`Error: ${err}`);
  }
}
Message rendering with verbose mode:
function renderMessage(msg: SDKMessage): void {
  switch (msg.type) {
    case "assistant": {
      for (const block of msg.message.content) {
        switch (block.type) {
          case "text":
            process.stdout.write(block.text);
            break;
          case "thinking":
            if (VERBOSE) {
              console.log(`\n[thinking] ${block.thinking.slice(0, 200)}...`);
            }
            break;
          case "tool_use":
            console.log(`\n> ${block.name}(${JSON.stringify(block.input).slice(0, 120)})`);
            break;
        }
      }
      break;
    }

    case "system": {
      switch (msg.subtype) {
        case "task_started":
          console.log(`\nTask started: ${msg.description}`);
          break;
        case "task_progress":
          if (VERBOSE) {
            console.log(`  Progress: ${msg.description} (${msg.usage.tool_uses} tool uses)`);
          }
          break;
        case "task_notification":
          console.log(`  Task ${msg.status}: ${msg.summary}`);
          break;
        case "init":
          if (VERBOSE) {
            console.log(`  [init] model=${msg.model} tools=${msg.tools?.length ?? "?"}`);
          }
          break;
      }
      break;
    }

    case "result": {
      console.log(); // newline after streamed text
      if (msg.is_error) {
        console.error(`Error: ${msg.subtype}`);
      } else {
        const cost = msg.total_cost_usd;
        const turns = msg.num_turns;
        const duration = (msg.duration_ms / 1000).toFixed(1);
        console.log(`--- ${turns} turn(s), ${duration}s, $${cost.toFixed(4)} ---`);
      }
      break;
    }

    case "rate_limit_event":
      console.log(`Rate limit: ${msg.rate_limit_info.status}`);
      break;
  }
}
Graceful shutdown:
process.on("SIGINT", async () => {
  console.log(`\nInterrupted — shutting down devbox ${devbox.id}...`);
  rl.close();
  await client.disconnect();
  process.exit(0);
});
See the complete working example at claude-cli.ts with model selection, custom system prompts, and cost tracking.

Building a Full-Stack App

The Claude module supports the same full-stack architecture as ACP, with streaming over WebSocket to React frontends. The flow is identical except for protocol-specific message types. Key differences from ACP:
  • Automatic session initialization on connect()
  • Async iterator pattern instead of callbacks
  • Different message types (assistant, system, result vs ACP session updates)
  • Control request/response pattern for permissions and user questions
See the complete working example at claude-app with similar architecture to the ACP app but Claude-specific message handling.

Claude API Reference

ClaudeAxonConnection
MethodDescription
constructor(axon, devbox, options?)Create connection. Options: { model?, systemPrompt?, verbose? }
connect()Establish connection and initialize session
send(message)Send user message to Claude
receiveResponse()Async iterator yielding SDKMessage objects
interrupt()Cancel current turn
disconnect()Clean up resources and close connection
Connection Options
interface ClaudeConnectionOptions {
  model?: string;          // e.g., "claude-sonnet-4.6-20250929"
  systemPrompt?: string;   // Custom system prompt
  verbose?: boolean;       // Enable debug logging
}
Message Types
for await (const msg of client.receiveResponse()) {
  switch (msg.type) {
    case "assistant":
      // Assistant response with content blocks (text, thinking, tool_use)
      break;
    case "system":
      // System messages (init, task_started, task_progress, task_notification)
      break;
    case "result":
      // Turn completion with cost, duration, and error status
      break;
    case "rate_limit_event":
      // Rate limit information
      break;
  }
}

Examples Repository

All examples are available in the GitHub repository with full source code and setup instructions:

ACP Hello World

Minimal script demonstrating basic ACP connection and single prompt

ACP CLI

Interactive REPL for ACP agents with verbose mode and session management

ACP App

Full-stack React + Express app with streaming UI and tool call visualization

Claude Hello World

Minimal script demonstrating basic Claude connection and single prompt

Claude CLI

Interactive REPL for Claude with model selection and cost tracking

Claude App

Full-stack React + Express app for Claude with similar architecture to ACP app

Running the Examples

# Clone the repository
git clone https://github.com/runloopai/agent-axon-client-ts
cd agent-axon-client-ts/examples

# Install dependencies and build
bun install && bun run build

# Set API keys
export RUNLOOP_API_KEY=your_runloop_api_key
export ANTHROPIC_API_KEY=your_anthropic_api_key  # Claude examples only

# Run any example
cd acp-hello-world
bun run acp-hello-world.ts

# Or with options
bun run acp-cli.ts --agent goose --verbose
bun run claude-hello-world.ts --model haiku-4.5

Axons Overview

Learn about Axon event streams and core concepts

ACP Protocol

Broker configuration and protocol details for ACP agents

Claude Protocol

Broker configuration and protocol details for Claude Code CLI

Axon + ACP Tutorial

Step-by-step tutorial building an ACP integration

Broker Overview

Learn how Broker bridges Axons to agents in devboxes

Base SDK

Core Runloop SDK documentation for devbox and axon management