# @bitskiff/host-node

The host SDK for a headless Node 22 process: a server, a bot, a signage box or a dedicated game
host with no browser in it. It is not a second host SDK. It is `@bitskiff/host` running on Node, so
the factory, the session, the events and every host type that package publishes are re-exported
from here by name.

This page covers what is different here. The full host reference is
[@bitskiff/host](/docs/packages/host/), and the guides walk the whole thing:
[quickstart](/docs/quickstart/) ·
[node hosts](/docs/guides/node-hosts/) ·
[channels](/docs/guides/channels/) ·
[lifecycle](/docs/guides/lifecycle/)

## Install

Take the tarball from the [downloads page](/docs/downloads/): `npm install` takes a URL as well as
a name.

```sh
npm install https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-host-node.tgz
npm pkg set type=module
```

The second line is what lets the `import` blocks below run. A package.json `npm init` made says
`"type": "commonjs"`, and Node refuses an `import` statement in a file under that.

To check a download, compare `sha256sum bitskiff-host-node.tgz` with the release's line at `https://api.dev.bitskiff.com/v1/releases/<version>/digests`, which the api serves apart from the downloads.

Every block below is JavaScript. Save one as a file in this project and run it with `node <file>`
on Node 22 or later.

`node-datachannel` comes with it. That is the WebRTC implementation a process with no
`RTCPeerConnection` attaches through.

## Hello world

**JavaScript**

```js
import { BitskiffHost, qrTerminal } from '@bitskiff/host-node';

const host = new BitskiffHost();                              // the key is in the environment
const hello = host.channel('hello');                          // declare before open; reliable by default
hello.on('message', (m) => console.log(m.client.id, m.data)); // only `hello` reaches this

const session = await host.open();                            // open last: it seals the channels
console.log(qrTerminal(session.joinCode.url));                // scannable from the terminal

// A broadcast reaches whoever is listening when it is sent, so it goes from a handler and not
// from the line after `open()`, where nobody has scanned the code yet.
session.on('clientJoined', () => {
  hello.broadcast('hello world');                             // a message is a string or bytes
});
```

**TypeScript**

```ts
import { BitskiffHost, qrTerminal, type Message } from '@bitskiff/host-node';

const host = new BitskiffHost();                              // the key is in the environment
const hello = host.channel('hello');                          // declare before open; reliable by default
hello.on('message', (m: Message) => console.log(m.client.id, m.data));   // only `hello` reaches this

const session = await host.open();                            // open last: it seals the channels
console.log(qrTerminal(session.joinCode.url));                // scannable from the terminal

// A broadcast reaches whoever is listening when it is sent, so it goes from a handler and not
// from the line after `open()`, where nobody has scanned the code yet.
session.on('clientJoined', () => {
  hello.broadcast('hello world');                             // a message is a string or bytes
});
```

`open()` resolves when the session is open and rejects with the `BitskiffError` that refused it, so
a supervisor sees a bad key as a thrown error rather than a process that sits there looking fine.

A refusal waiting can clear is not the rejection: `open()` waits and asks again until
`openBudget` runs out, `2m` by default, and `open({ openBudget: '0s' })` makes one attempt
instead.
[`open()` rejections](/docs/guides/lifecycle/#open-rejections) is
every code one can carry and which ones wait.

`BitskiffError` and its plain-object shape `BitskiffErrorInit` are re-exported here, so a program
raising one of its own does not install `@bitskiff/host` for the type.

## Credentials

Put the key in `BITSKIFF_SECRET_KEY` and pass no `key` option. A Node host's key is a secret and
belongs in the environment, not in source.

An explicit `key` option wins over the variable, and the value is never logged. With neither, the host is built without a key and `open` rejects with a
`validation` `BitskiffError` before anything reaches the network; the message names both places a
key can come from, the `key` option and `BITSKIFF_SECRET_KEY`.

This package takes no endpoint option: the published build talks to the service it was built for.

`key` also takes a `HostKey` function instead of a string, read again on every `open` and every
ticket mint, so a secret rotated by a file watch or a secret-manager read lands on the next attempt
with no restart -- the same type `@bitskiff/host` documents.

Two of the three host credentials work from here:

- **a secret key** (`bsk_sk_...`) for a fleet the operator controls with no backend of its own.
  Rotating the key is how that fleet revokes access.
- **a token** (`bsk_at_...`) for a fleet that has a backend. Fetch a token at boot with the device
  id as its subject. A restart resumes on the host token in the state directory, so you do not have
  to fetch a second token for that.

A publishable key does not work from here. It is bound to a browser-origin allowlist, and a Node
process sends no `Origin`. If your fleet has to survive a backend outage, raise the project's
`tokensMaxTtl` and hold a longer token per device. It is a raisable limit, to `720h` (thirty
days); see
[Raises](/docs/guides/limits/#raises). Tokens are listed by key and
revoked by id. Do not mint a key per device.

## Differences from the browser package

Four things a browser gives the SDK come from the runtime here:

| What | On Node |
|---|---|
| Where state is kept | The state directory under the OS user data directory: `%APPDATA%\\bitskiff\\host` on Windows, `~/Library/Application Support/bitskiff/host` on macOS, `$XDG_DATA_HOME/bitskiff/host` (`~/.local/share/bitskiff/host`) on Linux, with `BITSKIFF_STATE_DIR` overriding it. The host token is kept there by default, so a restart resumes the session the process was running. One owner-only file per slot, every write landing by rename, and a lock beside it so two processes on one slot refuse each other rather than flap. The file is a sealed record holding the host token and the identity key together, sealed under the secret key, or under `BITSKIFF_STATE_SECRET` for a host that holds a token; older secrets go in `BITSKIFF_SECRET_KEY_FALLBACKS`, comma-separated, and a record opened under one is re-sealed under the current secret |
| Presence | Nothing. A headless process is never hidden and has no page to come back from |
| Randomness and crypto | `node:crypto` |
| Transport | The custom engine's, over `node-datachannel`, installed by default. Nothing to pass |

A session the process ends on purpose forgets its stored token. One the process was killed out of
leaves the token behind, and that is what the next start resumes from.

Replacing any of those yourself is under [Advanced](#advanced).

## Added exports

These come from this package and not from `@bitskiff/host`:

| Export | What it is |
|---|---|
| `new BitskiffHost(options?)` | The host, with the four rows above already in place |
| `nodePlatform(overrides?)` | That platform row, for a caller replacing one member |
| `nodeStorage()`, `nodePersistence()` | The file store and the slot store on their own |
| `stateDirPath()` | Where the state directory is, printed |
| `nodeStateStore(backend)` | The state kept in your own store instead of the state directory, under [State store](#state-store) |
| `qrTerminal(text, opts?)` | A QR as terminal text; `QrTerminalOptions` is its second argument |
| `createNodeTransport()` | The Node transport, for a caller building its own transport stack |
| `HOST_NODE_PACKAGE_NAME` | This package's own name, for a log line or a user agent |

Everything else on this root is `@bitskiff/host`'s surface unchanged, `HOST_PACKAGE_NAME`
excepted, and the [@bitskiff/host page](/docs/packages/host/) documents it.

It does **not** re-export `browserPlatform`, `browserPresence`, `browserStorage` or
`browserPersistence`. They cannot work in Node, so they are not exported.

## Advanced

Three options on `new BitskiffHost()` replace pieces of the SDK itself. Leave them unset unless you
are running the host somewhere the defaults do not fit.

| Option | Type | What it replaces |
|---|---|---|
| `platform` | `Partial<Platform>` | The runtime pieces above, merged member by member over `nodePlatform()` |
| `transport` | `EngineTransportFactory` | The real-time transport; the default is `createNodeTransport()` |
| `api` | `HostApi` | The client for the service's HTTP routes; the default is the `fetch` client |

## State store

A host with no volume for the state directory, such as a pod that can be rescheduled, keeps its
state in a store you run instead. `nodeStateStore(backend)` returns a `NodeStateStore`: the
`persistence` and `crypto` members of `Platform`, built over that one backend. Pass it as
`platform`.

The host token and the identity key go into one sealed record, with a lease beside it. The backend
sees only sealed records and lease holder ids, never a private key.

A `NodeStateBackend` is the four operations you implement over your store. Names and values are
text:

| Operation | What it does |
|---|---|
| `read(name)` | The value, or `undefined` when there is none |
| `write(name, value, ttlMs?)` | Replaces the value, expiring `ttlMs` ahead when one is given |
| `createIfAbsent(name, value, ttlMs)` | Creates the value unless a live one exists; `true` when created |
| `deleteIfEqual(name, value)` | Deletes the value only while it still holds `value`; `true` when deleted |

Every expiry is judged on the store's own clock, never the host's. Redis (`SET NX PX`) and a SQL
row compared against the database's `now()` qualify. A store with no server-side expiry, such as
Cloud Storage or Firestore alone, does not.

```js
import { BitskiffHost, nodeStateStore } from '@bitskiff/host-node';
import { Redis } from 'ioredis';

const redis = new Redis();                   // any ioredis client, pointed at your Redis
const DELETE_IF_EQUAL = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

const backend = {
  read: async (name) => (await redis.get(name)) ?? undefined,
  write: async (name, value, ttlMs) => {
    await (ttlMs ? redis.set(name, value, 'PX', ttlMs) : redis.set(name, value));
  },
  createIfAbsent: async (name, value, ttlMs) =>
    (await redis.set(name, value, 'PX', ttlMs, 'NX')) === 'OK',
  deleteIfEqual: async (name, value) => (await redis.eval(DELETE_IF_EQUAL, 1, name, value)) === 1,
};

const session = await new BitskiffHost({ platform: nodeStateStore(backend) }).open();
```

## Subpaths

Two imports sit beside the package root:

| Subpath | What it is |
|---|---|
| `@bitskiff/host-node/unattended` | The same `openForever` reopen-on-end loop and `DEFAULT_UNATTENDED_POLICY` that `@bitskiff/host/unattended` exports, so a headless host reaches them without installing that package too. The section below is the example; its policy and its arguments are on the [@bitskiff/host page](/docs/packages/host/) |
| `@bitskiff/host-node/operator` | The helpers below, for a host that runs as a program |

## Unattended hosts

A host with nobody standing at it, such as a signage host, a bot or a dedicated game host, has to
open another session when one ends. `openForever` is that loop, and the secret key comes from the
environment rather than a bundle or source:

**JavaScript**

```js
import { BitskiffHost } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';

// Your own functions: draw the code, forward a message, tell an operator, update what the host shows.
function showQr(url) { /* ... */ }
function play(m) { /* ... */ }
function warn(error, attempt, nextAt) { /* ... */ }
function paint(health) { /* ... */ }

const bitskiff = new BitskiffHost();        // the key comes from BITSKIFF_SECRET_KEY
bitskiff.channel('press').on('message', (m) => play(m));   // declared once, heard in every session
const loop = openForever(bitskiff, {
  session: { label: 'lobby-north', hostResumeWindow: '5m' },
});

loop.on('open', (session) => {              // every session, the first one included
  showQr(session.joinCode.url);
});
loop.on('reopen', (_session, { attempt, reason }) =>
  console.log(`reopened after ${reason} (attempt ${attempt})`),
);
loop.on('openRefused', (error, { attempt, nextAt }) => warn(error, attempt, nextAt));
loop.on('health', (health) => paint(health));       // the one thing a fallback page binds to
loop.on('stopped', ({ reason }) => console.log(`loop ended: ${reason}`));

// The SDK installs no signal handler, so the process picks the verb.
process.on('SIGTERM', () => loop.detach());     // a rolling deploy: the session rides the restart
process.on('SIGINT', () => void loop.stop());   // a real stop: close the session, free the slot
```

**TypeScript**

```ts
import { BitskiffHost, type Message, type Session } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';
import type { HostHealth, LoopStopped, OpenRefusedInfo, ReopenInfo } from '@bitskiff/host-node/unattended';

// Your own functions: draw the code, forward a message, tell an operator, update what the host shows.
function showQr(url: string): void { /* ... */ }
function play(m: Message): void { /* ... */ }
function warn(error: unknown, attempt: number, nextAt: number | undefined): void { /* ... */ }
function paint(health: HostHealth): void { /* ... */ }

const bitskiff = new BitskiffHost();        // the key comes from BITSKIFF_SECRET_KEY
bitskiff.channel('press').on('message', (m: Message) => play(m));   // declared once, heard in every session
const loop = openForever(bitskiff, {
  session: { label: 'lobby-north', hostResumeWindow: '5m' },
});

loop.on('open', (session: Session) => {     // every session, the first one included
  showQr(session.joinCode.url);
});
loop.on('reopen', (_session: Session, { attempt, reason }: ReopenInfo) =>
  console.log(`reopened after ${reason} (attempt ${attempt})`),
);
loop.on('openRefused', (error: unknown, { attempt, nextAt }: OpenRefusedInfo) =>
  warn(error, attempt, nextAt),
);
loop.on('health', (health: HostHealth) => paint(health));   // the one thing a fallback page binds to
loop.on('stopped', ({ reason }: LoopStopped) => console.log(`loop ended: ${reason}`));

// The SDK installs no signal handler, so the process picks the verb.
process.on('SIGTERM', () => loop.detach());     // a rolling deploy: the session rides the restart
process.on('SIGINT', () => void loop.stop());   // a real stop: close the session, free the slot
```

`openForever(bitskiff, { session, policy })` takes one options object, and the policy defaults come
from the same subpath. Every duration in either -- `hostResumeWindow` above, and the policy's own
waits -- is a string with its unit on it, `5m` or `500ms`:
[Durations](/docs/guides/limits/#durations) has the grammar and what a
refused one does.

**JavaScript**

```js
import { openForever, DEFAULT_UNATTENDED_POLICY } from '@bitskiff/host-node/unattended';
```

**TypeScript**

```ts
import { openForever, DEFAULT_UNATTENDED_POLICY } from '@bitskiff/host-node/unattended';
import type { UnattendedOptions, UnattendedPolicy } from '@bitskiff/host-node/unattended';
```

The [@bitskiff/host page](/docs/packages/host/) has what each default is and the `reopenOn`
predicate.

The loop carries two more things beside the five events. `session` is the session it holds right
now, or `undefined` between two of them. `health` is the snapshot a fallback page paints from:

| Field | What it is |
|---|---|
| `status` | `serving`, `recovering` or `down` |
| `attempt` | consecutive failures since the last serving run that held; serving alone does not reset it |
| `nextAt` | the monotonic instant of the next `open`; `undefined` when none is scheduled |
| `lastRefusal` | the last refusal the loop met. Sticky, so read it beside `status` |
| `lastEnd` | how the last session ended |
| `servingSince` | when the serving run in hand began; `undefined` when nothing is being served |
| `lastServingAt` | when serving last ended; `undefined` before the first one |

The counters sit beside them. `stop()` ends the loop and closes the session in hand, returning a
promise that never rejects. `detach()` ends the loop and leaves the session running out its host
grace, which is what a restart wants. A program that paints a fallback while nothing is open
switches on `health.status === 'down'`.

[Unattended hosts](/docs/guides/unattended-hosts/) is the guide to running one: fallback content,
what says a host needs a person, and the settings to change before a fleet ships.

## `@bitskiff/host-node/operator`

A host that runs as a program has an argv, a stderr and a supervisor that sends it SIGTERM. Four
problems come with that:

- where the key comes from
- what a signal does to the session
- what the program prints about where it is pointed
- how it leaves

This subpath answers all four the same way for every such program, so key handling stays one code
path instead of two that drift.

**Resolve the key from one of three places, in this order**: `--key-file <path>`, then
`--key <literal>`, then `BITSKIFF_SECRET_KEY`. Prefer the file or the variable. A secret key on
argv gets one stderr line and runs anyway, but argv is readable by `ps`, lands in shell history and
is echoed by most CI runners.

**JavaScript**

```js
import { KEY_SOURCES, SECRET_KEY_ENV, resolveSecretKey } from '@bitskiff/host-node/operator';

// Your own argv parsing -- --key-file, --key, and a plain file reader.
let keyFile;                                 // the --key-file path, when one was passed
let key;                                     // the --key literal, when one was passed
function readFile(path) { return ''; }       // yours reads the file at `path`

console.log(KEY_SOURCES);                    // the second usage line: where the key can come from
const secret = resolveSecretKey({ keyFile, key, env: process.env[SECRET_KEY_ENV], readFile });
for (const warning of secret.warnings) process.stderr.write(`${warning}\n`);
```

**TypeScript**

```ts
import { KEY_SOURCES, SECRET_KEY_ENV, resolveSecretKey } from '@bitskiff/host-node/operator';

// Your own argv parsing -- --key-file, --key, and a plain file reader.
let keyFile: string | undefined;             // the --key-file path, when one was passed
let key: string | undefined;                 // the --key literal, when one was passed
function readFile(path: string): string { return ''; }

console.log(KEY_SOURCES);                    // the second usage line: where the key can come from
const secret = resolveSecretKey({ keyFile, key, env: process.env[SECRET_KEY_ENV], readFile });
for (const warning of secret.warnings) process.stderr.write(`${warning}\n`);
```

`resolveSecretKey` throws `SecretKeyError` when there is no key to be had. Its message is written
for stderr. `hasSecretKeySource({ key, keyFile, env })` answers whether any of the three carries
anything, so a program can print its own usage before that throw rather than after it.

**Drain the session on a signal, before the process leaves.** A host holds a session that the
service only reclaims after the host grace, so exiting without closing leaves it standing for that
whole window. The first stop signal closes and waits up to `DRAIN_WINDOW_MS`. A second signal means
an operator is done waiting.

Wire `detach` as well and the drain picks the verb the signal means. `SIGTERM` and `SIGHUP` are
restarts by default: the host's own handle goes, the session runs out its host grace and the
process the supervisor starts next resumes it on the same join code. `SIGINT` is a person at the
terminal, so it closes.

`SIGTERM` is the default a rolling deploy needs, because a closed session cannot be resumed and
every host would show a new join code at every deploy. Pass `restartOn` for a process that wants
otherwise. Without a `detach`, every signal is a stop.

**JavaScript**

```js
import { createDrain, processSignals } from '@bitskiff/host-node/operator';
import { BitskiffHost } from '@bitskiff/host-node';

const session = await new BitskiffHost().open();
// Your own await for whatever else keeps the process alive.
function whateverEndsTheRun() { return new Promise(() => {}); }

const drain = createDrain({
  signals: processSignals(process),
  close: () => session.close(),
  err: (line) => process.stderr.write(`${line}\n`),
  subject: 'the session',        // what the two stderr lines call it; a fleet names its own
});
const ended = await Promise.race([whateverEndsTheRun(), drain.done]);
drain.dispose();
```

**TypeScript**

```ts
import { createDrain, processSignals } from '@bitskiff/host-node/operator';
import { BitskiffHost } from '@bitskiff/host-node';

const session = await new BitskiffHost().open();
// Your own await for whatever else keeps the process alive.
function whateverEndsTheRun(): Promise<never> { return new Promise(() => {}); }

const drain = createDrain({
  signals: processSignals(process),
  close: () => session.close(),
  err: (line: string) => process.stderr.write(`${line}\n`),
  subject: 'the session',        // what the two stderr lines call it; a fleet names its own
});
const ended = await Promise.race([whateverEndsTheRun(), drain.done]);
drain.dispose();
```

`processSignals` is the only piece that touches `process`. Pass your own `DrainSignals` instead to
drive the drain from somewhere else.

**Call `insecureEndpointWarning(endpoint)` in a program that prints where it is pointed.** It
returns one stderr line, or nothing, and never refuses anything. `http:` to loopback or an RFC 1918
IP address is a workshop LAN and passes quietly. `http:` anywhere else warns that the key and every
frame travel in cleartext.

A program built on this published package has no endpoint to check: the endpoint is fixed and there
is no option for it. The one program that does is your own build
against a deployment of your own, and the endpoint it checks is the one it resolved itself, such as
from a `--endpoint` flag of its own.

**Leave with `exitWith(code)`.** It sets the exit code and exits, waiting first for anything still
buffered on stdout, so a program's last line is not cut off on the way out.

## Next steps

- [Node hosts](/docs/guides/node-hosts/)
- [Unattended hosts](/docs/guides/unattended-hosts/)
- [Credentials](/docs/guides/credentials/)
- [The host package](/docs/packages/host/)

## For coding agents

Bitskiff lets a person control a host -- a page, an app, a signage player -- from their own device.

- Setting Bitskiff up for a developer? Read and follow [https://docs.dev.bitskiff.com/setup.md](https://docs.dev.bitskiff.com/setup.md): it asks the person two questions and runs the rest of the setup under the CLI's stored credential.
- First: `npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz whoami` (Node 22 or newer). If its `credential` row names a key, a key is stored, and the next lines need no person.
- A stale copy: npx keeps one per URL and runs it again after a new publish at the same URL. Delete this CLI's copy, and nothing else in npm's cache, with `node -e "const c=child_process.execSync('npm config get cache',{encoding:'utf8'}).trim(),n=path.join(c,'_npx');for(const d of fs.existsSync(n)?fs.readdirSync(n):[]){let u;try{u=JSON.parse(fs.readFileSync(path.join(n,d,'package.json'),'utf8')).dependencies['@bitskiff/cli']}catch{}if(!u)continue;fs.rmSync(path.join(n,d),{recursive:true,force:true});const h=crypto.createHash('sha256').update('make-fetch-happen:request-cache:'+u).digest('hex');fs.rmSync(path.join(c,'_cacache','index-v5',h.slice(0,2),h.slice(2,4),h.slice(4)),{force:true})}"`, then run whoami again.
- Key stored: the whole setup is one command, run in the project directory, because it leaves `bitskiff.json` for your code to read. Pass no `--project`, and do not take one from whoami's `project` row: outside an app directory it can name another app's project. `init` uses the project the directory's `bitskiff.json` names, else makes a new project named for the directory, so a second app gets a second project:

```sh
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz init
```

- Serve your own client page? Add `--join-url <url>` with the page's absolute URL: init sets the project's join URL, mints a client key for that page's origin that serves this host key, and writes it to `bitskiff.json` as `clientKey`. The sample client page needs no key.
- It writes the host key into `bitskiff.json` (`key`, `project`, `endpoint`, and with `--join-url`, `joinUrl` and `clientKey`) and prints no key string. Its `done` step names the file as `keyFile` and carries the host key's id as `hostKeyId`, the `<hostKeyId>` below. It writes a scaffold (`index.html`, `client.html`, `README.md`) only into an empty directory, or with `--scaffold`, so in an existing project `bitskiff.json` is the one file it adds.
- A `bitskiff.json` of the same project is kept, and no key is minted. With no `bitskiff.json`, it takes the project's newest active host key, and mints a host key only when the project has none or you pass `--new-key`. One of another project is refused: pass that project's `--project` to keep it, or add `--force` to replace it, which reuses the new project's host key when it has one.
- A host key `init` mints has no origin set, so it allows `http://localhost` and `http://127.0.0.1` on any port. A key `init` took keeps the origins it already had; check them with `keys` before adding yours. For a deployed page, name its origin and keep localhost, because `--origin` replaces the list:

```sh
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys update <hostKeyId> --origin <your origin> --origin 'http://localhost:*'
```

- Prove the project works, with no host code and no device: the CLI is not a host, so the scaffold carries `bitskiff-prove.mjs`. Run `npm install && node bitskiff-prove.mjs` (the `prove` value of `done`) in the directory `init` wrote the scaffold into. It opens a session on `@bitskiff/host-node`, joins one client, sends a message each way, then prints one JSON object per step on stdout, each transport's record and the session's usage record among them, and closes. A step that fails prints `{"step":"failed","failed":"<step>",...}` and exits 1. It reads two keys from the environment: `BITSKIFF_SECRET_KEY`, a secret key holding `sessions:open` and `sessions:read`, and `BITSKIFF_CLIENT_KEY`, a client key naming no host key (the `clientKey` from `--join-url` serves the page's host key only). `--only secret` prints a new key alone: redirect each into a file outside the project, and export the variable from there:

```sh
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys create --type secret --label prove --permission sessions:open --permission sessions:read --only secret
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys create --type publishable --preset client-page --label prove --only secret
```

- Refused `invalid_key`: the stored key no longer works. Run `npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz logout`, then the steps below.
- Refused `forbidden` with `details.reason` of `missing_permission`: the key lacks the permission `details.permission` names. Run `logout`, then `init` to sign in again.
- Refused `forbidden` without it: the key may not use that project. Pass `--project` of a project the key may use.
- If the key comes from `BITSKIFF_ORG_KEY`, `logout` does not remove it: unset the variable instead. If whoami's `org` row is then `(not set)`, add `--org <orgId>` to `init`, with the id of the organization the key belongs to.
- No key stored (`credential` is `(not set)`), and only then: the steps below, which wait on a person once.
- Run: `npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz init --format=json` (Node 22 or newer; the flag is what puts one JSON object per step on stdout). The CLI already knows the api it was published beside. It leaves the same `bitskiff.json`, and takes the same `--project` and `--join-url`.
- It blocks until a person approves it in the console, for up to ten minutes: run it in the background, or with a timeout at least that long.
- Or add `--no-wait`, and it returns at once: it prints one JSON object, `{ verificationUri, userCode, expiresAt, next }`, and exits 0. Show your developer the URL and the code, then run `next` through the same `npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz` in place of `bitskiff`: it waits for the approval and finishes the setup.
- **[HUMAN]** tell your developer to open the URL it prints and press Approve. Do not open it yourself: only a signed-in person can approve.
- Working through an MCP client instead of a shell? Add the CLI as a stdio MCP server, `{ "mcpServers": { "bitskiff": { "command": "npx", "args": ["--yes", "https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz", "mcp"] } } }`: every verb is a tool under the same stored key, `init` runs as `--no-wait`, and a refusal is an error result carrying the same `code` and `details.reason`.
- Sign in: [https://console.dev.bitskiff.com](https://console.dev.bitskiff.com)
- Quickstart: [https://docs.dev.bitskiff.com/docs/quickstart/](/docs/quickstart/)

This is the same host for Node: the sample above is the browser host's; a process with no page
opens the session and prints the code to the terminal:
```js
import { BitskiffHost, qrTerminal } from '@bitskiff/host-node';

// BITSKIFF_SECRET_KEY holds a secret key, never a publishable key; mint it with
// npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys create --type secret --preset host-only
const host = new BitskiffHost();                              // reads BITSKIFF_SECRET_KEY
const hello = host.channel('hello');                          // declare before open; reliable by default
hello.on('message', (m) => console.log(m.client.id, m.data)); // only `hello` reaches this
const session = await host.open();                            // open last: it seals the channels
console.log(qrTerminal(session.joinCode.url));                // scannable from the terminal
```
Run it with `node <file>`; no import map and no build step. The `.d.ts` files in this package and in `@bitskiff/host` are the contract: every type, option,
event and return is declared there, and where a declaration and this page disagree the declaration
is right. Every refusal is a `BitskiffError` carrying `code`, `retryable`, `retryAfterMs` and
`details`, so branch on `code` and `retryable` rather than on a message.
