Bitskiff

@bitskiff/host-node

On this page

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, and the guides walk the whole thing: quickstart · node hosts · channels · lifecycle

Install

Take the tarball from the downloads page: npm install takes a URL as well as a name.

Shell
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

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

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

WhatOn Node
Where state is keptThe 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
PresenceNothing. A headless process is never hidden and has no page to come back from
Randomness and cryptonode:crypto
TransportThe 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.

Added exports

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

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

OptionTypeWhat it replaces
platformPartial<Platform>The runtime pieces above, merged member by member over nodePlatform()
transportEngineTransportFactoryThe real-time transport; the default is createNodeTransport()
apiHostApiThe 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:

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

JavaScript
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:

SubpathWhat it is
@bitskiff/host-node/unattendedThe 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
@bitskiff/host-node/operatorThe 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:

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

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 has the grammar and what a refused one does.

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

The @bitskiff/host page 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:

FieldWhat it is
statusserving, recovering or down
attemptconsecutive failures since the last serving run that held; serving alone does not reset it
nextAtthe monotonic instant of the next open; undefined when none is scheduled
lastRefusalthe last refusal the loop met. Sticky, so read it beside status
lastEndhow the last session ended
servingSincewhen the serving run in hand began; undefined when nothing is being served
lastServingAtwhen 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 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.

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

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.

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

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

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: 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:
Shell
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:
Shell
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:
Shell
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
  • Quickstart: https://docs.dev.bitskiff.com/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:

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