Run commands, move files, keep stateful sessions, take snapshots and expose live preview URLs — through one ergonomic interface. Switch providers by changing a single line.
import { createSandboxClient } from '@devicai/sandboxtsc';
import { vercel } from '@devicai/sandboxtsc/vercel';
const client = createSandboxClient({
provider: vercel({ token, teamId, projectId }),
});
const sandbox = await client.create({ runtime: 'node24' });
const { stdout } = await sandbox.runCommand('node --version');
await sandbox.writeFile('app/index.js', 'console.log("hi")');
const snap = await sandbox.snapshot();
const url = await sandbox.getPreviewUrl(3000);
await sandbox.destroy();
Every provider is its own package, built on a zero-dependency core. Your
application code only ever sees the unified Sandbox interface —
the constructor is the only line that changes.
Vercel Sandbox via @vercel/sandbox. Credentials from config, env vars or OIDC.
Cloudflare Sandbox on Workers + Durable Objects. Dynamic port exposure built in.
Daytona cloud or self-hosted. The widest native surface: every capability supported.
E2B cloud via e2b. First-class snapshots, pause/resume and exact deadlines.
Self-hosted server with a native WebSocket terminal: real persistent shells and live streaming.
Any self-hosted container sandbox HTTP API. Plain fetch, no heavy dependencies.
One self-contained package: @devicai/sandboxtsc — the core API plus a
subpath export per provider; provider SDKs stay optional peers.
All durations are milliseconds. Command output is always eager — real strings, never lazy thunks. A non-zero exit code doesn't throw unless you ask it to. The same guarantees on every provider.
exitCode, stdout and stderr.const r = await sandbox.runCommand('npm test', {
timeoutMs: 120_000,
});
console.log(r.exitCode, r.stdout, r.stderr);
// non-zero exits don't throw by default…
await sandbox.runCommand('exit 1'); // → { exitCode: 1 }
// …unless you opt in
await sandbox.runCommand('exit 1', {
throwOnNonZero: true, // → CommandFailedError
});
cwd threads across runs on every provider — native shell or emulated.const session = await sandbox.createSession();
await session.run('cd /tmp');
const { stdout } = await session.run('pwd'); // "/tmp"
// live output where the provider supports it
const stream = await session.runStream('npm install');
for await (const ev of stream.events) {
process.stdout.write(ev.data);
}
const { exitCode } = await stream.done;
await sandbox.mkdir('src');
await sandbox.writeFile('src/app.ts', source);
const text = await sandbox.readText('src/app.ts');
const bytes = await sandbox.readFile('logo.png'); // Uint8Array
const files = await sandbox.listFiles('src');
await sandbox.uploadFile('./local.zip', 'bundle.zip');
await sandbox.downloadFile('out/report.pdf', './report.pdf');
const info = await sandbox.getInfo();
// status, remainingMs, resources, previewUrl…
await sandbox.extendTimeout(10 * 60_000);
await sandbox.stop();
const again = await client.connect(sandbox.id);
await again.destroy();
Most abstractions stop at running commands. SandboxTsc treats snapshots as a cross-provider primitive: capture a sandbox, restore it into a brand-new one, list and delete them — with the same code everywhere.
/usr/local/bin survive the restore, not just your workdir.UnsupportedCapabilityError instead of silently capturing less.// capture the sandbox state…
const snap = await sandbox.snapshot({ name: 'after-install' });
// …and rebuild a brand-new sandbox from it, any time
const clone = await client.snapshots.restore(snap.id, {
timeoutMs: 10 * 60_000,
});
await clone.runCommand('snaptool'); // installed pre-snapshot
// manage snapshots like any other resource
const page = await client.snapshots.list({ limit: 20 });
await client.snapshots.delete(snap.id);
// declare ports at create time…
const sandbox = await client.create({
runtime: 'node24',
ports: [3000],
exposedPort: 3000,
});
await sandbox.runCommand('npx serve -l 3000 &');
const url = await sandbox.getPreviewUrl(3000);
// → https://… publicly reachable
// …or expose them on the fly, where supported
if (sandbox.capabilities.exposePortDynamic !== 'unsupported') {
const live = await sandbox.exposePort(8080);
}
Start a dev server inside the sandbox and get a publicly reachable HTTPS URL for it — normalized across providers that expose ports at create time and providers that can open them dynamically at runtime.
getPreviewUrl(port) works on all six providers.exposePort at any time) are native on Cloudflare, Daytona and E2B.sandbox.capabilities.exposePortDynamic before relying on it — no surprises in production.
Every provider declares what it supports — native, emulated
(derived via shell commands but behaving equivalently) or unsupported (throws an
actionable UnsupportedCapabilityError). It's all introspectable at runtime
via provider.capabilities.
| Capability | vercel | container | operator | cloudflare | daytona | |
|---|---|---|---|---|---|---|
| runCommand | native | native | native | native | native | native |
| persistentSession | emulated | emulated | native | native | native | emulated |
| streaming | —1 | — | native | native | native | native |
| commandSudo | ✓ | — | — | — | — | ✓ |
| writeFile / readFile | native | native | native | native | native | native |
| mkdir | native | emulated | emulated | native | native | native |
| listFiles | emulated | emulated | emulated | native | native | native |
| uploadFile / downloadFile | native | emulated | emulated | —2 | native | emulated |
| snapshot | native3 | native | native | native4 | native | native5 |
| restoreSnapshot | native | native | native | native | native | native |
| deleteSnapshot / listSnapshots | native | native | native | —4 | native | native |
| stop | native | native | native | native | native | native5 |
| destroy | emulated3 | native | native | native | native | native |
| extendTimeout | native | native | native | —4 | emulated6 | native |
| previewUrl | native | native | native | native | native | native |
| exposePortDynamic | —7 | —7 | —7 | native | native | native |
| list | native | native | native | —4 | native | native |
writeFile/readFile with in-memory content.
3 Vercel stops the sandbox as part of snapshotting, and has no separate delete: destroy() aliases stop().
4 Cloudflare snapshots are directory backups (workdir scope), restorable but not listable; Durable Objects can't be enumerated, and lifetime is inactivity-based (sleepAfter).
5 E2B pauses the sandbox to take the snapshot and resumes it afterwards; stop() maps to pause — connect() auto-resumes with state intact.
6 Daytona's lifetime is an inactivity auto-stop interval; extendTimeout widens that interval.
7 Ports are fixed at create time — declare them via ports / exposedPort.