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

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:

export EXE_TOKEN='exe1.…'

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

Quick start

The examples use OpenAI with FileSystem and Shell. Install the harness and OpenAI model support, then configure the model's API key:

uv add pydantic-ai-harness "pydantic-ai-slim[openai]"
export OPENAI_API_KEY='your-openai-api-key'

For a different model provider, choose its extra and credentials and update the model in Agent(...). pydantic-ai-exedev itself does not require an OpenAI API key.

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('openai:gpt-6.1-sol', 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('openai:gpt-6.1-sol', 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 Personal integration names. Passed to image creation, or attached to the new copy during initialization. Additive, permanent VM grants; empty means no explicit grants.
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.

Copy with integrations

from pydantic_ai_exedev import ExeDevVMs, VMSpec, VMInitializationError

spec = VMSpec(
    base='golden',
    name='build-7f3a',
    integrations=['github', 'myapi'],
    egress='integrations',
    working_dir='app',
)

async with ExeDevVMs(scope_tag='agents') as vms:
    try:
        state = await vms.provision(spec)
    except VMInitializationError as error:
        ref = error.ref  # Persist this before leaving the recovery path.
        # After resolving the failure, retry on this VM, including from a new manager:
        state = await vms.initialize(ref, spec)
    # Persist state.ref and pass it to ExeDevSandboxBackend(ref=state.ref).
    # The caller decides when to pause or destroy it, including on failure.

The base must carry agents. Copying preserves its disk and inherits tags by default. The package attaches each requested integration using client.integrations.attach(name, 'vm:<copy-name>'). It never attaches to the base, a tag, or auto:all. Copy initialization requires permission for integrations attach, in addition to ls, cp and ssh. A denied request remains a failure; the package does not broaden credentials or fall back to another selector.

integrations is an additive list of required permanent attachments, not an allowlist or a replacement of existing grants. Empty leaves provider grants alone. Tags inherited by the copy can make tag-based integrations available, and account auto:all rules apply independently. copy_base_tags=False controls tags only. Team integrations support tag selectors in the SDK, so this setting only supports personal integrations that can be attached directly to a VM.

The SDK 0.4 copy implementation accepts no integrations argument and does not document whether direct base-VM grants are copied. The official attachment rules describe VM, tag and auto selectors. We therefore do not promise either inheritance or clearing of direct base-VM grants. Requested integrations are explicitly ensured on the copy regardless of inheritance; unlisted grants are never detached. A restricted egress policy still allows attached integrations. Do not treat this spec as a policy that removes other access.

The SDK documents re-attaching the same selector as updating that grant. Initialization always uses the same vm:<copy-name> selector with no duration or expiry. Repeating it ensures a permanent grant, including after a lost response. This also makes an existing expiring direct grant permanent. There is no automatic retry loop: a failed attach stops initialization and raises VMInitializationError. An explicit retry, or the creating backend's next operation, repeats these setters on the same VM. It neither assumes the failed request did nothing nor issues another create/copy. Successful grants are kept when a later integration fails. The SDK's own 429 retry policy still applies.

Image provisioning retains its existing new(integrations=...) request. Its recovery does not add separate attachment calls or require attachment permissions. A backend constructed with ref= ignores all provisioning settings, including integrations; call vms.initialize(ref, spec) explicitly to finish a copy's initialization before attaching it in a new backend.

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.

On cancellation, timeout, or an output-limit error, the backend sends a separate request to kill the command's process group before propagating the interruption. Closing the SDK's HTTP stream alone can leave commands running. Jobs in detached sessions survive. The VM needs setsid so the SDK can isolate each command; without an isolated group, the backend refuses to run user code with exit 126.

Stopping and control-file cleanup each have a 10-second bound and are shielded from caller cancellation. If the VM is unreachable or paused, stopping is best effort: retain its ref to inspect or destroy it. Each completed command uses an additional request to remove its control files. A cancellation marker can remain under /tmp if a cancelled request never starts; it prevents that request from running later.

A command's combined stdout and stderr are limited to 8 MiB; exceeding it raises WorkspaceOutputLimitError. Shell's max_output_chars separately limits the text returned to the model, retaining the tail. Read a growing log with a shell command such as tail or wc, or through Shell's command tools. Shell-derived read_text() and read_bytes() check file size across requests and can reject a file that changes during the read.

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

Create/copy uses wait_until='created' to obtain identity before setup. Provisioning then waits for command readiness, applies a copy's tags, comment and integrations, 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.

A backend shields creation until it records the ref, including repeated asyncio cancellation, then propagates cancellation. Readiness and initialization remain cancellable and leave the VM in place; the next operation retries pending initialization. get_vm() returns a handle only after initialization succeeds. VMState.status is a provider snapshot, not an initialization-complete flag.

A direct vms.provision() call has no backend in which to store a ref after cancellation. Cancellation before its create response follows SDK cleanup, which can fail or be interrupted. After that response, initialization leaves the VM in place. Keep the spec's fixed name and tags to recover via lookup(). Neither a failed initialization nor closing a client deletes a VM. The caller holds that decision.

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. Scripted HTTP tests also exercise the real SDK's copy and attach requests, including lost responses; they do not validate server behavior.

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

Two additional live tests need EXEDEV_TEST_INTEGRATION and EXEDEV_TEST_INTEGRATION_URL. Use an existing, dedicated personal integration with no auto/tag grants to test VMs, and a safe read-only GET URL that fails without attachment and succeeds with it. The tests never create or remove integrations; they attach only to their own temporary VMs. The credential also needs integrations attach permission.

EXE_TOKEN=... EXEDEV_TEST_INTEGRATION=myapi \
  EXEDEV_TEST_INTEGRATION_URL=https://myapi.int.exe.xyz/health \
  uv run pytest -m live -k 'restricted_copy_with_integrations or direct_integration_copy_inheritance' \
  --junitxml=/tmp/exedev-integrations.xml

These tests cover restricted-egress VM copying, attachment endpoint access, blocked public egress, base isolation, and repeated initialization. The inheritance test records the observed direct-grant inheritance in the JUnit property direct_vm_attachment_inherited, then ensures the explicit grant. Neither these new tests nor the existing live suite were run for this change. In particular, server-side duplicate-attach behavior, direct-grant copy inheritance, and restricted-egress attachment access remain unverified. The SDK contract is the basis for the implementation, not a live-provider result.

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

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.3
File Size Uploaded Provenance Actions
pydantic_ai_exedev-0.1.3.tar.gz 73.5 kB With provenance Details for pydantic_ai_exedev-0.1.3.tar.gz

Built distribution (wheel)

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

Total release size: 103.8 kB

Release files / pydantic_ai_exedev-0.1.3.tar.gz

Download URL pydantic_ai_exedev-0.1.3.tar.gz
Size 73.5 kB
Tags Source
SHA-256 checksum
How to use checksums
25994f2736cf27c4c81b11981c4a709031ddca5f230988273444263940a2e097
BLAKE2b-256 checksum
How to use checksums
b0b9869dd5252e5e23fac6b1819169add06aed2263522a4cbfd932871e41ea0d
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.3-py3-none-any.whl

Download URL pydantic_ai_exedev-0.1.3-py3-none-any.whl
Size 30.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7ea018cb144c74f643429500de220751c905daec1dc643ed386d04f985ddaf8a
BLAKE2b-256 checksum
How to use checksums
fde473ace83f9e0ba6a190e49ea45e89412f511778c913faa69a68b9bc8d8f93
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.3

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