DOCSThe TypeScript SDK
DOCS

The TypeScript SDK

@sandboxio/sdk is the Sandbox Router API (The API) as typed TypeScript methods: sandboxes and their execs, files, logs, snapshots and access tokens; MCP servers; providers; connectors and their workspace accounts; your users' connections and sign-ins; and your workspace's settings, audit log and billing. It has no runtime dependencies and runs on Node 22 or later, Deno, Bun and Cloudflare Workers.

Install the SDK

shell
npm install @sandboxio/sdk

Connect it to your workspace

The SDK sends an API key from your workspace. Create one in the console under Settings, as The API describes, and set it as SANDBOXROUTER_API_KEY:

shell
export SANDBOXROUTER_API_KEY=<api-key>

A client reads SANDBOXROUTER_API_KEY when you don't pass apiKey:

typescript
import { SandboxRouter } from "@sandboxio/sdk";

const sandboxrouter = new SandboxRouter();

Every request acts in the key's workspace.

Run a sandbox

typescript
const sandbox = await sandboxrouter.sandboxes.create({ labels: { name: "build-agent" }, size: "m", egress: ["registry.npmjs.org"] });

const result = await sandboxrouter.sandboxes.exec(sandbox.id, { command: ["node", "--version"] });
console.log(result.exitCode, result.stdout);

await sandboxrouter.sandboxes.files.write(sandbox.id, "/workspace/hello.txt", "hello\n");
const text = await sandboxrouter.sandboxes.files.readText(sandbox.id, "/workspace/hello.txt");

await sandboxrouter.sandboxes.stop(sandbox.id);
await sandboxrouter.sandboxes.delete(sandbox.id);

create, start, stop and restart each answer the sandbox once it settled, holding as long as that takes. Every method that names a sandbox takes its ID. An exec's command is an array, which runs as it is, or a string, which runs through /bin/sh -c. A command that exits nonzero isn't an error: read its exitCode. A stopped sandbox stays stopped until a start: an exec or a file call answers not_running.

Long-running commands

Pass detach: true to get a handle back at once, and wait for the command or cancel it later:

typescript
const job = await sandboxrouter.sandboxes.exec(sandbox.id, { command: ["make", "release"], detach: true, timeoutSeconds: 86_400 });
const done = await job.wait();

A detached command's output arrives when it ends.

Files, logs, metrics, ports and snapshots

typescript
await sandboxrouter.sandboxes.files.write(sandbox.id, "/workspace/app.js", bytes); // a file, whole
await sandboxrouter.sandboxes.files.delete(sandbox.id, "/workspace/app.js");

for await (const entry of sandboxrouter.sandboxes.followLogs(sandbox.id)) console.log(entry.kind, entry.message);

// its provider's own CPU, memory and disk samples, oldest first, null where it doesn't measure one
const { samples } = await sandboxrouter.sandboxes.metrics(sandbox.id, { from: Date.now() - 3_600_000 });

const { url } = await sandboxrouter.sandboxes.port(sandbox.id, 3000); // its provider's own URL, while it runs

const snapshot = await sandboxrouter.sandboxes.snapshots.create(sandbox.id); // its provider's own
await sandboxrouter.sandboxes.snapshots.delete(sandbox.id, snapshot.id);

MCP servers

typescript
const server = await sandboxrouter.mcpServers.create({
  name: "search",
  sandbox: { image: "registry.caas.sh/<tenant-id>/search-mcp@sha256:…", egress: ["api.search.example"] },
  transport: { http: { port: 8080 } },
});

MCP servers explains what each field does.

Your users

sandboxrouter.users signs your users in and makes their connect links, one user at a time (see Connect each user's account). Sign a user in to a connector they sign in to with a code, such as Codex (ChatGPT), before a sandbox of theirs needs it (see Sign users in with a code); waitForSignIn waits until they have, calling onCode with each code to show them:

typescript
await sandboxrouter.users.signIn("user_123", "codex");
await sandboxrouter.users.waitForSignIn("user_123", "codex", { onCode: (signIn) => showToUser(signIn.code!, signIn.url!) });
const sandbox = await sandboxrouter.sandboxes.create({ user: "user_123", connectors: ["codex"] });

waitForSignIn reads the user's connection again after each answer's pollAfterMs, never on a timer of its own, and calls onCode with each code it hasn't shown yet. It resolves with the connection once the user is signed in, and rejects with a SandboxRouterSignInError (its signInStatus, expired, failed or none, and reason) when the code expires or the sign-in fails, or when there's no sign-in to wait for. Pass a signal to stop waiting.

typescript
const { signIn } = await sandboxrouter.users.signIn("user_123", "codex"); // the code under way, a new one, or a sign-in again for a user who's signed in
const { connectUrl } = await sandboxrouter.users.link("user_123", { connector: "linear", returnUrl: "https://app.acme.com/settings" });
const connection = await sandboxrouter.users.connection("user_123", "linear");
await sandboxrouter.users.disconnect("user_123", "linear");

sandboxrouter.users.logins manages a user's logins, their sign-ins to websites, which their sandboxes' browsers sign in with; sandboxrouter.connectors.account.logins has the same methods for your workspace's logins, but link, its first argument the Logins connector's ID:

typescript
const login = await sandboxrouter.users.logins.create("user_123", { site: "github.com", username: "ana@acme.com", methods: ["passkey", "typed"], password });
const { connectUrl } = await sandboxrouter.users.logins.link("user_123", { site: "github.com" }, { returnUrl: "https://app.acme.com/settings" });
await sandboxrouter.users.logins.session.upload("user_123", login.id, await context.storageState());
const { signedIn } = await sandboxrouter.users.logins.check("user_123", login.id);

list, get, update and delete read and change logins; passkeys.rename and passkeys.delete a login's passkeys; session.get, session.upload and session.delete its saved sign-in, whose StorageState is Playwright's, so context.storageState() passes straight in (a Browserbase login takes no upload); and live.open, live.done and live.cancel a live view for a person to sign in by hand. A login never carries its password or code back. create sends an Idempotency-Key, its own unless you pass idempotencyKey, so a retry adds the login once.

Lists

Lists of sandboxes, MCP servers, logins and audit log entries come in pages. Await a list for one page, or iterate it for every item:

typescript
const page = await sandboxrouter.sandboxes.list({ limit: 20 });

for await (const sandbox of sandboxrouter.sandboxes.list()) console.log(sandbox.id, sandbox.labels.name);

Errors and retries

Every failure is a SandboxRouterError with the HTTP status, the API's code, a message and the requestId, which you can quote when reporting a problem. Subclasses name the status, such as NotFoundError, ConflictError and PaymentRequiredError:

typescript
import { ConflictError } from "@sandboxio/sdk";

try {
  await sandboxrouter.sandboxes.exec(sandbox.id, { command: ["npm", "test"] });
} catch (error) {
  if (error instanceof ConflictError && error.code === "not_running") console.log("start it first");
  else throw error;
}

The SDK retries a request that is safe to send again, twice by default, after a 429, a 5xx or no answer. Creating a sandbox, running an exec and creating an MCP server each send an Idempotency-Key, so a retry never creates anything twice.

Switch from Daytona

Code written for Daytona's TypeScript SDK (@daytonaio/sdk) runs on Sandbox Router as it is. Change two settings and nothing else:

shell
export DAYTONA_API_URL=https://api.sandboxrouter.io/daytona
export DAYTONA_API_KEY=<api-key>

Or pass the same two values in code:

typescript
import { Daytona } from "@daytonaio/sdk";

const daytona = new Daytona({ apiUrl: "https://api.sandboxrouter.io/daytona", apiKey: process.env.SANDBOXROUTER_API_KEY });
const sandbox = await daytona.create();
const { exitCode, result } = await sandbox.process.executeCommand("echo hi");

The key is an API key from your workspace, as above. Set DAYTONA_API_URL in the real environment (your shell, CI or platform settings), not in a .env file: the Daytona SDK reads it only from the environment, so a .env value sends your key to Daytona instead. Leave off a trailing slash.

What works: creating, listing, starting, stopping and deleting sandboxes, labels, the idle stop (autoStopInterval, 5 to 240 minutes), network settings, executeCommand, uploading, downloading and deleting files, and preview links. A sandbox's snapshot, or image given by name, is an image in your workspace's registry (Images). Pick a provider with the label sandboxrouter.io/provider, such as { labels: { "sandboxrouter.io/provider": "cloudflare" } }. executeCommand runs in zsh or bash when the image has one, else sh, as Daytona runs it, and in /workspace unless you pass a cwd, which is also where a relative file path goes. Its result is the command's output followed by its errors. daytona.get finds a sandbox by its ID or its name; a sandbox just created can take a moment to be found by name.

What answers an error instead: sessions, PTY, codeRun, git, LSP, computer use, listing or moving files, snapshots, volumes and archiving each raise a DaytonaError with status 501. A setting Sandbox Router can't honour, such as ttlMinutes, a GPU or autoStopInterval: 0, raises a DaytonaValidationError that names it. So does ephemeral: true, or any autoDeleteInterval but -1: a sandbox stops when idle and stays until you delete it. autoArchiveInterval and target are accepted and have no effect. A create answers once the sandbox runs; if your images take longer than a minute to start, pass a larger timeout, such as daytona.create(params, { timeout: 300 }), or the SDK gives up before it learns the sandbox's ID. An upload takes at most 5,000 files at once.

Use it from a browser

The API doesn't answer browsers' cross-origin requests, so keep the API key on your server and call the API from there.