Skip to content

Daytona Sandbox

Run your agent’s commands and file edits in an isolated Daytona cloud 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[daytona,anthropic]"

The anthropic extra is there because the examples use an Anthropic model; swap it for your model provider’s extra. Then set DAYTONA_API_KEY to an API key from your Daytona dashboard, 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.daytona_sandbox import DaytonaSandbox

agent = Agent('anthropic:claude-opus-5-5', capabilities=[DaytonaSandbox(), 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, not on your machine. With Coder, RepoContext creates the sandbox when the run starts, even without a tool call; use Coder(repo_context=False) for lazy creation. It keeps running after the run ends until Daytona stops it for being idle, and keeps its disk, and costs money, until you delete it; see Clean up.

Give the agent a project directory with DaytonaSandbox(working_dir='/home/daytona/project'): a new sandbox gets it created for you, and commands and relative paths start there.

Daytona’s default snapshot comes with git, Python, and ripgrep (rg), which Coder uses for searches. For your project’s dependencies, build a Daytona snapshot with them installed and pass DaytonaSandbox(snapshot='my-snapshot'). A snapshot Daytona doesn’t know fails on first use with WorkspaceUnavailableError.

Continue in the same sandbox

from pydantic_ai import Agent
from pydantic_ai_harness.coder import Coder
from pydantic_ai_harness.daytona_sandbox import DaytonaSandbox

agent = Agent('anthropic:claude-opus-5-5', capabilities=[DaytonaSandbox(), Coder()])
result = agent.run_sync('Clone https://github.com/pydantic/pydantic-ai and summarize how capabilities work.')

followup = agent.run_sync(
    'Which capability would you add next, and where would it live?',
    message_history=result.all_messages(),
)

The follow-up run finds the sandbox in the message history and works in it, so the clone is still there. Without the history, a run starts a new sandbox. If Daytona stopped the sandbox while it was idle, the follow-up run starts it again. If the sandbox has been deleted, the run raises WorkspaceUnavailableError instead of starting over in an empty one.

Choose the tools

For a narrower agent, use Shell and FileSystem instead of Coder, or write your own tool that runs in the sandbox:

from pydantic_ai import Agent, RunContext
from pydantic_ai_harness.daytona_sandbox import DaytonaSandbox
from pydantic_ai_harness.filesystem import FileSystem
from pydantic_ai_harness.shell import Shell

agent = Agent('anthropic:claude-opus-5-5', capabilities=[DaytonaSandbox(), Shell(), FileSystem()])


@agent.tool
async def run_python(ctx: RunContext, code: str) -> str:
    """Run a Python snippet in the sandbox."""
    result = await ctx.workspace.run(['python', '-c', code], timeout=10)
    return result.stdout + result.stderr

Commands run under sh -c as the non-root daytona user. File reads refuse FIFOs rather than waiting for a writer. File writes create missing parent directories, write through a symlink to its target, and keep an existing file’s mode.

What a timeout stops

A command timeout starts after the sandbox is ready. A timeout or a cancelled run stops the command, and the sandbox keeps running for other commands. WorkspaceTimeoutError carries the last 4 KiB of each stream’s output so far. If Daytona does not confirm the stop, the command may still be running: inspect the sandbox or delete it yourself. timeout=None removes the command deadline, not Daytona’s idle stop.

A command’s output is collected once it exits, so run() returns then even if a background child it started is still running; output that child writes afterwards is not returned. A command whose combined output passes 10 MiB raises WorkspaceOutputLimitError once it exits, with the first 64 KiB of each stream in stdout and stderr. Redirect large or background output to a file instead; Shell.start_command manages its own output log.

See Workspaces for more.

Reattach later

To come back to the sandbox without the message history, pass its ref back as workspace=:

from pydantic_ai import Agent
from pydantic_ai_harness.coder import Coder
from pydantic_ai_harness.daytona_sandbox import DaytonaSandbox

agent = Agent('anthropic:claude-opus-5-5', capabilities=[DaytonaSandbox(), Coder()])

result = agent.run_sync('Clone https://github.com/pydantic/pydantic-ai and summarize how capabilities work.')
ref = result.workspace.ref  # store this, e.g. in your database

later = agent.run_sync('Which capability would you add next, and where would it live?', workspace=ref)

The ref holds no credentials, so the process that reattaches needs DAYTONA_API_KEY too. Pass workspace='new' to start a fresh sandbox even when the message history names one.

snapshot, auto_stop_interval, and allow_internet_access only shape a new sandbox; working_dir and env apply to every command, including after you reattach. A new sandbox gets working_dir created for it; on an attached or caller-supplied sandbox it must already exist, or commands fail with WorkspaceError.

Already have a daytona.AsyncSandbox? Pass workspace=DaytonaSandboxBackend(sandbox=sandbox) to a run, with DaytonaSandboxBackend from pydantic_ai_harness.daytona_sandbox. DaytonaSandbox’s settings don’t apply to it; pass working_dir= and env= to the backend.

A run leaves a backend you built open. Call await backend.aclose() when you are done with it: that closes the client it opened from DAYTONA_API_KEY, not the sandbox, and a later operation opens a new one. A client= or sandbox= you passed is never closed.

Prepare a sandbox before the run

To seed files before the agent starts, build the backend yourself, write through a Workspace, and pass the backend to the run:

from pydantic_ai import Agent
from pydantic_ai.workspaces import Workspace
from pydantic_ai_harness.coder import Coder
from pydantic_ai_harness.daytona_sandbox import DaytonaSandbox, DaytonaSandboxBackend

agent = Agent('anthropic:claude-opus-5-5', capabilities=[DaytonaSandbox(), Coder()])


async def run_in_prepared_sandbox() -> str:
    backend = DaytonaSandboxBackend(working_dir='/home/daytona/project')
    try:
        await Workspace(backend).write_text('TASK.md', 'Add a `--version` flag to cli.py.\n')
        result = await agent.run('Do the task in TASK.md.', workspace=backend)
        return result.output
    finally:
        await backend.aclose()
        ref = backend.ref  # set once the sandbox exists
        if ref is not None:
            await DaytonaSandbox().destroy(ref)

The run uses the backend as it is, so DaytonaSandbox’s settings don’t apply to it, and leaves it open: close it with aclose(). To keep the sandbox instead, store backend.ref and pass it as workspace= to reattach later.

Clean up

Pydantic AI never stops or deletes the sandbox. Daytona stops a container sandbox, such as one from the default snapshot, after 15 idle minutes by default (set auto_stop_interval= to change that, or 0 to keep it running). A stopped sandbox keeps its disk and is archived after 7 days. A sandbox from a VM snapshot is paused after 60 idle minutes instead, and is never archived. Either way, a later run starts it again, and Daytona never deletes it. Delete it with the ref you stored:

from pydantic_ai.workspaces import WorkspaceRef
from pydantic_ai_harness.daytona_sandbox import DaytonaSandbox


async def delete_sandbox(ref: WorkspaceRef) -> None:
    await DaytonaSandbox().destroy(ref)

destroy(ref) deletes by sandbox ID without starting a stopped sandbox, and a sandbox that no longer exists returns quietly. Only destroy sandboxes you own. Use backend(ref) to construct a lazy backend for an existing sandbox; get_sandbox() attaches to it and returns its native daytona.AsyncSandbox (on a backend with no sandbox yet, it creates one).

A failed run returns no result, so there is no ref to store. To delete its sandbox, clean up in an on_run_error hook; after_run doesn’t run when a run fails:

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.daytona_sandbox import DaytonaSandbox

hooks = Hooks()


@hooks.on.run_error
async def delete_failed_run_sandbox(ctx: RunContext[None], *, error: BaseException) -> AgentRunResult[Any]:
    if ctx.workspace.ref is not None:
        await DaytonaSandbox().destroy(ctx.workspace.ref)
    raise error


agent = Agent('anthropic:claude-opus-5-5', capabilities=[DaytonaSandbox(), Coder(), hooks])

A run that ends before its ref is stored, such as a crash, Ctrl-C, or a killed worker right after creation, leaves the sandbox behind. Every sandbox the backend creates is labelled created-by=pydantic-ai, and its ID is logged at INFO as Created Daytona sandbox <id>; delete it with await DaytonaSandbox().destroy(WorkspaceRef(provider='daytona', id=...)).

Configuration

OptionWhat it does
clientA daytona.AsyncDaytona to share across runs on one event loop, or to configure its API URL or target. Default: None, a client each run opens from DAYTONA_API_KEY and closes when it ends. You close a client you pass; DaytonaSandbox never does.
snapshotDaytona snapshot a new sandbox starts from. Default: None, Daytona’s default. An unknown snapshot fails on first use.
auto_stop_intervalIdle minutes before Daytona stops a new sandbox; 0 disables it. Default: None, Daytona’s default: 15 minutes for a container, while a VM pauses after 60 minutes instead.
allow_internet_accessWhether a new sandbox can reach the internet. When False, what is blocked depends on your Daytona organization’s tier: on Tier 3 and 4 everything, while lower tiers keep package registries, GitHub, and AI APIs reachable, so there it is not an exfiltration boundary. See Daytona’s network limits. Default: True.
working_dirAbsolute directory commands start in and relative paths resolve against. Default: None, the sandbox’s own (/home/daytona, where commands run as non-root daytona); set a project directory, or use relative paths for portable code. Created on a new sandbox; on an attached or caller-supplied sandbox it must already exist.
envEnvironment variables every command gets. Default: None. Nothing from your machine’s environment reaches the sandbox.

Daytona runs on the asyncio event loop only: its SDK uses asyncio tasks, so under Trio the backend raises UserError.

Durable execution

Each activity opens its own Daytona client and closes it once its operation ends, so the worker has nothing to close. Run a Temporal dev server on localhost:7233 first.

import asyncio
import uuid

from pydantic_ai import Agent
from pydantic_ai.durable_exec.temporal import PydanticAIPlugin, PydanticAIWorkflow, TemporalDurability
from pydantic_ai_harness.coder import Coder
from pydantic_ai_harness.daytona_sandbox import DaytonaSandbox
from temporalio import workflow
from temporalio.client import Client
from temporalio.worker import Worker

agent = Agent(
    'anthropic:claude-opus-5-5',
    name='daytona_coder',
    capabilities=[DaytonaSandbox(), Coder(), TemporalDurability()],
)


@workflow.defn
class SandboxWorkflow(PydanticAIWorkflow):
    __pydantic_ai_agents__ = [agent]

    @workflow.run
    async def run(self, prompt: str) -> str:
        return (await agent.run(prompt)).output


async def main() -> None:
    client = await Client.connect('localhost:7233', plugins=[PydanticAIPlugin()])
    async with Worker(client, task_queue='sandbox', workflows=[SandboxWorkflow]):
        print(
            await client.execute_workflow(
                SandboxWorkflow.run,
                'Use the shell tool to run pwd.',
                id=f'sandbox-{uuid.uuid4()}', task_queue='sandbox',
            )
        )


if __name__ == '__main__':
    asyncio.run(main())

Coder, Shell, and FileSystem work under DBOS, Temporal and Prefect. See the Coder, Shell, and FileSystem guides for engine-specific limits.

Removing a capability while workflows using it are still running changes their replay history. Drain those workflows or use Temporal worker versioning before deploying the change.

Telemetry

DaytonaSandbox emits no spans of its own. Core’s instrumentation records the sandbox on the agent run span as 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; operations at run start, such as RepoContext loading repo instructions, run in the agent run span. Creating a sandbox logs its ID at INFO (Created Daytona sandbox <id>) on the pydantic_ai_harness.daytona_sandbox._backend logger, so a sandbox that outlives its run can still be found.

API reference

DaytonaSandbox

Bases: AbstractCapability[AgentDepsT]

Supply an isolated Daytona sandbox as the run’s workspace.

A run with no reference creates a fresh sandbox on first use; pass a WorkspaceRef to attach to an existing one. Ending a run does not delete the sandbox: Daytona stops it when idle and keeps it until you delete it. Commands run under sh -c as the sandbox’s default user.

This capability supplies the workspace only. Compose it with capabilities that use it, such as Coder, Shell, and FileSystem. See Workspaces for more.

Attributes

client

A daytona.AsyncDaytona to share across runs on one event loop; you close it. When None, each run opens one from DAYTONA_API_KEY and closes it when the run ends.

Type: AsyncDaytona | None Default: None

snapshot

Daytona snapshot a newly created sandbox starts from; Daytona’s default when None. An unknown snapshot raises WorkspaceUnavailableError on first use.

Type: str | None Default: None

auto_stop_interval

Idle minutes before Daytona stops a newly created sandbox; 0 disables it, and None keeps Daytona’s default (15 minutes for a container; a VM pauses after 60 instead). Attaching starts a stopped or paused sandbox again.

Type: int | None Default: None

allow_internet_access

Whether a newly created sandbox may reach the internet. When False, Daytona’s Tier 3 and 4 block everything; lower tiers keep package registries, GitHub, and AI APIs reachable.

Type: bool Default: True

working_dir

Absolute directory commands start in and relative paths resolve against; None uses the sandbox’s own. Created on a new sandbox; an attached sandbox must already have it.

Type: str | None Default: None

env

Environment variables every command gets, also on 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)

Methods

backend
def backend(ref: WorkspaceRef) -> DaytonaSandboxBackend

Construct a lazy backend for an existing sandbox.

Returns

DaytonaSandboxBackend

destroy

@async

def destroy(ref: WorkspaceRef) -> None

Delete a sandbox by ID without starting it, including one Daytona has stopped.

A sandbox that no longer exists is already gone, so destroying it returns quietly.

Returns

None

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

Build the backend for this run. No I/O here: it attaches or creates on first use.

Returns

WorkspaceBackend | None

DaytonaSandboxBackend

Bases: WorkspaceBackend, SupportsCommands, SupportsFilesystem, SupportsRealpath

A Daytona sandbox as a Pydantic AI WorkspaceBackend.

Commands and file operations run inside a Daytona sandbox, so the host is never exposed.

Building one does no I/O. The first operation creates or attaches to a sandbox, and the typed daytona.AsyncSandbox is available through get_sandbox(). The backend does not stop or delete the sandbox; that is the application’s job.

A command’s complete output is returned once it exits. A shell string runs under /bin/sh -c; a timeout or cancellation stops the command.

A deleted sandbox, one Daytona stopped while idle (attaching again starts it), rejected credentials, and an unknown snapshot raise WorkspaceUnavailableError. Path failures raise the built-in errors, such as FileNotFoundError.

Constructor Parameters

sandbox : AsyncSandbox | None Default: None

A live daytona.AsyncSandbox you already have. Whoever created it owns deleting it.

client : AsyncDaytona | None Default: None

A daytona.AsyncDaytona API client to create or attach with. The caller owns closing it. Without one, the backend opens its own from the environment on first use and closes it in aclose().

ref : WorkspaceRef | None Default: None

Identity of an existing sandbox to attach to on first use.

snapshot : str | None Default: None

Daytona snapshot a newly created sandbox starts from; Daytona’s default when None.

auto_stop_interval : int | None Default: None

Idle minutes before Daytona stops a newly created sandbox; 0 disables it, and None keeps Daytona’s default (15 minutes for a container; a VM pauses after 60 instead).

allow_internet_access : bool Default: True

Whether a newly created sandbox may reach the internet.

working_dir : str | None Default: None

Absolute directory commands start in and relative paths resolve against; the sandbox’s own default when None, discovered with pwd -P on first use.

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

Environment variables every command gets; a command’s own env is layered on top. They are also set on a newly created sandbox.

Attributes

ref

Identity of the sandbox, or None before one has been created.

The id is always Daytona’s sandbox ID, also after attaching by a ref that names the sandbox.

Type: WorkspaceRef | None

Methods

get_sandbox

@async

def get_sandbox() -> AsyncSandbox

Return the typed daytona.AsyncSandbox, creating or attaching to it on first use.

Attaching by ref to a sandbox that no longer exists raises WorkspaceUnavailableError; it does not create a replacement.

Returns

AsyncSandbox

aclose

@async

def aclose() -> None

Close the AsyncDaytona API client this backend opened for itself.

A client passed as client= is left alone, and the sandbox keeps running. A later operation opens a new client and attaches by ref. DaytonaSandbox calls this when the run that used the backend ends.

Returns

None

working_dir

@async

def working_dir() -> str

Return the canonical directory commands start in, probed once per connection.

Returns

str