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 |
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.
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
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, ownerdaikeren, repositorypydantic-ai-exedev, workflowrelease.yml, and environmentpypi. - Create the repository's
pypienvironment under Settings > Environments.
To release:
-
Update
project.versioninpyproject.toml, runuv lock, and commit the release changes. -
Push the commit and wait for CI to pass.
-
Create a GitHub Release with a matching version tag, such as
v0.1.0, pointing to that commit. -
Check the release notes and publish the release.
-
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.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| pydantic_ai_exedev-0.1.2.tar.gz | 68.1 kB | With provenance | Details for pydantic_ai_exedev-0.1.2.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| pydantic_ai_exedev-0.1.2-py3-none-any.whl | Python 3 | none | any | Details for pydantic_ai_exedev-0.1.2-py3-none-any.whl |
Total release size: 96.1 kB
Release files / pydantic_ai_exedev-0.1.2.tar.gz
| Download URL | pydantic_ai_exedev-0.1.2.tar.gz |
|---|---|
| Size | 68.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
35786ce7e6e6e859b293113b23bb9ca512e6e27a1f545efbb1356393d1a9b9a5
|
|
BLAKE2b-256 checksum How to use checksums |
bdbf2affd93f1885ce63ac5b13b5d6fd7be4f2629fadd9ada216c4096cdd5cbf
|
| 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 logRelease files / pydantic_ai_exedev-0.1.2-py3-none-any.whl
| Download URL | pydantic_ai_exedev-0.1.2-py3-none-any.whl |
|---|---|
| Size | 28.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
06aa9ed05d4d2d0134826e5ac845bd69c915741bdc85d6ac00d1617d1055ad65
|
|
BLAKE2b-256 checksum How to use checksums |
fb48d5a33af79bc29ddbb5175cce8adffe75828bff44b19f49bfbc35b0c63fa7
|
| 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 logSecurity for pydantic-ai-exedev 0.1.2
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.
Attestation for pydantic_ai_exedev-0.1.2.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 logA 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.