@bitskiff/host

The host SDK for a browser page or an Electron renderer: BitskiffHost opens sessions, hands back a handle, and moves strings and bytes between the host and the phones seated in it. The phone side is @bitskiff/client, a separate install. A headless Node process installs @bitskiff/host-node.

This page is the reference. The guides walk the whole thing: quickstart · channels · lifecycle and errors · node hosts

Install

npm install @bitskiff/host

Hello world

import { BitskiffHost } from '@bitskiff/host';

const session = await new BitskiffHost({ key: 'mrc_pk_...' }).open();
console.log(session.joinCode.code, session.joinCode.url);     // show these, or draw the QR

const press = session.channel('press');                       // a channel is a name you wrote
const readout = session.channel('readout');                   // reliable by default

press.on('message', (m) => {                                  // only `press` reaches this
  readout.send(m.from.id, `pressed ${m.data}`);               // a message is a string or bytes
});

new BitskiffHost(options?)

Synchronous, does no network. Every option is optional, so new BitskiffHost() is a whole call—a host built without a key can resume but not open.

OptionTypeWhat it is
keystringThe one credential slot; the prefix says which credential (table below)
platformPartial<Platform>Merged member by member over the browser default: storage, persistence, presence, crypto, random, timers, logger, now
transportTransportFactoryThe engine adapter; the default is this package's own
reconnectPartial<ReconnectPolicy>baseMs, capMs, minSpacingMs, stableMs, openBudgetMs
apiHostApiThe control-plane client; the default is this package's fetch client
e2ee'required'This host mints its own identity key and encrypts every payload
joinTrustJoinTrustThe public key your backend signs join assertions with

There is no endpoint option: the published package talks to the api it was built for.

The three host credentials

PrefixWhat it isWhere it belongs
mrc_pk_publishable keya page you serve, on an allowed origin
mrc_sk_secret keya server or a device you control; never a browser bundle
mrc_at_tokena page or app behind your login, minted per user by your backend

The SDK never parses the value—expiry, permissions and limits are the service's answers. A page that opened with a token holds an ordinary host token afterwards, and that is what a reload resumes on, so nothing fetches a second token for the reload.

BitskiffHost

MemberTypeWhat it does
open(options?)Promise<Session>Opens a session. Resolves when it is open; rejects with the BitskiffError that refused it
resume(hostToken, options?)Promise<Session>Reattaches to a session this process already holds a host token for. Same rule
sessionsReadonlyMap<string, Session>Live sessions by id
close()Promise<void>Closes every session
dispose()voidLocal teardown of every session; calling it twice is calling it once

open() rejects when the api refuses. A key it will not take, a blocked origin, a spent quota or a retry budget that ran out all come back as a thrown BitskiffError, and a refused session never becomes a handle you hold:

import { BitskiffHost, isBitskiffError } from '@bitskiff/host';

// Your own functions: draw the code, report what refused.
function draw(url: string): void { /* ... */ }
function report(code: string, requestId?: string): void { /* ... */ }

try {
  const session = await new BitskiffHost({ key: 'mrc_pk_...' }).open();
  draw(session.joinCode.url);          // your own function
} catch (error) {
  if (!isBitskiffError(error)) throw error;
  // `error.code` is the registry code -- `invalid_key`, `origin_not_allowed`, `quota_exceeded`,
  // `invalid_token`. `error.resetAtUnixMs` is when a quota frees up, when the refusal named one,
  // and `error.details.reason` says `expired`, `revoked` or `unknown` on an `invalid_token`.
  report(error.code, error.requestId);
}

A link that drops *after* the session opened is a different thing and is not an error: the handle goes reconnecting, then suspended if the budget runs out, and session.retry() is the way back. The lifecycle guide has the block that turns those phases into words for whoever is standing at the host.

SessionConfig—what open takes

Every field is optional and each is a plain session value; channels are not config.

FieldTypeWhat it is
maxControllers, maxViewersnumberSeat ceilings for the two classes
idleTimeoutMsnumberNobody joined for this long ends the session; 0 is unlimited
maxSessionTimeMsnumberThe session's own time limit; 0 is unlimited
hostResumeWindowMsnumberThe host grace: how long the service holds the session open for a host that dropped
reconnectBudgetMs, backgroundBudgetMsnumberWhat a phone gets to reconnect, and to sit in the background
joinCodeRotationSnumberThe join code's rotation step, in seconds
warningLeadMsnumberHow long before a terminal deadline on('warning') fires
inactivityMsnumberPer-seat inactivity window, kept by this SDK and restamped by markActive
persist{ in?: PersistMedium; as?: string }Where the host token is kept and which saved host this is; { in: 'none' } keeps nothing
joinGrantsGrantsWhat a new seat is seated with; must not be empty
label, metadatastring, Record<string, string>Your own labels, echoed back on the session

The service clamps these to the project's own limits and the handle reads back what it clamped.

Session—the handle open hands back

MemberTypeWhat it is
idstringThe session id
stateSessionStateThe snapshot: phase, changed, ended, suspended, grants, config, held
lifecycleSessionLifecycleThe last transition and the reason for it
joinCodeJoinCode{ code, url, expiresAt }—always there on a handle you hold
participantsReadonlyMap<string, Participant>The seats, by participant id
channelsReadonlyMap<string, HostChannel>Every channel this session declared
hostTokenstring | undefinedThe rotating host token a reload resumes on
deadlinesreadonly Deadline[]Every armed deadline, session-scoped and seat-scoped
now()numberThe monotonic clock this SDK stamps receivedAt and sentAt on
apiNowMs()number | undefinedThe service's clock, absolute Unix ms, for counting a deadline down
channel(name, spec?)HostChannelDeclare a channel, or read back the one under that name
markActive(id)voidRestamp a seat's inactivity window
setGrants(id, grants)Promise<void>Change what one seat may do
boot(id, reason?)Promise<void>Remove one seat
retry()Promise<void>Start the attach loop again from suspended
close()Promise<void>End the session and tell the phones
dispose()voidLocal teardown: the session runs out its host grace waiting for a restart

Events, all through session.on(event, fn), each returning an unsubscribe function: state, lifecycle, joinCode, participant, deadline, warning, error, diagnostic. The first join code and the first state are already on the handle—subscribing does not replay them—so read the snapshot, then subscribe.

JoinCode—the string a phone joins on

FieldTypeWhat it is
codestringThe whole string a phone passes to client.start(code). Under e2ee: 'required' it carries the host key fingerprint after a .
urlstringcode in the project's own join URL, ready for a QR
expiresAtnumberAbsolute Unix ms the service stops accepting this code

Building your own link is buildJoinUrl(session.joinCode, { base }), or your own string with code in it; the page it lands on hands that string straight back to start. Never split it—under encryption the half you dropped is what verifies the host.

qrMatrix(text, opts?) encodes to a boolean grid and qrSvg(text | matrix, opts?) to an SVG string. Where the SVG goes is your page's: qr.innerHTML = qrSvg(session.joinCode.url).

Channels

session.channel(name, spec?) hands back the object every message goes through: its own send, its own on('message'), its own counters and pressure. spec is { delivery?: 'reliable' | 'lossy'; maxHz?: number; maxBytes?: number; latestOnly?: boolean }; omitted, a channel is reliable. maxHz and latestOnly are lossy-only.

send(to, data, opts?) takes one participant id, an array of them, or everyone, and data is a string or a Uint8Array. It answers 'sent' | 'queued' | 'dropped' and never throws for anything the network did: a message over the channel's maxBytes is 'dropped' with a tooLarge diagnostic, and so is one the lossy pace held back. There is no ack and no request-reply—a reply is a message on a channel of your own.

The channels guide has the two delivery classes, the limits and what a latestOnly channel is for.

Subpaths

SubpathWhat it is
@bitskiff/host/nodecreateNodeTransport() for an Electron main-process or dual-bundled host that wants the Node transport with the browser platform. Needs @livekit/rtc-node, an optional peer
@bitskiff/host/unattendedopenForever(host, sessionConfig?, policy?): the reopen-on-end loop below

Unattended hosts

A host with nobody standing at it—a signage host, a bot, a dedicated game host—opens another session when one ends. openForever is that loop. A host like that is usually headless, so the block below is the Node package's: same loop, same events, and the secret key comes from the environment, never from a browser bundle and never from source.

import { BitskiffHost, type HostMessage } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';

// Your own functions: draw the code, forward a message, tell an operator, page someone.
function showQr(url: string): void { /* ... */ }
function play(m: HostMessage): void { /* ... */ }
function warn(error: unknown, strikes: number, retryInMs: number): void { /* ... */ }
function page(error: unknown): void { /* ... */ }

const host = openForever(new BitskiffHost());   // the key comes from BITSKIFF_SECRET_KEY

host.on('open', (session) => {              // every session, the first one included
  showQr(session.joinCode.url);
  session.channel('press').on('message', (m) => play(m));
});
host.on('reopen', (_session, { attempt, reason }) =>
  console.log(`reopened after ${reason} (attempt ${attempt})`),
);
host.on('openFailed', (error, { strikes, retryInMs }) => warn(error, strikes, retryInMs));
host.on('gaveUp', ({ error }) => page(error));      // an event, never a throw

openForever(host, sessionConfig?, policy?) takes three arguments and the policy is the third one, not four more fields on the config: sessionConfig is what open takes, and policy is initialBackoffMs (2000), maxBackoffMs (30000), maxStrikes (20) and reopenOn, a predicate over the end reason that defaults to everything but disposed. A { maxStrikes: 5 } passed as the second argument is read as a session config and the loop keeps its own defaults. The handle carries current(), stop() and the four events above. It acts on ends only and never reconnects—a dropped transport is the SDK's own loop, and this one would fight it.