Skip to main content

pydantic-ai-exedev

Run a Pydantic AI agent's commands and file operations on an exe.dev VM. ExeDevSandbox supplies the agent's workspace; compose it with FileSystem, Shell, or other capabilities that use the workspace. Your application can also provision and manage VMs outside an agent run.

This is a community package, independent of Pydantic AI. While it is on 0.x releases, its API may change between minor versions.

Install

Requires Python 3.11 or later.

uv add pydantic-ai-exedev "pydantic-ai-harness[anthropic]"

The anthropic extra is for the model used in the examples. Choose the extra and credentials for your model provider if you use a different one. pydantic-ai-exedev itself does not require an Anthropic API key.

Create an exe.dev API token from a computer whose SSH key is registered with your exe.dev account. VM creation requires an active exe.dev plan. This token can manage VMs tagged agents, with permissions for commands, creation, copying, deletion, pause, resume, and comments:

ssh exe.dev ssh-key generate-api-key --tag=agents --exp=30d \
  --cmds=new,ls,ssh,rm,cp,pause,resume,comment

Set the returned token in the environment where your Python application runs. For the Anthropic examples, also configure the model's API key:

export EXE_TOKEN='exe1.…'
export ANTHROPIC_API_KEY='your-anthropic-api-key'

See exe.dev token permissions and scope tags for the difference between command permissions and VM access.

Quick start

from pydantic_ai import Agent
from pydantic_ai_exedev import ExeDevSandbox, VMSpec
from pydantic_ai_harness import FileSystem, Shell

sandbox = ExeDevSandbox(spec=VMSpec(working_dir='app'), scope_tag='agents')
agent = Agent('anthropic:claude-opus-5-5', capabilities=[sandbox, FileSystem(), Shell()])

result = agent.run_sync('Write a Python script that prints the first ten square numbers, then run it.')
print(result.output)

The first workspace operation creates a VM and its app directory. Commands and relative file paths start there. With no image, exe.dev uses its default exeuntu image; a custom image must supply the shell and programs your tools need.

The VM persists after the run. Keep its reference so you can use it again or delete it. A run that never uses the workspace does not create a VM, and its workspace ref remains None.

Continue in the same VM

Pass the previous run's messages to continue the conversation with the same files and installed packages:

followup = agent.run_sync(
    'Add a test for the script and run it.',
    message_history=result.all_messages(),
)
print(followup.output)

Pydantic AI stores the workspace reference in the messages and uses it to attach on the next run. Without a reference or message history, a run creates a new VM when it first uses the workspace. Pass workspace='new' to start fresh even when the history contains an existing reference.

Reattach without the conversation

Save result.workspace.ref and pass it as workspace= when a new conversation should use the same VM:

ref = result.workspace.ref
assert ref is not None

agent.run_sync('List the files in this project and explain what they do.', workspace=ref)

A WorkspaceRef contains the provider, 'exedev', and the VM name as its id. It identifies the VM; it does not include previous prompts or tool results. A missing VM raises VMNotFoundError, a WorkspaceUnavailableError, instead of silently creating a replacement.

Prepare a VM before the run

Use ExeDevVMs when your application creates the VM and decides when to delete it. Pass a backend to the run to use that VM directly:

import asyncio

from pydantic_ai import Agent
from pydantic_ai_exedev import ExeDevSandboxBackend, ExeDevVMs, VMSpec
from pydantic_ai_harness import FileSystem, Shell


async def main() -> None:
    spec = VMSpec(working_dir='app')
    async with ExeDevVMs(scope_tag='agents') as vms:
        state = await vms.provision(spec)

    backend = ExeDevSandboxBackend(ref=state.ref, spec=spec, scope_tag='agents')
    try:
        agent = Agent('anthropic:claude-opus-5-5', capabilities=[FileSystem(), Shell()])
        result = await agent.run('Write a README for an empty Python project.', workspace=backend)
        print(result.output)
        print(state.ref)
    finally:
        await backend.aclose()


asyncio.run(main())

provision() creates the VM, waits until it can run commands, and creates working_dir. Closing the manager or backend closes its HTTP client; the VM remains available.

A backend constructed with ref= attaches on first use. It does not create a replacement or apply the spec's provisioning settings. Its working_dir must already exist. Without ref, a backend provisions from spec on first use, just like the capability.

Clean up

Ending a run does not delete its VM. When you no longer need it, delete it explicitly. In async code:

await sandbox.destroy(ref)

Or manage a VM by reference or name, independently of the agent:

from pydantic_ai_exedev import ExeDevVMs

async with ExeDevVMs(scope_tag='agents') as vms:
    state = await vms.inspect(ref)
    await vms.pause(ref)
    await vms.resume(ref)
    deleted = await vms.destroy(ref)

destroy() deletes the VM and its disk. It returns False if the VM no longer exists. pause() saves memory to disk and releases CPU and memory; resume() waits until commands can run. inspect(), lookup(), get_vm(), pause(), and destroy() do not resume paused VMs. A workspace command on a paused VM resumes it after exe.dev refuses the command before execution.

Use await vms.get_vm(ref) for the SDK's exedev.AsyncVM and operations this package does not wrap. sandbox.vms() creates a manager with the capability's client and scope tag; close it with async with or aclose().

Configuration

VMSpec configures a new VM or a copy of an existing VM:

spec = VMSpec(
    image='ubuntu:24.04',
    cpu=4,
    memory='8G',
    disk='50G',
    working_dir='app',
    comment='nightly build',
)
Setting Behavior
name Optional VM name. Omit it to let exe.dev choose. A fixed name must be available when provisioning.
image Container image for a new VM. Omit it to use exe.dev's default image.
base Name of an existing VM to copy instead of creating from an image. Cannot be combined with image.
cpu, memory, disk Resources for a new VM or copy. Omitted values use exe.dev's defaults.
egress 'all' for internet access or 'integrations' for exe.dev integrations only. A copy inherits its base's policy when omitted.
standalone Reserved capacity, requiring a plan that supports it. Defaults to False.
integrations Integration names to attach to a new VM. Cannot be used with base.
tags Tags applied at creation, or after copying. Subject to the token's tag restrictions.
comment Up to 200 bytes. Set at creation, or after copying.
copy_base_tags Whether a copy inherits its base's tags. Defaults to inheritance; valid only with base.
working_dir Directory created during provisioning. Relative paths start from the VM user's home.

A spec validates local constraints without I/O. Unsupported combinations raise ValueError before a request is sent. exe.dev still validates image availability, resources, integrations, and permissions. A copy keeps its base's disk and image. Its tags and comment are applied after creation because the copy API does not accept them; the token needs tag and comment respectively when those updates are needed.

Scope tags

scope_tag is available on ExeDevSandbox, ExeDevSandboxBackend, and ExeDevVMs. The package adds it to new VMs and checks it before accessing a named VM, including a copy's base. A VM without the tag raises VMOutOfScopeError.

This check prevents accidental access through an incorrect reference. exe.dev enforces authorization through the token. Use a token scoped to the same tag, as in the installation example.

Tag-scoped tokens cannot change VM tags, and exe.dev rejects additional tags outside their scope. With the example token, omit tags or use only tags=['agents']. Adding tag to the command permissions does not remove that restriction. Workflows that assign other tags need credentials authorized to do so.

A copy must inherit the scope tag from its base. copy_base_tags=False with scope_tag is rejected.

Environment and commands

Pass env= to the capability or backend for variables that every command needs. Each call's env, including Shell(env=...), overrides those values. The VM's login environment supplies the defaults; host environment variables are not automatically forwarded.

Commands start in working_dir under the VM user's login shell. At the workspace API, pass an argv list for literal arguments, or a string with shell=True for shell syntax. Shell tools handle this for you. A working directory does not restrict commands to that directory.

File operations use Pydantic AI's shell fallback, transferring data in 64 KiB chunks. Large files take multiple requests; for directory transfers, consider an archive through Shell.

Background commands and limits

Shell's start_command, check_command, and stop_command tools, and its opt-in persistent shell tool, store job state and output under .pydantic-ai-harness/ in the working directory. With setsid available, jobs detach into their own sessions and can survive runs and backend reattachments. Custom images need the programs required by Shell, including setsid for process-group isolation and mv and base64 for job files.

A plain server & can keep a command waiting by inheriting its output streams. Redirect them, as in server > server.log 2>&1 &, or use Shell's background tools.

Cancellation and timeouts close the command request. exe.dev stops its process group, while jobs in detached sessions survive. A command's combined stdout and stderr are limited to 8 MiB; exceeding it raises WorkspaceOutputLimitError and stops the command. Shell's max_output_chars separately limits the text returned to the model, retaining the tail.

Provisioning and recovery

Unknown creation outcome

A network failure, unreadable response, or server error can occur after exe.dev accepted a create or copy request. ProvisionOutcomeUnknownError means a VM may exist. The package does not retry the creation; a backend that encountered this error raises it again on later operations.

For recoverable provisioning, choose a stable, unique name and explicit tags before creating the VM. This spec works with the installation example's scoped token:

spec = VMSpec(name='build-req-7f3a', tags=['agents'], working_dir='app')

After ProvisionOutcomeUnknownError, call await vms.lookup(spec) with the same spec. lookup() requires non-empty spec.tags, even when scope_tag is set. It never creates, changes, or resumes a VM. With credentials that allow additional tags, a tag unique to the request can also identify the VM.

Lookup status Meaning
'found' One visible VM matches the name, if supplied, and all spec and scope tags. Its state is in vm.
'not_found' No matching VM is visible now. This does not prove creation failed; check again before retrying.
'conflict' The name exists but some required tags are missing. Inspect it before attaching.
'ambiguous' Without a name, several VMs match. Their states are in candidates.

A fixed name prevents another creation with that name while it is taken. Names and tags help you find a VM; they do not guarantee exactly-once creation. A create refusal such as a name conflict propagates as exedev.APIError; authentication and permission failures are translated to ExeDevAccessError.

Initialization failure and cancellation

After creation, provisioning waits for command readiness, applies a copy's tags and comment, and creates working_dir. A failure raises VMInitializationError with the VM's ref. The VM is kept. Retry initialization with await vms.initialize(error.ref, spec), or delete it explicitly. Initialization can be repeated; it does not change resources, image, or egress. A backend that created the VM records its ref before initialization and retries initialization on its next operation.

Cancellation depends on the stage. While the SDK is creating or waiting for the VM, it attempts to delete the VM before propagating cancellation. Cleanup can fail or be interrupted. Once the SDK has returned the VM, cancellation during tag updates, comment updates, or directory creation leaves it in place. Retain the spec so you can find it with lookup() and decide whether to initialize or delete it.

Clients and errors

Pass client=exedev.AsyncClient(...) to configure SDK retries or transport settings. A supplied client belongs to the caller and is never closed by this package. Otherwise, clients are created lazily from EXE_TOKEN and closed as follows:

Entry point Client lifetime
ExeDevSandbox The backend created for a run closes its client when that run ends, including on failure or cancellation. A workspace passed in from another run remains owned by that run.
ExeDevVMs Close with aclose() or async with.
ExeDevSandboxBackend Close with aclose(), which waits for in-progress provisioning to record its ref first.

aclose() does not pause or delete the VM. Reusing a closed manager or backend creates a new client. This also applies to operations through result.workspace after a run; close that client's backend with await result.workspace.backend.aclose() when finished.

Once client cleanup starts, it finishes before propagating asyncio or AnyIO cancellation. Repeated cancellation does not leave cleanup in the background. There is no cleanup timeout. A failed close retains the client so aclose() can retry. If cancellation occurs during a failed close, the caller receives CancelledError with the close error as its cause; otherwise, the close error propagates.

The package adds no general retries. It retries a command only when exe.dev refused it before execution because the VM was paused, after resuming the VM. Commands with unknown outcomes are not repeated.

Error Meaning
VMNotFoundError VM missing, including deletion during a command.
VMOutOfScopeError VM lacks the configured scope tag.
ExeDevAccessError Missing credentials, rejected token, or denied command. status is None, 401, or 403.
WorkspaceTimeoutError Command deadline exceeded. Partial output is available on the error.
WorkspaceOutputLimitError Combined command output exceeded 8 MiB. Partial output is available on the error.
CommandOutcomeUnknownError The command's exit status or response was lost and the VM is not known to be gone. Check its effects before repeating it. stdout and stderr contain any captured output.
ProvisionOutcomeUnknownError Creation may have succeeded. Use lookup() to investigate.
VMInitializationError VM exists but initialization failed. The error carries its ref.

These errors inherit from WorkspaceError; Pydantic AI's durable workspace calls record them rather than retrying them as transient failures. The first three also inherit from WorkspaceUnavailableError. Other SDK errors propagate unchanged, allowing the durable engine's configured retry policy to apply.

Verification

Local tests use a fake of the exedev 0.4.0 SDK and execute commands as real local processes. They cover Pydantic AI's WorkspaceBackendSuite, FileSystem and Shell across runs and backends, provisioning, lookup, scope checks, lifecycle operations, and client cleanup.

Real-provider behavior remains unverified until live tests are run. In particular, the fake cannot establish server-side token restrictions, pause behavior, command termination, background-job survival, or the outcome of a create that times out at the gateway. The live suite covers several of these paths; it does not establish every failure mode or permission combination.

Development

uv sync
uv run pytest -m "not live"
uv run ruff check .
uv run ruff format --check .
uv run ty check

Live tests create billed VMs and need an EXE_TOKEN with creation, commands, copying, lifecycle, comment, and tag permissions. They assign request-specific tags, so the installation example's tag-scoped token is not sufficient. Use a separate test credential with the required access. The suite's cleanup deletes all VMs carrying pydantic-ai-exedev-test, including pre-existing ones; reserve that tag for these tests.

EXE_TOKEN=... uv run pytest -m live

Releasing

The release workflow publishes to PyPI using Trusted Publishing. PRs and pushes to main run CI. Publishing a GitHub Release runs CI again, builds and checks the distributions, then uploads them. Pushing a tag alone does not publish the package.

One-time setup:

  • Add a PyPI pending publisher for project pydantic-ai-exedev, owner daikeren, repository pydantic-ai-exedev, workflow release.yml, and environment pypi.
  • Create the repository's pypi environment under Settings > Environments.

To release:

  1. Update project.version in pyproject.toml, run uv lock, and commit the release changes.

  2. Push the commit and wait for CI to pass.

  3. Create a GitHub Release with a matching version tag, such as v0.1.0, pointing to that commit.

  4. Check the release notes and publish the release.

  5. Wait for the Publish to PyPI workflow to succeed, then verify the published package:

    uv run --isolated --no-project --with pydantic-ai-exedev==0.1.0 python -I -c 'import pydantic_ai_exedev'
    

Use the released version in the verification command. Each PyPI version is immutable; corrections need a new version.

License

MIT.

Metadata

Release files for pydantic-ai-exedev 0.1.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pydantic-ai-exedev 0.1.1
File Size Uploaded Provenance Actions
pydantic_ai_exedev-0.1.1.tar.gz 64.4 kB With provenance Details for pydantic_ai_exedev-0.1.1.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for pydantic-ai-exedev 0.1.1
File Interpreter ABI Platform Actions
pydantic_ai_exedev-0.1.1-py3-none-any.whl Python 3 none any Details for pydantic_ai_exedev-0.1.1-py3-none-any.whl

Total release size: 90.5 kB

Release files / pydantic_ai_exedev-0.1.1.tar.gz

Download URL pydantic_ai_exedev-0.1.1.tar.gz
Size 64.4 kB
Tags Source
SHA-256 checksum
How to use checksums
2c25d5c5f13acedd342705a262573b5d5459117ab106a1474792c2a6ac952322
BLAKE2b-256 checksum
How to use checksums
86c34656ef80473d3a1773d34daf605c50caf3706a6a57985b85403946e7edfa
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 14, 2026.

Transparency log

Release files / pydantic_ai_exedev-0.1.1-py3-none-any.whl

Download URL pydantic_ai_exedev-0.1.1-py3-none-any.whl
Size 26.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0254f3cfc5fc23383228d5e4647bfa5cfd6e21f71f0d953ff61c33e882d2a8f0
BLAKE2b-256 checksum
How to use checksums
5182519a2dce6e44fc117e9bcc4927bec78f07e363b104dfa151aa61fb710472
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 14, 2026.

Transparency log

Security for pydantic-ai-exedev 0.1.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

Both files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for pydantic_ai_exedev-0.1.1.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 14, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page