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

# TypeScript SDK

> Programmatic access to boxd machines from Node, Bun, and Deno.

The `@boxd-sh/sdk` package gives you programmatic access to the full boxd API — create and manage machines, run commands in them, stream output, move files, and manage proxies, snapshots, disks, env vars and secrets. It talks to the same API as the [CLI](/reference/external-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

<CodeGroup>
  ```bash npm theme={"theme":"github-dark"}
  npm install @boxd-sh/sdk
  ```

  ```bash bun theme={"theme":"github-dark"}
  bun add @boxd-sh/sdk
  ```
</CodeGroup>

## Quick start

```typescript theme={"theme":"github-dark"}
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 (or name) as its first argument.

There are eight namespaces:

| Namespace        | For                                                           |
| ---------------- | ------------------------------------------------------------- |
| `boxd.machines`  | machines, plus `.files`, `.ports`, `.proxies`, `.checkpoints` |
| `boxd.snapshots` | reusable point-in-time captures                               |
| `boxd.disks`     | persistent disks                                              |
| `boxd.env`       | environment variables                                         |
| `boxd.secrets`   | secrets (write-only values)                                   |
| `boxd.orgs`      | the organizations you belong to                               |
| `boxd.apiKeys`   | API keys                                                      |
| `boxd.account`   | your identity and cluster defaults                            |

## The client

```typescript theme={"theme":"github-dark"}
new Boxd();                                        // production
new Boxd({ apiKey: "bxd_..." });
new Boxd({ baseURL: "https://boxd.example.com:9443" }); // any other cluster
```

<Note color="#E05A6D">
  `baseURL` selects the cluster.
</Note>

| 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.

Call `close()` when you're done, or let the runtime do it:

```typescript theme={"theme":"github-dark"}
{
  await using boxd = new Boxd({ apiKey: "bxd_..." });
  await boxd.machines.list();
  // boxd.close() runs at scope exit
}
```

`await using` needs Node 20+, Bun, or Deno.

## Authentication

```typescript theme={"theme":"github-dark"}
new Boxd({ apiKey: "bxd_..." });   // recommended
new Boxd({ token: "..." });        // a token you already hold
new Boxd();                        // BOXD_API_KEY, then BOXD_TOKEN
```

Credentials are resolved in this order, first match wins:

1. an explicit `token`
2. an explicit `apiKey`
3. `BOXD_TOKEN`
4. `BOXD_API_KEY`
5. automatic, inside a boxd machine (below)

If none of those produce a credential, the first request throws `AuthenticationError`.

Mint a key with `boxd auth keys create NAME` (see the [CLI](/reference/external-cli#api-keys)), in the [console](https://boxd.sh/app), or with [`boxd.apiKeys.create()`](#api-keys). An API key is exchanged for a short-lived session 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 machine

Inside a boxd machine, `new Boxd()` authenticates automatically — no API key, no configuration:

```typescript theme={"theme":"github-dark"}
const boxd = new Boxd();
const mine = await boxd.machines.list();
```

It also targets the cluster the surrounding machine belongs to, so the same code runs unchanged wherever it is deployed. An explicit `baseURL` or credential still wins.

<Warning>
  Inside a **shared** machine the SDK can manage the organization's shared machines, but it cannot read env vars or secrets, and cannot reach machines that are private to another member. Pass an API key for those.
</Warning>

## Machines

```typescript theme={"theme":"github-dark"}
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.delete(machine.id);
```

`list()` returns a plain array. Pass `{ org: "acme" }` for one organization's machines, or `{ allContexts: true }` for everything you can reach.

State:

```typescript theme={"theme":"github-dark"}
await boxd.machines.start(id);
await boxd.machines.stop(id);
await boxd.machines.reboot(id);
await boxd.machines.pause(id);        // suspend to RAM — { suspendUs }
await boxd.machines.resume(id);       // { resumeUs }
await boxd.machines.hibernate(id);    // suspend to disk
await boxd.machines.wake(id);
```

See [Suspend & resume](/how-it-works/suspend-resume) for the difference between `stop`/`start` (cold) and `pause`/`resume` (warm).

Everything else:

```typescript theme={"theme":"github-dark"}
const fork = await boxd.machines.fork(id, { name: "fork-1" });   // live clone
await boxd.machines.share(id);        // visible to your whole org
await boxd.machines.unshare(id);
await boxd.machines.rename(id, "new-name");            // returns the new name
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.suggestName();                     // a free, generated name
```

<Warning>
  `rename` reboots the machine. It is its own call rather than part of an `update()` for exactly that reason.
</Warning>

`create` and `fork` return once the machine is scheduled, not once it is usable. Call `waitUntilReady` before doing anything that depends on it running — especially before [forking](/how-it-works/fork) it again. It polls for up to 90 seconds by default (`{ timeout, pollInterval }`, both in milliseconds).

### Creating

```typescript theme={"theme":"github-dark"}
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",
    disk: "100G",
    autoSuspendTimeout: 300,               // seconds; 0 disables
    autoDestroyTimeout: 0,
    ssh: true,
    proxies: [{ name: "api", port: 3000 }],
    volumes: [{ diskId: "d_...", mountPath: "/data" }],
  },
});

// 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" });
```

Every field is optional — `create({})` boots the [default image at the default size](/reference/resources). `fork(id, { name, shared, config })` takes the same `config`; anything you leave out is inherited from the source.

<Note color="#E05A6D">
  A snapshot restores the machine that was captured, so `create({ fromSnapshot })` takes only `name`, `org` and `config`. Passing `image`, `env`, `cmd`, `restartPolicy` or `shared` alongside it is a **type error** — the two ways of creating a machine are separate shapes, and mixing them is rejected before the code runs. Plain JavaScript, which has no compiler to catch it, gets the same refusal at runtime.
</Note>

Renaming reboots the machine, so it is its own call. Everything else is readable straight off `Machine`.

### The `Machine` record

```typescript theme={"theme":"github-dark"}
interface Machine {
  id: string;
  name: string;
  status: MachineStatus;   // "pending" | "starting" | "running" | "suspended" |
                           // "hibernated" | "stopped" | "failed" | "destroyed" | "migrating"
  imageRef: string;        // the image the machine boots
  restartPolicy: string | null;
  createdAt: Date | null;

  resources: {             // the machine's CPU, memory and disk
    vcpu: number;
    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;  // the port to SSH to; null if it has none yet
    domain: string;          // the domain the machine is addressed under
    url: string;             // the machine's public HTTPS address
  };

  idle: {                  // seconds of inactivity before each action; 0 disables
    suspendAfter: number;
    hibernateAfter: number;
    destroyAfter: number;
  };

  source: MachineSource | null;    // null = booted from an image
  hibernatedAt: Date | null;       // when it went to disk; null = not hibernated
  lastConnectedAt: Date | null;    // null = never connected
  bootTimeMs: number | null;       // how long the last boot took
}

interface MachineSource {
  kind: "fork" | "snapshot";
  name: string;                    // the source machine, or the snapshot name
  version: number | null;          // the snapshot version; a fork has none
  id: string | null;               // the machine or snapshot this came from
}
```

`org` is the organization 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 — see [Organizations](/organizations/overview).

`source.id` **may not resolve** if the machine or snapshot it points at was since deleted. A lookup that finds nothing is normal.

Every machine status the SDK knows is exported as `MACHINE_STATUSES`, with `isKnownMachineStatus(s)` to check one. An unrecognised status is passed through rather than thrown on, so a machine in a newly added state still prints.

### Exec

One-shot exec collects the output:

```typescript theme={"theme":"github-dark"}
const r = await boxd.machines.exec(id, { command: ["python", "script.py"] });
r.stdout;    // string
r.stderr;    // string
r.exitCode;  // number
r.success;   // boolean — true when the command exited 0

await boxd.machines.exec(id, {
  command: ["sh", "-c", "echo $FOO"],
  env: { FOO: "bar" },
  timeout: 30_000,          // milliseconds
});
```

An array `command` is shell-quoted for you; a string is passed through as a shell command line. `timeout` is in milliseconds and cancels the call — the remote process may keep running. `exec` also takes `tty`, `cols` and `rows`: under a PTY the terminal layer merges stderr into stdout, so `stderr` comes back empty and everything lands in `stdout`.

Interactive and PTY sessions use a stream handle, the one stateful object besides the client:

```typescript theme={"theme":"github-dark"}
const stream = boxd.machines.streamExec(id, { command: "bash", tty: true });

stream.write("echo hello\n");
for await (const chunk of stream) process.stdout.write(chunk);
const code = await stream.wait();
stream.close();
```

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:

```typescript theme={"theme":"github-dark"}
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),
);
```

Unset `cols`/`rows` fall back to 80×24. `resize()` on a non-PTY exec is a harmless no-op.

Headless one-shots that read stdin (`jq`, `cat`, `claude -p`) hang waiting for input. Pass `closeStdin: true` to send EOF immediately — or call `stream.end()` yourself. It is rejected together with `tty`, where stdin must stay open.

### Logs

```typescript theme={"theme":"github-dark"}
for await (const chunk of boxd.machines.logs(id)) process.stdout.write(chunk);
for await (const chunk of boxd.machines.logs(id, { follow: true })) { /* ... */ }
```

`follow: true` keeps the stream open for new output.

## Files

```typescript theme={"theme":"github-dark"}
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");
```

`upload` returns the number of bytes written; large uploads are chunked for you. `download` returns a `Uint8Array`. Paths inside the machine are absolute, or relative to `/home/boxd`.

## Ports

Raw TCP/UDP forwards on a public address — see [Port forwarding](/how-it-works/port-forwarding). Max 3 per machine.

```typescript theme={"theme":"github-dark"}
const fwd = await boxd.machines.ports.expose(id, 5432, { protocol: "tcp" });
fwd.dns;          // hostname to connect to
fwd.publicPort;   // port to connect on
fwd.machinePort;  // the port inside the machine
fwd.protocol;     // "tcp" | "udp" | "both"

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);
```

Re-exposing the same machine port keeps its public port and just updates the protocol; `"both"` shares one public port across TCP and UDP.

## Proxies

HTTPS routes into a machine — see [Proxies](/how-it-works/proxies). The machine argument takes an id or a name, like everywhere else.

```typescript theme={"theme":"github-dark"}
await boxd.machines.proxies.create(machine.name, "api", 3000);
const routes = await boxd.machines.proxies.list(machine.name);
routes[0].port;        // number — where traffic actually goes
routes[0].portMode;    // "locked" (you pinned it) | "auto" (detected for you)
routes[0].domain;
routes[0].isDefault;

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");
```

`name` and `port` are both required on `create` — a named route is always pinned to a port. `"auto"` is only accepted for a machine's default route, which is what `setPort` addresses when you leave `name` off.

## Checkpoints

Per-machine save points, restored in place — see [Checkpoints](/how-it-works/checkpoints). They are deleted with the machine.

```typescript theme={"theme":"github-dark"}
await boxd.machines.checkpoints.create(id, "before-upgrade");   // machine must be running
const points = await boxd.machines.checkpoints.list(id);
points[0].status;      // "pending" | "ready" | "failed"
points[0].available;   // restorable right now
points[0].sizeBytes;
points[0].createdAt;

await boxd.machines.checkpoints.restore(id, "before-upgrade");
await boxd.machines.checkpoints.delete(id, "before-upgrade");
```

`create` returns while the capture is still `"pending"`.

## Snapshots

Reusable, named captures — see [Snapshots](/how-it-works/snapshots). Boot one with `machines.create({ fromSnapshot })`.

```typescript theme={"theme":"github-dark"}
await boxd.snapshots.create(id, "golden");
const snap = await boxd.snapshots.get("golden");
await boxd.snapshots.list();
await boxd.snapshots.delete("golden");
```

Saving under an existing name adds a version. A `Snapshot` carries `createdAt` (the first capture), `updatedAt` (the most recent one), `version`, `status`, `sizeBytes`, `vcpu`, `memoryBytes` and `useCount`. `get`, `list` and `delete` take an optional `{ org }`.

## Disks

```typescript theme={"theme":"github-dark"}
const disk = await boxd.disks.create("data", "10G");
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);
```

`size` takes a human string (`"10G"`) or a byte count. A disk is always created writable; read-only is chosen per attachment. A disk can be attached to one machine at a time. `status` is `"creating"`, `"ready"` or `"destroyed"` — it can only be attached once it is `"ready"`. `list()` also returns each disk's current `attachments`.

You can mount a disk at create time instead, with `config.volumes`.

## Env vars and secrets

Two namespaces with identical methods — see [Env vars & secrets](/reference/env-secrets). The difference is what comes back: an env var has a readable `value`, a secret does not.

```typescript theme={"theme":"github-dark"}
await boxd.env.set("API_URL", "https://example.com", { scope: "all" });
await boxd.env.list();       // [{ name, scope, value }]
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" });
```

`set`, `delete` and `move` each hand back the server's confirmation message as a string.

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:

```typescript theme={"theme":"github-dark"}
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.

## Organizations

```typescript theme={"theme":"github-dark"}
const orgs = await boxd.orgs.list();
orgs[0].slug;        // the org's unique key — pass this wherever a method takes `org`
orgs[0].name;        // display label
orgs[0].isAdmin;
orgs[0].isDefault;   // your default org
```

## API keys

```typescript theme={"theme":"github-dark"}
const key = await boxd.apiKeys.create({ name: "ci", org: "acme" });
key.apiKey;      // shown once — store it now
key.expiresAt;   // null = no expiry

await boxd.apiKeys.list();     // id, name, keyPrefix, createdAt, lastUsedAt, expiresAt, org, kind
await boxd.apiKeys.delete(key.id);
```

Pass `expiresIn` (seconds) for a key that expires. Every key belongs to exactly one organization: `org` names it, and omitting `org` uses your own. `kind: "member"` (the default) acts as you within that organization; `kind: "org"` is limited to the organization's shared machines and requires an org admin to create.

<Warning>
  `create` is the only time the raw key is returned. `list` shows the prefix only. Deleting a key takes effect immediately.
</Warning>

## Account

```typescript theme={"theme":"github-dark"}
const me = await boxd.account.get();
me.userId;
me.displayName;           // null falls back to userId
me.sshKeyFingerprints;
me.billing.maxVms;              // your effective quota
me.billing.subscriptionStatus;  // null = never subscribed

await boxd.account.linkSshKey({ pubkey: "ssh-ed25519 AAAA..." });

const cfg = await boxd.account.config();
cfg.defaultImage;
cfg.zone;
```

`linkSshKey` takes the verbatim contents of a `.pub` file and lets you [SSH to your machines](/reference/ssh) with it. Pass `deviceId` to keep one key per device — re-linking from the same device replaces that device's key instead of accumulating stale ones.

## Errors

```typescript theme={"theme":"github-dark"}
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](https://grpc.github.io/grpc/core/md_doc_statuscodes.html), for finer-grained handling:

```typescript theme={"theme":"github-dark"}
import { BoxdError } from "@boxd-sh/sdk";

try {
  await boxd.machines.create({ name: "my-machine" });
} catch (e) {
  if (e instanceof BoxdError && e.grpcCode === 8 /* RESOURCE_EXHAUSTED */) {
    // hit your quota — surface a 'wait or upgrade' path
  }
  throw e;
}
```

## Update 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@latest
```

It fires at most once per process and never causes a request to fail. The installed version is also exported:

```typescript theme={"theme":"github-dark"}
import { VERSION } from "@boxd-sh/sdk";
```

## Reference

<Columns cols={2}>
  <Card title="CLI" icon="https://mintcdn.com/azin/Ax1V0serIwQf0x_2/images/icons/command.svg?fit=max&auto=format&n=Ax1V0serIwQf0x_2&q=85&s=6c33d9e29e4e937c0950311233ec5659" href="/reference/external-cli" width="16" height="16" data-path="images/icons/command.svg">
    Same API, accessed from the terminal. Useful for one-offs and shell scripting.
  </Card>

  <Card title="Python SDK" icon="https://mintcdn.com/azin/Ax1V0serIwQf0x_2/images/icons/python.svg?fit=max&auto=format&n=Ax1V0serIwQf0x_2&q=85&s=50aa9d4f66d47baaef6fd6846b681b78" href="/reference/python-sdk" width="16" height="16" data-path="images/icons/python.svg">
    The same surface, sync and async, for Python codebases.
  </Card>
</Columns>
