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 execs

sandal.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:

ClassHTTPDiscriminantExample values
SandalAuthError401codeinvalid_api_key, expired_token
SandalPermissionError403coderegion_not_enabled, scope_insufficient
SandalNotFoundError404codesandbox_not_found, template_not_found
SandalConflictError409reasontemplate_locked, snapshot_restoring, resource_already_paused, resource_already_running, concurrent_mutation
SandalValidationError422fieldErrorsper-field messages
SandalQuotaError429resourcesandbox_concurrency, device_concurrency, runner_minutes, api_rate_limit
SandalServerError5xxcodeinternal_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