Sprites Sandbox
Run your agent’s commands and file edits in a persistent Fly.io Sprite 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[sprites,anthropic]"
uv add "pydantic-ai-harness[sprites,anthropic]"
The anthropic extra is there because the examples use an Anthropic model; swap it for your model provider’s extra. Then set SPRITE_TOKEN to your Sprites API token, and ANTHROPIC_API_KEY for the examples.
from pydantic_ai import Agent
from pydantic_ai_harness.coder import Coder
from pydantic_ai_harness.sprites_sandbox import SpritesSandbox
agent = Agent('anthropic:claude-opus-5-5', capabilities=[SpritesSandbox(), 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 a Sprite, not on your machine. With Coder, RepoContext creates the Sprite when the run starts, even without a tool call; use Coder(repo_context=False) for lazy creation. It has no lifetime limit: it sleeps when idle and keeps its files, and costs money, until you delete it; see Clean up.
Give the agent a project directory with SpritesSandbox(working_dir='/home/sprite/project'): a new Sprite gets it created for you, and commands and relative paths start there.
A new Sprite comes with git, Python, and Node.js (preinstalled tools), but not pytest or ripgrep (rg). Install what your project needs, such as pytest; Sprites retain installed packages. For faster Coder searches, run sudo apt-get update && sudo apt-get install ripgrep once in the Sprite and store its ref to reuse it on later runs.
from pydantic_ai import Agent
from pydantic_ai_harness.coder import Coder
from pydantic_ai_harness.sprites_sandbox import SpritesSandbox
agent = Agent('anthropic:claude-opus-5-5', capabilities=[SpritesSandbox(), 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 Sprite in the message history and works in it, so the clone is still there. Without the history, a run starts a new Sprite. If the Sprite has been deleted, the run raises WorkspaceUnavailableError instead of starting over in an empty one. A command exiting 137 after confirmed Sprite deletion also raises this error; a SIGKILLed command in a live Sprite returns exit 137.
For a narrower agent, use Shell and FileSystem instead of Coder, or write your own tool that runs in the Sprite:
from pydantic_ai import Agent, RunContext
from pydantic_ai_harness.filesystem import FileSystem
from pydantic_ai_harness.shell import Shell
from pydantic_ai_harness.sprites_sandbox import SpritesSandbox
agent = Agent('anthropic:claude-opus-5-5', capabilities=[SpritesSandbox(), Shell(), FileSystem()])
@agent.tool
async def run_python(ctx: RunContext, code: str) -> str:
"""Run a Python snippet in the Sprite."""
result = await ctx.workspace.run(['python', '-c', code], timeout=10)
return result.stdout + result.stderr
A Sprite pauses processes between commands unless you run them as a Sprites service.
Commands run as the non-root sprite user, but Unix permissions do not restrict it: a file with mode 000 is still readable. Do not rely on them to keep tools out of a path.
File reads refuse FIFOs rather than waiting for a writer. File writes go through the Sprites filesystem API, which writes through a symlink to its target and creates missing parent directories.
A command timeout starts after the Sprite is ready. The backend closes that command’s exec connection and asks Sprites to stop it after one second; it does not delete the Sprite. Stopping is best effort: the command’s process group, plain & children of a shell included, is not guaranteed to have stopped. If stopping is uncertain, inspect the Sprite or delete it explicitly. timeout=None removes the command deadline, not the Sprite’s idle pause or transport limits.
A background child that inherits stdout or stderr keeps run() waiting until that child exits, because Sprites reports the exit status only after the output stream closes. Redirect background output to a file when starting a long-running job; Shell.start_command manages its own output log.
See Workspaces for more.
To come back to the Sprite 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.sprites_sandbox import SpritesSandbox
agent = Agent('anthropic:claude-opus-5-5', capabilities=[SpritesSandbox(), 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 SPRITE_TOKEN too. Pass workspace='new' to start a fresh Sprite even when the message history names one.
runtime only shapes a new Sprite, and an unknown one raises a clear error on first use; working_dir and env apply to every command, including after you reattach. A new Sprite gets working_dir created for it; on an attached or caller-supplied Sprite it must already exist, or commands fail with WorkspaceError.
Already have a sprites.AsyncSprite? Pass workspace=SpritesSandboxBackend(sandbox=sprite) to a run, with SpritesSandboxBackend from pydantic_ai_harness.sprites_sandbox. SpritesSandbox’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 SPRITE_TOKEN, not the Sprite, and a later operation opens a new one. A client= or sandbox= you passed is never closed.
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.sprites_sandbox import SpritesSandbox, SpritesSandboxBackend
agent = Agent('anthropic:claude-opus-5-5', capabilities=[SpritesSandbox(), Coder()])
async def run_in_prepared_sprite() -> str:
backend = SpritesSandboxBackend(working_dir='/home/sprite/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 Sprite exists
if ref is not None:
await SpritesSandbox().destroy(ref)
The run uses the backend as it is, so SpritesSandbox’s settings don’t apply to it, and leaves it open: close it with aclose(). To keep the Sprite instead, store backend.ref and pass it as workspace= to reattach later.
The Sprite keeps its files and installed packages after the run ends. Pydantic AI never deletes it. It sleeps when idle and wakes on the next command: you pay for compute while it is active and for storage until you delete it. Delete it with the ref you stored:
from pydantic_ai.workspaces import WorkspaceRef
from pydantic_ai_harness.sprites_sandbox import SpritesSandbox
async def delete_sprite(ref: WorkspaceRef) -> None:
await SpritesSandbox().destroy(ref)
destroy(ref) deletes by Sprite id without attaching or waking it, and a Sprite that no longer exists returns quietly. Only destroy Sprites you own. Use backend(ref) to construct a lazy backend for an existing Sprite; get_sandbox() attaches to it and returns its native sprites.AsyncSprite (on a backend with no Sprite yet, it creates one). A backend you build yourself outside a run opens its own client, which you close with aclose(); after that, call get_sandbox() again for a working handle. See Sprite lifecycle.
A failed run returns no result, so there is no ref to store. To terminate its sandbox, clean up in an on_run_error hook; after_run doesn’t run when a run fails. If creation’s reply was lost, the ref may identify a Sprite that is not yet visible to the API; a lookup failure does not prove it was never created:
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.sprites_sandbox import SpritesSandbox
hooks = Hooks()
@hooks.on.run_error
async def terminate_failed_run(ctx: RunContext[None], *, error: BaseException) -> AgentRunResult[Any]:
if ctx.workspace.ref is not None:
await SpritesSandbox().destroy(ctx.workspace.ref)
raise error
agent = Agent('anthropic:claude-opus-5-5', capabilities=[SpritesSandbox(), Coder(), hooks])
Unlike Modal and E2B sandboxes, a Sprite has no lifetime timeout: it persists until deleted. So a run that ends before its ref is stored, such as a crash, Ctrl-C, or a killed worker right after creation, leaves the Sprite behind. Its id is logged at INFO as Created Sprite <id> when it is created; delete it with await SpritesSandbox().destroy(WorkspaceRef(provider='sprites', id=...)).
| Option | What it does |
|---|---|
client | A sprites.AsyncSpritesClient to share across runs on one event loop, or to set its base URL or timeout. Default: None, a client each run opens from SPRITE_TOKEN and closes when it ends. You close a client you pass; SpritesSandbox never does. |
runtime | Runtime for a new Sprite. Default: None, Sprites’ default. An unknown runtime fails on first use. |
working_dir | Absolute directory commands start in and relative paths resolve against. Default: None, the Sprite’s own (/home/sprite, where commands run as non-root sprite); set a project directory, or use relative paths for portable code. Created on a new Sprite; on an attached or caller-supplied Sprite it must already exist. |
env | Environment variables every command gets. Default: None. Nothing from your machine’s environment reaches the Sprite. |
Sprites runs on the asyncio event loop only: its SDK uses asyncio tasks, so under Trio the backend raises UserError.
If the exec socket drops after connection but before an exit status arrives, the command may have run. This raises a non-retryable workspace error rather than replaying a potentially non-idempotent command. A handshake that fails before the socket opens cannot have started the command: the backend retries it three times over about five seconds, then lets it propagate as retryable.
A shared client is created at module import so activities on this worker reuse it. Close it when the worker stops. Run a Temporal dev server on localhost:7233 first.
import asyncio
import os
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.sprites_sandbox import SpritesSandbox
from temporalio import workflow
from temporalio.client import Client
from temporalio.worker import Worker
# The provider SDK must not be re-imported inside Temporal's restricted workflow sandbox.
with workflow.unsafe.imports_passed_through():
from sprites import AsyncSpritesClient
CLIENT = AsyncSpritesClient(token=os.environ['SPRITE_TOKEN'])
agent = Agent(
'anthropic:claude-opus-5-5',
name='sprites_coder',
capabilities=[SpritesSandbox(client=CLIENT), 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 CLIENT:
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.
SpritesSandbox emits no spans of its own. Core’s instrumentation records the Sprite 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 Sprite logs its name at INFO (Created Sprite <name>) on the pydantic_ai_harness.sprites_sandbox._backend logger, so a Sprite that outlives its run can still be found.
Bases: AbstractCapability[AgentDepsT]
Run the agent’s workspace in a persistent Fly.io Sprite.
A run with no reference creates a fresh Sprite on first use; pass a WorkspaceRef to attach
to an existing one. Ending a run does not delete the Sprite: it sleeps when idle and persists
until you delete it. Commands run under sh -c in the Sprite’s own shell environment.
This capability supplies the workspace only. Compose it with capabilities that use it, such
as Coder, Shell, and FileSystem. See
Workspaces for more.
A caller-owned sprites.AsyncSpritesClient, which is never closed for you. When omitted,
each run’s backend creates one on first use from SPRITE_TOKEN and closes it when the run
ends; a backend this capability supplies outside a run (under Temporal, one per activity)
closes its client after each operation. A backend from backend(ref) needs aclose(). Supply one to share its connections across runs, on one event loop.
Type: AsyncSpritesClient | None Default: None
Runtime for a newly created Sprite; an unknown runtime fails on first use.
Type: str | None Default: None
Absolute directory commands start in and relative paths resolve against; None uses the Sprite’s default.
Created on a new Sprite; an attached Sprite must already have it.
Type: str | None Default: None
Environment variables every command gets, on top of the Sprite’s own; a command’s env is layered on top.
Type: Mapping[str, str] | None Default: field(default=None, repr=False)
def backend(ref: WorkspaceRef) -> SpritesSandboxBackend
Construct a lazy backend for an existing Sprite.
SpritesSandboxBackend
@async
def destroy(ref: WorkspaceRef) -> None
Delete a Sprite by id without attaching or waking it.
A Sprite that no longer exists is already gone, so destroying it returns quietly.
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.
WorkspaceBackend | None
Bases: WorkspaceBackend, SupportsCommands, SupportsFilesystem
A Fly.io Sprite behind the Pydantic AI WorkspaceBackend protocol.
Pass sandbox= to wrap a sprites.AsyncSprite you already have, or ref= to reattach to one.
Construction does no I/O. The typed sprites.AsyncSprite is available through get_sandbox().
Without client=, the backend creates an AsyncSpritesClient from SPRITE_TOKEN on first use
and closes it in aclose().
The backend does not delete the Sprite; that is the application’s job, through the native
handle. Commands run under /bin/sh -c with shell=True, in the Sprite’s own environment
plus env. A working_dir is created on a Sprite the backend creates; an attached or
caller-supplied Sprite must already have it. File writes go through the Sprite’s filesystem
API; the other file operations run as shell commands.
@async
def get_sandbox() -> AsyncSprite
Return the typed sprites.AsyncSprite, for Sprites features the workspace API does not cover.
On a backend with no Sprite yet, this creates one (which Sprites bills) or attaches to the one
ref names, just like the first operation. Attaching by ref to a Sprite that no longer
exists raises WorkspaceUnavailableError; it does not create a replacement. After that it
returns the cached handle without checking that the Sprite still exists: one deleted
elsewhere surfaces on the next operation. Calling this does not make you responsible for
deleting the Sprite; whoever holds the ref decides, as before.
The handle belongs to the backend’s AsyncSpritesClient, and this never returns a handle the
backend built without looking the Sprite up. Once aclose() closes the backend’s own client,
handles from it stop working, so call this again. A backend you build yourself outside a run
opens its own client, which you close with aclose().
AsyncSprite
UserError— The event loop is not asyncio.
@async
def aclose() -> None
Close the AsyncSpritesClient this backend created, if it created one.
The Sprite is untouched: the next operation opens a fresh client and reattaches by ref.
A caller-supplied client= or sandbox= handle is never closed. SpritesSandbox
calls this for the backend it supplied when each run ends. A close that fails or times out
is logged, not raised.