AI
OpenAI Agents SDK Sandbox Agents: Manifest, Docker, E2B
October 202612 min read

A SandboxAgent is a normal Agent with a workspace attached. It keeps tools, handoffs, guardrails and the usual Runner APIs, and adds a default Manifest, a run_as user and capabilities such as shell and file editing. Where the workspace actually runs is chosen per run through SandboxRunConfig.
No. On Linux it runs commands as host processes with no OS-level confinement, and on macOS the Python client restricts the filesystem through sandbox-exec but provides no network isolation. It also inherits the full host environment unless you set inherit_host_environment=False. The docs recommend Docker or a hosted provider for anything influenced by untrusted input.
Install openai-agents[docker], then pass DockerSandboxClient with DockerSandboxClientOptions(image=..., network_mode="none") in SandboxRunConfig. none is the only explicit network mode the SDK accepts, and it cannot be combined with exposed ports. The image must already contain your dependencies, because nothing can be downloaded inside the container.
A snapshot seeds a fresh sandbox session from saved workspace files, while session_state reconnects to a specific backend session you serialised earlier. Snapshots include only the workspace root, not mounts or extra path grants. Serialised state strips host paths and mount credentials, so you must resume with a current trusted manifest.
On Windows, the docs say to use Docker or a hosted sandbox client, because Unix-local supports only macOS and Linux. Sandbox agents launched in Python in April 2026, and the TypeScript SDK now documents them too, with SandboxAgent and Manifest exported from @openai/agents/sandbox and Node.js 22 or later required.

Key Takeaway
OpenAI Agents SDK sandbox agents pair a normal agent with a workspace: a SandboxAgent declares a Manifest of files, repos and mounts, capabilities add shell and file-editing tools, and SandboxRunConfig picks the backend. Unix-local runs commands as host processes with no network isolation, so untrusted work belongs in Docker or a hosted provider such as E2B.
The first agent I wanted to give a shell to was a boring one: read a bug report about tax rounding on credit notes in an ERP invoicing module, reproduce it with the failing test, patch it, rerun the test, write a short report. The code was the easy part. The hard part was the question every agent with a terminal raises: whose filesystem is it typing into, and what else can it reach from there?
OpenAI answered that on 15 April 2026 with native sandbox execution in the Agents SDK, launched first in Python with TypeScript to follow, and the TypeScript guide now documents the same model. This post covers the openai agents sdk sandbox agents design from the official Python docs: SandboxAgent, the Manifest that describes the workspace, capabilities and lazily loaded skills, the Unix-local, Docker and hosted clients, snapshots and resume, and the warnings in the docs that decide which backend you can actually use.
A SandboxAgent is still an Agent. It keeps instructions, tools, handoffs, MCP servers, guardrails and hooks, and it still runs through Runner.run, run_sync and run_streamed. What changes is the execution boundary, and the docs split that boundary into five pieces with deliberately separate jobs:
The split matters because the agent definition never names a backend. The same SandboxAgent runs on your laptop during development and in a container or a provider's VM in production, and only the run config changes. The docs also make one boundary explicit that is easy to miss: the outer runtime still owns approvals, tracing and handoffs, while the sandbox session only owns commands and file changes. A sandbox does not replace your approval policy; it limits the damage when that policy lets something through.
The Manifest describes what a fresh sandbox should contain before the first model call. Small synthetic inputs and output folders use File and Dir, host material that should be copied in uses LocalFile and LocalDir, a repository uses GitRepo, and external storage uses mounts such as S3Mount, GCSMount, R2Mount, AzureBlobMount and BoxMount. Here is the manifest for the invoicing agent, with a writable repo copy, read-only fixtures and an output directory, all owned by a dedicated sandbox user:
from agents.sandbox import FileMode, Manifest, Permissions, SandboxAgent, User
from agents.sandbox.entries import Dir, LocalDir
# The identity every model-facing tool runs as: shell commands,
# file reads, apply_patch. Declaring it in the manifest is what
# lets the entries below grant it exactly what it needs.
engineer = User(name="engineer")
WRITABLE = Permissions(
owner=FileMode.ALL, group=FileMode.ALL, other=FileMode.NONE, directory=True
)
READ_ONLY = Permissions(
owner=FileMode.READ | FileMode.EXEC,
group=FileMode.READ | FileMode.EXEC,
other=FileMode.NONE,
)
manifest = Manifest(
users=[engineer],
entries={
# COPIED into the sandbox. The agent edits the copy; your
# working tree is untouched until you take a diff out.
# src resolves against the SDK process cwd and must stay
# under it unless extra_path_grants says otherwise.
"repo": LocalDir(src="./erp-invoicing", group=engineer, permissions=WRITABLE),
# Reference data the agent may read but never "fix".
"fixtures": LocalDir(src="./fixtures/tax-2026", group=engineer, permissions=READ_ONLY),
# Dir is synthetic: created inside the sandbox, read from
# nowhere on the host. This is where the report must land.
"output": Dir(group=engineer, permissions=WRITABLE),
},
)
agent = SandboxAgent(
name="Invoice fixer",
model="gpt-5.6-sol",
instructions=(
"Read repo/task.md before editing. Make the smallest correct change, "
"run pytest tests/test_tax_rounding.py, and write a summary to output/report.md."
),
default_manifest=manifest,
run_as=engineer,
)
# Rejected before anything runs: entry paths are workspace-relative.
# "/etc/erp.conf": LocalFile(...) absolute path
# "../secrets": LocalDir(...) escapes the workspaceThree rules in the docs shape every manifest. Entry paths are workspace-relative and may not be absolute or climb out with two dots, which keeps one manifest portable across local, Docker and hosted clients. LocalDir sources resolve against the SDK process working directory and must stay under it unless you add extra_path_grants. And Dir does not read from the host at all: it is created inside the sandbox, which is exactly what you want for an output folder the agent must write to.
Permissions here are file mode bits on the materialised entries, not model permissions or API credentials. By default an entry is readable and executable by everyone and writable by its owner. Declaring a User and setting run_as is what turns those bits into a real rule: shell commands, file reads and patches execute as that identity, so a fixtures folder without a write bit stays unmodified however confident the model is that the fixture is wrong. Treat extra_path_grants as trusted configuration too, and never build them from model output.
Capabilities are how sandbox-native tools reach the model. Shell adds exec_command, plus write_stdin when the backend supports a PTY. Filesystem adds apply_patch and view_image. Skills indexes skill folders and materialises them on demand. Memory distils lessons from earlier runs into workspace files, and Compaction trims context in long runs. The default, Capabilities.default(), is Filesystem, Shell and Compaction, and that default is the source of the most common mistake:
from agents.sandbox.capabilities import Capabilities, LocalDirLazySkillSource, Skills
from agents.sandbox.entries import GitRepo, LocalDir
# Wrong: a list REPLACES Capabilities.default(). This agent can
# load skills, but it has lost exec_command, apply_patch and
# compaction. It will read the task and then have no way to act.
capabilities = [
Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src="./skills"))),
]
# Right: extend the default set (Filesystem + Shell + Compaction).
capabilities = Capabilities.default() + [
Skills(
lazy_from=LocalDirLazySkillSource(
# A HOST path, read by the SDK process. Only the skills the
# model asks for are copied into the sandbox (".agents").
source=LocalDir(src="./skills"),
)
),
]
# Skills with their own release cadence: pull them from a repo.
capabilities = Capabilities.default() + [
Skills(from_=GitRepo(repo="acme/erp-agent-skills", ref="v3")),
]Lazy skills are the right default for a large skills directory: the model sees the index first and calls load_skill only for what it needs, which keeps a dozen ERP playbooks out of the context window until one is relevant. The source path is read by the SDK process on the host, so point it at the original directory, not a path that only exists inside the sandbox image. Use Skills with from_ and a LocalDir for a small bundle you want staged up front, or a GitRepo when the skills have their own release cycle.
A turn is still a model step, not a shell command. The docs are explicit that there is no one-to-one mapping between sandbox operations and turns: another turn is consumed only when the runtime needs another model response. Size max_turns for how often the agent must think, not for how many commands the test suite will run.
The client is the real security decision, and the docs are blunt about it. On Linux, UnixLocalSandboxClient runs commands as ordinary host processes and adds no OS-level confinement: a command can reach any file or network resource the host process can, and the workspace directory, HOME or cwd restricts nothing. On macOS, the Python client applies filesystem restrictions through sandbox-exec but still offers no network isolation. The TypeScript guide says its Unix-local client adds neither filesystem nor network isolation on either platform. I work on Windows, where neither SDK supports Unix-local at all, so the docs send me straight to Docker or a hosted provider.
What each documented client gives you, according to the SDK docs and client source:
| Client | Install | Isolation boundary | Use it for |
|---|---|---|---|
| UnixLocalSandboxClient | None, included | Host processes. Linux: no confinement. macOS Python: sandbox-exec filesystem rules, no network isolation | Trusted local development, or inside isolation you already provide |
| DockerSandboxClient | openai-agents[docker] | A container from the image you choose; network_mode none removes networking | Untrusted commands on your own machine or CI, image parity with production |
| E2BSandboxClient | openai-agents[e2b] plus E2B_API_KEY | A provider-managed sandbox; internet access is on unless you disable it | Hosted execution without running container infrastructure yourself |
| Blaxel, Cloudflare, Daytona, Modal, Runloop, Vercel | The matching extra, for example openai-agents[modal] | Provider-managed; mount support differs per provider | Production-style isolation on infrastructure you already pay for |
| Your own client | Implement the sandbox client interface | Whatever your platform enforces | Bringing an internal sandbox provider into the same agent code |
Unix-local also passes the agent the complete host environment by default. Every command the model runs starts with the variables of the process that launched it, which on a developer machine usually includes OPENAI_API_KEY, cloud credentials and database URLs. If you use Unix-local at all, create the client with inherit_host_environment=False, which keeps only a short allowlist such as PATH, LANG, TZ and the CA bundle variables, and remember that this filters variables only. It adds no confinement.
Mounts deserve the same suspicion. A mount helper running inside a model-controlled sandbox can see the credentials it uses, so the SDK refuses an in-container mount that needs protected authority until trusted code acknowledges the exposure for that exact mount path. The docs recommend provider-native strategies where they exist, and otherwise sandbox-scoped, short-lived, least-privilege credentials.
Moving from Unix-local to a real boundary changes the run config and nothing else. The Docker client takes a docker-py client and an image; the E2B client comes from agents.extensions.sandbox and reads E2B_API_KEY. The two options worth setting on day one are the network switches, because both clients leave networking on unless told otherwise:
from docker import from_env as docker_from_env
from agents import Runner
from agents.run import RunConfig
from agents.sandbox import SandboxRunConfig
from agents.sandbox.sandboxes.docker import DockerSandboxClient, DockerSandboxClientOptions
from agents.extensions.sandbox import E2BSandboxClient, E2BSandboxClientOptions, E2BSandboxType
# pip install "openai-agents[docker]"
docker_run = RunConfig(
sandbox=SandboxRunConfig(
client=DockerSandboxClient(docker_from_env()),
options=DockerSandboxClientOptions(
image="python:3.14-slim",
# "none" is the only explicit mode the SDK accepts. Omit it
# and the container keeps Docker's default networking.
# It cannot be combined with exposed_ports.
network_mode="none",
labels={"com.example.owner": "invoice-fixer"},
),
),
)
# pip install "openai-agents[e2b]" and export E2B_API_KEY
e2b_run = RunConfig(
sandbox=SandboxRunConfig(
client=E2BSandboxClient(),
options=E2BSandboxClientOptions(
sandbox_type=E2BSandboxType.E2B,
# The options default this to True. Turn it off unless the
# task really has to install packages from the internet.
allow_internet_access=False,
workspace_persistence="snapshot", # default is "tar"
),
),
)
# Same SandboxAgent, different boundary. Nothing on the agent changes.
result = await Runner.run(agent, "Fix the bug described in repo/task.md.", run_config=docker_run)For the invoicing agent, network_mode none was the obvious choice: the repo and fixtures are already in the manifest and the test suite needs nothing from outside. The cost is that the image must already contain every dependency, because pip install will fail inside the container. Labels are cheap operational insurance, and the SDK verifies them when it reconnects to an existing container, raising ValueError on a mismatch rather than silently attaching to the wrong one. On E2B, pause_on_exit and workspace_persistence decide what survives between runs, and the default persistence mode is a tar archive of the workspace.
There are two lifecycle modes. Pass a client and the runner owns everything: it creates the sandbox, runs the agent, persists the snapshot and cleans up. Pass a live session and you own it, which is what you want for a multi-step job, for inspecting files after the run, or for streaming over a sandbox you started yourself:
from pathlib import Path
from agents.sandbox import LocalSnapshotSpec
client = DockerSandboxClient(docker_from_env())
# Developer-owned lifecycle: you create it, you decide when it dies.
sandbox = await client.create(
manifest=agent.default_manifest,
snapshot=LocalSnapshotSpec(base_path=Path("/var/lib/agents/snapshots/invoice-fixer")),
options=DockerSandboxClientOptions(image="python:3.14-slim", network_mode="none"),
)
try:
await sandbox.start()
run_config = RunConfig(sandbox=SandboxRunConfig(session=sandbox))
await Runner.run(agent, "Reproduce the failing test. Do not fix it yet.", run_config=run_config)
# A checkpoint, not a shutdown: persists the snapshot-backed
# workspace and leaves the sandbox running for the next run.
await sandbox.stop()
await Runner.run(agent, "Now fix it and rerun the test.", run_config=run_config)
finally:
# The full cleanup path: pre-stop hooks, stop(), resource shutdown.
await sandbox.aclose()
# Later, in another process: reconnect from state you stored yourself.
# Serialized state is stripped of host_path values and mount
# credentials, so pass the CURRENT trusted manifest back in.
restored = client.deserialize_session_state(load_saved_payload())
resume_run = RunConfig(
sandbox=SandboxRunConfig(client=client, session_state=restored, manifest=manifest),
)The names in that block are easy to confuse. stop persists snapshot-backed workspace contents and does not tear the sandbox down. aclose is the full cleanup path. A snapshot seeds a new session from saved files, while session_state reconnects to a specific backend session you serialised earlier. Only the workspace root goes into a snapshot: mounted paths and extra path grants are runtime access, not durable state, so the bucket you mounted is not copied into the archive.
The resume path is also where the SDK protects you from your own storage. Serialised state drops host_path values, cloud mount credentials and credential-exposure acknowledgements, and resume fails before the sandbox starts unless you pass a current trusted manifest with the same mount layout. Serialised state never grants authority by itself, which is the right default for anything you keep in a job queue or database. One provider exception is documented: Vercel cannot resume a mounted session, so you start a new one instead.
Sandbox agents compose with handoffs and with agent.as_tool, and the sandbox adds one decision: does the sub-agent get its own workspace or reuse the parent's? Sharing a live session is fast, because nothing is created, hydrated or snapshotted twice, and run_as plus file modes keep the reviewer read-only inside the same filesystem:
# For a SHARED session, the manifest must already declare
# users=[engineer, reviewer_user], and repo/ and output/ need
# other=FileMode.READ | FileMode.EXEC: the reviewer is "other",
# so it can read the fix but holds no write bit to change it.
# An injected live session cannot add users after the fact.
reviewer_user = User(name="reviewer")
reviewer = SandboxAgent(
name="Diff reviewer",
instructions="Read repo/ and output/report.md. Report risks. Do not edit files.",
run_as=reviewer_user,
)
sandbox = await client.create(manifest=manifest)
async with sandbox:
shared = RunConfig(sandbox=SandboxRunConfig(session=sandbox))
fixer = SandboxAgent(
name="Invoice fixer",
instructions="Fix the bug, then call review_fix before you answer.",
run_as=engineer,
tools=[
reviewer.as_tool(
tool_name="review_fix",
tool_description="Review the change in repo/ for regressions.",
run_config=shared, # same live workspace, no second hydrate
max_turns=2, # nested run, its own turn budget
)
],
)
result = await Runner.run(fixer, "Fix repo/task.md and get it reviewed.", run_config=shared)The two composition styles count differently. A handoff keeps one top-level run, and the sandbox agent simply takes the next turn. An as_tool call starts a nested run with its own turn loop, max_turns, approvals and usually its own run config, and none of its turns increment the parent's counter. Approvals raised inside the nested sandbox run still surface on the outer run and resume the nested run when you approve. When the sub-agent should edit independently or needs a different image, give its as_tool call its own SandboxRunConfig with a Docker client instead.
For jobs that must survive a crashed worker, Temporal shipped an integration alongside the launch, published on 16 April 2026, with a runnable example in openai-agents-python under examples/sandbox/extensions/temporal. temporal_sandbox_client wraps any sandbox client so model calls, the sandbox lifecycle and commands run as durable Temporal activities, and a session can be forked onto a different backend, starting in Docker and continuing on Daytona, with the workspace carried over by snapshot.
This is the order I would put a sandboxed ERP repo agent into a CI job, with each step closing a gap the previous one leaves open:
The useful mental model is that the sandbox is a property of the run, not of the agent. Write the SandboxAgent and Manifest once, keep them narrow, and pick the boundary per environment: Unix-local only for code you already trust, Docker or a hosted provider with networking off for everything a stranger could have written. The agent is the same in both places. What it can reach is not.
Sources