# Console (/docs/guides/clients/console)

> The Next.js console runs Better IAM and Better Compute on one origin, built on console-kit, framework-free stores with live updates and permission-aware actions.



The console is a web UI for people: tenants reserve GPUs, create instances and open terminals; platform operators
watch nodes, rollouts and fleet drift. It comes in two layers you can use separately:

* **`apps/console`**, a Next.js 15 app that runs Better IAM and Better Compute in one process, on one origin. It is
  private to the repository (not published) and serves as the reference deployment.
* **`@better-compute/console-kit`**, the framework-free view models the console's pages are built on: resource
  stores, the live event stream, permission-aware actions, forms, charts and a terminal. Use it to build your own
  UI in React, Solid, Vue or anything else.

> **Ahead of its phase.** 
  PLAN.md schedules the console for v0.5. Both layers are written and tested today (`tests/console-kit*.test.ts`),
  but the console's configuration is a development-first reference, not a hardened product.

## One origin, one sign-in [#one-origin-one-sign-in]

<Mermaid
  chart="flowchart LR
  B[&#x22;Browser&#x22;]
  subgraph P[&#x22;node server.mjs (one process)&#x22;]
    N[&#x22;Next.js pages&#x22;]
    IA[&#x22;/api/iam: Better IAM&#x22;]
    CA[&#x22;/api/compute: Better Compute&#x22;]
    W[&#x22;/api/compute/watch: SSE&#x22;]
    T[&#x22;/api/compute/tunnels/{ticket}: WebSocket&#x22;]
  end
  G[&#x22;gateway&#x22;]
  B -->|&#x22;IAM session cookie&#x22;| N
  B --> IA
  B --> CA
  B --> W
  B --> T
  T -->|&#x22;mTLS relay&#x22;| G"
/>

Better IAM is mounted at `/api/iam` and Better Compute at `/api/compute` on the same origin, so the host-only IAM
session cookie authenticates both. Compute is built with `acceptIamCookie: true`: it accepts the cookie only on
POSTs that carry `X-Better-Compute: 1` and an `Origin` equal to the console's own. The compute IAM bridge installs
compute's action catalog in Better IAM and serves compute's decisions from its projection, as in any
[embedded](/docs/guides/iam#wire-embedded-mode) deployment.

`server.mjs` runs Next.js plus the one thing a route handler cannot do: the WebSocket upgrade of
`/api/compute/tunnels/{ticket}` for browser terminals and node consoles, served by `handleTunnel` and relayed over
mTLS to the gateway that holds the node's session. Tunnels open only from the console's own origin, and a signed-in
browser can only use its own person's tickets.

## Run it [#run-it]

```bash
cd apps/console
node server.mjs --dev      # or: pnpm dev
```

`--dev` sets `CONSOLE_DEV=1`, the only mode in which the console generates secrets for itself: the session secret,
the exec ticket key, the break-glass and sandbox SSH CA keys (under `.console-data/`) and, on the first start, the root administrator's
password, printed once. It also allows gateways without TLS. Open `http://localhost:3100/login` and sign in as
`admin@console.local`. Members of an organization sign in at `/login?org=<slug>`.

For production, build and start without `--dev`; every secret then comes from the environment:

```bash
cd apps/console
pnpm build                   # next build
CONSOLE_ORIGIN=https://console.example.com CONSOLE_SECRET=… CONSOLE_ADMIN_PASSWORD=… \
BETTER_COMPUTE_DATABASE_URL=postgres://… CONSOLE_IAM_DATABASE_URL=postgres://… \
node server.mjs --prod       # or: pnpm start
```

Run it from `apps/console`: the stack file and the data directory are resolved against the working directory.

| Variable                                                                     | With `--dev`                                      | Otherwise                                                                                                                                         |
| ---------------------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `CONSOLE_ORIGIN`                                                             | `http://localhost:3100`                           | set it to the public origin: cookie, origin and tunnel checks compare against it                                                                  |
| `CONSOLE_SECRET`                                                             | generated into `.console-data/secret`             | required, at least 32 random characters                                                                                                           |
| `CONSOLE_DATA_DIR`                                                           | `.console-data`                                   | the same                                                                                                                                          |
| `CONSOLE_IAM_DATABASE_URL`                                                   | a libSQL file, `.console-data/iam.db`             | PostgreSQL for Better IAM when set                                                                                                                |
| `BETTER_COMPUTE_DATABASE_URL`                                                | PGlite in `.console-data/compute`                 | `postgres://…` for compute                                                                                                                        |
| `CONSOLE_ADMIN_EMAIL`, `CONSOLE_ADMIN_PASSWORD`                              | `admin@console.local`, a generated password       | the password is required on the first start                                                                                                       |
| `CONSOLE_EXEC_TICKET_KEY_FILE`                                               | a generated `exec-ticket.pem`                     | unset: tunnels are off                                                                                                                            |
| `CONSOLE_SSH_CA_KEY_FILE`                                                    | a generated `ssh-ca.pem`                          | unset: no break-glass SSH                                                                                                                         |
| `CONSOLE_SANDBOX_SSH_CA_KEY_FILE`                                            | a generated `sandbox-ssh-ca.pem` (Ed25519)        | unset: no `sandboxes.exec` (`UNSUPPORTED`), and sandbox guests trust no CA                                                                        |
| `CONSOLE_GATEWAY_TLS_CERT`, `_KEY`, `_CA`                                    | none needed (h2c gateways)                        | all three or none; tunnels need them                                                                                                              |
| `CONSOLE_AUDIT_KEY_FILE`                                                     | a generated `audit-checkpoint.pem` (P-256 PKCS#8) | unset: no audit checkpoints; every replica shares the key                                                                                         |
| `CONSOLE_RELEASE_PINS`, `CONSOLE_DOWNLOADS_URL`, `CONSOLE_RELEASE_THRESHOLD` | unset: no rollout module                          | set the first two to mount rollouts                                                                                                               |
| `CONSOLE_GATEWAY_URL`, `CONSOLE_GATEWAY_SERVER_NAME`                         | unset: no `gateway` section                       | published in `/.well-known/better-compute` as `gateway` for `bcd join --server`; the console has no PKI bundle, so no serving-CA pin is published |
| `CONSOLE_CONFIG`                                                             | `console.config.mjs`                              | another stack file                                                                                                                                |
| `PORT`, `HOSTNAME`                                                           | `3100`, `localhost`                               | the same                                                                                                                                          |

Every replica and gateway must share the exec ticket key, and nodes pin its public key and the SSH CA's, so
production supplies the key files rather than generating them. The sandbox SSH CA is a separate key, so a sandbox
certificate never opens a node and a break-glass one never opens a sandbox; the console passes its public key to
`vps({ sandboxExec })` so every sandbox guest trusts it, and certifies each guest's host key with it
(`certifyHostKey`), so `sandbox exec` against the console pins guests instead of trusting them on first use. Every
replica must share it too. The stack is built once per
process from `console.config.mjs` (loaded at run time, never bundled): Better IAM, then compute with the `vps`, `fleet` and
`events` (webhooks) modules and `retentionModule()` (the console builds `betterCompute()` itself, so the CLI's
automatic retention does not apply), `audit` when an audit key is available, and `rollout` when release pins are set.

Before the workers start, the stack calls `installIamHandlers(compute, { bridge, streams: [watches] })`, as `serve`
does for `host.iam`: a revoked delegation or suspended agent closes its tunnels on every gateway (over the console's
gateway mTLS) and its `/api/compute/watch` streams in this process, and revocations drop the IAM caches. See
[tunnels](/docs/guides/access/tunnels#closing-on-iam-revocation).

## Screens [#screens]

Every page needs a session; without one the layout redirects to `/login`. Pages render in the browser over
console-kit, for the tenant the session acts in. Platform pages appear only for the root tenant.

| Page               | Path                                                                                   | Shows and does                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| ------------------ | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Overview           | `/`                                                                                    | capacity per pool, quota alerts                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| Reservations       | `/reservations`, `/reservations/[id]`                                                  | the form with a capacity preview, the earliest start and approvals, optional host CPUs and memory (GiB), and a Kubernetes checkbox only for a pool the tenant owns with `policy.kubernetes` (4533265); the list and each reservation's actions                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| Instances          | `/instances`, `/instances/[id]`, `/instances/[id]/terminal`, `/instances/[id]/console` | the form with sizes per pool; list, detail, a browser terminal (op `exec`) and the instance's serial console (op `console`, 191898b), linked as **Open a terminal** and **Serial console**, both decided as `compute:instances:exec`. bcd refuses instance `exec` until guests have an agent, so the serial console is the terminal that works on real hosts                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| Sandboxes          | `/sandboxes`                                                                           | a create form over the tenant's templates (each with its flavor and warm pool size) and a lifetime in minutes, capped at the template's maximum; the person's own sandboxes, or everyone's, with state, a countdown to expiry (a warning in the last 5 minutes), **+30 min**, **Snapshot** and **Delete**, and the `better-compute sandbox exec` line for a shell; templates, with their volumes (attach and detach, dd5555b)                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| Volumes            | `/volumes`                                                                             | [sandbox volumes](/docs/guides/vps/sandboxes#volumes) (50add82): a create form (pool, name, size in GiB); the person's own volumes, or everyone's (e68aba7), with id, device path, pool, host, size and state; **Grow** and **Delete** (below)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| Nodes              | `/nodes`, `/nodes/[id]`, `/nodes/[id]/console`                                         | a filterable table; conditions, cordon, uncordon and drain, the node's labels (key, value, source) and taints with forms to set or remove an operator label and add or remove a taint (`nodes.label`, `nodes.taint`, gated like the other node actions on `compute:nodes:update`; keys, values and taints are checked against the API's patterns before the button enables; 3ff9fb3), recent commands, the node console, and the break-glass SSH panel (below). A node in a bare-metal provision state (not `adopting` or `adopted`) also gets a **Lifecycle** panel (0cd1d79): `manage`, `inspect`, `provide`, `rescue`, `unrescue`, `abort`, `recover`, `undeploy` and `retire`, each offered as the provision state machine and IAM (`compute:nodes:provision`) allow; deploy and power stay in the CLI (`metal deploy`, `metal power`), and PXE MAC confirmation has its own page (below) |
| PXE discovery      | `/discovery`                                                                           | a site picker and what the site's agents saw PXE-booting from MACs with no boot entry (`metal.discovered`): MAC and request count, product UUID, arch and relay, last seen, and what each row is (a candidate for a machine, not enrolled, or an enrolled node); **Confirm** on a candidate and a form to bind a MAC by hand (`metal.confirmMac`), each behind a browser confirmation (15c11ee; below)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| Batches            | `/batches`                                                                             | destructive batches held for the approver quorum: pending by default (or approved, expired, cancelled, all), each with its reason, command count, approvals of required, state and the window left; a batch's scope by kind, who approved, the `better-compute batch approve` line, and **Cancel** (below)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| Pools & capacity   | `/pools`, `/pools/[id]`                                                                | pools owned or offered, a 7-day booking calendar, join tokens and join requests                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| Quotas             | `/quotas`                                                                              | used of max per pool and resource                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| Usage & spend      | `/usage`                                                                               | usage per meter, day, project and person, with a CSV export                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| Activity           | `/activity`                                                                            | the live event stream: the last 300 events this session received, filterable by type                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| Webhooks           | `/webhooks`                                                                            | the tenant's endpoints: create one (URL, event patterns, description) with its signing secret shown once to copy, pause and resume, ping, rotate the secret with a 1 h overlap, recent deliveries with redelivery of dead ones, delete                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| Rollouts (root)    | `/admin/rollouts`                                                                      | daemon rollouts wave by wave; pause, resume and abort                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| Fleet drift (root) | `/admin/fleet`                                                                         | drift of the fleet from its applied FleetConfig                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |

A "live" badge in the navigation shows the event stream's state. While the stream is down, the stores a page has
loaded refresh every 20 seconds instead, and only while the tab is visible.

Buttons follow two checks. A button is enabled only when the item's state allows the action (a released
reservation offers nothing) and Better IAM would allow it for the signed-in person. The second check is advisory: a
server action batches up to 50 checks through Better IAM's `can`, and the compute API still decides the real call.
A delegated call held back for approval shows "awaiting approval" with the confirmation id.

Actions that destroy or release something ask first, naming what happens: draining a node, stopping or deleting an
instance, releasing or cancelling a reservation (d40af29, 0919f86), a bare-metal node's `undeploy`, `retire`,
`abort` and `provide` (0cd1d79), and binding a machine's PXE MAC, after which it boots from that MAC (15c11ee).

### Sandboxes [#sandboxes]

The **Sandboxes** page (`createSandboxesView` in console-kit) works on [sandboxes](/docs/guides/vps/sandboxes) of the
session's tenant. With no template, the create form says an administrator creates them. Create sends `sandboxes.create` with a fresh `console-…` idempotency key and waits up to 20 seconds
for the sandbox to leave `starting`. The list shows the person's own sandboxes, or, with **Everyone's sandboxes**,
the tenant's (`scope: 'all'`), each with its template name, `warm` and the snapshot it was restored from. A memory
restore whose `identityRebound` is `false` (console-kit's `SandboxRow` carries it) shows a warning that it still
answers to its source's SSH identity, so exec waits until the source and sibling restores are gone or the host gives
it its own (9958865). The countdown ticks every second from `expiresAt` and reads `expired` at zero.

Its buttons are not gated by IAM checks in advance: the call is decided and an error is shown in place. **+30 min**
is `sandboxes.extend` with `by` of 30 minutes, **Snapshot** takes a memory snapshot that goes with the sandbox, and
**Delete** asks for confirmation first. A shell is not opened in the browser: the page shows the
`better-compute sandbox exec sbx_…` command, which runs OpenSSH through a tunnel. The console's own stack configures
the sandbox SSH CA but no snapshot store, so **Snapshot** answers `INVALID_CONFIG` there until you give
`vps({ snapshots })` a store in `console.config.mjs`. On that answer the page says snapshots are not configured on
this deployment and hides the button for the rest of the session.

A **Templates** section serves tenant administrators (`compute:sandboxes:manage`; the calls are decided, not the
section hidden). It lists the tenant's templates with pool, flavor, image, lifetime and warm pool, with **Warm +1**,
**Warm −1** (`sandboxTemplates.update` of `warm`) and delete, and a form that creates one from a name, pool, flavor,
image, warm minimum and longest lifetime in hours. console-kit's `SandboxesView` exposes the same as
`createTemplate`, `updateTemplate` and `deleteTemplate`. Templates can also come from
`better-compute sandbox-template create` or a FleetConfig document.

Since dd5555b the templates table also shows each template's [volumes](/docs/guides/vps/sandboxes#volumes) (volume
name, mount path, `(read-only)`), each with **Detach**, and an **Attach a volume** form picks a template, a `ready`
volume of that template's pool, a mount path (default `/data`; it must start with `/`) and **Read-only**. Every
sandbox from the template then mounts the volume and runs on the volume's host. Both send the template's whole volume
list through `sandboxTemplates.update`. **Warm +1** is disabled for a template with volumes, since the server keeps
no warm pool beside volumes. The volume picker lists everyone's volumes (`sandboxVolumes.list` with `scope: 'all'`);
without the right to list them it is empty.

### Volumes [#volumes]

The **Volumes** page (50add82) works on the session tenant's [sandbox volumes](/docs/guides/vps/sandboxes#volumes),
an agent's persistent memory, through the API directly (`sandboxVolumes.*`; there is no console-kit view for it).
The create form takes a pool from the tenant's pools, a name (lowercase letters, digits, `.`, `_` and `-`, at most 63,
checked before the button enables) and a size from 1 to 16,384 GiB, and sends `sandboxVolumes.create` with a fresh
`console-…` idempotency key. The list shows the person's own volumes (`scope: 'mine'`), or, with
**Everyone's volumes**, the tenant's (`scope: 'all'`, e68aba7), each with its id and
`/dev/disk/by-id/virtio-<serial>` path, pool and host ("not placed yet" without one), size and state. For a `ready`
volume, **Grow** asks for the new size and calls `sandboxVolumes.resize` only with a larger one, and **Delete** asks
for confirmation, warning that the data is wiped, before `sandboxVolumes.delete`. As on the Sandboxes page, the
buttons are not gated by IAM checks in advance; the call is decided and an error is shown in place. A volume is
attached to a template on the Sandboxes page's **Templates** section (dd5555b, above), or with
`better-compute sandbox-template update --volumes`, the API or FleetConfig.

### PXE discovery [#pxe-discovery]

The **PXE discovery** page (`/discovery`, 15c11ee) is the console's side of
[discovering new machines](/docs/guides/bare-metal/sites#discovering-new-machines): a machine whose BMC cannot name
its NICs (IPMI), enrolled with only its product UUID, waits in `metal.ensureRamdisk` until an operator confirms its
PXE MAC. The page is built on console-kit's `createPxeDiscoveryView`.

A site picker lists the session tenant's sites (`sites/list`, the first one selected), and the table shows what that
site's agents saw PXE-booting from MACs with no boot entry in the last 24 hours ([`metal.discovered`](/docs/reference/api/metal#discovered);
only the tenant that owns the site sees it). **Refresh** reloads it. `pxeCandidates` orders the rows by what needs a
decision first: candidates, then unknown machines, then enrolled ones, newest first within each.

| Column       | Shows                                                                                                                   |
| ------------ | ----------------------------------------------------------------------------------------------------------------------- |
| MAC          | the MAC, and how many requests it sent                                                                                  |
| Product UUID | the SMBIOS UUID from DHCP, or a dash                                                                                    |
| Arch / relay | the client architecture, and `via <relays>` for a relayed request or `agent segment` for one on the agent's own segment |
| Last seen    | `just now`, `N min ago` or `N h ago` (`seenAgo`)                                                                        |
| Machine      | what the row is to the operator: `candidate for <node>`, `not enrolled`, or the enrolled node's id                      |

Nothing on the page binds a MAC by itself, since discovery data is unauthenticated DHCP. A candidate row (the MAC
metal saw under the machine's UUID right after power-cycling it) has a **Confirm** button. It first asks, in a browser
confirmation, "Bind `<mac>` as the PXE MAC of `<node>`? The machine will then boot from it.", and only then calls
[`metal.confirmMac`](/docs/reference/api/metal#confirmmac) and reloads the list. Unknown and enrolled rows have no
button: an unknown machine is never answered or enrolled from here.

**Bind a MAC by hand** serves a machine enrolled without MACs whose PXE MAC you know from its label or its BMC. It
takes a node id (it must start with `node_`) and a MAC; `normalizeMac` accepts upper or lower case and `-` or `:` as
the separator, and the button stays disabled until both are well formed. It goes through the same browser
confirmation and `metal.confirmMac` call. As with the CLI's `metal confirm-mac`, the call is decided as
`compute:nodes:provision` on the node and audited as `node.pxe-mac.confirmed`; the buttons are not gated by IAM
checks in advance, so a refusal (for example `CONFLICT` for a MAC another machine of the site has, or
`INVALID_STATE` for a node that already has MACs) is shown in place.

### Destructive batches [#destructive-batches]

The **Batches** page (`createBatchesView` in console-kit, b24d043) shows the tenant's
[destructive batches](/docs/operations/pki#the-destructive-batch-quorum-v10): fleet-wide reimages, wipes and power
actions held until enough approvers sign their scope. It lists pending batches by default, with a filter for
`approved`, `expired`, `cancelled` or all, and counts down each pending batch's window from `notAfter`. Each row shows
its reason and who created it; a batch an agent proposed shows a warning line instead, "Proposed by agent X for Y
(delegation D): the reason is the agent's own text" (bd80209, from `createdByAgent` and `delegationId`).

**Details** loads the batch (`commandBatches.get`) and its whole scope (`commandBatches.scope`, paged 5,000 at a time
and capped at 20,000 entries, with a warning when cut short), grouped by command kind. Subjects read as a person
would: a node id, `BMC 10.0.0.7 (via node_site)` for a BMC reached through a site agent, or `HTTP to node_…`. The
panel also shows the scope root, the approvals so far (key id, approver and time) and the member commands by state.
**Cancel** asks for confirmation and calls `commandBatches.cancel`, so none of the batch's commands run.

The page cannot approve. A browser never holds an approver key, so for a pending batch it shows the command for the
approver's terminal, `better-compute batch approve --batch <id> --key <approver key>`, where the CLI lists the scope,
recomputes the root from it and signs. Reading needs `compute:batches:read` and cancelling `compute:batches:approve`,
both decided when called.

### Break-glass SSH [#break-glass-ssh]

A node's page carries the **Break-glass SSH** panel for [SSH certificates](/docs/guides/access/ssh), for platform
administrators. It takes your public key (a pasted private key, several lines or an unsupported key type is refused
before anything is sent), the account (default `root`) and a lifetime of 5, 15, 30 or 60 minutes, and issues a
certificate with `nodes.issueSshCert`; the certificate is shown once, with a copy button and the commands to save it
as `<key>-cert.pub` and connect. A session without MFA is told to sign in again with a second factor.

Below it, **Who holds access** lists the node's certificates from `nodes.listSshCerts`: holder, principal, issue
and expiry times, and state (`active`, `expired` or `revoked`, with the revocation reason), with **Include expired**
to show those too. Each active certificate has a **Revoke** button, which asks for confirmation and calls
`nodes.revokeSshCert` with the reason `revoked from the console`.

## console-kit [#console-kit]

`@better-compute/console-kit` depends only on the typed client and core. Every store has `subscribe(listener)` and
`get()`, the contract of React's `useSyncExternalStore`, so a framework adapter is a few lines.

```ts
import { createComputeClient } from '@better-compute/client';
import { createPermissions, describeAction, startLiveConsole } from '@better-compute/console-kit';

const client = createComputeClient({ baseURL, token: () => session.token });

const live = await startLiveConsole(client, {
  tenantId,
  watchUrl: `${baseURL}/watch`, // wherever your host mounts handleWatch
  headers: () => ({ authorization: `Bearer ${session.token}` }),
  permissions: createPermissions({ authorize: (checks) => iamAuthorizeMany(checks) }),
});

live.reservations.subscribe(() => render(live.reservations.get()));
live.connection.subscribe(() => showBadge(live.connection.get().status));
```

| Area            | Exports                                                                                                 | Does                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| --------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Stores          | `createConsoleResources`, `createListResource`, `createValueResource`, `createStore`                    | `nodes`, `pools`, `reservations`, `instances` and `capacity` for one tenant; lists page with `loadMore()`, a stale response never overwrites a newer one, and an error keeps the last good items                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| Live            | `watchEvents`, `startLiveConsole`, `matchesType`, `parseSseBlock`                                       | Server-Sent Events over `fetch`, resumed with `Last-Event-ID`; each event re-reads the item it names                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| Actions         | `describeAction`, `actionsFor`, `createPermissions`                                                     | map a button to its IAM action and resource from core's API surface; batch checks (50 per call) in one tick and cache answers for 10 s                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| Detail pages    | `createDetail`, `applicable`                                                                            | one reservation, node, instance or pool with each action gated by state and by IAM; `run(path)` answers `done` or `confirmation-required`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| Forms           | `createReservationForm`, `validateReservation`, `createInstanceForm`, `validateInstance`                | validation that mirrors the server, a debounced `capacity.preview`, one idempotency key per attempt kept across retries and confirmations. Since e833433 the reservation form also takes `vcpu` (0 to 4096) and `memoryGiB` (0 to 65,536), sent as `resources` (left out: the pool's per-GPU share), and `kubernetes` for `binding: 'kubernetes'`, and the preview books them too. The console app's reservation page shows them since 4533265, with the Kubernetes checkbox only for a pool the tenant owns that has `policy.kubernetes`                                                                                                                                          |
| Views           | `capacityChart`, `quotaView`, `createQuotaView`, `timelineView`, `earliestFit`, `usageView`, `usageCsv` | stacked capacity bars, quota meters (warning from 80 %), the booking calendar and earliest free window, usage and estimated spend                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| Admin           | `createRolloutView`, `rolloutProgress`, `createFleetDriftView`, `nodeTable`                             | rollout waves, fleet drift by kind, a filterable, stably sorted node table                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| Sandboxes       | `createSandboxesView`, `timeLeft`, `sandboxExecCommand`, `withTemplateVolume`, `maxTemplateVolumes`     | a tenant's sandboxes (`scope` mine or all) and templates, `detail`, `create`, `extend`, `snapshot` and `remove`, each refreshing the list; the remaining lifetime as `12m 03s` or `1h 05m`, urgent in the last 5 minutes; the CLI line that opens a shell. Since dd5555b a `SandboxTemplateRow` carries its `volumes` (`volumeId`, `mountPath`, `readOnly`), `TemplateFields.volumes` replaces a template's whole list, `volumes()` lists the tenant's volumes a template can attach, and `withTemplateVolume(current, { add } or { remove })` returns the new list, refusing a second volume at the same mount path or more than `maxTemplateVolumes` (8) before anything is sent |
| Batches         | `createBatchesView`, `scopeByKind`, `describeSubject`, `batchApproveCommand`                            | a tenant's destructive batches over `commandBatches` (`filter(state or 'all')`, default `pending`), `detail`, `scope(batchId, max?)` (every leaf, paged, at most 20,000 by default, stopping on a page that does not advance; `truncated` when capped) and `cancel`; the scope grouped by kind with sorted subjects; a subject as text; the approver's CLI line                                                                                                                                                                                                                                                                                                                    |
| PXE discovery   | `createPxeDiscoveryView`, `pxeCandidates`, `seenAgo`, `normalizeMac`                                    | one site's `metal.discovered` list (`{ siteId, machines }`, each a `DiscoveredMachineRow`), `site(siteId)` to switch sites, and `confirmMac(nodeId, mac)`, which normalizes the MAC (throwing before any call for one that is not a MAC), calls `metal.confirmMac` and reloads the list; the rows as `{ kind, row }` with `kind` `candidate`, `unknown` or `enrolled`, in that order, newest first; a last-seen time as text; a MAC as the API takes it (lower case, colons) or `undefined` (15c11ee)                                                                                                                                                                              |
| SSH access      | `createSshAccessView`, `checkSshPublicKey`, `sshUsage`                                                  | one node's break-glass certificates: `issue` (held in `view.issued` until dismissed), `revoke`, `showExpired`; a public key check that refuses private keys; the commands to use a certificate                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| Webhooks        | `createWebhooksView`, `deliverySummary`                                                                 | a tenant's webhooks over the `webhooks` group (list, create, pause, rotate, delete, ping, deliveries, redeliver, event types); the secret from create or rotate sits in `view.secret` until `dismissSecret()` and is never stored or re-read; delivery counts by state and the latest failure                                                                                                                                                                                                                                                                                                                                                                                      |
| Break-glass SSH | `createSshAccessView`, `checkSshPublicKey`, `sshUsage`                                                  | one node's certificates over `nodes.listSshCerts` (expired ones with `showExpired(true)`), `issue({ publicKey, account?, minutes? })` (1 to 60 minutes, 15 by default) and `revoke(serial, reason?)`; a pasted private key, a multi-line paste or a key type other than Ed25519 or ECDSA P-256 is refused before anything is sent; the issued certificate sits in `issued` until `dismissIssued()`, and `sshUsage` gives the commands to use it. The console's node page uses it                                                                                                                                                                                                   |
| Terminal        | `createTerminalSession`, `createScreen`, `keyInput`                                                     | `tunnels.open`, then the WebSocket (an instance with op `exec`, `ssh` or `console` for its serial console, or a node's console); a VT100/xterm screen model with scrollback, no DOM                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| React           | `bindReact`, and `@better-compute/console-kit/react`                                                    | `useStore`, `useList`, `usePermission`, `useDetail`, `useAction`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |

### Live updates [#live-updates]

`watchEvents({ url, types?, lastEventId?, headers?, onEvent, onStatus? })` reads the stream with `fetch` rather
than `EventSource`, so it can send an `Authorization` header (or, in the console, `X-Better-Compute: 1`). It
remembers the `id:` of the last event and sends it as `Last-Event-ID` when it reconnects, so it resumes exactly
after that event. It reconnects after the server's `retry:` delay (2 s by default) when the server closes the
stream, and with doubling backoff, capped at `maxBackoffMs` (60 s by default), after a failure. `onStatus` reports
`connecting`, `open`, `retrying` and `stopped`.

`applyEvent` routes each event by its subject: `reservation/…`, `node/…` and `instance/…` re-read that one item
(and drop it on `NOT_FOUND` or `ACCESS_DENIED`), and `pool/…` refreshes the pools. Reservation, pool and node events
(except `node.status`) also refresh the capacity summary, debounced by 250 ms. `startLiveConsole` does all of this
in one call and clears cached permission answers after events whose type starts with `iam.`, `binding.`,
`delegation.`, `agent.` or `project.members`. See [watch](/docs/guides/events/watch) for the server side.

### Permission-aware actions [#permission-aware-actions]

```ts
const release = describeAction('reservations', 'release', reservation.id);
// { path: 'reservations/release', action: 'compute:reservations:release',
//   resource: { type: 'compute-reservation', id: 'rsv_…' }, kind: 'mutation' }
const allowed = await permissions.can(release);
```

`describeAction` throws for a method outside the API table, and `actionsFor(kind, id)` lists every mutation on a
resource kind, optionally limited to the phases and modules your deployment runs. `createPermissions` takes your
`authorize(checks)`, usually Better IAM's `authorizeMany` for the caller's tenant behind a server endpoint. A failed
batch rejects its checks and leaves them unanswered; it never answers "denied", so a button stays disabled rather
than lying. The server still decides every call, so a stale "allowed" only ever ends in a clean `ACCESS_DENIED`.

### React [#react]

```tsx
import type { LiveConsole } from '@better-compute/console-kit';
import { useList } from '@better-compute/console-kit/react';

function Reservations({ live }: { live: LiveConsole }) {
  const { items, status } = useList(live.reservations);
  if (status === 'loading') return <p>Loading</p>;
  return <ul>{items.map((item) => <li key={item.id}>{item.id}: {String(item.state)}</li>)}</ul>;
}
```

The `/react` entry binds the hooks to the app's `react`, an optional peer (React 19). Apps that bring their own
React build, or `preact/compat`, call `bindReact(React)` from the main entry instead. `useList` and `useDetail` load
on first use; `usePermission` returns `undefined` while IAM answers (render the button disabled), then `true` or
`false`; `useAction(detail, path)` returns `run`, `pending`, and the `outcome` or `error`. The hooks use
`useSyncExternalStore` with the same snapshot on the server, so they render during SSR.

Related: [TypeScript client](/docs/guides/clients/typescript-client), [tunnels](/docs/guides/access/tunnels),
[delegated confirmation](/docs/guides/iam/delegated-confirmation).
