Skip to main content
The @boxd-sh/sdk package gives you programmatic access to the full boxd API — create and manage machines, exec commands, stream output, transfer files, manage proxies, templates, disks. It talks to the same public gRPC API as the CLI, so everything you can do from the terminal you can do from TypeScript. Promise-only, ESM-only. Runs on Node 20+, Bun, and Deno.

Install

npm install @boxd-sh/sdk
# or: bun add @boxd-sh/sdk

Quick start

import { Compute } from "@boxd-sh/sdk";

const c = new Compute({ apiKey: "bxk_..." });

const box = await c.box.create({ name: "my-vm" });
const result = await box.exec(["echo", "hello"]);
console.log(result.stdout);

await box.destroy();
await c.close();

Authentication

new Compute({ apiKey: "bxk_..." });        // API key (recommended)
new Compute({ token: "eyJ..." });          // direct JWT
new Compute();                             // reads BOXD_API_KEY or BOXD_TOKEN
API keys are minted via boxd keys create (CLI), the boxd.sh console UI, or directly via the gRPC API. The SDK itself only issues short-lived JWTs (c.token.create()), not long-lived API keys — see Tokens.

Configuration

By default, Compute connects to production. For custom or self-hosted endpoints, override URLs explicitly:
new Compute({
  apiKey: "bxk_...",
  apiUrl: "http://my-boxd.example.com:9443",
  exchangeUrl: "https://my-boxd.example.com/api/v1/auth/token",
});

Environment variables

All Compute options can be supplied via env vars. Constructor args win over env vars.
VariableSetsDefault
BOXD_API_KEYAPI key (long-lived, recommended)—
BOXD_TOKENDirect JWT (short-lived)—
BOXD_API_URLgRPC endpointhttp://boxd.sh:9443
BOXD_EXCHANGE_URLToken-exchange URLhttps://boxd.sh/api/v1/auth/token
apiUrl accepts an optional URL scheme that controls TLS:
apiUrl valueTransport
http://host:portplaintext (scheme stripped before connecting)
https://host:portTLS (scheme stripped before connecting)
bare host:portTLS, except localhost / 127.* which stay plaintext
The default http://boxd.sh:9443 matches production. Self-hosted clusters can pass apiUrl: "http://my-cluster:9443" to opt into plaintext.

Machine lifecycle

const box = await c.box.create({ name: "my-vm" });
const boxes = await c.box.list();
const found = await c.box.get("my-vm");                   // by name or id
const forked = await c.box.fork("my-vm", { name: "f1" });

await box.start();
await box.stop();
await box.reboot();
await box.destroy();
const s = await box.suspend();   // { suspendUs }
const r = await box.resume();    // { resumeUs }
See Suspend & resume for the difference between stop/start/reboot (cold) and suspend/resume (warm).

Box fields

Box exposes server-returned fields. Which fields are populated depends on how the Box was obtained:
Fieldcreateforklistget
id, name, image, publicIp, status✓✓✓✓
url, bootTimeMs✓✓——
forkedFrom—✓——
restartPolicy, diskBytes, autoSuspendTimeoutSecs———✓
If you need the URL after a list / get round-trip, the https://<name>.boxd.sh form is stable, or call box.proxies(). If you need the lifecycle fields off a Box from list / create / fork, re-fetch via c.box.get(box.name).

BoxConfig

create, fork, and template.createVm all take an optional config:
import type { BoxConfig } from "@boxd-sh/sdk";

const config: BoxConfig = {
  vcpu: 2,                                 // default 2
  memory: "4G",                            // default "8G"
  env: { API_KEY: "secret" },              // env vars exposed to the VM
  restartPolicy: "always",                 // "always" | "never"
  lifecycle: {
    autoSuspendTimeout: 300,               // idle network secs; 0 disables
    autoDestroyTimeout: 0,                 // total lifetime secs; 0 disables
  },
};

const box = await c.box.create({ name: "my-vm", config });

Exec

// Simple — collect all output
const r = await box.exec(["python", "script.py"]);
r.stdout;     // string (subprocess fd 1)
r.stderr;     // string (subprocess fd 2)
r.exitCode;   // number
r.success;    // boolean

// With env vars and timeout
await box.exec(["sh", "-c", "echo $FOO"], {
  env: { FOO: "bar" },
  timeoutMs: 30_000,
});

// Streaming — proc.stdout and proc.stderr are separate AsyncIterables
const proc = await box.exec(["cargo", "build"], { stream: true });
for await (const chunk of proc.stdout) process.stdout.write(chunk);
// (read proc.stderr concurrently if you need warnings live, e.g. via Promise.all)
const code = await proc.wait();

// Headless one-shot — close stdin immediately so commands like
// `claude -p`, `jq`, `cat` see EOF and don't hang waiting for input.
const r2 = await box.exec(
  ["claude", "-p", "summarize this PR"],
  { stream: true, closeStdin: true },
);

// Interactive (PTY + stdin) — stdin must stay open for user input.
// `closeStdin: true` is REJECTED with this combination (throws).
const sh = await box.exec(["bash"], { stream: true, pty: true });
sh.stdin.write("echo hello\n");
await sh.stdin.end();

Streams: stdout vs stderr

proc.stderr / r.stderr is populated for non-PTY execs. The subprocess’s fd 2 is delivered separately from fd 1 — useful when a tool’s progress lives on stderr while the answer is on stdout (e.g. codex exec, cargo build). Under pty: true / interactive: true, the kernel TTY layer merges stderr into stdout (that’s how terminals work), so everything arrives on proc.stdout and proc.stderr stays empty. Set pty: false if you need the split.

closeStdin

Set closeStdin: true (only valid with stream: true on a non-PTY exec) to have the SDK close the client send half of the bidi stream right after the command starts. Headless one-shots that read stdin (claude -p, jq, cat file) see EOF immediately and proceed; without it they hang for several seconds (or forever) waiting on stdin. Passing it together with pty: true or interactive: true throws a clear error rather than silently dropping the flag — a PTY shell needs stdin open for user input.

PTY size + resize

For PTY/interactive execs, pass cols and rows to set the initial terminal geometry, and call proc.resize(cols, rows) to update it mid-session when the local terminal changes size. This is what makes TUI apps like claude, vim, htop render at the right width.
const proc = await box.exec(["claude"], {
  stream: true,
  pty: true,
  cols: process.stdout.columns,
  rows: process.stdout.rows,
});

// Forward local SIGWINCH so resize propagates into the VM's PTY.
process.stdout.on("resize", () => {
  proc.resize(process.stdout.columns, process.stdout.rows);
});

// Forward raw bytes both directions; the local terminal renders ANSI.
process.stdin.setRawMode?.(true);
process.stdin.on("data", (d) => proc.stdin.write(d));
for await (const chunk of proc.stdout) process.stdout.write(chunk);
Zero / unset cols and rows fall back to the server default of 80×24. resize() on a non-PTY exec is a harmless no-op.

File transfer

await box.writeFile("/app/file.txt", "text content");
await box.writeFile("/app/file.bin", new Uint8Array([1, 2, 3]));
await box.writeFile("/app/file.py", { fromPath: "local.py" });    // Node/Bun only
const data = await box.readFile("/app/output.json");              // Uint8Array
Paths on the VM side are absolute or relative to /home/boxd.

Proxies

Every box is live at https://name.boxd.sh forwarding to port 8000 by default. Additional subdomain proxies forward to other ports.
await box.proxies();                              // Proxy[]
await box.createProxy("api", 3001);               // api.<vm>.boxd.sh -> port 3001
await box.setProxyPort(3000);                     // change default proxy port
await box.setProxyPort(3001, { name: "api" });    // change a named proxy
await box.deleteProxy("api");

Logs

// Snapshot of available console output
for await (const chunk of box.streamLogs()) {
  process.stdout.write(chunk);
}

// Follow (keeps the stream open for new chunks)
for await (const chunk of box.streamLogs({ follow: true })) {
  process.stdout.write(chunk);
}

Templates

Reusable image + BoxConfig frozen together.
const t = await c.template.create({
  name: "t1",
  image: "ghcr.io/org/img:tag",
  config: { vcpu: 2, memory: "4G" },
});
await c.template.list();

// createVm accepts a Template object OR a template ID string. Pass an
// optional `config` to override the template's defaults (e.g. bump memory
// for one specific VM).
const box = await c.template.createVm({ template: t, name: "from-t" });
const big = await c.template.createVm({
  template: t.id,
  name: "from-t-big",
  config: { memory: "16G" },
});
await c.template.delete(t.id);

Disks

Independent persistent volumes you can attach and detach from boxes.
const d = await c.disk.create("data", "10G");
d.id; d.name; d.sizeBytes; d.status;

// attach / detach take a Box instance OR a name/id string
await d.attach(box, "/mnt/data");
await d.attach("my-vm", "/mnt/data", { readOnly: true });
await d.detach("my-vm");

await d.destroy();

// list returns DiskHandle[] — same methods as above
for (const d of await c.disk.list()) {
  console.log(d.name, d.status);
}

Domains

Custom HTTPS domains bound to a box. DNS must already point at the boxd proxy — see HTTPS.
await c.domain.bind("app.example.com", box);            // accepts Box, name, or id
await c.domain.bind("app.example.com", "my-vm");
await c.domain.list();                                   // [{ domain, vmId }]
await c.domain.unbind("app.example.com");

Networks

Custom L2 networks for VM-to-VM communication.
const n = await c.network.create();              // server assigns id
const named = await c.network.create("staging");

// `create` returns the new network's id only — `subnet` and `status` come
// back populated once provisioning settles. Re-fetch via `list` to read them.
for (const net of await c.network.list()) {
  console.log(net.id, net.subnet, net.status);
}

Tokens

Issue scoped JWTs for delegated access. The raw token string is only returned at creation — store it then.
const t = await c.token.create(3600);                    // TTL in seconds; 0 = server default
t.token;          // "eyJ..." — save this; list() will not return it
t.expiresAt;      // unix seconds

for (const info of await c.token.list()) {
  console.log(info.jti, info.createdAt, info.expiresAt);
}
await c.token.revoke(info.jti);

// Use the token to authenticate a new client
const c2 = new Compute({ token: t.token });

Identity

const me = await c.whoami();
me.userId;              // "gh-username"
me.fingerprints;        // ["SHA256:..."]
me.defaultNetworkId;    // "net-..."

const cfg = await c.config();
cfg.defaultImage;       // "ubuntu:latest"
cfg.zone;               // "boxd.sh"
The package also exports a VERSION constant matching package.json:
import { VERSION } from "@boxd-sh/sdk";
console.log("on", VERSION);

Errors

import { NotFoundError, BoxdError } from "@boxd-sh/sdk";

try {
  await c.box.get("nope");
} catch (e) {
  if (e instanceof NotFoundError) { /* ... */ }
}
All errors extend BoxdError. Subclasses:
ClassgRPC status
AuthenticationErrorUNAUTHENTICATED, PERMISSION_DENIED
NotFoundErrorNOT_FOUND
QuotaExceededErrorRESOURCE_EXHAUSTED
InvalidArgumentErrorINVALID_ARGUMENT, ALREADY_EXISTS
TimeoutErrorDEADLINE_EXCEEDED
ConnectionErrorUNAVAILABLE
InternalErrorINTERNAL, UNKNOWN
Each error carries the underlying grpcCode (numeric gRPC status — see grpc.StatusCode) for finer-grained handling:
try {
  await c.box.create({ name: "my-vm" });
} catch (e) {
  if (e instanceof BoxdError && e.grpcCode === 8 /* RESOURCE_EXHAUSTED */) {
    // hit per-user quota — e.g. surface a 'wait or upgrade' UI
  }
  throw e;
}

Update notifications

Every gRPC response carries an x-boxd-ts-sdk-latest header set by the boxd proxy. The SDK’s interceptor compares it to the installed version and prints a one-time stderr line via console.warn if a newer release is available:
A new version of @boxd-sh/sdk is available (v0.1.2, you have v0.1.1). Update with:
  npm install @boxd-sh/sdk@latest
The notice fires at most once per process, never causes a request to fail, and is silent if the proxy isn’t advertising a newer version. Compares as semver-ish (numeric prefix, then per-component compare on -dev.N suffixes).

Async disposal

Node 20+, Bun, and Deno support TC39 explicit-resource-management. Compute implements Symbol.asyncDispose, so await using cleans up the gRPC channel automatically:
{
  await using c = new Compute({ apiKey: "bxk_..." });
  const box = await c.box.create({ name: "job-vm" });
  try {
    await box.exec(["./run.sh"]);
  } finally {
    await box.destroy();
  }
  // c.close() called automatically at scope exit
}
Without await using, call await c.close() when you’re done.

Reference

https://mintcdn.com/azin/Ax1V0serIwQf0x_2/images/icons/command.svg?fit=max&auto=format&n=Ax1V0serIwQf0x_2&q=85&s=6c33d9e29e4e937c0950311233ec5659

CLI

Same API, accessed from the terminal. Useful for one-offs and shell scripting.
https://mintcdn.com/azin/Ax1V0serIwQf0x_2/images/icons/python.svg?fit=max&auto=format&n=Ax1V0serIwQf0x_2&q=85&s=50aa9d4f66d47baaef6fd6846b681b78

Python SDK

Same API, sync and async, for Python codebases.