Skip to content

Create sandboxes from TypeScript

Full VMs as sandboxes. Create, control, and destroy them from your code.

@exedev/sdk is exe.dev's TypeScript SDK. exe.new() gives you a VM, vm.ssh() runs code in it, and vm.rm() deletes it. In our tests that whole round trip, with a Python script uploaded and run in between, took about three seconds.

The sandbox is a whole VM, not a container: its own kernel, root through sudo, Docker, and an HTTPS address. Use one for code an AI agent 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.

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

  • --tag=sandbox scopes the token. Every VM it creates is tagged sandbox, and it can see and touch no other VM: ls lists only sandboxes, and ssh to any other VM answers "not found". It can't change tags, either.
  • --cmds lists the commands the token may run. Keep ssh: it runs commands in sandboxes, and the SDK uses it to tell when a new 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.

Set up a project#

The SDK is an ES module with no dependencies, for Node.js 18 or later. To follow along with TypeScript files run by tsx:

mkdir sandboxes && cd sandboxes
npm init -y && npm pkg set type=module
npm install @exedev/sdk
npm install -D tsx
export EXE_TOKEN=exe1.โ€ฆ   # the token from the previous step

Create a sandbox#

// create.ts
import exedev from "@exedev/sdk";

const exe = exedev({ token: process.env.EXE_TOKEN! });

const vm = await exe.new({ "no-email": true });
console.log(vm.name);     // halo-but-valley
console.log(vm.httpsUrl); // https://halo-but-valley.exe.xyz

const { stdout } = await vm.ssh("python3 --version");
console.log(stdout);      // Python 3.12.3

Run it with npx tsx create.ts. new returns once the VM accepts commands, so you can use it straight away.

Don't pick names: VM names are global across exe.dev, so let it choose a free one. Without "no-email": true, exe.dev emails you about every VM it creates.

Options are the flags of new, spelled the same way:

Option What it does
cpu, memory, disk Size: { cpu: 4, memory: "8GB", disk: "50GB" }
sandbox: true A standalone VM: capacity of its own, billed by the second
image A container image instead of the default Ubuntu 24.04 image (private registries work too)
integration Integrations to attach, such as a GitHub repository
comment A note shown in ls, like a job ID
env Environment variables, ["KEY=value"], set in login shells
"setup-script" A script the default image runs as exedev when the VM boots
prompt A task for Shelley, the agent on the VM
waitUntil "setup" also waits for the setup script to finish. "created" returns as soon as the VM exists, without waiting for it to accept commands.

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

const vm = await exe.new({
  "no-email": true,
  "setup-script": "#!/bin/bash\nset -e\nsudo apt-get install -y -q jq\n",
  waitUntil: "setup",
});

If the script fails, new throws a VMReadyError whose cause is a VMSetupError, with the script's exitCode and the end of its log in details().output. The VM is left for you to inspect or delete (see Delete it).

Only the default image and images built on it run setup scripts. On others, waiting for the script throws a VMSetupError whose result is "unsupported". The script also runs again each time the VM restarts, so write it to be safe to repeat, as apt-get install is.

Run code in it#

vm.ssh() runs a shell command in the sandbox and waits for it to finish:

const r = await vm.ssh("echo hello from $(hostname); echo oops >&2; exit 3");
r.stdout;   // "hello from halo-but-valley\n"
r.stderr;   // "oops\n"
r.exitCode; // 3

A failing command isn't an exception: check exitCode. Commands run in bash as the exedev user, in its home directory, with no terminal and no stdin. Each call starts a fresh shell, so cd and variables don't carry over from one to the next.

The command is a bash script, so quote anything you put into it with the SDK's quote: vm.ssh(`cat ${quote(path)}`). The API has no stdin, so files travel inside commands too. Put these helpers in a sandbox.ts next to your code:

// sandbox.ts
import exedev, { quote, type VM } from "@exedev/sdk";

export const exe = exedev({ token: process.env.EXE_TOKEN! });

// Writes a file in the sandbox. A request to exe.dev can be at most 64 KB,
// so the file travels in 48 KB pieces of base64.
export async function writeFile(vm: VM, path: string, data: string | Uint8Array) {
  const b64 = Buffer.from(data).toString("base64");
  for (let i = 0; i === 0 || i < b64.length; i += 49152) {
    const piece = b64.slice(i, i + 49152);
    const r = await vm.ssh(`printf %s ${piece} | base64 -d ${i ? ">>" : ">"} ${quote(path)}`);
    if (r.exitCode !== 0) throw new Error(`writeFile ${path}: ${r.stderr}`);
  }
}

// Reads a file from the sandbox, up to 8 MiB.
export async function readFile(vm: VM, path: string): Promise<Uint8Array> {
  const r = await vm.ssh(`cat ${quote(path)}`);
  if (r.exitCode !== 0) throw new Error(`readFile ${path}: ${r.stderr}`);
  return r.stdoutBytes;
}

Requests are capped at 64 KB, so writeFile needs 28 of them for a 1 MB file, about 11 seconds. For bigger files, let the sandbox fetch them (git clone, curl), or use scp.

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

// hello.ts
import { exe, writeFile } from "./sandbox.ts";

const vm = await exe.new({ "no-email": true, comment: "hello from TypeScript" });
console.log(`created ${vm.name}`);
try {
  await writeFile(vm, "main.py", 'import platform\nprint("hello from", platform.node())\nraise SystemExit(3)\n');
  const r = await vm.ssh("python3 main.py");
  process.stdout.write(r.stdout);
  console.log(`exit ${r.exitCode}`);
} finally {
  await vm.rm();
  console.log(`deleted ${vm.name}`);
}
$ npx tsx hello.ts
created halo-but-valley
hello from halo-but-valley
exit 3
deleted halo-but-valley

Variables from new({ env }) are set in login shells. To be sure a command sees them, run it in one: vm.ssh("bash -lc 'echo $FOO'"). Or pass values on the command line: vm.ssh(`API_URL=${quote(url)} python3 main.py`).

Stream output and set time limits#

To see output as it happens, pass callbacks:

const r = await vm.ssh("cd app && make test", {
  onStdout: (text) => { process.stdout.write(text); },
  onStderr: (text) => { process.stderr.write(text); },
});

ssh still collects the output for r.stdout and r.stderr, up to 8 MiB in all, and throws an SSHStreamError past that. Raise the limit with maxOutputBytes, or pass capture: false to only stream it.

To give a command a time limit, pass a signal. Aborting stops the command in the sandbox, too:

await vm.ssh("cd app && make test", { signal: AbortSignal.timeout(60_000) });

Run long jobs#

ssh waits as long as the command runs, even if it prints nothing for many minutes. But the command lasts only as long as the request: if your process exits or the connection drops, the command stops. For jobs that should outlive your process, start them in the background and check on them:

import type { SSHResult } from "@exedev/sdk";

await vm.ssh("setsid nohup bash -c './build.sh; echo $? > build.exit' > build.log 2>&1 < /dev/null &");

let done: SSHResult;
while ((done = await vm.ssh("cat build.exit")).exitCode !== 0) {
  await new Promise((r) => setTimeout(r, 5000));
}
console.log(`build.sh exited ${done.stdout.trim()}`);
console.log((await vm.ssh("tail -n 50 build.log")).stdout);

Redirect all three of the job's standard streams, as above. A background process that still holds the output keeps ssh waiting until it exits.

Serve a preview#

exe.dev's proxy serves port 8000 of every VM at https://<vm>.exe.xyz/, with TLS. Start a server and share the address:

await writeFile(vm, "index.html", "<h1>Hello from a sandbox</h1>\n");
await vm.ssh("setsid nohup python3 -m http.server 8000 > http.log 2>&1 < /dev/null &");
await vm.shareSetPublic();
console.log(vm.httpsUrl);

Redirect the server's standard streams here too, or ssh waits for it to exit.

The address is private until shareSetPublic: visitors are sent to exe.dev's login and need to be someone you've shared the VM with. shareSetPublic opens it to anyone with the link, and shareSetPrivate closes it again. 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.
const base = await exe.new({ "no-email": true, comment: "base" });
await base.ssh("uv venv -q ~/venv && uv pip install -q --python ~/venv pandas && sync");
await base.pause();

// For each task: a copy of it, ready for commands, like new().
const vm = await base.cp();
await vm.ssh("~/venv/bin/python -c 'import pandas; print(pandas.__version__)'");
  • cp copies the disk. Run sync first, or recent writes that are still in memory are left out of the copy.
  • A paused VM can be copied. 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:

await vm.pause();  // about 2 seconds
await vm.resume(); // about 4 seconds, ready for commands

While it's paused, ssh throws an ExeAPIError with status 422 ("is not running"). Pausing or restarting a VM cuts off commands that are running through ssh: they throw an SSHStreamError.

To change a sandbox's size, resize it and restart it. The new CPU count applies after the restart:

await vm.resize({ cpu: 4 });
await vm.ssh("sync");
await vm.restart();

Restarting is like pulling the plug: files written in the last half minute or so that haven't reached the disk are lost. Run sync first.

Delete it#

await vm.rm();

Delete sandboxes in a finally, as hello.ts does, so a failed task doesn't leave its VM behind. rm throws if the VM wasn't deleted, with status 404 if it was already gone.

If new creates a VM that never becomes ready, or whose setup script fails, it throws a VMReadyError without deleting the VM. Its vm is the VM, to inspect or delete:

import { VMReadyError } from "@exedev/sdk";

try {
  const vm = await exe.new({ "no-email": true });
  // ...
} catch (err) {
  if (err instanceof VMReadyError) await err.vm.rm();
  throw err;
}

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.ts
import { exe } from "./sandbox.ts";

const hour = 60 * 60 * 1000;
const stale = (await exe.ls()).filter(
  (vm) => vm.info.status === "running" && Date.now() - Date.parse(vm.info.createdAt ?? "") > hour,
);
const { deleted, failed } = await exe.rm(stale);
console.log(`deleted ${deleted.length}, failed ${failed.length}`);

exe.rm deletes a list of VMs in one request. It doesn't throw for the ones it couldn't delete: they're in failed, with reasons in errors. Run the script 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: it can reach the internet, its web address is private until you share it, and only you and your tokens can run commands in it.

Errors and limits#

  • ExeAPIError: exe.dev refused the request. The message says why, as in exe.dev API error (422) during ssh: VM "x" is not running, and status is the HTTP status.
  • SSHStreamError: a command's output was cut off. code is "interrupted" (the VM restarted or paused, or the connection failed) or "output_limit". The command may or may not have finished, so don't blindly run it again. stdout and stderr have what arrived.
  • ExeNetworkError: the request failed on the way, for example because the connection dropped. Whether it took effect is unknown: new may have created a VM. Find it with ls() by its comment, or let the cleanup script catch it.
  • VMReadyError: a VM was created, copied, restarted, or resumed but didn't accept commands within 60 seconds (timeoutMs), or its setup script failed or didn't run. vm is the VM, and cause the reason: a VMSetupError for the setup script.

An aborted call rejects with the signal's reason, such as a TimeoutError. The HTTP statuses you'll see:

Status Means
401 The token is malformed, expired, or revoked
403 The token isn't allowed that command (--cmds)
413 The request is over 64 KB
422 The command failed: VM not found or not running, a bad flag
429 Too many requests from this token's key; slow down and retry

The HTTPS API docs list the rest. Every method's documentation comment shows the command's help, and the API explorer shows what each command accepts.

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, Docker, Python, uv, 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 1.6 seconds. Each ssh took about a third of a second plus the command's own time, cp about 1.3 seconds, restart about 2, and resume about 4. Images other than the default can take longer to start the first time.

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({ sandbox: 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.

Can I use SSH instead?

Yes. Every sandbox is also ssh <vm>.exe.xyz for the SSH keys on your account, so the ssh command or an SSH library gives you stdin, interactive sessions, and scp. The SDK is simpler to run from a server: one token, no keys on disk, no known_hosts.

Can I call the SDK from a browser?

Don't: anyone who can read the token can create VMs on your account until it expires. Keep it on your server, and have the browser ask your server for a sandbox.

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 19, 2026 · tested end to end Oct 19, 2026 · Markdown · manifest.json · Something wrong? Tell us