---
title: "Create sandboxes from Python"
description: "Use the exedev Python SDK to create exe.dev VMs in seconds, run code and servers in them from sync or asyncio code, move files, and delete them."
url: https://exe.dev/deploy/python-sandboxes
category: "AI agents"
updated: 2026-10-22
---

# Create sandboxes from Python

Full VMs as sandboxes. A with block creates one, runs your code, and deletes it.

- **Software:** Python SDK (https://pypi.org/project/exedev/)
- **Source:** https://github.com/boldsoftware/exe-sdk-python
- **License:** MIT
- **Machine-readable:** https://exe.dev/deploy/python-sandboxes/manifest.json

`exedev` is exe.dev's Python SDK. `exe.new()` gives you a VM, `vm.run()`
runs a command in it the way `subprocess.run()` would, and the end of a
`with` block deletes it:

```python
import exedev

exe = exedev.Client()  # reads EXE_TOKEN

with exe.new() as vm:  # a new VM, deleted at the end of the block
    result = vm.run(["python3", "-c", "print(6 * 7)"], check=True)
    print(result.stdout)  # 42
```

In our tests that round trip took about three seconds. The sandbox is a
whole VM, not a container: its own kernel, root through `sudo`, Python and
`uv`, Docker, and an HTTPS address. Use one for code an LLM wrote, a test
run, or a preview of a branch, and delete it when you're done. Or keep it,
copy it, or pause it: it's an ordinary exe.dev VM. Everything here works
from `asyncio` too.

## Get a token

The SDK talks to exe.dev's [HTTPS API](/docs/https-api) with a token. Make
one that can only touch sandboxes. On your computer:

```bash
ssh exe.dev ssh-key generate-api-key --label=sandboxes --tag=sandbox --exp=90d \
  '--cmds="new,ls,rm,ssh,cp,pause,resume,restart,resize,stat,comment,share set-public,share set-private"'
```

It prints a token that starts with `exe1.`. Only the account's owner can
make one, from a computer whose SSH key is on the account.

- `--tag=sandbox` scopes the token. Every VM it creates is tagged
  `sandbox`, and it can see and touch no other VM: `exe.vms()` lists only
  sandboxes, and running a command in any other VM answers "not found". It
  can't change tags, either.
- `--cmds` lists the commands the token may run. Keep `ssh`: `vm.run()`
  uses it, and so does `new()`, to tell when a VM is ready. Leave out what
  you won't use.
- The token expires in 90 days. To revoke it sooner, remove its key:
  `ssh exe.dev ssh-key remove sandboxes`.

Keep the token on your server, in an environment variable or a secret
store. Anyone who has it can create VMs on your account.

To make tokens from code, say one per customer so that one customer's
sandboxes are invisible to another's token, use
`exe.ssh_key.generate_api_key()`. It takes the same options, with the
commands as a list, and the new token is in the result's `"token"`:

```python
SANDBOX_CMDS = [
    "new", "ls", "rm", "ssh", "cp", "pause", "resume", "restart", "resize",
    "stat", "comment", "share set-public", "share set-private",
]

token = exe.ssh_key.generate_api_key(
    label=f"customer-{cid}", tag=f"customer-{cid}", cmds=SANDBOX_CMDS, exp="90d"
)["token"]
```

The token you call it with needs `ssh-key generate-api-key` in its
`--cmds`, so it isn't the sandbox token above. A token that can make
tokens is worth more to an attacker than any one of them: keep it
separate, and out of reach of anything that runs untrusted code.

## Install the SDK

The SDK is [`exedev` on PyPI](https://pypi.org/project/exedev/). It needs
Python 3.11 or later, and depends only on
[HTTPX](https://www.python-httpx.org). With
[uv](https://docs.astral.sh/uv/):

```bash
uv init sandboxes && cd sandboxes
uv add exedev
export EXE_TOKEN=exe1.…   # the token from the previous step
```

Or `pip install exedev`, in a virtual environment. `exedev.Client()` reads
the token from `EXE_TOKEN`; pass `token=` to give it one yourself.

This page is for exedev 0.4 (`exedev.__version__`). Until 1.0, a minor
release can change the API, so pin it in anything that lasts:
`uv add "exedev>=0.4,<0.5"`.

## Create a sandbox

```python
# create.py
import exedev

exe = exedev.Client()

with exe.new() as vm:
    print(vm.name)       # halo-but-valley
    print(vm.https_url)  # https://halo-but-valley.exe.xyz
    print(vm.run("python3 --version").stdout)  # Python 3.12.3
```

Run it with `uv run create.py`. `new()` returns once the VM accepts
commands, so you can use it straight away, and the `with` block deletes it
when the block ends, even if the block raises. Without `with`, call
`vm.delete()` yourself. If the VM doesn't become ready, or `new()` is
interrupted, by Ctrl-C or `asyncio` cancellation, `new()` deletes the VM
before it raises.

Don't pick names: VM names are global across exe.dev, so let it choose a
free one.

A VM's `name`, `status` (`"running"` or `"paused"`), `https_url`,
`comment`, `tags`, and `created_at` are as exe.dev last reported them, and
`vm.refresh()` fetches them again. `exe.vms()` lists your VMs, and
`exe.vm(name)` fetches one, or raises `exedev.NotFoundError`.

`new()` takes the flags of [`new`](/api?cmd=help+new) as keyword
arguments:

| Argument | What it does |
| --- | --- |
| `cpu`, `memory`, `disk` | Size: `cpu=4, memory="8GB", disk="50GB"` |
| `image` | A container image instead of the default Ubuntu 24.04 image ([private registries](/docs/private-image) work too, with `registry_auth`) |
| `setup_script` | A script the default image runs as `exedev` at first boot |
| `wait_until` | `"setup"` also waits for the setup script to finish. `"created"` returns as soon as the VM exists, before it accepts commands. |
| `env` | Environment variables for every command, as a dict |
| `comment` | A note shown in `ls`, like a job ID |
| `integrations` | [Integrations](/docs/integrations) to attach, such as a GitHub repository |
| `standalone=True` | A [standalone VM](/docs/standalone-vms): capacity of its own, billed by the second |
| `prompt` | A task for [Shelley](/docs/shelley/intro), the agent on the VM |
| `timeout` | Seconds to wait for the VM to be ready: 60, or 600 with `wait_until="setup"` |
| `keep_on_failure=True` | Keep a VM that doesn't become ready, to debug it, as the exception's `vm` |
| `email=True` | Email you about the new VM, as `ssh exe.dev new` does |

To install things before you use a sandbox, give `new()` a setup script and
wait for it:

```python
vm = exe.new(
    setup_script="#!/bin/bash\nset -e\nsudo apt-get install -y -q jq\n",
    wait_until="setup",
)
```

If the script fails, `new()` deletes the VM and raises
`exedev.VMSetupError`, with the script's `exit_code`, and the end of its
output in `output`. The script runs once, at first boot. Only the default image
and images built on it run setup scripts; on others, waiting for one
raises a `VMSetupError` whose `result` is `"unsupported"`.

## Run code in it

`vm.run()` runs a command in the sandbox, waits for it to finish, and
returns a `CommandResult`:

```python
r = vm.run("echo hello from $(hostname); echo oops >&2; exit 3")
r.stdout     # 'hello from halo-but-valley\n'
r.stderr     # 'oops\n'
r.exit_code  # 3
r.ok         # False
```

As with `subprocess.run()`, a failing command isn't an exception unless you
ask for one with `check=True`. Then it raises `exedev.CommandError`, whose
message ends with the last line of the command's standard error and whose
`result` has the rest:

```python
vm.run("uv run --with pytest pytest -q", cwd="app", env={"CI": "1"}, check=True)
```

A string is a bash command line, with pipes, `&&`, and `$(...)`. A list is
an argument list, which the SDK quotes for you, so code and values with
spaces, quotes, or newlines arrive intact:

```python
code = 'import sys\nprint("hello,", sys.argv[1])'
vm.run(["python3", "-c", code, "it's me"]).stdout  # "hello, it's me\n"
```

In a list, nothing is expanded: `~`, `$HOME`, and `*` arrive as written,
so use paths relative to the home directory, where commands start. When
you put a value into a command string, quote it with `shlex.quote()`. A
command can be any length: one too long for a request is uploaded to the
VM first.

Commands run as the `exedev` user, who has passwordless `sudo`, in a bash
login shell, so `PATH` is as in a terminal. They start in the home
directory, with no terminal and no stdin. Each call starts a new shell, so
`cd`, variables, and Python state don't carry over from one call to the
next. Files do. `cwd=` (relative to the home directory) and `env=` set the
directory and environment for one command.

The default image has Python 3.12 and [uv](https://docs.astral.sh/uv/).
Its system `pip` refuses to install packages
([PEP 668](https://peps.python.org/pep-0668/)), so use uv:

- `uv run --with pandas python job.py` runs `job.py` with pandas, in a
  cached environment. On a fresh sandbox that took about two seconds.
- A script with [inline metadata](https://packaging.python.org/en/latest/specifications/inline-script-metadata/)
  (a `# /// script` block listing its dependencies) brings its own:
  `uv run job.py`.
- For an environment that lasts, `uv venv ~/venv && uv pip install
  --python ~/venv -r requirements.txt`, then run `~/venv/bin/python`.

## Move files

```python
vm.write_file("job/main.py", code)              # str or bytes; makes directories
vm.write_file("job/run.sh", script, mode=0o755)
data = vm.read_file("job/out.csv")              # bytes
vm.upload("./src", "app", exclude=[".git", ".venv", "__pycache__"])
vm.download("app/dist", "./dist")               # a file or a directory
```

Paths in the sandbox are relative to the home directory unless they're
absolute. `upload()` and `download()` take a file or a directory; a
directory's contents are copied into the destination, which is made if
need be. `exclude` patterns, such as `".git"` or `"*.pyc"`, are matched
against the name of every file and directory, at any depth. A missing file raises the built-in `FileNotFoundError`, and a
directory given to `read_file()` raises `IsADirectoryError`.

Files travel through the same API as commands, in compressed pieces sent
in parallel. In our tests, writing 1 MB of random bytes took under 2
seconds, and 5 MB about 6; text compresses, so 1.2 MB of it took a third of
a second. Reading 5 MB took half a second. For much bigger files, let the
sandbox fetch them (`git clone`, `curl`, `uv`), or use `scp`.

Now the whole life of a sandbox: create it, upload a script, run it, delete
it.

```python
# hello.py
import exedev

MAIN = """\
import platform
print("hello from", platform.node())
raise SystemExit(3)
"""

exe = exedev.Client()

with exe.new(comment="hello from Python") as vm:
    print("created", vm.name)
    vm.write_file("main.py", MAIN)
    r = vm.run("python3 main.py")
    print(r.stdout, end="")
    print("exit", r.exit_code)
print("deleted", vm.name)
```

```console
$ uv run hello.py
created halo-but-valley
hello from halo-but-valley
exit 3
deleted halo-but-valley
```

## Stream output and set time limits

To see output as it happens, pass callbacks. Each gets one line at a time,
without its line ending:

```python
vm.run("cd app && make test", on_stdout=print, on_stderr=print)
```

To tell parallel jobs apart, prefix the lines:
`on_stdout=lambda line: print(f"[{job}] {line}")`. `run()` still collects
the output, up to 8 MiB in all, and raises `exedev.OutputLimitError` past
that. Raise the limit with `max_output`, or pass `capture=False` to only
stream it; then the result and the exceptions below carry no output
either.

To give a command a time limit, pass `timeout` in seconds:

```python
try:
    vm.run("cd app && make test", timeout=60)
except exedev.CommandTimeoutError as err:  # also a TimeoutError
    print("too slow; it printed:", err.stdout)
```

When time runs out, the SDK stops the command and every process it
started, within a quarter of a second. So does cancelling an `asyncio` task that's waiting on `run()`.

## Run in the background

`run()` lasts only as long as its request: if your program exits or the
connection drops, the command stops. For servers and long jobs, use
`spawn()`. It starts a command and returns a `Process` at once, and the
process runs on its own until it exits or you stop it, whatever happens to
your program:

```python
job = vm.spawn("./build.sh", name="build", cwd="app")

job.poll()                       # None while it runs, then its exit status
print(job.logs(tail=20).stdout)  # the last 20 lines it printed so far
result = job.wait(timeout=3600, check=True)  # a CommandResult, with its output
```

- `wait()` reconnects if the connection drops, until it knows how the
  process ended. With a `timeout`, it raises `TimeoutError` if the process
  is still running then, and leaves it running.
- `terminate()`, `kill()`, and `send_signal()` reach the process and
  everything it started. `with vm.spawn(...) as p:` kills it at the end of
  the block.
- The process belongs to the VM, not to your program, so another program
  can pick it up: `exe.vm(vm_name).process("build")`. `vm.processes()`
  lists everything `spawn()` started. A name that isn't found raises the
  built-in `ProcessLookupError`.
- A name is letters, digits, `.`, `_`, and `-`, and can't be reused while
  that process runs. Without one, `spawn()` picks a random name.
- The process's output goes to files on the VM, as the process writes it.
  Processes keep running through `pause()` and `resume()`, but not
  through `restart()`: after one, `poll()` and `wait()` raise
  `exedev.ProcessLostError`.

## Serve a preview

exe.dev's [proxy](/docs/proxy) serves port 8000 of every VM at
`https://<vm>.exe.xyz/`, with TLS. Start a server in the background and
share the address:

```python
vm.write_file("site/index.html", "<h1>Hello from a sandbox</h1>\n")
vm.spawn("python3 -m http.server 8000", name="web", cwd="site")
vm.run("curl -sf --retry 9 --retry-connrefused localhost:8000", check=True)
vm.share.set_public()
print(vm.https_url)  # https://halo-but-valley.exe.xyz
```

The `curl` waits until the server answers, so the address works as soon
as you hand it out.

The address is private until `set_public()`: visitors are sent to
exe.dev's login and need to be someone you've [shared](/docs/sharing) the
VM with. `set_public()` opens it to anyone with the link, and
`vm.share.set_private()` closes it again. The proxy also forwards ports
3000 to 9999, at `vm.url(3000)`, but only to people with access; only one
port can be public (`vm.share.port()` chooses it). For your own code to
reach a private sandbox, give it a
[VM token](/docs/https-tokens-for-vms).

## Copy, pause, and resize

Set up a sandbox once, then copy it for each task instead of installing
the same things every time:

```python
# Once: a base sandbox with your dependencies.
base = exe.new(comment="base")
base.run("uv venv -q ~/venv && uv pip install -q -p ~/venv pandas", check=True)
base.pause()

# For each task: a copy, ready for commands, and deleted at the end of the block.
with base.copy() as vm:
    vm.run("~/venv/bin/python -c 'import pandas'", check=True)
```

`copy()` copies the disk. A paused VM can be copied, and copies keep
their source's tags and comment, so the token can still see them.

Pausing writes a VM's memory to disk and frees its CPU and memory.
Resuming brings it back with its processes still running:

```python
vm.pause()   # about 2 seconds
vm.resume()  # about 4 seconds, ready for commands
```

While it's paused, `run()` and `spawn()` raise `exedev.VMNotRunningError`.
Pausing or restarting a VM cuts off `run()` commands that are running:
they raise `exedev.CommandInterruptedError`.

To change a sandbox's size, resize it and restart it, since new memory and
CPUs apply after a restart:

```python
vm.resize(cpu=4, restart=True)
```

`copy()`, `pause()`, and `restart()` first flush the VM's recent writes
to its disk, so a copy, a paused VM, and a restarted one all have every
file written so far. `flush=False` skips that, for speed, when you know
nothing needs keeping. Processes don't survive a restart: everything
`spawn()` started reads as lost.

## Run many at once

`exedev.AsyncClient` is the same API for `asyncio`: every method is a
coroutine, and `async with` deletes a sandbox at the end of the block. Ten
sandboxes at once:

```python
# many.py
import asyncio

import exedev


async def square(exe: exedev.AsyncClient, n: int) -> str:
    async with exe.new(comment=f"square {n}") as vm:
        r = await vm.run(["python3", "-c", f"print({n} ** 2)"], check=True)
        return r.stdout.strip()


async def main() -> None:
    async with exedev.AsyncClient() as exe:
        async with asyncio.TaskGroup() as tg:
            tasks = [tg.create_task(square(exe, n)) for n in range(10)]
    print([t.result() for t in tasks])


asyncio.run(main())
```

```console
$ uv run many.py
['0', '1', '4', '9', '16', '25', '36', '49', '64', '81']
```

Note `async with exe.new()`, without `await`; outside `async with`, it's
`vm = await exe.new()`. `spawn()` and `copy()` work the same way. The VM
is an `exedev.AsyncVM`, its processes are `exedev.AsyncProcess`, and
`exe.vm(name)` needs an `await` too. Output callbacks may be coroutine
functions.

To cap how many sandboxes run at once, wrap the work in an
`asyncio.Semaphore`: your plan has a VM limit. Requests refused for going
over the rate limit are retried, up to `max_retries` times (5, set on the
client), before they raise `exedev.RateLimitError`.

## Give a model a sandbox

The usual reason to want a sandbox: an LLM writes code, and something has
to run it. Give the model a tool that runs Python in a VM, and a VM for the
conversation. This uses the [Anthropic SDK](https://github.com/anthropics/anthropic-sdk-python)
(`uv add anthropic`):

```python
# agent.py
from typing import Any

import anthropic
import exedev
from anthropic.types import (
    MessageParam,
    ToolParam,
    ToolResultBlockParam,
    ToolUseBlock,
)

claude = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY
exe = exedev.Client()

RUN_PYTHON: ToolParam = {
    "name": "run_python",
    "description": (
        "Run a Python 3.12 program on a Linux VM and return its exit code and "
        "output. Print what you want to see. Files it writes stay for later calls."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "code": {"type": "string", "description": "The program"},
            "packages": {
                "type": "array",
                "items": {"type": "string"},
                "description": "PyPI packages to install first, such as pandas",
            },
        },
        "required": ["code"],
    },
}


def run_python(vm: exedev.VM, code: str, packages: list[str]) -> str:
    with_packages = [f"--with={p}" for p in packages]
    command = ["uv", "run", "-q", *with_packages, "python", "-c", code]
    try:
        r = vm.run(command, timeout=120)
    except exedev.CommandTimeoutError:
        return "The program ran for 120 seconds and was stopped."
    return f"exit code {r.exit_code}\n" + (r.stdout + r.stderr)[-10_000:]


def answer(vm: exedev.VM, call: ToolUseBlock) -> ToolResultBlockParam:
    args: dict[str, Any] = call.input
    print(f"run_python:\n{args['code']}\n")
    output = run_python(vm, args["code"], args.get("packages", []))
    return {"type": "tool_result", "tool_use_id": call.id, "content": output}


def ask(question: str) -> str:
    messages: list[MessageParam] = [{"role": "user", "content": question}]
    with exe.new(comment="agent") as vm:
        while True:
            reply = claude.messages.create(
                model="claude-sonnet-5-5",
                max_tokens=4096,
                tools=[RUN_PYTHON],
                messages=messages,
            )
            messages.append({"role": "assistant", "content": reply.content})
            if reply.stop_reason != "tool_use":
                return "".join(b.text for b in reply.content if b.type == "text")
            results = [answer(vm, b) for b in reply.content if b.type == "tool_use"]
            messages.append({"role": "user", "content": results})


print(ask("Using pandas, find the median of the squares of the first 101 primes."))
```

```console
$ uv run agent.py
run_python:
import pandas as pd
from sympy import prime
s=pd.Series([prime(i)**2 for i in range(1,102)])
print(s.median(), prime(51))

The median of the squares of the first 101 primes is **54289**.

I built a pandas Series of those 101 squares and called `.median()`. With
101 values, the median is the 51st one. …
```

- One VM per conversation, not per call: files the model writes, and the
  packages it installs, are still there on its next turn. The `with` block
  deletes the VM when the conversation ends.
- The model's code goes in as an argument list, so it needs no quoting,
  and it can be any length. The time limit stops the program and
  anything it started.
- The tool returns failures as text instead of raising, so the model sees
  its tracebacks and can fix its code. The output is cut to its last
  10,000 characters to keep the conversation small.
- On an exe.dev VM, `anthropic.Anthropic(base_url="https://llm.int.exe.xyz",
  api_key="unused")` uses the [LLM integration](/docs/integrations-llm)
  instead, with no API key. The OpenAI SDK works the same way, with the
  tool under `tools=[{"type": "function", "function": {...}}]`.

The model's code is code you don't trust. Read [Run untrusted
code](#run-untrusted-code) before you give it anything else.

## Clean up

Delete sandboxes with `with`, as the examples do, or in a `finally`, so a
failed task doesn't leave its VM behind. `with` deletes on any way out of
the block, including Ctrl-C and `asyncio` cancellation. `vm.delete()`
raises `exedev.NotFoundError` if the VM was already gone; `with` ignores
that.

`new()` and `copy()` clean up after themselves too. If the VM doesn't
become ready, or they're interrupted, they delete it before they raise,
even when the interrupt lands while exe.dev is still creating it: they
wait for the VM to exist, then delete it.

What gets past both: a second Ctrl-C, which gives up on cleanup that's
under way; a `NetworkError` from `new()`, after which the VM may or may
not exist; and a process that's killed outright. So give each job's
sandboxes a `comment` that names the job, and before the job exits,
delete the ones still there:

```python
exe.delete(*[vm for vm in exe.vms() if vm.comment == comment])
```

`exe.delete()` deletes several VMs in one request. It doesn't raise for
the ones it couldn't delete: they're in the result's `failed` (and
`not_found`), with reasons in `errors`. With `AsyncClient`, it's
`await exe.vms()` and `await exe.delete(...)`.

To keep some sandboxes, say the ones whose tests failed, don't use `with`:
create the VM with `vm = exe.new(...)`, and in a `finally`, delete it
unless you mean to keep it. Give a kept VM a new comment with
`vm.set_comment(...)`, so the sweep above passes it by.

Processes still die without cleaning up, so sweep up now and then. This
deletes running sandboxes more than an hour old; paused ones, like the
base you copy from, stay. The token sees only VMs tagged `sandbox`, so
nothing else can be touched.

```python
# reap.py
from datetime import datetime, timedelta, timezone

import exedev

exe = exedev.Client()

cutoff = datetime.now(timezone.utc) - timedelta(hours=1)
stale = [
    vm
    for vm in exe.vms()
    if vm.status == "running" and vm.created_at and vm.created_at < cutoff
]
result = exe.delete(*stale)
print(f"deleted {len(result['deleted'])}, failed {len(result['failed'])}")
```

It deletes every sandbox of that age, whoever made it: if some live
longer, or several apps share the token, filter on `vm.comment` too. Run
it from cron, or before creating new sandboxes.

## Run untrusted code

> [!WARNING]
> Integrations attached to `auto:all` reach every new VM, sandboxes
> included. On a new account those are the LLM gateway, billed to you, and
> reflection, which tells the VM your email address. Code you don't trust
> can use them.

To keep an integration off sandboxes, attach it by tag instead (see
[Attaching integrations](/docs/integrations-attach)). Then only VMs with
that tag get it, so tag your own VMs that need it. On your computer:

```bash
ssh exe.dev integrations list
ssh exe.dev integrations detach llm auto:all
ssh exe.dev integrations attach llm tag:dev
```

Otherwise a sandbox is like any VM: its web address is private until you
share it, only you and your tokens can run commands in it, and it can reach
the internet. To cut it off, set its egress policy to `"integrations"`.
Install what the code needs in a base VM that has the internet, then run
the code in copies that don't:

```python
base = exe.new(comment="base")
base.run("uv venv -q ~/venv && uv pip install -q -p ~/venv pandas", check=True)
base.pause()

with base.copy(egress="integrations") as vm:
    vm.write_file("untrusted.py", code)
    r = vm.run("~/venv/bin/python untrusted.py", timeout=60)
```

`exe.new(egress="integrations")` works the same way. With that policy,
names of public hosts don't resolve, so `pip install`, `uv`, and HTTP
requests fail with DNS errors. Copies keep their source's policy unless
you pass `egress=`. exe.dev's own addresses and the integrations attached
to the VM, such as the LLM gateway, still work, which is why the warning
above matters too. `vm.egress` is a VM's policy, `"integrations"` or
`"all"`, and `vm.set_egress("all")` changes it, if the token has `egress`
in its `--cmds`. See [Egress policy](/docs/internet-access).

## Errors and limits

Every exception the SDK defines is an `exedev.ExeError`:

```text
ExeError
├── APIError                   the request was refused: .status, .operation, .body
│   ├── AuthenticationError    401: the token is malformed, expired, or revoked
│   ├── PermissionDeniedError  403: the token may not run that command (--cmds)
│   ├── NotFoundError          404: no such VM, or not one the token can see
│   ├── RateLimitError         429: still too many requests after max_retries
│   └── VMNotRunningError      run() or spawn() on a paused or stopped VM
├── NetworkError               the request failed; whether it took effect is unknown
├── ProtocolError              a response the SDK didn't understand
├── CommandError               check=True and the command exited nonzero: .result
├── CommandInterruptedError    the exit status is unknown: .stdout, .stderr so far
│   ├── OutputLimitError       more output than max_output
│   └── CommandTimeoutError    run(timeout=...) ran out; also a TimeoutError
├── ProcessLostError           a spawned process ended without an exit status
└── VMReadyError               a VM didn't become ready: .vm
    └── VMSetupError           its setup script failed: .exit_code, .output
```

File methods raise the built-in `FileNotFoundError` and
`IsADirectoryError`, `vm.process()` raises `ProcessLookupError`, and
`Process.wait(timeout=...)` raises `TimeoutError`.

- After a `NetworkError` from `new()`, a VM may or may not exist. Find it
  by its `comment`, or let the [cleanup script](#clean-up) catch it.
- After a `CommandInterruptedError` (the VM restarted or paused, or the
  connection failed), the command may or may not have finished. Don't
  blindly run it again; for work that must finish, use `spawn()` and
  `wait()`.
- A `RateLimitError` has already been retried. Back off before trying
  again.
- Other `APIError`s have `status` 422: the command failed, as with a bad
  argument. The [HTTPS API](/docs/https-api#troubleshooting) docs list the
  rest.

Messages never include the token or the command line. Every method's
docstring is the command's help, and the [API explorer](/api?cmd=help+new)
shows what each command accepts.

## Coming from E2B

The ideas carry over. What changes is that a sandbox is a VM that lasts
until you delete it:

| E2B | exedev |
| --- | --- |
| `Sandbox.create()` | `exe.new()`, in a `with` block |
| `sandbox.commands.run(cmd)` | `vm.run(cmd)` |
| `commands.run(cmd, background=True)` | `vm.spawn(cmd)`: [Run in the background](#run-in-the-background) |
| `sandbox.run_code(code)` | `vm.run(["python3", "-c", code])`, a new process each time |
| `sandbox.files.write()`, `.read()` | `vm.write_file()`, `vm.read_file()`, `vm.upload()`, `vm.download()` |
| `Sandbox.connect(sandbox_id)` | `exe.vm(name)` |
| `sandbox.get_host(port)` | `vm.https_url` for port 8000, `vm.url(port)` for others |
| `sandbox.pause()` | `vm.pause()`, then `vm.resume()` |
| `sandbox.kill()` | `vm.delete()` |
| Templates | `image=`, `setup_script=`, or a paused base VM to `copy()` |
| `timeout=` on the sandbox | None: a VM runs until you pause or delete it |
| `allow_internet_access=False` | `egress="integrations"`: [Run untrusted code](#run-untrusted-code) |

## Frequently asked questions

### Is a sandbox a container or a VM?

A VM, with its own kernel. The default image is Ubuntu 24.04 with `sudo`, Python 3.12, `uv`, Docker, Go, and git, so code in a sandbox can install packages, run containers, and start services as it would on any Linux machine. Pass `image=` to `new()` to start from another container image instead.

### How fast is it?

In our tests `new()` returned a VM ready for commands in about 2 seconds, and each `run()` took about a third of a second plus the command's own time. `copy()` and `pause()` took about 2 seconds, `restart()` 3, and `resume()` 4. Ten sandboxes created at once with `asyncio` were all done in about 7 seconds.

### What does a sandbox cost?

Ordinary VMs share your plan's CPU and memory: more sandboxes split the same capacity rather than add to the bill, up to the plan's VM limit (50 on [Personal](/docs/billing/personal), 25 per vCPU in a [Work pool](/docs/pools)). Disk and bandwidth beyond your allowance are billed as [usage](/docs/billing/usage). `new(standalone=True)` makes a [standalone VM](/docs/standalone-vms) instead, with capacity of its own, billed by the second while it runs: ten seconds of 2 vCPU is about $0.0003. See [pricing](https://exe.dev/pricing).

### Does Python state carry over between run() calls, like a notebook?

No. Each `run()` starts a new process, as if you typed a new command in a terminal. Files, installed packages, and processes started with `spawn()` stay. For notebook-style state, keep it in files, or `spawn()` a long-lived interpreter and talk to it.

### Can I use it from FastAPI, Django, or Celery?

Yes. Use `exedev.AsyncClient` in async code such as FastAPI handlers, and `exedev.Client` in Django views, Celery tasks, and scripts. Make the client once and reuse it: it keeps its connections to exe.dev open.

### Can I use SSH instead?

Yes. Every sandbox is also `ssh <vm>.exe.xyz` for the SSH keys on your account, so `subprocess` with `ssh`, Paramiko, or AsyncSSH give you stdin, interactive sessions, and SFTP. The SDK is simpler to run from a server: one token, no keys on disk, no known_hosts.

### Can code in a sandbox use AI models?

Yes, if your [LLM integration](/docs/integrations-llm) is attached to it, as it is by default: code in the sandbox can call `https://llm.int.exe.xyz` without an API key, billed to you. `new(prompt=...)` goes further and hands the sandbox's own agent, Shelley, a task to work on.


## Related

- [Create sandboxes from TypeScript](https://exe.dev/deploy/typescript-sandboxes.md): Full VMs as sandboxes. Create, control, and destroy them from your code.
- [Running Agents](https://exe.dev/deploy/agents.md): Use exe.dev VMs as a sandbox
- [Deploy a FastAPI app](https://exe.dev/deploy/fastapi.md): Your API on a VM of its own. Push to deploy; the data stays put.
- [Run Claude Code on a remote server](https://exe.dev/deploy/claude-code.md): A computer for Claude. A breather for your laptop.
- [Host OpenClaw](https://exe.dev/deploy/openclaw.md): An AI sidekick with its own computer. Your laptop can take a nap.
- [Run Codex on a remote server](https://exe.dev/deploy/codex.md): A computer for Codex. An early night for your laptop.
