Skip to content

Create sandboxes from Python

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

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:

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 with a token. Make one that can only touch sandboxes. On your computer:

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":

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. It needs Python 3.11 or later, and depends only on HTTPX. With uv:

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#

# 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 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 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 to attach, such as a GitHub repository
standalone=True A standalone VM: capacity of its own, billed by the second
prompt A task for Shelley, 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:

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:

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:

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:

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. Its system pip refuses to install packages (PEP 668), 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 (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#

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.

# 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)
$ 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:

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:

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:

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 serves port 8000 of every VM at https://<vm>.exe.xyz/, with TLS. Start a server in the background and share the address:

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

Copy, pause, and resize#

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

# 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:

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:

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:

# 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())
$ 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 (uv add anthropic):

# 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."))
$ 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 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 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:

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.

# 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). Then only VMs with that tag get it, so tag your own VMs that need it. On your computer:

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:

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.

Errors and limits#

Every exception the SDK defines is an exedev.ExeError:

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 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 APIErrors have status 422: the command failed, as with a bad argument. The HTTPS API 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 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
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

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, 25 per vCPU in a Work pool). Disk and bandwidth beyond your allowance are billed as usage. new(standalone=True) makes a standalone VM instead, with capacity of its own, billed by the second while it runs: ten seconds of 2 vCPU is about $0.0003. See 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 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.

Updated Oct 22, 2026 · tested end to end Oct 22, 2026 · Markdown · manifest.json · Something wrong? Tell us