JS/TS SDK
npm install sandkilnEverything this SDK wraps is listed below. One daemon feature has no wrapper yet: remote storage mounts (an S3-compatible bucket mounted inside a sandbox) are available on the daemon HTTP API only — see Daemon HTTP API.
Configuration
Section titled “Configuration”- Daemon URL: pass
baseUrlto any method, or setSANDKILN_DAEMON_URL. Defaults tohttp://127.0.0.1:7777. - Auth: pass
authToken, or setSANDKILN_AUTH_TOKEN. Omit entirely for an unauthenticated local daemon.
import { Sandbox } from "sandkiln";
// Explicit per-call config...const sandbox = await Sandbox.create({ baseUrl: "https://daemon.internal:7777", authToken: process.env.SANDKILN_TOKEN,});
// ...or set both as environment variables once and omit them everywhere:// SANDKILN_DAEMON_URL=https://daemon.internal:7777// SANDKILN_AUTH_TOKEN=...const sameSandbox = await Sandbox.create();Sandbox
Section titled “Sandbox”Create, run, clean up — the basic loop
Section titled “Create, run, clean up — the basic loop”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.nameis a caller-given identity, unique among live sandboxes and held snapshots (409if already taken) — seebyName/getOrCreatebelow 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),options.rateLimit({ bandwidthBytesPerSec?, opsPerSec? }, Firecracker’s own token-bucket I/O limiter — unlimited if omitted),options.drives(existing persistent drives to attach, seeDrivebelow). A call with nodrives/rateLimitmatching a configured pool’s image/resources transparently resumes a warm snapshot instead of cold-booting — seePoolbelow.sandbox.runCommand(command, args?)— returns{ stdout, stderr, exitCode }.sandbox.readFile(path)— returns file contents asUint8Array.sandbox.writeFile(path, content)—contentis astringorUint8Array.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 realisDir/isSymlink/size/mode/mtimemetadata) — the full filesystem operation set, all over the same vsock channelrunCommanduses.sandbox.pty(options?)— opens a live, interactive shell session and returns a nativeWebSocket(no added runtime dependency — needs Node.js ≥22 or a browser).options.cols/options.rowssize the terminal once, at open time; no live resize yet. Distinct fromrunCommand’s request/response shape — seekiln sandbox ptyfor the CLI equivalent.sandbox.stop(options?)— stops the sandbox. Default (options.keepomitted ortrue) preserves state as a resumable snapshot, returns{ kept, snapshotId }.{ keep: false }fully destroys instead.
Attaching to an id you already have
Section titled “Attaching to an id you already have”// 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
Section titled “Named sandboxes — find the same environment again later”// 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 — usegetOrCreatefor that.Sandbox.getOrCreate(options)— resolvesoptions.nameto a sandbox in one race-safe call: live as-is, resumed if stopped, created fresh otherwise. Returns{ sandbox, created }.
Listing and filtering
Section titled “Listing and filtering”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.tagsfilters by exact match on every given key.
Dev-server preview
Section titled “Dev-server preview”const sandbox = await Sandbox.create();await sandbox.runCommand("sh", ["-c", "npm run dev &"]); // background the dev serverconst url = sandbox.previewUrl(3000, { path: "/health" });console.log(url); // open this in a browser, or fetch() it yourselfsandbox.previewUrl(port, options?)— the URL a browser can open to reach a server listening onportinside the sandbox.
Streamed background exec
Section titled “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:
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?)— startscommandin the background, returns a session id immediately.sandbox.attachLogs(sessionId)— returns aWebSocket(requires Node.js >= 22 or a browser’s built-inWebSocket) 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
Section titled “Snapshot, resume, fork”const snapshotId = await sandbox.snapshot(); // save state, stop the VM
// ...later, possibly in a different process...const resumed = await Sandbox.resume(snapshotId); // consumes the snapshotawait 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 thereconst fork2 = await Sandbox.fork(snapshotId); // fork it againsandbox.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.409while an earlier fork is still live.Sandbox.listSnapshots(options?)— lists snapshots.options.sourceSandboxIdnarrows to the one taken from that original sandbox id.
import { Image, Sandbox } from "sandkiln";
const registered = await Image.register("node-lts-custom", "/home/t1000/images/node-lts-custom.ext4");if (!registered.guestAgentVerified) { console.warn(registered.verificationHint);}
const sandbox = await Sandbox.create({ imageId: "node-lts-custom" });
const images = await Image.list();await Image.delete("node-lts-custom"); // 409 while any sandbox/snapshot still references itImage.register(id, path, options?)— registers an already-built ext4 rootfs file atpathon the daemon’s own host filesystem, forSandbox.create({ imageId })to boot from. Not a file upload.guestAgentVerifiedon the response is alwaysfalse— see Custom & managed images.Image.list(options?)/Image.delete(id, options?)— list registered images, or delete one (409while anything references it).
import { Drive, Sandbox } from "sandkiln";
const drive = await Drive.create(512); // 512 MiB, emptyconst sandbox = await Sandbox.create({ drives: [{ id: drive.id }] });// A second sandbox can share it read-only alongside others, but not read-write:const reader = await Sandbox.create({ drives: [{ id: drive.id, readOnly: true }] });
await Drive.delete(drive.id); // 409 while any sandbox or held snapshot still attaches itDrive.create(sizeMib, options?)— creates a new empty persistent drive, returns its id.Drive.list(options?)— lists drives, including every current holder (attachedTo) and whether each is read-only.Drive.delete(id, options?)— permanently removes a drive and its backing file.
import { Pool, Sandbox } from "sandkiln";
await Pool.create("build-workers", { vcpuCount: 2, memSizeMib: 1024, warmCount: 3 });
// Some time later, once the background replenisher has warmed instances up:// matches the pool's image/resources and no drives/rateLimit -- resumes// a warm snapshot automatically instead of cold-booting. No special call.const sandbox = await Sandbox.create({ vcpuCount: 2, memSizeMib: 1024 });Pool.create(id, options?)— configures a pool under a caller-givenid(409if already taken — delete it first to reconfigure).options.imageId/options.vcpuCount/options.memSizeMibdefine which creates can match it;options.warmCount(default0) is how many resumable snapshots to keep ready.Pool.list(options?)— lists configured pools, includingwarmReady(how many are actually ready right now — replenishment happens in the background, not instantly).Pool.delete(id, options?)— removes a pool’s configuration and destroys whatever it currently has warm. A sandbox already claimed from it is unaffected.
There’s no Pool.claim() — claiming is entirely transparent, done by Sandbox.create() itself matching a configured pool. See Startup latency & the pre-warmed pool for real measured numbers, including a genuinely non-rare resume failure mode and how it’s handled.