Docs / runtime / agents

Agent Adapters

Connect Claude Code, Codex, OpenCode, and custom AI agents to Runra Runtime using agent adapters. Adapter interface, built-in adapters, and writing custom adapters

Agent adapters translate between Runra's runtime tool interface and each AI agent's native communication protocol. This lets you swap agents without changing your sandbox or observability configuration.

Built-in Adapters

Runra ships with adapters for the most popular AI coding agents:

AdapterProvider KeyDescription
Claude Codeclaude-codeAnthropic's CLI-based coding agent
CodexcodexOpenAI's coding agent
OpenCodeopencodeOpen-source coding agent
CustomcustomBring your own agent logic

Using an Adapter

Claude Code

import { Runra } from "@runra/runtime";

const runra = new Runra({
  sandbox: {
    provider: "runra-sandbox",
    apiKey: process.env.RUNRA_API_KEY,
  },
  agent: {
    provider: "claude-code",
    config: {
      model: "claude-sonnet-4-20250514",
      maxTurns: 50,
      allowedTools: ["bash", "read", "write", "edit", "glob", "grep"],
      permissionMode: "auto-approve", // or "prompt"
    },
  },
  observability: {
    provider: "axiom",
    token: process.env.AXIOM_TOKEN,
    dataset: "runra-events",
  },
});

// Start the agent with a prompt
const result = await runra.start({
  prompt: "Create a TypeScript Express server with a /health endpoint",
  sandboxOptions: {
    image: "node:22",
    resources: { cpu: 2, memoryMb: 4096 },
  },
});

console.log(`Agent finished: ${result.summary}`);

Codex

const runra = new Runra({
  agent: {
    provider: "codex",
    config: {
      model: "gpt-5",
      maxTurns: 30,
      approvalMode: "auto",
    },
  },
  // ... sandbox and observability config
});

OpenCode

const runra = new Runra({
  agent: {
    provider: "opencode",
    config: {
      model: "anthropic/claude-sonnet-4-20250514",
      maxTurns: 40,
      workdir: "/workspace",
    },
  },
  // ... sandbox and observability config
});

Configuration Reference

All adapters share a common configuration interface:

interface AgentConfig {
  provider: string;                      // Adapter to use
  config: {
    model: string;                       // LLM model for this agent
    maxTurns: number;                    // Max agent tool call loops (default: 50)
    allowedTools?: string[];             // Restrict available tools
    permissionMode?: "auto-approve" | "prompt" | "plan";
    workdir?: string;                    // Agent working directory
    systemPrompt?: string;               // Override system prompt
    apiKey?: string;                     // Agent-specific API key (falls back to LLM provider)
  };
}

Permission Modes

ModeBehavior
auto-approveAgent can run any allowed tool without confirmation
promptEach tool call requires human approval (for interactive use)
planAgent creates a plan first, then executes with auto-approve

Tool Allowlisting

Restrict which tools the agent can use:

agent: {
  provider: "claude-code",
  config: {
    allowedTools: ["bash", "read", "write", "edit"],
    // Disabled: "glob", "grep", "task", "web_search", "web_fetch"
  },
}

Adapter Interface

If the built-in adapters don't fit your needs, implement the AgentAdapter interface:

interface AgentAdapter {
  /** Unique identifier for this adapter */
  readonly id: string;

  /** Initialize the adapter with config */
  initialize(config: AgentAdapterConfig): Promise<void>;

  /** Start the agent with a prompt and sandbox context */
  start(params: StartParams): Promise<AgentResult>;

  /** Send a message mid-run (for interactive use) */
  sendMessage?(message: string): Promise<void>;

  /** Gracefully stop the agent */
  stop?(): Promise<void>;

  /** Clean up adapter resources */
  dispose(): Promise<void>;
}

interface StartParams {
  prompt: string;
  sandboxId: string;
  sandboxProvider: SandboxProvider;
  eventEmitter: EventEmitter;
  workdir?: string;
  env?: Record<string, string>;
}

interface AgentResult {
  success: boolean;
  summary: string;
  totalTurns: number;
  totalDurationMs: number;
  totalCost: number;
  error?: string;
}

Writing a Custom Adapter

Create a custom adapter to integrate any agent. Here's an adapter for a hypothetical REST-based agent:

import type {
  AgentAdapter,
  AgentAdapterConfig,
  AgentResult,
  SandboxProvider,
  StartParams,
} from "@runra/runtime";

interface MyAgentConfig {
  endpoint: string;
  apiKey: string;
  model: string;
  maxTurns: number;
}

export class MyAgentAdapter implements AgentAdapter {
  readonly id = "my-agent";

  private config!: MyAgentConfig;
  private sandboxProvider!: SandboxProvider;
  private sandboxId!: string;

  async initialize(config: AgentAdapterConfig): Promise<void> {
    this.config = config.config as MyAgentConfig;
  }

  async start(params: StartParams): Promise<AgentResult> {
    this.sandboxProvider = params.sandboxProvider;
    this.sandboxId = params.sandboxId;

    const startTime = Date.now();
    let messages = [{ role: "user", content: params.prompt }];

    for (let turn = 0; turn < this.config.maxTurns; turn++) {
      // 1. Ask the agent what to do
      const response = await fetch(this.config.endpoint, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${this.config.apiKey}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ model: this.config.model, messages }),
      });

      const choice = await response.json();
      const agentMessage = choice.messages[0];

      // Agent is done
      if (agentMessage.stop_reason === "end_turn") {
        return {
          success: true,
          summary: agentMessage.content,
          totalTurns: turn + 1,
          totalDurationMs: Date.now() - startTime,
          totalCost: 0,  // Calculate from usage
        };
      }

      // 2. Execute the tool call in the sandbox
      const toolCall = agentMessage.tool_use;
      let toolResult: string;

      switch (toolCall.name) {
        case "bash": {
          const execResult = await this.sandboxProvider.execute(
            this.sandboxId,
            toolCall.input.command,
            { cwd: toolCall.input.cwd, env: toolCall.input.env }
          );
          toolResult = execResult.stdout || execResult.stderr;
          break;
        }
        case "read_file": {
          toolResult = await this.sandboxProvider.readFile(
            this.sandboxId,
            toolCall.input.path
          );
          break;
        }
        case "write_file": {
          await this.sandboxProvider.writeFile(
            this.sandboxId,
            toolCall.input.path,
            toolCall.input.content
          );
          toolResult = "File written";
          break;
        }
        default:
          toolResult = `Unknown tool: ${toolCall.name}`;
      }

      // 3. Feed result back to agent
      messages.push(agentMessage);
      messages.push({
        role: "user",
        content: [
          {
            type: "tool_result",
            tool_use_id: toolCall.id,
            content: toolResult,
          },
        ],
      });

      // Emit observability event
      params.eventEmitter.emit("agent.tool_call", {
        sandboxId: this.sandboxId,
        tool: toolCall.name,
        args: toolCall.input,
        result: toolResult,
      });
    }

    return {
      success: false,
      summary: "Max turns reached",
      totalTurns: this.config.maxTurns,
      totalDurationMs: Date.now() - startTime,
      totalCost: 0,
      error: "MAX_TURNS_EXCEEDED",
    };
  }

  async dispose(): Promise<void> {
    // Cleanup
  }
}

Registering a Custom Adapter

import { Runra } from "@runra/runtime";
import { MyAgentAdapter } from "./my-agent-adapter";

// Register the custom adapter
Runra.registerAgentAdapter("my-agent", () => new MyAgentAdapter());

// Use it
const runra = new Runra({
  agent: {
    provider: "my-agent",
    config: {
      endpoint: "https://my-agent.internal/v1/chat",
      apiKey: process.env.MY_AGENT_KEY,
      model: "gpt-5",
      maxTurns: 30,
    },
  },
  // ... sandbox and observability config
});

Tool Mapping

Adapters map agent-native tools to Runra's sandbox operations:

Agent ToolRunra OperationDescription
bash / execute / shellsandbox.execute()Run shell command
read / read_filesandbox.readFile()Read file contents
write / write_filesandbox.writeFile()Create or overwrite file
edit / str_replacesandbox.readFile() + sandbox.writeFile()Modify existing file
glob / list_filessandbox.listFiles()List directory contents
grep / searchsandbox.execute("grep ...")Search file contents
web_fetch / httpsandbox.execute("curl ...")Make HTTP request
web_searchProvider-nativeWeb search (agent-level, not sandbox)

Multi-Agent Runs

Run multiple agents in the same sandbox (one at a time):

const sandbox = await runra.sandboxes.create({
  image: "node:22",
  resources: { cpu: 4, memoryMb: 8192 },
});

// Agent 1: Set up the project
await runra.start({
  prompt: "Initialize a Next.js project with TypeScript",
  sandboxId: sandbox.id,
  agent: { provider: "claude-code", config: { maxTurns: 10 } },
});

// Agent 2: Add features
await runra.start({
  prompt: "Add a /api/health endpoint and a Home page",
  sandboxId: sandbox.id,
  agent: { provider: "codex", config: { maxTurns: 10 } },
});

Next Steps