One TypeScript interface for cloud sandboxes. Write your code once; switch providers with a single line.
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
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();
result.exitCode, or opt in with { throwOnNonZero: true }.
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()
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. 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,
});
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
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.
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
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.
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()
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.
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
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();
}
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');
/. 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.
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);
capabilities.snapshot.
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.
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.
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);
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
Each operation is native, emulated (derived from shell commands but behaves the same), or unsupported (throws UnsupportedCapabilityError). Inspect them at runtime via client.capabilities.
| Capability | vercel | operator | cloudflare | daytona | e2b |
|---|---|---|---|---|---|
| runCommand | native | native | native | native | native |
| persistentSession | emulated | native | native | native | emulated |
| streaming | unsupported | native | native | native | native |
| writeFile / readFile | native | native | native | native | native |
| mkdir | native | emulated | native | native | native |
| listFiles | emulated | emulated | native | native | native |
| uploadFile / downloadFile | native | emulated | unsupported | native | emulated |
| snapshot | native (stops sandbox) | native | native (workdir backup) | native (full FS, by name) | native (full FS, resumes) |
| restore / delete / list snapshots | native | native | restore only | native | native |
| stop | native | native | native | native | native (pause) |
| destroy | emulated (= stop) | native | native | native | native |
| previewUrl | native | native | native + dynamic ports | native + dynamic ports | native + dynamic ports |
| list | native | native | unsupported | native | native |
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.
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);
}
}