> ## Documentation Index
> Fetch the complete documentation index at: https://sandkiln.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# JS/TS: Sandbox

> Every Sandbox method -- lifecycle, filesystem, preview, tunnel, streamed exec, snapshots.

## `Sandbox`

### Create, run, clean up — the basic loop

```ts theme={null}
import { Sandbox } from "sandkiln";

const sandbox = await Sandbox.create({
  tags: { env: "ci", owner: "pipeline" },
  vcpuCount: 2,
  memSizeMib: 1024,
});

const result = await sandbox.runCommand("python3", ["analyze.py", "--input", "data.csv"]);
if (result.exitCode !== 0) {
  throw new Error(`analyze.py failed: ${result.stderr}`);
}
console.log(result.stdout);

await sandbox.writeFile("/tmp/report.json", JSON.stringify({ ok: true }));
const report = await sandbox.readFile("/tmp/report.json");

// One-shot run, never needed again — skip the snapshot entirely.
await sandbox.stop({ keep: false });
```

* **`Sandbox.create(options?)`** — boots a sandbox. `options.name` is a caller-given identity, unique among live sandboxes and held snapshots (`409` if already taken) — see `byName`/`getOrCreate` below to find it again later. `options.tags`, `options.baseUrl`, `options.authToken`, `options.vcpuCount`, `options.memSizeMib` (override the daemon's configured defaults, subject to its ceiling), `options.imageId` (boots from a registered image instead of the default rootfs — see [Image](/api-reference/js-sdk/image)), `options.rateLimit` (`{ bandwidthBytesPerSec?, opsPerSec? }`, Firecracker's own token-bucket I/O limiter — unlimited if omitted), `options.drives` (existing persistent drives to attach, see [Drive](/api-reference/js-sdk/drive)). A call with no `drives`/`rateLimit` matching a configured pool's image/resources transparently resumes a warm snapshot instead of cold-booting — see [Pool](/api-reference/js-sdk/pool).
* **`sandbox.runCommand(command, args?)`** — returns `{ stdout, stderr, exitCode }`.
* **`sandbox.readFile(path)`** — returns file contents as `Uint8Array`.
* **`sandbox.writeFile(path, content)`** — `content` is a `string` or `Uint8Array`.
* **`sandbox.chmod(path, mode)`**, **`sandbox.chown(path, uid, gid)`**, **`sandbox.mkdir(path, options?)`** (`{ parents?: true }`), **`sandbox.rename(from, to)`**, **`sandbox.copy(from, to)`**, **`sandbox.symlink(target, linkPath)`**, **`sandbox.readlink(path)`**, **`sandbox.truncate(path, size)`**, **`sandbox.listDir(path)`** (returns entries with real `isDir`/`isSymlink`/`size`/`mode`/`mtime` metadata) — the full filesystem operation set, all over the same vsock channel `runCommand` uses.
* **`sandbox.pty(options?)`** — opens a live, interactive shell session and returns a native `WebSocket` (no added runtime dependency — needs Node.js ≥22 or a browser). `options.cols`/`options.rows` size the terminal once, at open time; no live resize yet. Distinct from `runCommand`'s request/response shape — see `kiln sandbox pty` for the CLI equivalent.
* **`sandbox.stop(options?)`** — stops the sandbox. Default (`options.keep` omitted or `true`) preserves state as a resumable snapshot, returns `{ kept, snapshotId }`. `{ keep: false }` fully destroys instead.

### Attaching to an id you already have

```ts theme={null}
// A worker process that only has an id passed to it — no create() round-trip needed.
const sandbox = Sandbox.attach(process.env.SANDBOX_ID!, { authToken: process.env.SANDKILN_TOKEN });
await sandbox.runCommand("echo", ["still here"]);
```

* **`Sandbox.attach(id, options?)`** — wraps an already-existing sandbox id without a network round-trip.

### Named sandboxes — find the same environment again later

```ts theme={null}
// First call today: creates a fresh sandbox named "agent-session-42".
// Every later call, any day: resumes it if it was stopped, returns it as-is if still live.
const { sandbox, created } = await Sandbox.getOrCreate({ name: "agent-session-42" });
if (created) {
  await sandbox.runCommand("npm", ["install"]); // one-time setup for a brand-new environment
}
await sandbox.runCommand("npm", ["test"]);
await sandbox.stop(); // preserved by default — nothing to do here

// Elsewhere: resolve the same name to a *live* sandbox without creating anything.
try {
  const live = await Sandbox.byName("agent-session-42");
} catch (err) {
  // 409 if the name currently belongs to a stopped sandbox instead — use getOrCreate for that.
}
```

* **`Sandbox.byName(name, options?)`** — resolves a name to a *live* sandbox. Rejects (`409`) if the name currently belongs to a stopped (snapshotted) sandbox instead — use `getOrCreate` for that.
* **`Sandbox.getOrCreate(options)`** — resolves `options.name` to a sandbox in one race-safe call: live as-is, resumed if stopped, created fresh otherwise. Returns `{ sandbox, created }`.

### Listing and filtering

```ts theme={null}
const ciSandboxes = await Sandbox.list({ tags: { env: "ci" } });
for (const info of ciSandboxes) {
  console.log(info.id, info.name, info.createdAt);
}
```

* **`Sandbox.list(options?)`** — lists sandboxes. `options.tags` filters by exact match on every given key.

### Dev-server preview

```ts theme={null}
const sandbox = await Sandbox.create();
await sandbox.runCommand("sh", ["-c", "npm run dev &"]); // background the dev server
const url = sandbox.previewUrl(3000, { path: "/health" });
console.log(url); // open this in a browser, or fetch() it yourself
```

* **`sandbox.previewUrl(port, options?)`** — the URL a browser can open to reach a server listening on `port` inside the sandbox.

### Local tunnel

The reverse of preview: expose a service running on **this machine** to code inside the sandbox.

```ts theme={null}
const sandbox = await Sandbox.create();
const tunnel = await sandbox.tunnel(8080, { localPort: 3000 });
// Code inside the sandbox connecting to 127.0.0.1:8080 now reaches
// whatever's listening on this machine's 127.0.0.1:3000.
await sandbox.runCommand("curl", ["http://127.0.0.1:8080/"]);
await tunnel.close();
```

* **`sandbox.tunnel(guestPort, options)`** — `options.localPort` (required), `options.localHost` (default `127.0.0.1`). Node-only (forwarding to a real local socket needs `node:net`, which doesn't exist in a browser). Returns a `TunnelHandle` with `.tunnelId` and `.close()`. See [Local tunnel](/concepts/local-tunnel).

### Streamed background exec

`runCommand` waits for the process to exit and returns everything at once — for a long-running command you want to watch live instead, start it in the background and attach a `WebSocket` to its output:

```ts theme={null}
const sessionId = await sandbox.execStream("sh", ["-c", "for i in 1 2 3; do echo tick $i; sleep 1; done"]);

const ws = sandbox.attachLogs(sessionId);
ws.onmessage = async (event) => {
  // Buffered replay lines arrive as a Blob; the final "[process exited
  // with code N]" sentinel arrives as a plain string -- handle both.
  const text = typeof event.data === "string" ? event.data : await event.data.text();
  console.log(text.trimEnd());
};
// replays everything captured so far, then live-tails anything new --
// reconnecting later (even after this connection closes) replays the
// same full history again, since the daemon buffers it, not the socket.

const sessions = await sandbox.listExecStreams();
console.log(sessions); // [{ id, command, args, startedAt, exitCode }, ...]
```

* **`sandbox.execStream(command, args?)`** — starts `command` in the background, returns a session id immediately.
* **`sandbox.attachLogs(sessionId)`** — returns a `WebSocket` (requires Node.js >= 22 or a browser's built-in `WebSocket`) that replays buffered output then live-tails new output, whether or not the process has already finished.
* **`sandbox.listExecStreams()`** — lists every streamed session tracked against this sandbox, running or finished.
* Not carried across `resume()`/`fork()`, and doesn't survive the daemon restarting — a fresh sandbox (or one just resumed) has no sessions yet.

### Snapshot, resume, fork

```ts theme={null}
const snapshotId = await sandbox.snapshot(); // save state, stop the VM

// ...later, possibly in a different process...
const resumed = await Sandbox.resume(snapshotId); // consumes the snapshot
await resumed.runCommand("echo", ["back from a snapshot"]);

// Or keep the snapshot reusable by forking instead of resuming:
const fork1 = await Sandbox.fork(snapshotId);
await fork1.stop(); // frees the fork; the snapshot is still there
const fork2 = await Sandbox.fork(snapshotId); // fork it again
```

* **`sandbox.snapshot()`** — saves full state to disk and stops; returns a snapshot id.
* **`Sandbox.resume(snapshotId, options?)`** — boots from a snapshot, **consuming** it.
* **`Sandbox.fork(snapshotId, options?)`** — boots from a snapshot **without** consuming it. `409` while an earlier fork is still live.
* **`Sandbox.listSnapshots(options?)`** — lists snapshots. `options.sourceSandboxId` narrows to the one taken from that original sandbox id.

### Remote storage mounts

```ts theme={null}
const sandbox = await Sandbox.create();
const mount = await sandbox.mount({
  bucket: "build-cache",
  endpoint: "https://s3.example.com",
  accessKey: "...",
  secretKey: "...",
  mountPath: "/mnt/cache",
});
// Ordinary readFile()/writeFile() against mountPath now read/write through to the bucket.
await sandbox.unmount(mount.id);
```

* **`sandbox.mount(options)`** — mounts an S3-compatible bucket at `options.mountPath` via `rclone mount`. `options.endpoint` is the full URL of the S3-compatible service, never defaulted. Needs the optional FUSE-capable rootfs setup in `SELF_HOSTING.md`; throws `SandkilnApiError` on a guest-side mount failure. Not carried across `resume()`/`fork()` — it's a live guest FUSE process, already captured by Firecracker's own snapshot.
* **`sandbox.listMounts()`** — lists every remote-storage mount currently active in this sandbox.
* **`sandbox.unmount(mountId)`** — tears down one mount. Best-effort on the guest side — a failed `umount` is a daemon-log warning, not a failed request.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.