TypeScript SDK
Install and use @sandal/sdk to create sandboxes, devices, and CI runners from Node.js, Bun, or Deno.
Install#
npm install @sandal/sdk# or
pnpm add @sandal/sdk
bun add @sandal/sdk@sandal/sdk ships ESM + CJS builds and full TypeScript types. No native
dependencies, it's a thin wrapper over the Sandal HTTP/WebSocket API.
Initialize the client#
import { Sandal } from "@sandal/sdk";
const sandal = new Sandal({
apiKey: process.env.SANDAL_API_KEY, // sk_live_... or sk_test_...
region: "us", // "us" | "eu", defaults to your account's home region
});Client options#
new Sandal({
apiKey: string, // required
region?: "us" | "eu", // default: account default
baseUrl?: string, // override for self-hosted / proxy setups
timeoutMs?: number, // default: 30_000
maxRetries?: number, // default: 2, exponential backoff on 429/5xx
fetch?: typeof fetch, // inject a custom fetch (undici, node-fetch, etc.)
});Namespaces#
The client exposes four resource namespaces. Each mirrors the REST API 1:1 and returns typed objects.
sandal.sandboxes; // Firecracker/Cloud Hypervisor microVMs
sandal.devices; // iOS/Android simulators
sandal.runners; // GitHub Actions runner pools
sandal.builds; // code-interpreter style one-shot execssandal.sandboxes#
const sb = await sandal.sandboxes.create({
template: "node-24", // or "python-3.12", "ubuntu-24.04", custom template id
cpu: 2, // vCPUs
memoryMb: 2048,
timeoutMs: 15 * 60_000, // auto-pause after 15 min idle
metadata: { userId: "u_123" },
});
// Run a command and stream output
const result = await sb.exec("pnpm test", {
cwd: "/workspace",
env: { CI: "true" },
onStdout: (chunk) => process.stdout.write(chunk),
onStderr: (chunk) => process.stderr.write(chunk),
});
console.log(result.exitCode, result.durationMs);
// Filesystem
await sb.fs.write("/workspace/index.ts", "console.log('hi')");
const contents = await sb.fs.read("/workspace/index.ts");
await sb.fs.mkdir("/workspace/data");
const entries = await sb.fs.ls("/workspace");
// Expose a running dev server publicly
const url = await sb.expose(3000); // -> https://3000-sb_xxxx.sandal.dev
// Lifecycle: pause = $0 billed, resume in <200ms when warm
await sb.pause();
await sb.resume();
// Fork a running sandbox (copy-on-write, near-instant)
const child = await sb.fork();
// Snapshots, per-minute incremental, encrypted
const snap = await sb.snapshot.create({ label: "post-install" });
const restored = await sandal.sandboxes.createFromSnapshot(snap.id);
await sb.kill();sandal.devices#
const dev = await sandal.devices.create({
kind: "ios-simulator", // "ios-simulator" | "android-emulator"
deviceModel: "iphone-16-pro",
osVersion: "18.2",
});
await dev.installApp("./build/MyApp.ipa");
await dev.launchApp("com.example.myapp");
await dev.tap(180, 420);
await dev.swipe({ from: [200, 600], to: [200, 200] });
await dev.typeText("hello@sandal.dev");
const png = await dev.screenshot();
// Live 60fps stream, drop straight into a React component
import { RemoteControl } from "@sandal/sdk/react";
<RemoteControl device={dev} />;
// Appium / ADB tunnels for existing test suites
const { adbUrl } = await dev.tunnels.adb();
const { wdaUrl } = await dev.tunnels.appium();
await dev.terminate();sandal.runners#
// Pool of hosted GitHub Actions runners
const pool = await sandal.runners.pools.create({
os: "macos", // "linux" | "windows" | "macos"
labels: ["sandal-macos"],
minWarm: 2, // keep 2 warm for near-instant job pickup
maxConcurrency: 10,
});
// In your workflow YAML:
// runs-on: sandal-ubuntu-24.04
const jobs = await sandal.runners.jobs.list({ poolId: pool.id, status: "running" });
await sandal.runners.pools.update(pool.id, { minWarm: 4 });
await sandal.runners.pools.delete(pool.id);sandal.builds#
One-shot, stateless code execution, the code-interpreter surface.
const build = await sandal.builds.run({
language: "python",
code: `
import pandas as pd
df = pd.read_csv("data.csv")
print(df.describe())
`,
files: [{ path: "data.csv", content: csvString }],
});
console.log(build.stdout);
console.log(build.artifacts); // generated files, plots, etc.Helpers#
waitUntilReady#
Polls a sandbox/device until it reports running, or throws on failed.
const sb = await sandal.sandboxes.create({ template: "node-24" });
await sandal.waitUntilReady(sb, { timeoutMs: 10_000 });streamEvents#
Subscribe to lifecycle + billing events over WebSocket.
const unsubscribe = sandal.streamEvents(
{ resourceId: sb.id },
(event) => {
switch (event.type) {
case "sandbox.paused":
case "sandbox.resumed":
case "sandbox.exec.completed":
console.log(event);
break;
}
},
);
// later
unsubscribe();execCommand#
A standalone helper when you just want "run this and get output" without juggling the sandbox object.
import { execCommand } from "@sandal/sdk";
const { stdout, exitCode } = await execCommand(sandal, sb.id, "pytest -q");Error model#
All SDK errors are instances of SandalError (subclassing Error) and are
discriminated by error.status and error.code.
import { SandalError, SandalConflictError, SandalQuotaError } from "@sandal/sdk";
try {
await sandal.sandboxes.create({ template: "node-24" });
} catch (err) {
if (err instanceof SandalConflictError) {
// HTTP 409, typed reasons, exhaustively switchable
switch (err.reason) {
case "template_locked":
console.error("Template is currently being built, retry shortly");
break;
case "snapshot_restoring":
console.error("Snapshot restore already in progress");
break;
case "resource_already_paused":
case "resource_already_running":
console.error(`Sandbox is already ${err.reason.split("_").pop()}`);
break;
case "concurrent_mutation":
console.error("Another request modified this resource concurrently");
break;
}
} else if (err instanceof SandalQuotaError) {
// HTTP 429, typed quota resource + reset time
switch (err.resource) {
case "sandbox_concurrency":
console.error(`Max concurrent sandboxes reached, resets ${err.resetsAt}`);
break;
case "device_concurrency":
console.error("Max concurrent simulators reached");
break;
case "runner_minutes":
console.error("Monthly runner-minute quota exhausted");
break;
case "api_rate_limit":
console.error(`Rate limited, retry after ${err.retryAfterMs}ms`);
break;
}
} else if (err instanceof SandalError) {
console.error(err.status, err.code, err.message);
}
throw err;
}Full error taxonomy:
| Class | HTTP | Discriminant | Example values |
|---|---|---|---|
SandalAuthError | 401 | code | invalid_api_key, expired_token |
SandalPermissionError | 403 | code | region_not_enabled, scope_insufficient |
SandalNotFoundError | 404 | code | sandbox_not_found, template_not_found |
SandalConflictError | 409 | reason | template_locked, snapshot_restoring, resource_already_paused, resource_already_running, concurrent_mutation |
SandalValidationError | 422 | fieldErrors | per-field messages |
SandalQuotaError | 429 | resource | sandbox_concurrency, device_concurrency, runner_minutes, api_rate_limit |
SandalServerError | 5xx | code | internal_error, region_unavailable |
Compatibility shim: e2b#
Migrating from e2b? Swap the import, the shim maps e2b's Sandbox API onto
Sandal's sandboxes namespace so most call sites need zero changes.
// Before
// import { Sandbox } from "e2b";
// After
import { Sandbox } from "@sandal/sdk/compat/e2b";
const sandbox = await Sandbox.create({ template: "base" });
await sandbox.filesystem.write("/tmp/hello.txt", "hi");
const output = await sandbox.process.startAndWait("echo hi");
await sandbox.close();// Config maps 1:1 onto Sandal templates. Any e2b template id you don't have
// pinned locally falls back to the closest Sandal-managed equivalent.
import { Sandbox } from "@sandal/sdk/compat/e2b";
const sandbox = await Sandbox.create({
apiKey: process.env.SANDAL_API_KEY, // accepts SANDAL_API_KEY or E2B_API_KEY
template: "node-24",
metadata: { source: "migrated" },
});Next steps#
- Python SDK, the same surface,
snake_case, sync + async - CLI reference,
sandal init,sandal exec,sandal runners - MCP server, give any MCP-capable agent direct sandbox access