pydantic-ai-exedev
Run a Pydantic AI agent's workspace on an exe.dev VM, so the native FileSystem, Shell and other Pydantic AI Harness capabilities work there. You can also manage those VMs from your app, outside any agent run.
This is a community package, not part of Pydantic AI. It is 0.x: the API may change between minor releases.
Install
uv add pydantic-ai-exedev "pydantic-ai-harness[anthropic]"
Put an exe.dev API token in EXE_TOKEN. Scope it to a tag, so it can only manage the VMs you tag:
ssh exe.dev ssh-key generate-api-key --tag=agents --exp=30d
In an agent
from pydantic_ai import Agent
from pydantic_ai_exedev import ExeDevSandbox, VMSpec
from pydantic_ai_harness import FileSystem, Shell
sandbox = ExeDevSandbox(spec=VMSpec(image='ubuntu:24.04', working_dir='app'), scope_tag='agents')
agent = Agent('anthropic:claude-opus-5-5', capabilities=[sandbox, FileSystem(), Shell()])
result = agent.run_sync('Clone https://github.com/pydantic/pydantic-ai and run the harness tests.')
# Later, on the same VM: pass its ref, or continue from the message history.
agent.run_sync('Fix the first failing test.', workspace=result.workspace.ref)
The first workspace operation of a run with no ref provisions a VM from spec. A run given a WorkspaceRef, by workspace= or from message history, attaches to that VM. Pass workspace='new' for a fresh one. Ending a run never deletes the VM.
Provision first, then hand the ref to a workspace
When your app owns the VM's lifecycle, provision it with ExeDevVMs, keep its WorkspaceRef, and attach a backend to it:
from pydantic_ai import Agent
from pydantic_ai_exedev import ExeDevSandboxBackend, ExeDevVMs, VMSpec
from pydantic_ai_harness import FileSystem, Shell
spec = VMSpec(name='build-42', tags=['req-7f3a'], image='ubuntu:24.04', working_dir='app')
async with ExeDevVMs(scope_tag='agents') as vms:
state = await vms.provision(spec) # creates the VM, waits until it runs commands, makes `app`
backend = ExeDevSandboxBackend(ref=state.ref, spec=spec, scope_tag='agents')
try:
agent = Agent('anthropic:claude-opus-5-5', capabilities=[FileSystem(), Shell()])
await agent.run('Set up the project.', workspace=backend)
finally:
await backend.aclose() # closes the client it made; the VM keeps running
ExeDevSandboxBackend(ref=...) attaches without creating, resuming or changing the VM. spec then only says where commands start (working_dir), which must already exist. Without ref, the backend provisions from spec on first use, exactly as ExeDevVMs.provision does: the capability, the backend and ExeDevVMs share one implementation.
VMSpec: image or base VM, resources, network
A VMSpec describes a new VM made from an image, or a copy of a base VM:
VMSpec(
name='build-42', # optional: exe.dev generates one
comment='nightly build', # at most 200 bytes
tags=['req-7f3a', 'ci'],
image='ubuntu:24.04', # or base='golden', a VM to copy
cpu=4,
memory='8G',
disk='50G',
egress='integrations', # reach only exe.dev integrations, not the internet ('all')
standalone=False, # reserved capacity (needs a plan with it)
integrations=['github'], # new VMs only
working_dir='app', # created while getting the VM ready
)
It is checked when built, with no I/O. A combination exe.dev can't apply raises ValueError; nothing is silently dropped:
New VM (image) |
Copy (base) |
|
|---|---|---|
name, cpu, memory, disk, egress, standalone |
in the create request | in the copy request |
tags, comment |
in the create request | set right after the copy (exe.dev's copy takes neither) |
image |
yes | refused: a copy keeps its base's disk |
integrations |
yes | refused: exe.dev's copy can't attach them |
copy_base_tags |
refused | whether the copy gets the base's tags (default: yes) |
working_dir |
created after the VM is ready | created after the VM is ready |
A copy's egress defaults to its base's. The base is looked up first, through the same scope check as any VM you name.
Scope tag
scope_tag (on ExeDevVMs, ExeDevSandboxBackend and ExeDevSandbox) is added to every VM they create. Every operation on a VM you name refuses one without it, with VMOutOfScopeError: attach, inspect, pause, resume, initialize, destroy, and a copy's base. It keeps a ref from message history, or a typo, away from your other VMs.
It is not authorization. Anyone whose token can reach a VM can still reach it outside this package. Use an API token scoped to the same tag to enforce it. A copy gets the scope tag only by inheriting it from its base, so copy_base_tags=False with a scope_tag is refused.
When a create's result is unknown
A create or copy request can fail after it reached exe.dev: a network error, an unreadable response, or a server error (5xx, including the 504 after /exec's 30-second limit). The VM may or may not exist. provision then raises ProvisionOutcomeUnknownError and retries nothing. A backend that hit one raises it again on every later operation rather than create a second VM.
To recover, give the spec a stable name and a tag unique to the request, and look for it:
from pydantic_ai_exedev import ProvisionOutcomeUnknownError
spec = VMSpec(name='build-42', tags=['req-7f3a'])
try:
state = await vms.provision(spec)
except ProvisionOutcomeUnknownError:
found = await vms.lookup(spec)
if found.status == 'found':
state = found.vm # it was created: use it (initialize it, below, if needed)
elif found.status == 'not_found':
... # not visible now: look again later, or provision again
else: # 'conflict' or 'ambiguous'
... # a VM you can't confirm is yours: inspect it yourself
lookup never creates, resumes or changes a VM. It needs tags, because a name alone could belong to a VM you made some other way.
| Status | Meaning |
|---|---|
'found' |
Exactly one VM has the spec's name (if it has one) and all its tags, plus the scope tag. |
'not_found' |
None is visible now. That does not prove the create failed. |
'conflict' |
A VM has the name but lacks some of the tags. It may be another VM, or a copy whose tags weren't set yet. It is never attached for you. |
'ambiguous' |
With no name to go by, several VMs carry all the tags (candidates). |
A stable name also means a repeated provision is refused when the name is taken, rather than making a second VM. This is not exactly-once creation: a name or tags make a VM findable, nothing more. A 4xx from create is a definite refusal (such as a name in use) and propagates as the SDK's exedev.APIError. A cancelled provision leaves no VM, because the SDK deletes one it made before re-raising; if that delete fails, lookup finds the VM.
When getting a VM ready fails
Once the VM exists, provision waits until it runs commands. For a copy, it then sets the spec's tags and comment, and finally makes working_dir. If any of that fails, it raises VMInitializationError, whose ref names the VM. The VM is kept, never deleted for you. Retry with await vms.initialize(error.ref, spec), which is safe to repeat and changes nothing else, or delete it. A backend that provisioned the VM records the ref first and retries initialization on its next operation.
Lifecycle outside a run
async with ExeDevVMs(scope_tag='agents') as vms:
state = await vms.inspect(ref) # VMState: status, tags, comment, egress, region, created_at, ...
await vms.pause(ref) # saves memory to disk, frees CPU and memory
await vms.resume(ref) # waits until it runs commands
vm = await vms.get_vm(ref) # the SDK's exedev.AsyncVM, for anything not wrapped here
deleted = await vms.destroy(ref) # True if this call deleted it, False if it didn't exist
Each takes a WorkspaceRef or a VM name. Only resume resumes a paused VM: inspect, lookup, pause, get_vm and destroy never do. A workspace command on a paused VM resumes it first: exe.dev refuses the command before it starts, so it runs once, after the resume. ExeDevSandbox.vms() gives a manager with the capability's client and scope tag, and ExeDevSandbox.destroy(ref) deletes directly.
Permissions the token needs: new (provision), cp (copy), ls (lookup, inspect, attach, and before pause, resume and destroy), pause, resume, rm (destroy), tag and comment (a copy's tags and comment), and ssh (commands, readiness waits, working_dir, and the flush before a pause or copy).
Clients, retries and errors
Clients. Pass client=exedev.AsyncClient(...) to ExeDevVMs, ExeDevSandboxBackend or ExeDevSandbox, and it is never closed for you; that is also how you set the SDK's retry policy (max_retries, or your own http_client). Without one, a client is made from EXE_TOKEN on first use:
ExeDevVMscloses it inaclose()or at the end ofasync with, including on errors and cancellation.- A backend closes it in
aclose(), after waiting for a VM it is provisioning, so that VM's ref is recorded first. ExeDevSandboxcloses the client of the backend it built for a run when that run ends, however it ends. It never closes a workspace that another run handed in (a parent's, given to a subagent).result.workspacestill works after the run: its next operation makes a new client, whichawait result.workspace.backend.aclose()closes.
aclose() never touches the VM: it doesn't delete, pause or recreate it.
Cancellation during cleanup. Once aclose() (or the end of an async with block, or of a run) starts closing a client, the close finishes before a cancellation reaches the caller. This holds for asyncio's Task.cancel(), however many times it is called, and for AnyIO's cancel scopes. The cancellation is not lost, only delayed: it is raised as soon as the close is done, and no cleanup is left running in the background. There is no timeout, so a close that hangs holds the cancellation back for as long as it hangs.
If closing fails, the client is kept, and calling aclose() again retries. Without a cancellation, the close's error is raised as is. If the caller was cancelled meanwhile, the CancelledError is raised, with the close's error as its __cause__.
Retries. This package adds no retries of its own. It makes one exception: a command on a paused VM is refused before it starts, so it is run once more after the resume. A command whose result is unknown is never run again.
Errors. All of these are WorkspaceErrors, which durable engines don't retry:
| Error | Meaning |
|---|---|
VMNotFoundError (a WorkspaceUnavailableError) |
The VM doesn't exist: deleted, including during a command, or never created. |
VMOutOfScopeError (a WorkspaceUnavailableError) |
The VM lacks the scope tag. |
ExeDevAccessError (a WorkspaceUnavailableError) |
status 401: token rejected; 403: the token may not run the command; None: no token. |
WorkspaceTimeoutError |
The command passed its timeout. It was stopped, with everything it started. |
WorkspaceOutputLimitError |
The command wrote more than MAX_OUTPUT (8 MiB) of stdout and stderr together. It was stopped. |
CommandOutcomeUnknownError |
The command's result can't be known, and the VM still exists. Either the connection ended before the exit status arrived, or exe.dev's response to an accepted command couldn't be read (the SDK's ProtocolError). The command may have finished. stdout/stderr hold what arrived, which is nothing for an unreadable response. |
ProvisionOutcomeUnknownError |
See above. |
VMInitializationError |
See above. |
Other exedev errors, such as a network failure before anything ran, a rate limit after the client's retries, or a server error on a safe operation, propagate unchanged, so a durable engine can retry them. Messages and reprs name VMs and tags, never tokens, environment values or response bodies.
Commands, files, and Shell's background work
Commands run in the VM user's login shell, starting in working_dir. The environment is the VM's login environment, plus the capability's or backend's env, plus each call's env (including Shell(env=...)), with later layers winning. An argv list is passed literally; a string needs shell=True, as the WorkspaceBackend protocol says.
Files go through Workspace's shell fallback, in 64 KiB pieces, so binary files of any size round-trip, at about one request per 64 KiB. For large trees, use tar through Shell instead.
Output the command leaves open. exedev's run returns once the command has exited and its output has closed. A background process that inherits stdout or stderr (server &) keeps the command waiting until it exits or the timeout stops everything. Redirect it: server > server.log 2>&1 &.
Shell's background tools. Shell's start_command, check_command and stop_command, and its persistent shell tool, need nothing from this package. Their launcher detaches the job into its own session with setsid (exeuntu has it) and returns, so the job keeps running after the launcher's command ends, after the run, and across backends. A later run, attached by ref with a new backend, checks or stops the job by its ID or PID. The job's logs and status live in the VM, under .pydantic-ai-harness/ in the working directory.
Cancellation and timeouts. Cancelling a command, or its timeout, closes the request. exe.dev then stops the command's process group, but not a job that detached into its own session. A deleted VM fails a running command with VMNotFoundError. A dropped connection to a live VM, or a response the SDK can't read, raises CommandOutcomeUnknownError; the command is never re-run.
Output limits. A single command's output over 8 MiB raises WorkspaceOutputLimitError. Shell's own max_output_chars keeps the tail of what the model sees.
What's verified, and what isn't
The test suite runs against a local fake of the exedev SDK. The fake follows the SDK's documented contract (exedev 0.4.0) and runs commands as real local processes. The checks include core's WorkspaceBackendSuite, Harness's FileSystem and Shell across runs and backends, and every provisioning, recovery and lifecycle path above.
These are assumptions from the SDK's source and documentation that only real exe.dev can confirm. uv run pytest -m live checks most of them, but those tests have not been run yet:
- A deleted VM fails a running command, and a paused one refuses a command before it starts.
lsandpausedon't resume a VM.runwaits for output a background process inherits.- A detached Shell job outlives its launcher on a real VM.
- A copy request takes the documented flags.
- The status exe.dev returns for a VM name that is taken. The fake uses 422; the SDK doesn't document it.
- Whether a create that timed out at the gateway (504) went on to make the VM.
Development
uv sync
uv run pytest -m "not live"
uv run ruff check . && uv run ruff format --check . && uv run ty check
EXE_TOKEN=... uv run pytest -m live # creates billed VMs, tagged pydantic-ai-exedev-test, and deletes them
Releasing
Releases are published to PyPI by .github/workflows/release.yml, 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 to PyPI. 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 tag matching the version, such as
v0.1.0, pointing to that commit. -
Check the release notes and click Publish release. This triggers the PyPI upload.
-
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.0
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.0.tar.gz | 63.7 kB | With provenance | Details for pydantic_ai_exedev-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| pydantic_ai_exedev-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for pydantic_ai_exedev-0.1.0-py3-none-any.whl |
Total release size: 89.1 kB
Release files / pydantic_ai_exedev-0.1.0.tar.gz
| Download URL | pydantic_ai_exedev-0.1.0.tar.gz |
|---|---|
| Size | 63.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
17e7552e00d8c1151b27b1ee7fb5aefe3126d7f850e2531b8ffe9e9be92f302a
|
|
BLAKE2b-256 checksum How to use checksums |
193492cd4805477d3e24b0906b55b7537cd9fd53929cee2a35cb5c8e9cd592c2
|
| 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.0-py3-none-any.whl
| Download URL | pydantic_ai_exedev-0.1.0-py3-none-any.whl |
|---|---|
| Size | 25.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ae139440992842305a32ab93b7384660f6b566f0f0ba08358e152befe9c10a88
|
|
BLAKE2b-256 checksum How to use checksums |
e132b40b92b1fa3596c9dc274832c287587d96baae68bfc05568bfdbfd562154
|
| 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.0
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.0.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.