Skip to content

Render Sandbox

Run your agent’s commands and file edits in an isolated Render sandbox instead of on your machine.

Source

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.

Install

Terminal
pip install "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.

Quick start

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.

Choose the tools

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.

What a timeout stops

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.

Reattach later

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.

Prepare a sandbox before the run

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.

Clean up

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.

Configuration

OptionWhat it does
clientA render.RenderAsync you own; it is never closed for you. Default: None, which reads RENDER_API_KEY and RENDER_WORKSPACE_ID.
allow_internet_accessWhether a new sandbox can reach the internet. Default: True.
sandbox_timeoutSeconds a new sandbox lives before Render terminates it, at most 86_400. Default: 3_600.
working_dirAbsolute directory commands start in and relative paths resolve from, created on a new sandbox. Default: None, the sandbox’s home directory.
envEnvironment variables every command gets. Default: None. Nothing from your machine’s environment is passed.

RenderSandbox needs asyncio; on Trio it raises UserError.

Render Workflows

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.

Telemetry

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.

API reference

RenderSandbox

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.

Attributes

client

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

allow_internet_access

Whether a newly created sandbox may reach the internet; without it, commands such as pip install fail.

Type: bool Default: True

sandbox_timeout

Seconds a newly created sandbox lives before Render terminates it, up to 86400.

Type: int Default: DEFAULT_SANDBOX_TIMEOUT

working_dir

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

env

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)

Methods

backend
def backend(ref: WorkspaceRef) -> RenderSandboxBackend

Attach lazily to an existing Render sandbox.

Returns

RenderSandboxBackend

destroy

@async

def destroy(ref: WorkspaceRef) -> None

Terminate the sandbox identified by ref; an already terminated or unknown one returns quietly.

Rejected credentials raise WorkspaceUnavailableError.

Returns

None

get_workspace
def get_workspace(
    ctx: RunContext[AgentDepsT],
    *,
    ref: WorkspaceRef | None,
) -> WorkspaceBackend | None

Supply a new sandbox workspace or attach to a recognized Render reference.

Returns

WorkspaceBackend | None

RenderSandboxBackend

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.

Constructor Parameters

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.

working_dir : str | None Default: None

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.

env : Mapping[str, str] | None Default: None

Environment variables every command gets; per-command env is layered on top. Nothing is read from the host environment.

Attributes

ref

The sandbox’s identity: None until a fresh one is created, then set for good.

Type: WorkspaceRef | None

Methods

run

@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.

Returns

CommandResult