---
title: "Create sandboxes from TypeScript"
description: "Use @exedev/sdk to create exe.dev VMs from TypeScript in seconds, run code and serve previews in them, then copy, pause, or delete them."
url: https://exe-staging.dev/deploy/typescript-sandboxes
category: "AI agents"
updated: 2026-10-19
---

# Create sandboxes from TypeScript

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

- **Software:** TypeScript SDK (https://www.npmjs.com/package/@exedev/sdk)
- **Source:** https://github.com/boldsoftware/exe-sdk-js
- **License:** MIT
- **Machine-readable:** https://exe-staging.dev/deploy/typescript-sandboxes/manifest.json

[`@exedev/sdk`](https://www.npmjs.com/package/@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](/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.`.

- `--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`](https://tsx.is):

```bash
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

```ts
// 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`](/api?cmd=help+new), spelled the same way:

| Option | What it does |
| --- | --- |
| `cpu`, `memory`, `disk` | Size: `{ cpu: 4, memory: "8GB", disk: "50GB" }` |
| `sandbox: true` | A [standalone VM](/docs/standalone-vms): capacity of its own, billed by the second |
| `image` | A container image instead of the default Ubuntu 24.04 image ([private registries](/docs/private-image) work too) |
| `integration` | [Integrations](/docs/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](/docs/shelley/intro), 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:

```ts
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](#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:

```ts
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:

```ts
// 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.

```ts
// 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}`);
}
```

```console
$ 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:

```ts
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:

```ts
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:

```ts
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](/docs/proxy) serves port 8000 of every VM at
`https://<vm>.exe.xyz/`, with TLS. Start a server and share the address:

```ts
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](/docs/sharing) 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](/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:

```ts
// 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:

```ts
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:

```ts
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

```ts
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:

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

```ts
// 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](/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: 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](#delete-it) 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/https-api#troubleshooting) docs list the rest. Every
method's documentation comment shows the command's help, and the
[API explorer](/api?cmd=help+new) 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](/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({ sandbox: 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).

### 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](/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

- [Running Agents](https://exe-staging.dev/deploy/agents.md): Use exe.dev VMs as a sandbox
- [Run Claude Code on a remote server](https://exe-staging.dev/deploy/claude-code.md): A computer for Claude. A breather for your laptop.
- [Dev, Test, Prod: Choose One, Two, or Three](https://exe-staging.dev/deploy/dev-test-prod.md): You can build, test, and deploy on exe.dev.
- [Host OpenClaw](https://exe-staging.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-staging.dev/deploy/codex.md): A computer for Codex. An early night for your laptop.
- [Run OpenCode on a remote server](https://exe-staging.dev/deploy/opencode.md): Every model, one VM, and an agent that works while your laptop sleeps.
