Skip to content
sandbox-management-sdk
v0.1.0MIT licensedTypeScript

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.

bun add @schemavaults/sandbox-management-sdk

Runs on Node.js 24+ and Bun. The core, the Vercel adapter and the dbh store also run in serverless functions.

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

bashTerminal
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 fromContents
@schemavaults/sandbox-management-sdkSandboxManager, InMemorySandboxStore, types, errors, helpers for writing providers
…/dockerDockerSandboxProvider
…/vercelVercelSandboxProvider
…/dbhDbhSandboxStore, createSandboxesMigration, up / down
…/dbh/migrationup / down only, to re-export from a migrations directory
…/testingMockSandboxProvider, InMemorySandboxStore

Quick start

From zero to a running sandbox

Register providers and a store once, then manage every sandbox through the same SandboxManager.

tssandboxes.ts
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);
  1. Register providers

    Each provider is a backend. defaultProvider picks one when create is not given a provider.

  2. Pick a store

    DbhSandboxStore keeps records in Postgres so every server process sees the same sandboxes. InMemorySandboxStore suits a single process.

  3. Describe the sandbox

    The spec is stored with the record, so any process can later resume the sandbox by ID alone.

  4. Drive it

    getUrl, exec, stop, list and destroy. 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()

  1. provisioning
  2. setting_up
  3. starting
  4. running

Stop

via stop()

  1. running
  2. stopping
  3. stopped

Wake

via any operation, or resume()

  1. stopped
  2. resuming
  3. running

Destroy

via destroy()

  1. any state
  2. destroying
  3. destroyed

Fail

via setup or provider error

  1. any step
  2. failed
  • Auto-resume

    exec, getUrl, readFile, writeFiles, logs and the other operations wake a stopped sandbox and launch resume first. Turn it off with autoResume: false and call resume(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 sandbox failed and keeps the provider sandbox so you can inspect it. resume retries it without re-running setup.

  • Reconcile

    reconcile(ids?) marks sandboxes stopped behind the SDK's back as stopped, vanished ones as failed, and settles transitions abandoned by a crashed process.

  • Destroyed records are kept

    list hides them unless you pass includeDestroyed: 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.

FieldMeaning
workdirAbsolute directory commands run in. Default: /workspace (Docker), /vercel/sandbox (Vercel).
envEnvironment 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.
setupOne command or a list of them, run once after creation. Stops at the first non-zero exit.
startThe main process, launched detached after setup.
resumeLaunched detached as the new main process whenever a stopped sandbox is woken. Defaults to start.
portsPorts reachable through getUrl.
timeoutMsMaximum run time. Vercel enforces it itself. For Docker, call stopExpired() periodically.
resources{ vcpus?, memoryMb? }. Vercel only supports vcpus (2 GB of memory per vCPU).
providerOptionsProvider-specific settings: { docker: { image, user, network, pullPolicy, labels } } or { vercel: { image | runtime, region, networkPolicy, tags } }.
Commands
A command is either a string, run with sh -c, or { cmd, args?, cwd?, env? }, run as an exact argv with no shell. A relative cwd resolves against workdir.
Secrets in the spec
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.

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

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

tsdocker.ts
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>, labeled dev.schemavaults.sandbox.id=<id>, running an idle PID 1 with --init. The image needs /bin/sh.
  • stop and wake map to docker stop and docker start, so the container filesystem persists.
  • Published ports get new random host ports on every start, and getUrl looks them up.
  • Docker has no native timeout: run sandboxes.stopExpired() from a cron job.
  • If an exec with timeoutMs or signal is 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.

tssrc/db/migrations/00012-create-sandboxes.ts
export { up, down } from "@schemavaults/sandbox-management-sdk/dbh/migration";
tsstore.ts
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.

tsapp.test.ts
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 with callsFor(id, operation).
  • Simulate stop, wake and external stops such as a provider-side timeout with simulateExternalStop, and feed the main log with appendMainLog.

Spin up your first sandbox

Install the SDK, register a provider and a store, and call create.

bun add @schemavaults/sandbox-management-sdk