Render Sandbox
Run your agent’s commands and file edits in an isolated Render sandbox instead of on your machine.
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,render,anthropic]"
uv add "pydantic-ai-harness[coder,render,anthropic]"
The coder extra installs the coding tools. The anthropic extra is there because the examples use an Anthropic model; swap it for your model provider’s extra. Then set RENDER_API_KEY to a Render API key, RENDER_WORKSPACE_ID to the tea-... ID of a workspace with Sandboxes access and billing configured, and ANTHROPIC_API_KEY for the examples.
from pydantic_ai import Agent
from pydantic_ai_harness.coder import Coder
from pydantic_ai_harness.render_sandbox import RenderSandbox
agent = Agent('anthropic:claude-opus-5-5', capabilities=[RenderSandbox(), Coder()])
result = agent.run_sync('Clone https://github.com/pydantic/pydantic-ai and summarize how capabilities work.')
Coder’s shell and file tools now run in the sandbox. With Coder, the sandbox is created when the run starts; pass Coder(repo_context=False) to create it on the first tool call instead. The sandbox keeps running, and billing, after the run ends; see Clean up.
A new sandbox can reach the internet; pass allow_internet_access=False to block it. It lives for 1 hour; pass up to sandbox_timeout=86400 for longer.
Commands start in the sandbox’s home directory. Pass working_dir= for another absolute directory, created on a new sandbox.
RenderSandbox supplies the workspace only. Add Coder for a coding agent, or the narrower Shell and FileSystem.
A string command runs under /bin/sh -c; a list runs as given. Standard input is empty, and a non-zero exit is a normal result. Output over 10 MiB raises WorkspaceOutputLimitError with the first 64 KiB of each stream and attempts to stop the command; redirect large output to a file. A command and its environment must fit in about 110 KB; write larger content with the file tools.
The sandbox image needs python3. Render’s default image has it.
Render allows 100 API requests a minute. A rate-limited request raises the SDK’s RateLimitError, and is not retried.
Sandbox setup timeouts raise TimeoutError. Missing sandboxes and rejected access raise WorkspaceUnavailableError; other SDK errors propagate. A connection failure during creation can leave a sandbox running without a ref, so check your Render workspace for unused sandboxes.
On a timeout or cancellation, stopping the command and its children is best effort; the sandbox remains usable. If stopping cannot be confirmed, a warning identifies the sandbox and the original exception still propagates. Background processes that detach from the command can keep running.
See Workspaces for more.
To come back to the sandbox, 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.render_sandbox import RenderSandbox
agent = Agent('anthropic:claude-opus-5-5', capabilities=[RenderSandbox(), Coder()])
result = agent.run_sync('Write a fizzbuzz.py and run it.')
ref = result.workspace.ref # store this, e.g. in your database
later = agent.run_sync('Add tests for fizzbuzz.py.', workspace=ref)
The process that reattaches needs RENDER_API_KEY and RENDER_WORKSPACE_ID too. A terminated sandbox raises WorkspaceUnavailableError, and no replacement is created. Pass workspace='new' to start a fresh sandbox even when the message history names one.
allow_internet_access and sandbox_timeout apply only to a new sandbox. working_dir and env apply to every command; an attached sandbox must already have working_dir.
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.render_sandbox import RenderSandbox, RenderSandboxBackend
agent = Agent('anthropic:claude-opus-5-5', capabilities=[RenderSandbox(), Coder()])
async def main() -> None:
backend = RenderSandboxBackend()
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:
ref = backend.ref # set once the sandbox exists
if ref is not None:
await RenderSandbox().destroy(ref)
if __name__ == '__main__':
asyncio.run(main())
The finally terminates the sandbox even if setup or the run fails. Read any files you want to keep before it.
The sandbox keeps running, and billing, until sandbox_timeout runs out: RenderSandbox turns off Render’s idle suspension for the sandboxes it creates. Pydantic AI never terminates it. Terminate it with the ref you stored:
from pydantic_ai.workspaces import WorkspaceRef
from pydantic_ai_harness.render_sandbox import RenderSandbox
async def terminate_sandbox(ref: WorkspaceRef) -> None:
await RenderSandbox().destroy(ref)
destroy(ref) terminates the sandbox by ID. It returns quietly if the sandbox is already gone, and raises WorkspaceUnavailableError if Render rejects the credentials. New sandbox IDs are also logged at INFO.
To terminate the sandbox after every run, including failed ones, use after_run and run_error hooks:
from typing import Any
from pydantic_ai import Agent, RunContext
from pydantic_ai.capabilities import Hooks
from pydantic_ai.run import AgentRunResult
from pydantic_ai_harness.coder import Coder
from pydantic_ai_harness.render_sandbox import RenderSandbox
sandbox = RenderSandbox()
hooks = Hooks()
@hooks.on.after_run
async def terminate_sandbox(ctx: RunContext[None], *, result: AgentRunResult[Any]) -> AgentRunResult[Any]:
if ctx.workspace.ref is not None:
await sandbox.destroy(ctx.workspace.ref)
return result
@hooks.on.run_error
async def terminate_failed_run(ctx: RunContext[None], *, error: BaseException) -> AgentRunResult[Any]:
if ctx.workspace.ref is not None:
await sandbox.destroy(ctx.workspace.ref)
raise error
agent = Agent('anthropic:claude-opus-5-5', capabilities=[sandbox, Coder(), hooks])
To keep results, read them through ctx.workspace in after_run before calling destroy.
| Option | What it does |
|---|---|
client | A render.RenderAsync you own; it is never closed for you. Default: None, which reads RENDER_API_KEY and RENDER_WORKSPACE_ID. |
allow_internet_access | Whether a new sandbox can reach the internet. Default: True. |
sandbox_timeout | Seconds a new sandbox lives before Render terminates it, at most 86_400. Default: 3_600. |
working_dir | Absolute directory commands start in and relative paths resolve from, created on a new sandbox. Default: None, the sandbox’s home directory. |
env | Environment variables every command gets. Default: None. Nothing from your machine’s environment is passed. |
RenderSandbox needs asyncio; on Trio it raises UserError.
RenderSandbox composes with RenderWorkflows: model and tool tasks share one sandbox. The render extra covers both; set the Render variables on every worker. A retried root task runs the agent again, so terminate the sandbox from the task that runs the agent, as in Clean up.
With instrumentation, the agent run span identifies the sandbox with pydantic_ai.workspace.provider and pydantic_ai.workspace.id, and each command or file operation a tool makes runs inside that tool call’s span.
Bases: AbstractCapability[AgentDepsT]
Supply an isolated Render sandbox as the run’s workspace.
A run with no reference creates a fresh sandbox on first use. Pass a WorkspaceRef supplied by the
application to attach to an environment managed elsewhere. Ending a run does not terminate the
sandbox: store result.workspace.ref and call destroy(ref), or let sandbox_timeout end it.
This capability supplies execution only. Compose it with tools or capabilities that use the
workspace, such as Coder, Shell, or FileSystem.
A caller-owned render.RenderAsync, never closed for you.
When omitted, credentials come from RENDER_API_KEY and RENDER_WORKSPACE_ID.
Type: RenderAsync | None Default: None
Whether a newly created sandbox may reach the internet; without it, commands such as pip install fail.
Type: bool Default: True
Seconds a newly created sandbox lives before Render terminates it, up to 86400.
Type: int Default: DEFAULT_SANDBOX_TIMEOUT
Absolute directory commands start in and relative paths resolve against.
Created on a new sandbox; an attached sandbox must already have it. None uses the sandbox’s home directory.
Type: str | None Default: None
Environment variables every command gets, also on an attached sandbox; nothing is read from the host.
Type: Mapping[str, str] | None Default: field(default=None, repr=False)
def backend(ref: WorkspaceRef) -> RenderSandboxBackend
Attach lazily to an existing Render sandbox.
RenderSandboxBackend
@async
def destroy(ref: WorkspaceRef) -> None
Terminate the sandbox identified by ref; an already terminated or unknown one returns quietly.
Rejected credentials raise WorkspaceUnavailableError.
def get_workspace(
ctx: RunContext[AgentDepsT],
*,
ref: WorkspaceRef | None,
) -> WorkspaceBackend | None
Supply a new sandbox workspace or attach to a recognized Render reference.
WorkspaceBackend | None
Bases: WorkspaceBackend, SupportsCommands, SupportsFilesystem
A Render sandbox as a Pydantic AI WorkspaceBackend.
Building one does no I/O. Built without a ref, the first operation creates a sandbox; built with one,
it attaches and never creates a replacement. The backend never terminates the sandbox: whoever holds
the ref does, with RenderSandbox.destroy(ref).
Commands and file operations use the same sandbox. On timeout or cancellation, stopping a command and its children is best effort. An unconfirmed stop is logged, and the sandbox remains usable.
client : RenderAsync | None Default: None
An optional caller-owned render.RenderAsync, never closed here. When None, credentials
come from RENDER_API_KEY and RENDER_WORKSPACE_ID.
ref : WorkspaceRef | None Default: None
Identity of an existing sandbox to attach to on first use.
allow_internet_access : bool Default: True
Whether a newly created sandbox may reach the internet.
sandbox_timeout : int Default: DEFAULT_SANDBOX_TIMEOUT
Seconds a newly created sandbox lives before Render terminates it, 1 to 86400.
Absolute directory commands start in and relative paths resolve against. Created on a
new sandbox; an attached sandbox must already have it. None uses the sandbox’s home directory.
Environment variables every command gets; per-command env is layered on top. Nothing is
read from the host environment.
The sandbox’s identity: None until a fresh one is created, then set for good.
Type: WorkspaceRef | None
@async
def run(
command: WorkspaceCommand,
*,
shell: bool = False,
env: Mapping[str, str] | None = None,
timeout: float | None = None,
) -> CommandResult
Run once with stdin at EOF, attempting to stop the command on timeout or cancellation.
CommandResult