SandboxTsc

One TypeScript interface for cloud sandboxes. Write your code once; switch providers with a single line.

runCommand · sessions · files · snapshots · preview URLs ESM + CJS + types Apache-2.0

Install

Install the umbrella package (core API plus every provider via subpaths), then add a provider's peer dependency only if you use it.

bash# core API + provider subpaths
npm install @devicai/sandboxtsc

# the Vercel provider needs the Vercel SDK:
npm install @vercel/sandbox

# the Cloudflare provider (Workers-only) needs the Cloudflare SDK:
npm install @cloudflare/sandbox

# the Daytona provider needs the Daytona SDK (plus ws, required by it in Node):
npm install @daytonaio/sdk ws

# the E2B provider needs the E2B SDK (Node >= 20.19 for the CJS build):
npm install e2b

# the operator provider needs a WebSocket only on Node < 22:
npm install ws

The idea: one interface, swap the provider

You build a client from a provider. Everything after that — creating sandboxes, running commands, moving files — is identical no matter which provider you chose.

typescriptimport { createSandboxClient } from '@devicai/sandboxtsc';
import { vercel } from '@devicai/sandboxtsc/vercel';
import { operator } from '@devicai/sandboxtsc/operator';

// Pick ONE provider. The rest of your code never changes.
const client = createSandboxClient({
  provider: vercel(),
  // provider: operator({ baseUrl: 'https://your-host/api/v1', apiKey: process.env.API_KEY }),
});

const sandbox = await client.create({ runtime: 'node24', timeoutMs: 5 * 60_000 });
const result = await sandbox.runCommand('node --version');
console.log(result.exitCode, result.stdout); // 0  v24.x

await sandbox.destroy();
Durations are always milliseconds. Command output is eager (real strings, never lazy thunks). A non-zero exit does not throw by default — read result.exitCode, or opt in with { throwOnNonZero: true }.

Provider: Vercel

Wraps @vercel/sandbox. Credentials come from explicit config, the standard VERCEL_* environment variables, or OIDC when running on Vercel.

typescriptimport { vercel } from '@devicai/sandboxtsc/vercel';

const provider = vercel({
  teamId: process.env.VERCEL_TEAM_ID,
  projectId: process.env.VERCEL_PROJECT_ID,
  token: process.env.VERCEL_ACCESS_TOKEN,
});

// Or, when the VERCEL_* env vars are set, just: vercel()

Provider: operator

Talks to the operator server. It shares the generic REST surface but, on top of it, opens the server's native WebSocket terminal for first-class persistent sessions and live streaming.

typescriptimport { operator } from '@devicai/sandboxtsc/operator';

const provider = operator({
  baseUrl: 'https://your-host/api/v1', // the ws URL is derived; override with `wsUrl`
  apiKey: process.env.API_KEY,
  authHeader: 'x-api-key',             // or 'bearer'
});
WebSocket peer. The native terminal needs a WebSocket. Node 22+ and browsers provide one globally. On Node < 22, install ws and pass it:
typescriptoperator({
  baseUrl: 'https://your-host/api/v1',
  apiKey: process.env.API_KEY,
  WebSocket: (await import('ws')).WebSocket,
});

Provider: Cloudflare

Wraps @cloudflare/sandbox and runs inside a Cloudflare Worker: you pass the Sandbox Durable Object binding from your Worker's env. Sessions and streaming are native, and it is the only provider with dynamic port exposure (exposePort works at any time).

typescriptimport { cloudflare } from '@devicai/sandboxtsc/cloudflare';

export default {
  async fetch(request: Request, env: Env) {
    const provider = cloudflare({
      binding: env.Sandbox,
      hostname: 'sandbox.example.com', // wildcard custom domain, for preview URLs
    });
    const sandbox = await provider.create();
    const { stdout } = await sandbox.runCommand('python --version');
    return new Response(stdout);
  },
};
export { Sandbox } from '@cloudflare/sandbox'; // required re-export
Worker-only. The container image is fixed by the Worker's Dockerfile, connect() cannot verify existence (Durable Objects are created on demand), snapshots are directory backups (workdir scope, GC'd by TTL), and upload/download are unsupported — a Worker has no local filesystem.

Provider: Daytona

Wraps @daytonaio/sdk against Daytona cloud or a self-hosted instance. It is the most complete provider: every capability is supported — native sessions and streaming, native local↔remote file transfer in both directions, full-filesystem snapshots that are listable and deletable, and dynamic port exposure.

typescriptimport { daytona } from '@devicai/sandboxtsc/daytona';

// Credentials from explicit config or DAYTONA_API_KEY / DAYTONA_API_URL / DAYTONA_TARGET.
const provider = daytona({ apiKey: process.env.DAYTONA_API_KEY });

const sandbox = await provider.create({ timeoutMs: 10 * 60_000 });
const url = await sandbox.exposePort(8080);    // dynamic, like cloudflare
const snap = await sandbox.snapshot();         // full filesystem, keyed by name
Notes. timeoutMs maps to Daytona's inactivity-based auto-stop interval (minutes), and extendTimeout widens it. Sandbox snapshots use the SDK's _experimental_createSnapshot, so treat them as subject to upstream change — and they require the organization's experimental features flag (Daytona Dashboard → Experimental, owner role); without it the API returns 403. A runtime/image source builds a Daytona snapshot on first use (slow once, cached after). In Node, install ws — the SDK requires it but does not declare it.

Provider: E2B

Wraps e2b against E2B cloud. The full surface is supported: first-class filesystem snapshots, pause/resume (a stopped sandbox keeps its state and connect() resumes it), exact extendTimeout over an absolute deadline, and any port reachable at any time.

typescriptimport { e2b } from '@devicai/sandboxtsc/e2b';

// Credentials from explicit config or E2B_API_KEY.
const provider = e2b({ apiKey: process.env.E2B_API_KEY });

const sandbox = await provider.create({ timeoutMs: 10 * 60_000 });
const url = await sandbox.getPreviewUrl(8080); // any port, any time
const snap = await sandbox.snapshot();         // full filesystem; sandbox keeps running
await sandbox.stop();                          // pause — resume later with provider.connect()
Notes. E2B boots from templates (default base, with Node.js and Python) — runtime is ignored and image/tarball sources throw; pin runtimes with a template via providerOptions.e2b.template. Resources are fixed by the template. The provider lifts the SDK's 60-second per-command default to 24 h (pass timeoutMs to limit a command) and converts the SDK's throw-on-non-zero into a normal CommandResult. snapshot() pauses the sandbox during capture and resumes it when it was running. Commands can run as another user (e.g. root) via providerOptions.e2b.user. The CJS build needs Node ≥ 20.19.

Running commands

Identical for every provider. The result is always eager.

typescriptconst r = await sandbox.runCommand('npm test', { cwd: 'app', env: { CI: '1' } });
// r = { exitCode, stdout, stderr, cwd }

// Pass args separately when you do not want shell parsing:
await sandbox.runCommand('node', { args: ['script.js', '--flag'] });

// Throw instead of inspecting exitCode:
await sandbox.runCommand('exit 1', { throwOnNonZero: true }); // throws CommandFailedError

Sessions & streaming

A session is a stateful shell: the working directory persists across run() calls. With operator the session is a native shell, so exported environment variables also survive; with other providers it is emulated (cwd persists, in-command exports do not).

typescriptconst session = await sandbox.createSession();

await session.run('cd app');
await session.run('export TOKEN=abc');
const who = await session.run('pwd && echo $TOKEN');
// who.stdout reflects the persisted cwd (and env, on a native session)

await session.close();

When the provider advertises native streaming, you can read stdout/stderr live as they arrive. Always check the capability first.

typescriptif (sandbox.capabilities.streaming === 'native') {
  const session = await sandbox.createSession();
  const stream = await session.runStream('npm install');

  const dec = new TextDecoder();
  for await (const ev of stream.events) {
    // ev.stream is 'stdout' | 'stderr'; ev.data is a Uint8Array
    process.stdout.write(dec.decode(ev.data));
  }

  const final = await stream.done; // authoritative { exitCode, stdout, stderr, cwd }
  await session.close();
}

Files

typescriptawait sandbox.writeFile('app/index.js', 'console.log("hi")');
const text = await sandbox.readText('app/index.js');
const bytes = await sandbox.readFile('app/logo.png'); // Uint8Array

await sandbox.mkdir('app/data');
const entries = await sandbox.listFiles('app');       // FileEntry[]

await sandbox.uploadFile('./local.txt', 'app/local.txt');
await sandbox.downloadFile('app/build.log', './build.log');
Tip. Relative paths are not rooted identically everywhere: Vercel resolves them against the working directory, while the container / operator files endpoint resolves them against /. Snapshots are now full-filesystem on both providers, so data persists wherever it lands — but for predictable layout, write through a command or session (those always run in the workdir), e.g. runCommand("printf '%s' ... > out.txt"), or use an absolute path.

Snapshots

typescriptconst snap = await sandbox.snapshot({ name: 'after-install' }); // SnapshotInfo
snap.scope; // 'filesystem' — what the provider actually captured

// Opt into a lighter, workdir-only snapshot (faster, smaller):
const lite = await sandbox.snapshot({ name: 'code-only', scope: 'workdir' });

const page = await client.snapshots.list();
const restored = await client.snapshots.restore(snap.id, { timeoutMs: 5 * 60_000 });

await client.snapshots.delete(snap.id);
Provider difference. Vercel stops the sandbox as part of snapshotting; the container / operator API keeps it running. Both behaviors are introspectable through capabilities.snapshot.
What gets captured. Both providers take a full filesystem snapshot, so software installed anywhere — global CLIs (npm i -g), apt/pip packages, /usr/local/bin, /etc configs — survives a restore, not just the working directory. Introspect via capabilities.snapshotScope ('filesystem'). To stay small the container / operator snapshot stores only the diff vs the base image (compressed), skips regenerable caches (apt lists, ~/.npm, ~/.cache, __pycache__, …), and excludes pseudo-filesystems (/proc, /sys, /dev, /tmp). Env vars and ports declared at create time are stored in the snapshot record and reapplied on restore. Verified live on both providers.
Rule of thumb. The base image (providerOptions.container.image) is still the cheapest place for heavy, rarely-changing dependencies — a restore always starts from it and it is shared across snapshots, so that cost is paid once. The snapshot then layers your incremental changes (installed tools, configs, workdir state) on top, keeping each artifact small.

Preview URLs

Ports are declared at create time. Ask for the public URL of an exposed port afterwards.

typescriptconst sandbox = await client.create({ runtime: 'node24', exposedPort: 3000 });
await sandbox.runCommand('node server.js &'); // start something on :3000
const url = await sandbox.getPreviewUrl(3000);
console.log(url);

Lifecycle & reconnecting

typescriptconst info = await sandbox.getInfo();   // { status, remainingMs, previewUrl, ... }
await sandbox.extendTimeout(5 * 60_000);

// Reconnect later from just the id:
const again = await client.connect(sandbox.id);

// List everything:
const all = await client.list({ limit: 20 });

await sandbox.stop();      // pause
await sandbox.destroy();   // tear down

Capabilities at a glance

Each operation is native, emulated (derived from shell commands but behaves the same), or unsupported (throws UnsupportedCapabilityError). Inspect them at runtime via client.capabilities.

Capabilityverceloperatorcloudflaredaytonae2b
runCommandnativenativenativenativenative
persistentSessionemulatednativenativenativeemulated
streamingunsupportednativenativenativenative
writeFile / readFilenativenativenativenativenative
mkdirnativeemulatednativenativenative
listFilesemulatedemulatednativenativenative
uploadFile / downloadFilenativeemulatedunsupportednativeemulated
snapshotnative (stops sandbox)nativenative (workdir backup)native (full FS, by name)native (full FS, resumes)
restore / delete / list snapshotsnativenativerestore onlynativenative
stopnativenativenativenativenative (pause)
destroyemulated (= stop)nativenativenativenative
previewUrlnativenativenative + dynamic portsnative + dynamic portsnative + dynamic ports
listnativenativeunsupportednativenative

There is also a generic container provider (@devicai/sandboxtsc/container) for any self-hosted sandbox HTTP API; operator extends it and adds the native WebSocket terminal.

Errors

typescriptimport {
  SandboxError,            // base class
  SandboxNotFoundError,
  ProviderAuthError,
  ProviderHttpError,       // carries .status
  CommandFailedError,      // carries .result
  UnsupportedCapabilityError, // carries .capability
} from '@devicai/sandboxtsc';

try {
  await sandbox.runCommand('false', { throwOnNonZero: true });
} catch (err) {
  if (err instanceof CommandFailedError) {
    console.error('exit code:', err.result.exitCode);
  }
}