One API for every sandbox
Create, set up, start, stop, resume, list and destroy sandboxes on Docker or Vercel Sandbox through a single TypeScript SDK, while the sandbox state lives in your app's own database.
Runs on Node.js 24+ and Bun. The core, the Vercel adapter and the dbh store also run in serverless functions.
const sandbox = await sandboxes.create({
spec: {
git: { url: "https://github.com/acme/app.git" },
setup: ["npm ci"],
start: "npm run dev -- --port 3000",
ports: [3000],
},
});
await sandboxes.getUrl(sandbox.id, 3000); // https://….vercel.run
await sandboxes.stop(sandbox.id); // filesystem is kept
await sandboxes.exec(sandbox.id, "ls"); // auto-resumes first- Works with
- Docker
- Vercel Sandbox
- Postgres via Kysely / dbh
- Your own provider
Features
Everything a sandbox needs, behind one interface
Write your sandbox logic once. Swap Docker for Vercel Sandbox, or run both side by side, without touching the code that drives them.
One lifecycle everywhere
create clones an optional git repo, runs setup once and launches start as the long-running main process. stop keeps the filesystem.
Auto-resume
Touch a stopped sandbox with exec, getUrl, readFile or logs and it wakes up and runs resume first, even if the provider stopped it on its own.
State in your database
Records go through a SandboxStore. An in-memory store and a Kysely / @schemavaults/dbh Postgres store with a migration ship in the box.
Safe under concurrency
Every status change is a compare-and-set on a version column. When many requests hit a stopped sandbox at once, exactly one resumes it and the others wait for it.
Pluggable providers
Docker and Vercel Sandbox adapters are included. Implement SandboxProvider for any other backend; shell helpers cover the main-process bookkeeping.
Runs where your app runs
Node.js 24+ and Bun. The core, the Vercel adapter and the dbh Postgres store also run in serverless functions.
Exec, files and logs
exec returns the exit code, stdout and stderr. Read and write files, and read or follow main-process logs that survive restarts.
Self-healing records
reconcile() syncs records with what the providers report and settles transitions a crashed process left behind.
Built for tests
MockSandboxProvider records every call, keeps files in memory and simulates stops, wakes and provider-side timeouts. No Docker required.
Install
Install the core, add only the backends you use
Docker, Vercel Sandbox and the Postgres store are optional peer dependencies, and each one lives behind its own entry point.
bun add @schemavaults/sandbox-management-sdk
# plus whichever backends you use (all optional peer dependencies):
bun add dockerode # Docker provider
bun add @vercel/sandbox # Vercel provider
bun add @schemavaults/dbh # Postgres store (or any Kysely instance)Entry points
| Import from | Contents |
|---|---|
| @schemavaults/ | SandboxManager, InMemorySandboxStore, types, errors, helpers for writing providers |
| …/ | DockerSandboxProvider |
| …/ | VercelSandboxProvider |
| …/ | DbhSandboxStore, createSandboxesMigration, up / down |
| …/ | up / down only, to re-export from a migrations directory |
| …/ | MockSandboxProvider, InMemorySandboxStore |
Quick start
From zero to a running sandbox
Register providers and a store once, then manage every sandbox through the same SandboxManager.
import { SandboxManager } from "@schemavaults/sandbox-management-sdk";
import { DockerSandboxProvider } from "@schemavaults/sandbox-management-sdk/docker";
import { VercelSandboxProvider } from "@schemavaults/sandbox-management-sdk/vercel";
import { DbhSandboxStore } from "@schemavaults/sandbox-management-sdk/dbh";
import createDbh from "@schemavaults/dbh";
const dbh = await createDbh("postgres", { environment: "production" });
const sandboxes = new SandboxManager({
providers: [
new DockerSandboxProvider({ defaultImage: "node:24-bookworm" }),
new VercelSandboxProvider(), // uses VERCEL_OIDC_TOKEN, or pass { credentials }
],
defaultProvider: "vercel",
store: new DbhSandboxStore({ db: dbh }),
});
const sandbox = await sandboxes.create({
ownerId: user.id,
labels: { project: "acme" },
spec: {
git: { url: "https://github.com/acme/app.git", revision: "main", depth: 1 },
setup: ["npm ci", "npm run build"],
start: "npm run dev -- --port 3000",
resume: "npm run dev -- --port 3000", // defaults to `start`
ports: [3000],
env: { NODE_ENV: "development" },
timeoutMs: 30 * 60_000,
resources: { vcpus: 2 },
},
onStdout: (chunk) => process.stdout.write(chunk), // setup output
});
await sandboxes.getUrl(sandbox.id, 3000); // https://….vercel.run
await sandboxes.exec(sandbox.id, "npm test"); // { exitCode, stdout, stderr }
await sandboxes.stop(sandbox.id); // filesystem is kept
await sandboxes.exec(sandbox.id, "ls"); // auto-resumes first
await sandboxes.list({ ownerId: user.id, labels: { project: "acme" } });
await sandboxes.destroy(sandbox.id);Register providers
Each provider is a backend.
defaultProviderpicks one whencreateis not given aprovider.Pick a store
DbhSandboxStorekeeps records in Postgres so every server process sees the same sandboxes.InMemorySandboxStoresuits a single process.Describe the sandbox
The spec is stored with the record, so any process can later resume the sandbox by ID alone.
Drive it
getUrl,exec,stop,listanddestroy. A stopped sandbox wakes on the next call.
Lifecycle
A lifecycle that survives restarts and races
Every sandbox moves through the same states on every provider. Stopping keeps the filesystem, and touching a stopped sandbox brings it back.
Create
via create()
- provisioning
- setting_up
- starting
- running
Stop
via stop()
- running
- stopping
- stopped
Wake
via any operation, or resume()
- stopped
- resuming
- running
Destroy
via destroy()
- any state
- destroying
- destroyed
Fail
via setup or provider error
- any step
- failed
Auto-resume
exec,getUrl,readFile,writeFiles,logsand the other operations wake a stopped sandbox and launchresumefirst. Turn it off withautoResume: falseand callresume(id)yourself.Concurrency
Status changes are compare-and-set on a version column. If several requests hit a stopped sandbox at once, exactly one resumes it and the others wait, up to
transitionTimeoutMs(10 minutes by default).Failures
A failing setup step (
SandboxCommandError) or provider error marks the sandboxfailedand keeps the provider sandbox so you can inspect it.resumeretries it without re-running setup.Reconcile
reconcile(ids?)marks sandboxes stopped behind the SDK's back asstopped, vanished ones asfailed, and settles transitions abandoned by a crashed process.Destroyed records are kept
listhides them unless you passincludeDestroyed: true.
The spec
Describe a sandbox once, resume it from anywhere
The SandboxSpec is stored with the sandbox, so any process can later resume it by ID alone. Every field is optional.
| Field | Meaning |
|---|---|
| workdir | Absolute directory commands run in. Default: /workspace (Docker), /vercel/sandbox (Vercel). |
| env | Environment variables for every command. |
| git | { url, revision?, depth?, auth? }, cloned into workdir before setup. revision may be a branch, tag or commit SHA. auth is sent as a one-off HTTP header and never written to .git/config. The image needs git. |
| setup | One command or a list of them, run once after creation. Stops at the first non-zero exit. |
| start | The main process, launched detached after setup. |
| resume | Launched detached as the new main process whenever a stopped sandbox is woken. Defaults to start. |
| ports | Ports reachable through getUrl. |
| timeoutMs | Maximum run time. Vercel enforces it itself. For Docker, call stopExpired() periodically. |
| resources | { vcpus?, memoryMb? }. Vercel only supports vcpus (2 GB of memory per vCPU). |
| providerOptions | Provider-specific settings: { docker: { image, user, network, pullPolicy, labels } } or { vercel: { image | runtime, region, networkPolicy, tags } }. |
sh -c, or { cmd, args?, cwd?, env? }, run as an exact argv with no shell. A relative cwd resolves against workdir.spec.env and git.auth are stored in your store as-is. Treat the table as sensitive, or pass short-lived credentials.Processes, logs and files
Talk to the sandbox like a machine
Run commands, follow the main process and move files in and out with the same calls on every provider.
Main process and logs
start and resume run detached. Their stdout and stderr are appended to /tmp/sandbox-sdk/main.log inside the sandbox, which survives restarts. Only the first log read auto-resumes a stopped sandbox, and a follow stream ends when the sandbox stops or signal aborts.
await sandboxes.getMainProcess(id); // { pid, running, exitCode }
await sandboxes.readLogs(id); // everything so far
const signal = AbortSignal.timeout(60_000);
for await (const chunk of sandboxes.logs(id, { follow: true, signal })) {
process.stdout.write(chunk);
}Files
Write text or bytes with an optional mode, and read them back. Relative paths resolve against the sandbox's workdir; readTextFile returns null when the file is missing.
await sandboxes.writeFiles(id, [
{ path: "prompt.md", content: "…" },
{ path: "/usr/local/bin/x", content: bytes, mode: 0o755 },
]);
// null if the file is missing
await sandboxes.readTextFile(id, "out/result.json");Providers
Docker and Vercel Sandbox included, anything else pluggable
A provider maps the lifecycle onto a backend. Register as many as you like in one manager and pick one per sandbox.
import Docker from "dockerode";
import { DockerSandboxProvider } from "@schemavaults/sandbox-management-sdk/docker";
new DockerSandboxProvider({
// default: new Docker(), which honors DOCKER_HOST
docker: new Docker({ socketPath: "/var/run/docker.sock" }),
defaultImage: "node:24-bookworm",
pullPolicy: "if-not-present", // or "always" / "never"
portBindingHost: "127.0.0.1", // where published ports bind
urlHost: "localhost", // host used in getUrl() URLs
});- Each sandbox is a long-lived container named
sandbox-<id>, labeleddev.schemavaults.sandbox.id=<id>, running an idle PID 1 with--init. The image needs/bin/sh. stopandwakemap todocker stopanddocker start, so the container filesystem persists.- Published ports get new random host ports on every start, and
getUrllooks them up. - Docker has no native timeout: run
sandboxes.stopExpired()from a cron job. - If an
execwithtimeoutMsorsignalis abandoned, the process inside the container keeps running.
Stores
Sandbox state lives in your database
Records go through a SandboxStore. Every status change is an atomic compare-and-set, so many server processes can share sandboxes safely.
Postgres via @schemavaults/dbh or Kysely
Add the migration to your dbh migrations directory. The store takes a dbh handler or any Kysely instance using the Postgres dialect, and never opens or closes connections itself, so the postgres-neon-proxy adapter works unchanged.
export { up, down } from "@schemavaults/sandbox-management-sdk/dbh/migration";import {
DbhSandboxStore,
createSandboxesMigration,
} from "@schemavaults/sandbox-management-sdk/dbh";
// a dbh handler, or any Kysely instance using the Postgres dialect
const store = new DbhSandboxStore({ db: dbh });
// custom table name:
const { up, down } = createSandboxesMigration("my_sandboxes");
const custom = new DbhSandboxStore({ db: dbh, tableName: "my_sandboxes" });In memory
InMemorySandboxStore ships with the core for a single process, scripts and tests.
Your own store
Implement insert, get, list, update and delete. update must be an atomic compare-and-set on version. Run the shared contract suite in tests/contracts/store-contract.ts against it to check.
Testing
Test your app without spinning up a sandbox
The /testing entry point ships a mock provider and an in-memory store that drop into the same SandboxManager your app uses.
import { SandboxManager } from "@schemavaults/sandbox-management-sdk";
import {
InMemorySandboxStore,
MockSandboxProvider,
} from "@schemavaults/sandbox-management-sdk/testing";
const provider = new MockSandboxProvider({
execHandler: ({ command }) =>
command.args.includes("npm test")
? { exitCode: 1, stderr: "1 failing" }
: undefined,
});
const sandboxes = new SandboxManager({
providers: [provider],
store: new InMemorySandboxStore(),
});
// …exercise your code, then assert on what happened:
provider.callsFor(id, "exec");
provider.simulateExternalStop(id); // e.g. a provider-side timeout
provider.appendMainLog(id, "server listening\n");- The mock executes nothing: it records every call and keeps files in memory.
- Script command results with
execHandler, then assert withcallsFor(id, operation). - Simulate stop, wake and external stops such as a provider-side timeout with
simulateExternalStop, and feed the main log withappendMainLog.
Spin up your first sandbox
Install the SDK, register a provider and a store, and call create.