@boxd-sh/sdk
v0.2.1
Published
TypeScript SDK for the boxd cloud VM platform
Downloads
1,903
Maintainers
Readme
@boxd-sh/sdk
TypeScript SDK for the boxd cloud machine 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 { Boxd } from "@boxd-sh/sdk";
const boxd = new Boxd({ apiKey: "bxd_..." });
const machine = await boxd.machines.create({ name: "my-machine" });
await boxd.machines.waitUntilReady(machine.id);
const result = await boxd.machines.exec(machine.id, { command: ["echo", "hello"] });
console.log(result.stdout);
await boxd.machines.delete(machine.id);
await boxd.close();The client is the only stateful object. Everything else is a namespace of flat
methods that return plain data — a Machine is a record of fields, not a
handle, and every operation takes the machine id as its first argument.
Client construction
new Boxd(); // production
new Boxd({ apiKey: "bxd_..." });
new Boxd({ baseURL: "https://boxd.example.com" }); // any other cluster| Option | Env var | Default |
|---|---|---|
| apiKey | BOXD_API_KEY | — |
| token | BOXD_TOKEN | — |
| baseURL | BOXD_BASE_URL (or the deprecated BOXD_API_URL) | http://boxd.sh:9443 |
| timeout | — | 60000 ms |
| maxRetries | — | 2 |
baseURL accepts an optional scheme that controls TLS:
| Value | Transport |
|---|---|
| http://host:port | plaintext |
| https://host:port | TLS |
| bare host:port | TLS, except localhost / 127.* |
Failed connections are retried up to maxRetries times with exponential
backoff. Timeouts are never retried — the request may already have been
applied — and authentication failures are never retried either.
Authentication
new Boxd({ apiKey: "bxd_..." }); // recommended
new Boxd({ token: "..." }); // a token you already hold
new Boxd(); // BOXD_API_KEY, then BOXD_TOKENCredentials are resolved in this order, first match wins:
- an explicit
token - an explicit
apiKey BOXD_TOKENBOXD_API_KEY- automatic, inside a boxd machine (below)
If none of those produce a credential, the first request throws
AuthenticationError.
An API key is exchanged for a short-lived session token and kept fresh for you.
Revoking a key takes effect immediately, so if a key is revoked mid-run the next
call fails with AuthenticationError rather than retrying.
Inside a boxd machine
Running inside a boxd machine, new Boxd() authenticates automatically — no
API key, no configuration:
const boxd = new Boxd();
const mine = await boxd.machines.list();The client also points itself at the cluster the surrounding machine belongs
to, so the same code runs unchanged wherever it is deployed. Passing an explicit
baseURL or credential still wins.
Limits inside a shared machine. Automatic credentials act within the organization that owns the machine. From a shared machine you can manage the organization's shared machines, but you cannot read env vars or secrets, and you cannot reach machines that are private to another member. Use an API key for those.
Machines
const machine = await boxd.machines.create({ name: "my-machine" });
const one = await boxd.machines.get(machine.id); // id or name
const all = await boxd.machines.list(); // Machine[]
await boxd.machines.list({ org: "acme" }); // one organization's machines
await boxd.machines.list({ allContexts: true }); // every org you belong to
await boxd.machines.delete(machine.id);
await boxd.machines.start(id);
await boxd.machines.stop(id);
await boxd.machines.reboot(id);
await boxd.machines.pause(id); // suspend to RAM → { suspendUs: number }
await boxd.machines.resume(id); // { resumeUs: number }
await boxd.machines.hibernate(id); // suspend to disk
await boxd.machines.wake(id);
const fork = await boxd.machines.fork(id, { name: "fork-1" });
await boxd.machines.fork(id, { shared: true, config: { vcpu: 8 } });
await boxd.machines.share(id); // visible to the whole org
await boxd.machines.unshare(id);
const renamed = await boxd.machines.rename(id, "new-name"); // the new name; reboots
await boxd.machines.setAutoSuspendTimeout(id, 300); // seconds; 0 disables
await boxd.machines.setAutoHibernateTimeout(id, 3600);
await boxd.machines.waitUntilReady(id); // running *and* exec works
await boxd.machines.waitUntilReady(id, { timeout: 180_000, pollInterval: 1_000 }); // ms
const suggested = await boxd.machines.suggestName();list() returns a plain array. A fork inherits the source's sizing for anything
config leaves unset, and is private to you unless you pass shared: true.
Creating
await boxd.machines.create({
name: "builder",
image: "ubuntu:24.04",
env: { API_URL: "https://example.com" },
cmd: ["/usr/local/bin/start"],
restartPolicy: "always", // "always" | "never"
config: {
vcpu: 2,
memory: "8G", // "8G", "512M", or a byte count
disk: "100G",
autoSuspendTimeout: 300, // seconds; 0 disables
autoDestroyTimeout: 0,
ssh: true, // give the machine an SSH port
proxies: [{ name: "api", port: 3000 }], // `port` optional — detected if omitted
volumes: [{ diskId: "d_...", mountPath: "/data", readOnly: false }],
},
});
// Every option is optional — this boots the cluster's default image.
await boxd.machines.create();
// In an organization. `shared` makes it visible to every member.
await boxd.machines.create({ org: "acme", shared: true });
// From a snapshot instead of an image.
await boxd.machines.create({ fromSnapshot: "golden", name: "from-golden" });Restoring a snapshot replays the machine as it was captured, so fromSnapshot
goes with name, org and config — the options that describe a fresh
machine are a compile error beside it.
Renaming reboots the machine, so it is its own call. Every other setting is
readable straight off Machine.
The Machine record
interface Machine {
id: string;
name: string;
status: MachineStatus; // "pending" | "starting" | "running" | "suspended" |
// "hibernated" | "stopped" | "failed" | "destroyed" | "migrating"
imageRef: string;
restartPolicy: string | null;
createdAt: Date | null; // null when no creation time is on record
resources: { // what the machine actually got, not what
vcpu: number; // you asked for — always concrete
memoryBytes: number;
diskBytes: number;
};
org: { id: string; name: string } | null; // null = your personal quota
shared: boolean; // shared with that org, or private to you
access: {
sshPort: number | null; // null until allocated
domain: string;
url: string; // `https://<name>.<domain>`
};
idle: { // seconds; 0 = that timer is disabled
suspendAfter: number;
hibernateAfter: number;
destroyAfter: number;
};
source: MachineSource | null; // null = booted from an image
hibernatedAt: Date | null; // null = not hibernated
lastConnectedAt: Date | null; // null = never connected
bootTimeMs: number | null; // last boot duration; null = never booted
}
interface MachineSource {
kind: "fork" | "snapshot";
name: string; // source machine, or snapshot name
version: number | null; // snapshots only; a fork has no version
id: string | null; // provenance — may not resolve
}null always means "not set": a port that was never allocated, a boot that
never happened, an org you do not have. Where 0 is a real answer — a disabled
idle timer — it stays 0.
org is the org the machine belongs to and is billed to; shared says whether
your teammates can see it. A private machine can still be org-billed, so
org set with shared: false is normal, not a contradiction.
source.id points at the machine or snapshot this one came from. It is a record
of where the machine came from, not a live link — it may not resolve, and a
lookup that finds nothing is normal.
MACHINE_STATUSES is exported as an array of every status this release knows,
alongside isKnownMachineStatus(s) to check one:
import { MACHINE_STATUSES, isKnownMachineStatus } from "@boxd-sh/sdk";Exec
One-shot exec collects the output:
const r = await boxd.machines.exec(id, { command: ["python", "script.py"] });
r.stdout; // string
r.stderr; // string — populated for non-PTY execs
r.exitCode; // number
r.success; // boolean
await boxd.machines.exec(id, {
command: ["sh", "-c", "echo $FOO"],
env: { FOO: "bar" },
timeout: 30_000, // milliseconds
});
// Under a PTY, stderr merges into stdout and `stderr` comes back empty.
await boxd.machines.exec(id, { command: "top -b -n1", tty: true, cols: 120, rows: 40 });command takes argv — shell-quoted for you — or a ready-made command line as a
string. timeout gives up on the call; whatever it started inside the machine
may well still be running.
Interactive and PTY sessions use a stream handle, the one stateful object besides the client:
const stream = boxd.machines.streamExec(id, { command: "bash", tty: true });
stream.write("echo hello\n");
stream.end(); // half-close stdin — the process sees EOF
for await (const chunk of stream) process.stdout.write(chunk);
const code = await stream.wait(); // resolves with the exit code
stream.exitCode; // number | undefined — set once the process is done
stream.close(); // cancel the session; safe to call twicestreamExec hands the stream back synchronously and buffers whatever you write
until the session is live. Iterating the stream yields stdout as Uint8Array
chunks.
Without tty, stderr arrives separately on stream.stderr — useful when a
tool's progress goes to stderr and its answer to stdout. With tty, the
terminal layer merges the two onto stdout, as terminals do.
For TUI apps, pass the initial geometry and forward resizes:
const stream = boxd.machines.streamExec(id, {
command: "vim",
tty: true,
cols: process.stdout.columns,
rows: process.stdout.rows,
});
process.stdout.on("resize", () =>
stream.resize(process.stdout.columns, process.stdout.rows),
);Headless one-shots that read stdin (jq, cat) hang waiting for input. Pass
closeStdin: true to send EOF immediately. It is rejected together with tty,
where stdin must stay open.
Logs
for await (const chunk of boxd.machines.logs(id)) process.stdout.write(chunk);
for await (const chunk of boxd.machines.logs(id, { follow: true })) { /* ... */ }Files, ports, proxies, checkpoints
// Files — large uploads are chunked for you.
const written = await boxd.machines.files.upload(id, "/app/file.txt", "text content");
await boxd.machines.files.upload(id, "/app/file.bin", new Uint8Array([1, 2, 3]));
await boxd.machines.files.upload(id, "/app/app.py", { fromPath: "local.py" });
const bytes = await boxd.machines.files.download(id, "/app/output.json"); // Uint8Arrayupload returns the number of bytes the machine confirmed it wrote.
// Raw TCP/UDP forwards (max 3 per machine).
const fwd = await boxd.machines.ports.expose(id, 5432); // tcp by default
await boxd.machines.ports.expose(id, 5353, { protocol: "udp" }); // "tcp" | "udp" | "both"
fwd.dns; fwd.publicPort; fwd.machinePort; fwd.protocol;
fwd.machineId; fwd.machineName;
await boxd.machines.ports.list(id); // one machine's forwards
await boxd.machines.ports.list(); // every forward you own
await boxd.machines.ports.unexpose(id, 5432); // echoes back the forward it removedConnect on dns:publicPort. Re-exposing a machine port keeps its public port
and just updates the protocol set; "both" shares one public port across TCP
and UDP.
// HTTPS routes — an id or a name works, like everywhere else.
const route = await boxd.machines.proxies.create(machine.name, "api", 3000);
route.name; route.port;
const routes = await boxd.machines.proxies.list(machine.name);
routes[0].name; // string | null — null on the machine's default route
routes[0].domain; // the hostname this route answers on
routes[0].port; // number — where traffic actually goes
routes[0].portMode; // "locked" (you pinned it) | "auto" (detected for you)
routes[0].isDefault;
routes[0].machineId; routes[0].machineName;
await boxd.machines.proxies.setPort(machine.name, 3001, { name: "api" });
await boxd.machines.proxies.setPort(machine.name, "auto"); // default route, auto-detected
await boxd.machines.proxies.delete(machine.name, "api");create answers as soon as the route is accepted, so it confirms the subdomain
and the port it was pointed at; list() reports the full domain and the
resolved port.
// Checkpoints — restore a machine in place.
const cp = await boxd.machines.checkpoints.create(id, "before-upgrade");
cp.id; cp.name; cp.status;
const saved = await boxd.machines.checkpoints.list(id);
saved[0].sizeBytes;
saved[0].createdAt; // Date
saved[0].createdBy; // string | null
saved[0].available; // restorable right now
await boxd.machines.checkpoints.restore(id, "before-upgrade");
await boxd.machines.checkpoints.delete(id, "before-upgrade");The machine must be running to take a checkpoint. status is "pending" until
the artifact lands, then "ready" (or "failed"); restore wants one that is
"ready" and available. Checkpoints belong to their machine and go away with
it.
Env vars and secrets
Two namespaces with identical methods. The difference is what comes back: an
env var has a readable value, a secret does not — secret values are
write-only and never leave the server.
await boxd.env.set("API_URL", "https://example.com", { scope: "all" });
await boxd.env.list(); // [{ name, scope, value }]
await boxd.env.list({ org: "acme" });
await boxd.env.delete("API_URL", { scope: "all" });
await boxd.secrets.set("STRIPE_KEY", "sk_live_...", { scope: "private" });
await boxd.secrets.list(); // [{ name, scope }] — no values
await boxd.secrets.delete("STRIPE_KEY", { scope: "private" });scope defaults to "shared" on set and delete; list takes org only
and reports every scope.
set, delete and move each return the server's human-readable
confirmation of what it did.
Scope decides which machines a value reaches:
| Scope | Applies to |
|---|---|
| private | only your own machines in that organization |
| shared | the organization's shared machines |
| all | every machine in the organization |
move changes the scope. It needs from as well as to, because the same
name can exist in several scopes at once:
await boxd.secrets.move("STRIPE_KEY", { from: "private", to: "all" });It is a move between two places, not a field update, so calling it twice fails the second time. Env vars and secrets share one name space per scope, so an existing env var can block a secret moving into that scope, and vice versa.
Pass { org: "acme" } to any of these to work in an organization instead of
your personal scope.
Snapshots and disks
const snap = await boxd.snapshots.create(id, "golden");
snap.id; snap.name; snap.version; snap.status;
await boxd.snapshots.get("golden"); // by name or id
await boxd.snapshots.list();
await boxd.snapshots.delete("golden");
await boxd.snapshots.list({ org: "acme" }); // `org` works on get/list/delete too
const disk = await boxd.disks.create("data", "10G"); // bytes or a human string
disk.id; disk.name; disk.sizeBytes; disk.status;
await boxd.disks.attach(disk.id, id, "/mnt/data");
await boxd.disks.attach(disk.id, id, "/mnt/data", { readOnly: true });
await boxd.disks.detach(disk.id, id);
await boxd.disks.list();
await boxd.disks.delete(disk.id);The machine must be running to snapshot it, and create answers before the
artifact lands — status is "pending" until it does. Snapshots stay inside
one organization.
A disk is always created writable; read-only is chosen per attachment. A disk can be attached to only one machine at a time.
create confirms only what the server can answer immediately; the full records
come back from get and list:
interface Snapshot {
id: string;
name: string;
version: number | null; // latest ready version; null = nothing captured yet
status: ArtifactStatus; // "pending" | "ready" | "failed"
sizeBytes: number;
createdAt: Date | null; // the first capture — it stays put
updatedAt: Date | null; // the most recent capture
vcpu: number; // the sizing the machine was captured at
memoryBytes: number;
useCount: number; // machines restored from it so far
}
interface Disk {
id: string;
name: string;
sizeBytes: number;
status: DiskStatus; // "creating" | "ready" | "destroyed" — attach once "ready"
createdAt: Date | null;
attachments: {
machineId: string;
machineName: string;
mountPath: string;
mountMode: "ro" | "rw";
}[];
}Organizations
const orgs = await boxd.orgs.list(); // Org[]
orgs[0].id;
orgs[0].name; // display label — it can repeat across organizations
orgs[0].slug; // the organization's unique key
orgs[0].isAdmin; // you administer it
orgs[0].isDefault; // where your personal machines are billedAnywhere a call takes org, it accepts an organization's name or id.
Credentials and account
const key = await boxd.apiKeys.create({
name: "ci",
org: "acme", // the organization the key is fenced to
kind: "member", // "member" (default) acts as you within that org;
// "org" is a userless service credential,
// limited to the org's shared fleet, org admin only
expiresIn: 60 * 60 * 24 * 30, // seconds; omit or 0 for no expiry
});
key.id;
key.apiKey; // shown once — store it now
key.expiresAt; // Date | null
const keys = await boxd.apiKeys.list();
keys[0].name; keys[0].keyPrefix; keys[0].createdAt;
keys[0].lastUsedAt; // Date | null — null = never used
keys[0].expiresAt; // Date | null — null = no expiry
keys[0].org; keys[0].kind; // "member" | "org"
await boxd.apiKeys.delete(key.id);
const me = await boxd.account.get();
me.userId;
me.displayName; // string | null — falls back to `userId`
me.sshKeyFingerprints; // string[]
me.billing.subscriptionStatus; // "active", "trialing", … | null
me.billing.pastDueSince; // Date | null
me.billing.maxVms; // effective quota
me.billing.vcpu; me.billing.memoryBytes;
await boxd.account.linkSshKey({ pubkey: "ssh-ed25519 AAAA..." });
await boxd.account.linkSshKey({
pubkey: "ssh-ed25519 AAAA...",
deviceId: "laptop", // one key kept per device — re-linking replaces it
label: "MacBook Pro", // shown wherever the device is listed
});
const cfg = await boxd.account.config();
cfg.defaultImage; cfg.zone;Every key is fenced to exactly one organization. Deleting one takes effect immediately.
Errors
import { NotFoundError } from "@boxd-sh/sdk";
try {
await boxd.machines.get("nope");
} catch (e) {
if (e instanceof NotFoundError) { /* ... */ }
}Everything thrown extends BoxdError:
| Class | Meaning |
|---|---|
| AuthenticationError | the credential was rejected, or none was found |
| PermissionDeniedError | authenticated, but not allowed |
| NotFoundError | no such resource |
| ConflictError | already exists, or the resource is in the wrong state |
| RateLimitError | quota or rate limit reached |
| APIStatusError | any other error returned by the server |
| APIConnectionError | the request never reached the server |
Every error carries grpcCode, the numeric
status code, for
finer-grained handling.
Async disposal
Node 20+, Bun, and Deno support TC39 explicit resource management:
{
await using boxd = new Boxd({ apiKey: "bxd_..." });
await boxd.machines.list();
// boxd.close() runs at scope exit
}Version notifications
The SDK prints a one-time console.warn on stderr when a newer release is
available:
A new version of @boxd-sh/sdk is available (v0.2.0, you have v0.1.9). Update with:
npm install @boxd-sh/sdk@latestIt fires at most once per process and never causes a request to fail. The installed version is also exported:
import { VERSION } from "@boxd-sh/sdk";