Skip to main content

sandbox-agents

Python client for the Sandbox Agents control-plane — agents that run in sandboxed containers, conversations against them, and the event log everything is observed through.

pip install sandbox-agents

Requires Python 3.11 or newer. Sync and async clients, typed models, resumable event streaming, client-tool dispatch and webhook verification are all included; httpx and pydantic are the only dependencies.

The model in three sentences

An agent is a reusable definition: instructions, a model, the manifest its sandboxes start from. A session is one conversation and the container it owns — the workspace, the ports, the snapshots. A run is one turn, and it is asynchronous: sending a message returns a run id, and the answer arrives over the session's event log.

Quickstart

from sandbox_agents import Client

client = Client(base_url="http://localhost:8000", api_key="ak_…")

agent = client.agents.create(
    slug="renewal-analyst",
    name="Renewal analyst",
    instructions="Inspect the files before answering. Cite the source of every claim.",
    model="gpt-5.4-mini",
    manifest={
        "entries": {
            "brief.md": {"kind": "file", "content": "# Northwind\n- Renewal: 2026-04-15\n"},
            "output": {"kind": "dir"},
        }
    },
)

session = client.sessions.create_and_wait(agent.slug, title="Northwind renewal")
result = client.sessions.run(session.id, "Write output/report.md listing every blocker.")

print(result.text)                                   # the model's answer
print(client.sessions.read_text(session.id, "output/report.md"))  # what it wrote
client.sessions.stop(session.id)

create_and_wait and run are the two waiting helpers: the first returns when the container is up, the second when the turn is over. Both follow the event log rather than holding a request open, so neither is affected by a proxy's idle timeout.

model is one of the names client.models.list() returns — the LiteLLM proxy's catalogue, with each model's context limits. The openai transport is the exception: it calls OpenAI directly, so any OpenAI model name works there.

Configuration

Argument Environment Default
base_url SANDBOX_AGENTS_BASE_URL http://localhost:8000
api_key SANDBOX_AGENTS_API_KEY —
project SANDBOX_AGENTS_PROJECT —
client = Client()                       # entirely from the environment
client = Client(project="acme")          # X-Project, by id or slug

project can be left out where the answer is unambiguous — a deployment with one project, or an API key bound to one. With several, a request that does not name one is refused rather than guessed at.

Keep one client for the process. It owns a connection pool, and it remembers where it is in each session's log — which is what lets run start from now instead of paging the whole conversation.

Async

The same surface, awaited. Prefer it for anything following more than one conversation: a turn is minutes of mostly waiting.

import asyncio
from sandbox_agents import AsyncClient

async def main() -> None:
    async with AsyncClient() as client:
        session = await client.sessions.create_and_wait("renewal-analyst")
        async for event in client.sessions.stream(session.id):
            if event.type == "agent.text_delta":
                print(event.text, end="", flush=True)

asyncio.run(main())

Streaming

sessions.stream yields the log from a cursor and then live events. It resumes on its own: a dropped connection reconnects from the last seq seen, and because that cursor is a database sequence it survives a server restart too.

for event in client.sessions.stream(session.id, after=cursor):
    if event.type == "agent.message":
        print(event.text)
    elif event.type == "agent.tool_use":
        print(f"→ {event.tool}")
    elif event.type in ("session.status_idle", "session.status_error"):
        break

Token deltas (agent.text_delta) are streamed but never stored, so they carry seq == 0 and never move the cursor — event.is_persistent is the check. sandbox_agents.events has every type name as a constant, plus TERMINAL_TYPES and EPHEMERAL_TYPES.

To render a transcript first and then follow it, page the log and stream from where the page ended:

history = list(client.sessions.history(session.id))
for event in client.sessions.stream(session.id, after=history[-1].seq):
    ...

Client tools

An agent can declare tools it does not implement. When the model calls one, the turn stops and waits for the caller to answer — that is how an agent in a container reaches the page a user is looking at.

from sandbox_agents import Tool

def open_account(args: dict) -> dict:
    return {"id": args["id"], "status": "active"}

result = client.sessions.run(
    session.id,
    "Look up account 42 and summarise it.",
    tools=[
        Tool(
            name="open_account",
            handler=open_account,
            description="Read an account by id",
            parameters={"type": "object", "properties": {"id": {"type": "string"}}},
        )
    ],
)

A Tool is declared for that turn and answered by it. For tools already declared on the agent, pass handlers by name instead: tools={"open_account": open_account}.

A handler that raises does not fail the run: the exception is reported to the model as the tool's output, because the turn is blocked on this answer and losing the conversation over one failed lookup is worse. Handlers may be async def on the async client.

Questions for a person: deferrable tools

An ordinary client tool holds the turn open for at most the server's timeout (120 s). For a question a person answers — a form, a confirmation — declare it deferrable. The turn then ends at the call instead of waiting inside it: the session goes idle with stop_reason="requires_action", and your answer continues the same run. It can come minutes later, from another process, after a deploy.

result = client.sessions.run(session.id, "Plan my trip.")   # the agent declares ask_user, deferrable
if result.requires_action:                                   # paused, not failed
    save_for_later(session.id, result.run_id, result.last_seq)

# later — another request, another worker:
for call in client.sessions.tool_calls(session.id):
    client.sessions.submit_tool_result(session.id, call.call_id, form_answer)
# From the saved cursor: from the start, the old pause would end the wait again.
client.sessions.wait_for_run(session.id, run_id=run_id, after=last_seq)

run answers a deferrable call itself when it has a handler for it (Tool(..., deferrable=True)), and then follows the turn to its end as usual. With no handler it leaves the call alone rather than answering for the person. submit_tool_result(..., is_error=True) tells the model the tool failed, with output as the reason.

Files, shell, ports

client.sessions.upload_file(session.id, "brief.pdf")            # → uploads/brief.pdf
client.sessions.list_files(session.id, "output")
client.sessions.download_file(session.id, "output/report.md", "./report.md")
client.sessions.exec(session.id, "ls -la output").check()
client.sessions.port(session.id, 8000).url                       # a preview URL

Snapshots

snapshot = client.sessions.create_snapshot(session.id, label="before-refactor")
fork = client.sessions.create_and_wait("renewal-analyst", from_snapshot_id=snapshot.id)

Skill bundles

A bundle is a named skills tree that CI pushes, instead of a repository the sandbox clones at boot. The difference is when the tree is chosen: a ref resolves at every boot, a version is a number.

client.skill_bundles.create("voc-runtime", description="the runtime tree")

# From a build step, straight after writing the archive.
pushed = client.skill_bundles.push("voc-runtime", "dist/bundle.tgz")
print(pushed.version, pushed.file_count, pushed.skills)

for version in client.skill_bundles.versions("voc-runtime"):
    print(version.version, version.digest[:12], len(version.skills))

Point an agent at it:

from sandbox_agents import BundleSkillsSource

client.agents.update(
    "analyst",
    skills_source=BundleSkillsSource(bundle="voc-runtime", subpath="skills"),
)

version defaults to "latest", so a push reaches the agent without an edit here. Pass an integer to pin it; a retired version's number is never handed out again, and an agent pinned to one fails at boot saying so rather than quietly getting a different tree.

The archive is checked before anything is stored, with the same rules a staged checkout passes: nothing that would write outside its root, no symlink the sandbox SDK cannot copy, no nested .git. A refusal raises and uses no version number up.

Webhooks

For consumers that are not connected when something happens. Bodies are signed with HMAC-SHA256 over "<timestamp>.<body>"; verify against the raw bytes, before anything parses the JSON.

from fastapi import FastAPI, Request, Response
from sandbox_agents import webhooks

app = FastAPI()

@app.post("/hooks/agents")
async def receive(request: Request) -> Response:
    raw = await request.body()
    if not webhooks.verify(SECRET, raw, request.headers.get(webhooks.SIGNATURE_HEADER)):
        return Response(status_code=401)
    delivery = webhooks.parse(raw)
    print(delivery.type, delivery.event.text if delivery.event else "")
    return Response(status_code=204)

Delivery is at-least-once; X-Anecdote-Delivery is stable across retries and is what makes a consumer idempotent.

Errors

from sandbox_agents import ConflictError, NotFoundError, RunFailedError

try:
    agent = client.agents.update(agent.id, version=agent.version, name="New name")
except ConflictError:
    agent = client.agents.get(agent.id)   # somebody edited it first; re-apply

APIStatusError and its subclasses (BadRequestError, AuthenticationError, PermissionDeniedError, NotFoundError, ConflictError, UnprocessableEntityError, RateLimitError, InternalServerError) carry status_code and detail. APIConnectionError and APITimeoutError mean no answer arrived. RunFailedError is a turn that ended in session.status_error; TimeoutExpiredError is a waiting helper giving up on something still running, and carries last_seq so the wait can be resumed.

Safe methods and 429s are retried with jittered backoff (max_retries=2). A POST is not: one that timed out may already have created the session.

Partial updates: None means clear

The control-plane distinguishes a field that was not sent from one sent as null, so this client does too. Anything you do not pass is left alone; None clears.

client.agents.update(ref, version=7, image=None)   # clears the image override
client.agents.update(ref, version=7, name="x")     # touches nothing else

Development

poetry install
poetry run pytest
poetry run ruff check . && poetry run mypy src

The full API reference — every resource, with the event catalogue and the webhook payloads — is in the control-plane UI under Developers → Python SDK, alongside the OpenAPI schema at /docs on the API itself.

Release files for sandbox-agents 0.4.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for sandbox-agents 0.4.0
File Size Uploaded
sandbox_agents-0.4.0.tar.gz 62.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for sandbox-agents 0.4.0
File Interpreter ABI Platform
sandbox_agents-0.4.0-py3-none-any.whl Python 3 none any Details

Total release size: 141.0 kB

Release files / sandbox_agents-0.4.0.tar.gz

Download URL sandbox_agents-0.4.0.tar.gz
Size 62.8 kB
Tags Source
SHA-256 checksum
How to use checksums
a83647ccdb5b68357d22eceeb1196dad9898d5c76c0eb148efb9901e265d7fb5
BLAKE2b-256 checksum
How to use checksums
f2b1a00c7e6333eeb9129e33f3d9f6983953018e0f1dfbc79943867efd8d414b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via poetry/2.3.2 CPython/3.13.7 Darwin/27.0.0

Release files / sandbox_agents-0.4.0-py3-none-any.whl

Download URL sandbox_agents-0.4.0-py3-none-any.whl
Size 78.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
2d5d29a69007dc688d862f54bf29fbecbe7244faa308e24414e5eada5c8972d0
BLAKE2b-256 checksum
How to use checksums
8d24a76e9f8cfc8884f4ced94848e1ad9d531d5c1f3836fdec7eda400e3c19cf
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via poetry/2.3.2 CPython/3.13.7 Darwin/27.0.0

Release history Release notifications | RSS feed

0.5.0

2 release files

This release

0.4.0 This release

2 release files

0.3.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page