@boxd-sh/sdk
@boxd-sh/sdk
TypeScript SDK for the boxd cloud VM platform.
Promise-only, ESM-only. Runs on Node 20+, Bun, and Deno.
Install
npm install @boxd-sh/sdk
# or: bun add @boxd-sh/sdkQuick 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_TOKENConfiguration
Pick a named cluster preset:
new Compute({ apiKey: "bxk_..." }); // production (default)
new Compute({ apiKey: "bxd_...", environment: "staging" }); // boxd-stg.shenvironment also reads from the BOXD_ENVIRONMENT env var ("production" or "staging").
For custom or self-hosted endpoints, override URLs explicitly — these take precedence over environment:
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; env vars win over the environment preset.
| Variable | Sets | Default |
|---|---|---|
BOXD_API_KEY |
API key (long-lived, recommended) | — |
BOXD_TOKEN |
Direct JWT (short-lived) | — |
BOXD_ENVIRONMENT |
Preset name (production or staging) |
production |
BOXD_API_URL |
gRPC endpoint, overrides preset | http://boxd.sh:9443 |
BOXD_EXCHANGE_URL |
Token-exchange URL, overrides preset | https://boxd.sh/api/v1/auth/token |
apiUrl accepts an optional URL scheme that controls TLS:
apiUrl value |
Transport |
|---|---|
http://host:port |
plaintext (scheme stripped before connecting) |
https://host:port |
TLS (scheme stripped before connecting) |
bare host:port |
TLS, 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.
VM 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 }Box fields
Box exposes server-returned fields. Which fields are populated depends on how the Box was obtained:
| Field | create |
fork |
list |
get |
|---|---|---|---|---|
id, name, image, publicIp, status |
✓ | ✓ | ✓ | ✓ |
url, bootTimeMs |
✓ | ✓ | — | — |
forkedFrom |
— | ✓ | — | — |
restartPolicy, diskBytes, autoSuspendTimeoutSecs |
— | — | — | ✓ |
sshPort |
— | — | — | ✓ |
If you need the URL or boot time after a list / get round-trip, the https://<name>.boxd.sh form is stable, or call box.proxies() for the full set. 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 the warnings, e.g. in a 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.
Files
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"); // Uint8ArrayProxies
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
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
Bind an external domain (DNS must already point at the boxd proxy).
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
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 } from "@boxd-sh/sdk";
try {
await c.box.get("nope");
} catch (e) {
if (e instanceof NotFoundError) { /* ... */ }
}All errors extend BoxdError. Subclasses:
| Class | gRPC status |
|---|---|
AuthenticationError |
UNAUTHENTICATED, PERMISSION_DENIED |
NotFoundError |
NOT_FOUND |
QuotaExceededError |
RESOURCE_EXHAUSTED |
InvalidArgumentError |
INVALID_ARGUMENT, ALREADY_EXISTS |
TimeoutError |
DEADLINE_EXCEEDED |
ConnectionError |
UNAVAILABLE |
InternalError |
INTERNAL, 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:
{
await using c = new Compute({ apiKey: "bxk_..." });
const box = await c.box.create({ name: "my-vm" });
// c.close() called automatically at scope exit
}Development
cd sdk/typescript
bun install
bun run proto # regenerate _generated/ from proto/api/v1/api.proto
bun run typecheck
bun run build
bun test tests/ # unit tests
BOXD_RUN_E2E=1 bun test tests/e2e.test.ts # e2e (needs a running boxd cluster)Architecture
sdk/typescript/
├── src/
│ ├── index.ts # public API exports
│ ├── client.ts # Compute (entry point) + auth/transport
│ ├── auth.ts # API key → JWT exchange + refresh
│ ├── boxes.ts # BoxService (create/list/get/fork)
│ ├── box.ts # Box (lifecycle/exec/files/proxies/logs)
│ ├── exec.ts # ExecResult, ExecProcess, byte queues
│ ├── templates.ts # TemplateService
│ ├── disks.ts # DiskService + DiskHandle
│ ├── domains.ts # DomainService
│ ├── networks.ts # NetworkService
│ ├── tokens.ts # TokenService
│ ├── types.ts # public type definitions
│ ├── errors.ts # BoxdError hierarchy + gRPC mapping
│ ├── utils.ts # parseSize, resolveEndpoint
│ └── _generated/ # ts-proto output (committed)
└── tests/ # bun:test unit + gated e2e