Run a coding agent in a sandbox

Start Claude Code, Codex, Gemini CLI and other harnesses inside a sandbox, stream their events, follow up, interrupt, give them MCP servers or a custom endpoint, and collect results and files.

sandbox.agents() runs a coding-agent harness (Claude Code, Codex, Gemini CLI, OpenCode, Goose, ...) inside the sandbox. The SDK installs the harness from pinned, checksum-verified sources, drives it over the Agent Client Protocol, and normalizes its output into events. A run lives in the sandbox (state under ~/.cua/agents/<run>), so it keeps working after your process exits; any client can reattach later.

The examples below are regions of examples/agents-in-sandboxes/tour.py, which runs them end to end.

Create a sandbox#

Any image with cua-spacesd works: the canonical ghcr.io/trycua/linux:24.04, your own registry image, or a benchmark image.

c = cua.embedded()
sb = await c.sandboxes().create(cua.SandboxCreateOptions(
    on="local",                               # or "aws"
    image=image,                              # any image with cua-spacesd
    name=name,
    memory_mb=4096,
    wait_for=[cua.ReadinessProbe(service="env")],
))

Run a harness#

Pass the provider key explicitly. env_from_host copies the named variables from this process's environment (known provider key names only); env takes values directly.

agents = await sb.agents()
run = await agents.run(
    "claude-code",
    "Write primes.py that prints the first 10 primes, then run it.",
    cua.AgentRunOptions(env_from_host=["ANTHROPIC_API_KEY"]),
)
print(run.run_id())

agents.run returns once the run has started. The first run of a harness in a sandbox also installs it (install events); await agents.ensure(["claude-code"]) installs ahead of time.

Stream events#

run.events(cursor, max) returns a page of events and the cursor to continue from. This helper prints them until the turn ends:

async def follow(run, cursor=0, timeout=600):
    """Prints events until the turn ends; returns the cursor to continue from."""
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        page = await run.events(cursor, None)
        cursor = page.cursor
        for e in page.events:
            if e.line:
                print(f"{e.kind:12} {e.line.splitlines()[0][:100]}")
            if e.kind in ("turn_ended", "exited"):
                return cursor
        if page.caught_up:
            await asyncio.sleep(0.5)
    raise TimeoutError(f"{run.run_id()} is still working")
cursor = await follow(run)

For a single turn, Python also has async for e in run.stream(): ....

Output of tour.py run against the scripted mock provider (cua-mock-llm), so the model reply is scripted, not generated; install lines are trimmed:

install      [install] claude-code verify sha512 integrity
install      [install] claude-code done /home/cua/.cua/tools/claude-code/2.1.281
turn_started > Write primes.py that prints the first 10 primes, then run it.
message      mock reply (scripted): ...
turn_ended   [turn 1 ended: end_turn]

Follow up and interrupt#

A follow-up continues the same session. It is queued while a turn runs.

await run.send("Now add a unit test for it and run the test.", None)
cursor = await follow(run, cursor)
await run.send("Rewrite it to use a sieve and benchmark both versions.", None)
await asyncio.sleep(5)
await run.interrupt()      # cancels the turn in flight; the session stays open
cursor = await follow(run, cursor)

Collect the result and files#

result() describes the last turn: status, stop reason, the agent's text, tool call count and token usage. artifacts() lists files the run created or changed in its working directory. run.wait(timeout_ms) waits for the turn to end, then returns the same result.

result = await run.result()
print(result.status, result.stop_reason, result.tool_calls, result.usage_json)
print(result.text)
for a in await run.artifacts():
    print(a.path, a.size)
await run.stop()

stop() ends the run and verifies its process is gone. remove() also deletes the run's directory, secrets included.

Fire and forget, reattach later#

exit_when_idle=True (--exit-when-idle) stops the run by itself once the prompt is answered. From the CLI:

cua agent run local:dev claude-code "Fix the failing test in tests/" \
  --env-from-host ANTHROPIC_API_KEY --repo https://github.com/<org>/<repo> --exit-when-idle
cua agent ls local:dev
cua agent logs local:dev <run-id> --follow
cua agent send local:dev <run-id> "Now add a regression test"
cua agent interrupt local:dev <run-id>
cua agent status local:dev <run-id>
cua agent stop local:dev <run-id>

Any process can pick up the runs of a sandbox:

agents = await sb.agents()            # any process, later
runs = await agents.list()            # newest first
for info in runs:
    print(info.run_id, info.harness, info.status, info.label)
run = await agents.get(runs[0].run_id)
print((await run.result()).text)

Give the agent MCP servers#

Every run gets the sandbox's own MCP tools (cua-driver: screen, input, windows), unless you set sandbox_mcp=False or the harness cannot take them. Pi has no MCP client, Hermes and OpenClaw read MCP only from their own config, and Antigravity has it off by default (see Harnesses).

Add more servers with mcp_servers. Each is a URL (streamable HTTP, reachable from inside the sandbox) or a command (stdio, run in the sandbox). Header values may be secrets, and the run treats them like keys.

run = await agents.run(
    "openai-codex",
    "Use the docs server to look up our API style guide, then review api.py.",
    cua.AgentRunOptions(
        env_from_host=["OPENAI_API_KEY"],
        mcp_servers=[
            cua.AgentRunMcpServer(name="docs", url="https://mcp.example.com/mcp",
                                  headers={"Authorization": "Bearer <token>"}),
            cua.AgentRunMcpServer(name="fs", command="npx",
                                  args=["-y", "@modelcontextprotocol/server-filesystem", "/srv"]),
        ],
        exit_when_idle=True,
    ),
)
cua agent run local:dev openai-codex "Review api.py" --env-from-host OPENAI_API_KEY \
  --mcp docs=https://mcp.example.com/mcp --no-sandbox-mcp

Use a custom endpoint or proxy#

base_url points the harness at a gateway, proxy or compatible server; wire picks its format (anthropic, openai-responses, openai-chat, gemini) when it differs from the harness's own.

run = await agents.run(
    "claude-code",
    "Summarize the repository in five bullet points.",
    cua.AgentRunOptions(
        env={"ANTHROPIC_API_KEY": "<gateway key>"},
        base_url="https://llm-gateway.internal.example.com",
        model="claude-sonnet-4-5",
        exit_when_idle=True,
    ),
)
cua agent run local:dev claude-code "Summarize the repository" \
  --env-from-host ANTHROPIC_API_KEY --base-url https://llm-gateway.internal.example.com \
  --model <model-id>

Harnesses#

cua agent harnesses
import json
 
import cua
 
print([h["id"] for h in json.loads(cua.agent_harnesses())])
# ['claude-code', 'openai-codex', 'gemini-cli', 'google-antigravity', 'opencode', 'goose', 'pi', 'hermes', 'openclaw']
HarnessKey variables (any one)Endpoint wireNotes
claude-codeANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_AUTH_TOKENanthropic
openai-codexOPENAI_API_KEY, CODEX_API_KEYopenai-responses
gemini-cliGEMINI_API_KEY, GOOGLE_API_KEYgeminiNeeds a paid API key
google-antigravityGEMINI_API_KEYgeminiHeadless ACP server, no terminal CLI; sandbox MCP off by default
opencodeANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, OPENROUTER_API_KEYopenai-chat
gooseOPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY, OPENROUTER_API_KEYopenai-chat
piANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, OPENROUTER_API_KEYopenai-chatCommunity ACP adapter; no MCP client
hermesOPENROUTER_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEYopenai-chatNeeds git; ignores run MCP servers; API keys only (the Claude Code and ChatGPT sign-ins are not given to it)
openclawANTHROPIC_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEYopenai-chatStarts its own loopback Gateway; no per-run MCP servers

cua agent harnesses (or cua.agent_harnesses()) prints the full record: what each harness installs, its interactive CLI, and known limits.

Credentials#

  • Keys reach a run only as explicit per-run env (env, env_from_host, --env-from-host). They are written with mode 0600 in the run's directory, redacted from its event log, and never passed in argv.
  • Nothing is copied from your machine's agent config (~/.claude, ~/.codex, and so on).
  • To use a signed-in session instead of a key, move it with teleport (claude-code is a built-in app), which shows what will move and asks for approval first.
  • See also Secrets.

Event kinds#

Each event has seq, turn, kind, optional text and tool fields, a one-line line rendering, and json (the raw record, ACP payload included).

KindMeaning
installInstalling the harness or its dependencies
initialized, authenticated, session, modeACP setup
turn_startedA prompt or follow-up began (text is the prompt)
message, thoughtAgent text and reasoning
user_messageA user message the agent echoed
tool_call, tool_updateA tool call and its progress (tool_title, tool_kind, tool_status)
planThe agent's plan entries
usageToken usage update
permissionA permission request, auto-approved (the sandbox is the boundary)
commands, info, noticeAvailable commands and informational updates
cancel_requestedinterrupt() was received
turn_endedThe turn finished (stop_reason, usage)
errorA harness or provider error
exitedThe run's process ended
otherAnything else the harness sent