OpenSandbox
Run your agent’s commands and file edits in a sandbox on your own OpenSandbox server, created on first use. The server runs sandboxes as Docker containers on one machine or as pods on Kubernetes; the agent’s code is the same either way.
While Pydantic AI Harness is on 0.x releases, the API may change between minor releases; when it does, deprecation warnings and release-note migration guidance tell you (or your agent) exactly how to upgrade. See the version policy.
pip install "pydantic-ai-harness[coder,opensandbox,anthropic]"
uv add "pydantic-ai-harness[coder,opensandbox,anthropic]"
The opensandbox extra installs the OpenSandbox SDK, and coder installs Coder’s dependencies. The anthropic extra is there because the examples use an Anthropic model; swap it for your model provider’s extra, and set ANTHROPIC_API_KEY for the examples.
The capability talks to an OpenSandbox server. To run one on this machine with Docker:
uvx opensandbox-server init-config ~/.sandbox.toml --example docker
uvx opensandbox-server
The capability needs execd 1.1.1 or later, the server’s in-sandbox command service: set execd_image = "opensandbox/execd:release-1.2.0" under [runtime] in ~/.sandbox.toml. Older versions drop the line breaks from command output, so the first operation raises WorkspaceUnavailableError saying so.
The capability finds the server through OPEN_SANDBOX_DOMAIN (default localhost:8080), and sends OPEN_SANDBOX_API_KEY if your server requires a key. For Kubernetes, see Run on Kubernetes. Requests use plain HTTP unless the domain starts with https://, such as OPEN_SANDBOX_DOMAIN=https://sandbox.example.com.
from pydantic_ai import Agent
from pydantic_ai_harness.coder import Coder
from pydantic_ai_harness.opensandbox_sandbox import OpenSandbox
agent = Agent('anthropic:claude-opus-5-5', capabilities=[OpenSandbox(image='python:3.13-slim'), Coder()])
result = agent.run_sync('Write a script that prints the first 10 primes, and run it.')
Coder’s shell and file tools now run in the sandbox, not on your machine. The sandbox is created on the run’s first use of the workspace. With Coder, RepoContext uses it when the run starts, so use Coder(repo_context=False) to create it only when a tool needs it. The sandbox keeps running after the run ends; see Clean up.
The image needs a POSIX sh and the usual file utilities. Commands run as the image’s user, usually root, and start in /workspace, which is created if it’s missing.
from pydantic_ai import Agent
from pydantic_ai_harness.coder import Coder
from pydantic_ai_harness.opensandbox_sandbox import OpenSandbox
agent = Agent('anthropic:claude-opus-5-5', capabilities=[OpenSandbox(image='python:3.13-slim'), Coder()])
result = agent.run_sync('Write a script that prints the first 10 primes, and run it.')
followup = agent.run_sync('Now make it print the first 100.', message_history=result.all_messages())
The follow-up run finds the sandbox in the message history and works in it, so the script is still there. Without the history, a run creates a new sandbox. If the sandbox has been destroyed, the run raises WorkspaceUnavailableError instead of starting over in an empty one.
To come back to the sandbox without the message history, store the run’s workspace ref and pass it back as workspace=:
from pydantic_ai import Agent
from pydantic_ai_harness.coder import Coder
from pydantic_ai_harness.opensandbox_sandbox import OpenSandbox
agent = Agent('anthropic:claude-opus-5-5', capabilities=[OpenSandbox(image='python:3.13-slim'), Coder()])
result = agent.run_sync('Write a script that prints the first 10 primes, and run it.')
ref = result.workspace.ref # store this, e.g. in your database
later = agent.run_sync('Now make it print the first 100.', workspace=ref)
The ref is WorkspaceRef(provider='opensandbox', id=<sandbox id>). Pass workspace='new' to create a fresh sandbox even when the message history names one. image, resource, allow_internet_access and secure_access only shape a new sandbox; working_dir, env and sandbox_timeout apply to an attached one too.
A ref is trusted input: whoever passes one runs commands in that sandbox, and the server serves every sandbox to every holder of its API key. Only pass refs your application stored itself.
To seed files or install packages before the agent starts, create the backend yourself, work in it through Workspace, and pass it to the run:
import asyncio
from pydantic_ai import Agent
from pydantic_ai.workspaces import Workspace
from pydantic_ai_harness.coder import Coder
from pydantic_ai_harness.opensandbox_sandbox import OpenSandbox, OpenSandboxBackend
agent = Agent('anthropic:claude-opus-5-5', capabilities=[OpenSandbox(image='python:3.13-slim'), Coder()])
async def main() -> None:
backend = OpenSandboxBackend(image='python:3.13-slim')
try:
workspace = Workspace(backend)
await workspace.write_text('calc.py', 'def add(a, b):\n return a - b\n')
install = await workspace.run(['pip', 'install', 'pytest'], timeout=300)
if install.exit_code != 0:
raise RuntimeError(f'pip install failed: {install.stderr}')
result = await agent.run('Fix the bug in calc.py.', workspace=backend)
print(result.output)
finally:
await backend.aclose()
if backend.ref is not None:
await OpenSandbox().destroy(backend.ref)
if __name__ == '__main__':
asyncio.run(main())
The finally destroys the sandbox even when setup or the run fails, and aclose() closes the backend’s connection to the server. To keep the sandbox instead, store backend.ref and pass it as workspace= to reattach later. OpenSandbox’s settings don’t apply to a backend you pass as workspace=; give the backend its own.
On a server deployed with OpenSandbox’s Helm charts, sandboxes are pods, created through OpenSandbox’s own BatchSandbox resources or through agent-sandbox; both work the same here. Pods are reachable only inside the cluster, so either deploy the server’s ingress gateway, or let the server proxy commands:
from opensandbox.config import ConnectionConfig
from pydantic_ai_harness.opensandbox_sandbox import OpenSandbox
sandbox = OpenSandbox(
image='python:3.13-slim',
connection=ConnectionConfig(domain='opensandbox.internal:8080', use_server_proxy=True),
resource={'cpu': '500m', 'memory': '1Gi'},
)
This example connects over plain HTTP inside the cluster. To connect over HTTPS, put the server behind TLS and pass ConnectionConfig(domain='https://opensandbox.example.com', protocol='https', use_server_proxy=True): the SDK uses the domain’s scheme for the server’s API and protocol for command and file requests, so a config needs both.
Each sandbox asks for one CPU and 2 GiB unless you pass resource. A pod that can’t be scheduled leaves the create waiting; the SDK gives up after its 30-second request timeout while the server may still finish it, so the backend finds the sandbox by a tag it set and keeps its ref, and the next operation attaches to it.
OpenSandbox(image='python:3.13-slim', secure_access=True) asks the server to turn on Secure Access when it creates the sandbox. It needs Kubernetes with the server’s ingress gateway ([ingress] with mode = "gateway"); Docker rejects it. The default is False.
With it on, the gateway and the server proxy reject requests to the sandbox’s endpoints that lack its token. The SDK fetches the token from the server’s API, using the server API key, and sends it with every command and file request, including after attaching. Requests that reach the pod without passing through the gateway or proxy, such as from elsewhere in the cluster, are not checked. Attaching never changes the setting.
OpenSandbox(image='python:3.13-slim', allow_internet_access=False) asks the server to create the sandbox with a deny-all egress policy. The default, True, sends no policy, so the server’s own configuration applies. Attaching never changes a sandbox’s policy.
The server enforces the policy with its egress sidecar. To block direct IP connections as well as DNS lookups, configure the server with:
[egress]
image = "opensandbox/egress:v1.1.7"
mode = "dns+nft"
In the default dns mode the sidecar filters DNS lookups only, so a command can still connect to an IP address directly. Server-level allow and deny rules apply in both modes. See OpenSandbox egress configuration for runtime requirements and configuration.
A run never destroys its sandbox. Destroy it, files included, with the ref you stored:
from pydantic_ai.workspaces import WorkspaceRef
from pydantic_ai_harness.opensandbox_sandbox import OpenSandbox
async def remove_sandbox(ref: WorkspaceRef) -> None:
await OpenSandbox().destroy(ref)
Destroying a sandbox that is already gone returns quietly. OpenSandbox() without an image attaches to and destroys sandboxes by ref, and leaves a run without a ref to the agent’s other workspace capabilities.
To have the server clean up for you, set sandbox_timeout: a sandbox unused for that many seconds is deleted, files included. Every operation renews it as it starts, so a long run keeps its sandbox, but a single command must finish within it: set it above your longest command. Without it, a sandbox lives until it is destroyed.
A failed run returns no result, so there is no ref to store. Destroy the sandbox in an on_run_error hook, as shown for E2B, or create the backend yourself as in Prepare a sandbox before the run. Every sandbox the capability creates carries a pydantic-ai-create metadata entry, so one left without a stored ref can be found by listing the server’s sandboxes.
On a timeout or cancellation, the command and the processes it started are stopped in the sandbox. Creating the sandbox, including pulling the image, is not bounded by a command’s timeout.
Each command is a request to the sandbox that takes about a fifth of a second, and file operations run as commands, so reading or writing a large file takes several seconds per MiB. Keep large data out of the workspace or fetch it from inside the sandbox.
A sandbox that is gone, or destroyed mid-command, raises WorkspaceUnavailableError. A command whose working directory has been deleted raises WorkspaceError. Other errors from the server, such as an image it can’t pull, are raised as the SDK reports them.
The OpenSandbox SDK runs on asyncio only, so under Trio the capability raises UserError.
Compose OpenSandbox(image=...) with TemporalDurability, DBOSDurability or PrefectDurability. Durable execution records the initial workspace operation’s ref so later operations and replays can reach the same sandbox. Configure the same workspace capability and server connection on every worker.
Temporal’s activity-local backends close their SDK-owned HTTP transports after each workspace operation. In-process engines can keep the run-owned transport open until the run ends. A transport passed through ConnectionConfig remains yours to close.
Sandbox creation is not exactly-once. If a create response is lost, the backend attempts to recover the ref using sandbox metadata; another operation on that same backend can attach to it. A fresh activity attempt or worker restart before the ref is recorded can create another sandbox. To avoid creation during workflow retries, create the sandbox and persist its ref before starting the workflow, then pass that ref as workspace=, not a live backend.
Set sandbox_timeout above the longest gap between workspace operations, including workflow waits and retries. There is no background renewal while the workflow is idle; without a timeout, clean up sandboxes left without a stored ref as described in Clean up.
The sandbox’s isolation is the server’s runtime: a Docker container shares the host’s kernel, and a Kubernetes pod shares its node’s, unless the server is configured with a secure runtime such as gVisor or Kata Containers. Inside, commands run as the image’s user, usually root. The capability passes allow_internet_access and secure_access to the server when it creates a sandbox, and the server applies them.
- With the default
allow_internet_access=True, a Docker-backed sandbox can reach its host and LAN, including the OpenSandbox server if it listens there.Falseon a server indns+nftmode blocks that; see Network access. - Requests to the server’s API carry the server API key. Command and file requests carry commands, file contents and
envvalues. Over plainhttpall of them are unencrypted. Without aconnection, anhttps://domain inOPEN_SANDBOX_DOMAINsends every request over HTTPS. AConnectionConfigyou pass is used as given, so set both anhttps://domain andprotocol='https'on it; see Run on Kubernetes. - The server API key, set as
api_keyunder[server], controls every sandbox on that server and can fetch their Secure Access tokens. Keep it out of the sandbox’senv. - Without the server proxy, command and file requests go straight to the sandbox’s endpoint, and the SDK does not send the server API key there. The endpoint checks only what the server configures for it, such as Secure Access at a Kubernetes gateway; see Endpoint authentication. With
use_server_proxy=True, they go through the server, which checks the API key. imageis pulled by tag; pin a digest, such aspython:3.13-slim@sha256:..., to get the same image every time.
Agent specs can set image, resource, allow_internet_access, secure_access, sandbox_timeout, working_dir and env. Configure the server connection through OPEN_SANDBOX_DOMAIN and OPEN_SANDBOX_API_KEY; a custom ConnectionConfig is a Python-only option.
| Option | What it does |
|---|---|
image | Image for a new sandbox, such as 'python:3.13-slim'. Default: None, which only attaches to and destroys sandboxes by ref. |
connection | The SDK’s ConnectionConfig for your server. Default: None, which reads OPEN_SANDBOX_DOMAIN and OPEN_SANDBOX_API_KEY. A config you pass is used as given; see Security model for HTTPS. |
resource | Resource limits for a new sandbox, such as {'cpu': '500m', 'memory': '1Gi'}. Default: None, one CPU and 2 GiB. |
allow_internet_access | Default: True, which sends no policy, so the server’s configuration applies. False asks the server for deny-all egress on a new sandbox; see Network access. |
secure_access | Default: False. True asks the server to make its Kubernetes ingress gateway and proxy require a token for a new sandbox’s endpoints; see Endpoint authentication. Docker rejects True. |
sandbox_timeout | Seconds a sandbox may go unused before the server deletes it, files included; every operation renews it, and a single command must finish within it. Default: None, kept until destroyed. |
working_dir | Absolute directory commands start in and relative paths resolve against, created on first use if missing. Default: /workspace; None uses the image’s own. |
env | Environment variables every command gets. Default: None. Nothing from your machine’s environment reaches the sandbox, and env is kept out of the capability’s repr, but the agent’s commands can read it. |
OpenSandbox emits no spans of its own. Core’s instrumentation records the sandbox on the agent run span as pydantic_ai.workspace.provider (opensandbox) and pydantic_ai.workspace.id. Each command or file operation a tool makes runs inside that tool call’s span. Creating a sandbox logs its ID at INFO (Created OpenSandbox sandbox <id>) on the pydantic_ai_harness.opensandbox_sandbox._backend logger.
The OpenSandbox SDK reports how long each create took to your server’s /metrics/events. Set OPENSANDBOX_DISABLE_METRICS=1, or pass ConnectionConfig(disable_metrics=True), to turn that off.
Bases: AbstractCapability[AgentDepsT]
Supply an OpenSandbox sandbox as the run’s workspace, created on first use.
from pydantic_ai import Agent
from pydantic_ai_harness import Coder, OpenSandbox
agent = Agent('anthropic:claude-opus-5-5', capabilities=[OpenSandbox(image='python:3.13-slim'), Coder()])
With an image, a run with no reference creates a fresh sandbox on your OpenSandbox server, on whichever
runtime it uses (Docker or Kubernetes). Pass a WorkspaceRef to attach to a sandbox created earlier.
Ending a run does not delete the sandbox: remove it with
destroy,
or set sandbox_timeout. Commands run as the image’s user. Outbound network access follows the server’s
policy unless allow_internet_access=False requests deny-all egress for a new sandbox.
This capability supplies the workspace only. Compose it with capabilities that use it, such as Coder,
Shell, and FileSystem. See Workspaces for more.
The image for a new sandbox, such as 'python:3.13-slim'; it needs a POSIX sh and the usual file utilities.
Without one, the capability only attaches to and destroys sandboxes by ref, and leaves a run without a ref to the agent’s other workspace capabilities.
Type: str | None Default: None
The opensandbox.config.ConnectionConfig for your server. When None, each run reads
OPEN_SANDBOX_DOMAIN and OPEN_SANDBOX_API_KEY, and an https:// domain applies to command and file
requests too. A config you pass is used as given: set protocol='https' on it for an encrypted connection.
On Kubernetes without the ingress gateway, pass ConnectionConfig(use_server_proxy=True).
Type: ConnectionConfig | None Default: field(default=None, repr=False)
Resource limits for a new sandbox, such as {'cpu': '500m', 'memory': '1Gi'}; None keeps the SDK’s
default of one CPU and 2 GiB.
Type: Mapping[str, str] | None Default: None
Whether a new sandbox keeps the server’s network policy. False requests deny-all egress.
Requires the server’s egress sidecar; direct IP connections are blocked only in dns+nft mode, not the
default DNS-only mode. Does not change the policy of an attached sandbox.
Type: bool Default: True
Ask the server to turn on Secure Access for a new sandbox.
The server’s ingress gateway and proxy then require the sandbox’s token on its endpoints; requests that reach
the pod directly are not checked. Requires Kubernetes with an ingress gateway; Docker rejects True. The SDK
fetches the token with the server API key and sends it automatically, including after attaching. Does not
change the setting of an attached sandbox.
Type: bool Default: False
Seconds a sandbox may go unused before the server deletes it, files included. Every operation renews it as it
starts, so a single command must finish within it. None keeps it until it is destroyed.
Type: int | None Default: None
Absolute directory commands start in and relative paths resolve against, created on first use if
missing; None uses the image’s own.
Type: str | None Default: '/workspace'
Environment variables every command gets, also in an attached sandbox; a command’s env is layered on
top. Nothing is read from the host.
Type: Mapping[str, str] | None Default: field(default=None, repr=False)
@classmethod
def from_spec(
cls,
*,
id: str | None = None,
description: str | None = None,
image: str | None = None,
resource: Mapping[str, str] | None = None,
allow_internet_access: bool = True,
secure_access: bool = False,
sandbox_timeout: int | None = None,
working_dir: str | None = '/workspace',
env: Mapping[str, str] | None = None,
**kwargs: Any,
) -> OpenSandbox[Any]
Build from an agent spec using fields expressible in JSON.
An explicit signature keeps the SDK’s ConnectionConfig annotation from hiding the configuration
schema. Specs use the server connection from the environment; pass connection in Python instead.
OpenSandbox[Any]
def backend(ref: WorkspaceRef) -> OpenSandboxBackend
Construct a lazy backend for an existing sandbox.
OpenSandboxBackend
@async
def destroy(ref: WorkspaceRef) -> None
Delete a sandbox by ID, files included; one that no longer exists returns quietly.
def get_workspace(
ctx: RunContext[AgentDepsT],
*,
ref: WorkspaceRef | None,
) -> WorkspaceBackend | None
Build the backend for this run. No I/O here: it creates or attaches on first use.
WorkspaceBackend | None
Bases: WorkspaceBackend, SupportsCommands
Run commands in an OpenSandbox sandbox, created on first use.
Built with image, the first operation creates a sandbox from it; built with ref, it attaches to that
sandbox and raises WorkspaceUnavailableError if it is
gone. The backend never kills the sandbox: call
OpenSandbox.destroy with its ref.
The image needs a POSIX sh and the usual file utilities. File operations run through that shell. The
OpenSandbox SDK runs on asyncio only, so under Trio every operation raises UserError.
The image for a new sandbox. Pass it or ref, not both.
ref : WorkspaceRef | None Default: None
A WorkspaceRef(provider='opensandbox', id=<sandbox id>) to attach to instead of creating one.
connection : ConnectionConfig | None Default: None
The opensandbox.config.ConnectionConfig for the server; None reads OPEN_SANDBOX_DOMAIN
and OPEN_SANDBOX_API_KEY. A transport you pass in it is yours to close.
Absolute directory commands start in and relative paths resolve against, created on first
use if missing; None uses the image’s own.
Environment variables every command gets; the per-call env goes on top. Nothing is read from the host.
Resource limits for a new sandbox, such as {'cpu': '500m', 'memory': '1Gi'}; None keeps the
SDK’s default of one CPU and 2 GiB.
allow_internet_access : bool Default: True
Whether a new sandbox keeps the server’s network policy. False requests deny-all
egress; blocking direct IP connections requires the server’s dns+nft egress mode. Ignored on attach.
secure_access : bool Default: False
Ask the server to turn on Secure Access for a new sandbox, so its ingress gateway and
proxy require the sandbox’s token. Requires Kubernetes with an ingress gateway; Docker rejects True.
Ignored on attach.
Seconds a sandbox may go unused before the server deletes it, files included. Every
operation renews it as it starts, so a single command must finish within it. None keeps it until it
is destroyed.
WorkspaceRef(provider='opensandbox', id=<sandbox id>), or None before a sandbox has been created.
Type: WorkspaceRef | None
@async
def get_sandbox() -> Sandbox
The opensandbox.Sandbox, for features the workspace API does not cover.
On a backend with no sandbox yet this creates one, or attaches to the one ref names, just like the
first operation. It shares the backend’s transport, so it stops working once the backend closes: at
aclose(), and, for a backend OpenSandbox built that no run owns, after each operation.
Sandbox
@async
def aclose() -> None
Close the HTTP transport the SDK opened; the sandbox keeps running.
The transport belongs to the event loop that opened it, so a backend used from another loop must be
closed first; a later operation attaches again by ref. An operation in flight keeps it open until the
last one ends. OpenSandbox calls this when the run that used the backend ends.