Bitskiff

@bitskiff/host

On this page

The host SDK for a browser page or an Electron renderer. BitskiffHost opens sessions and moves strings and bytes between the host and the clients in them. The client side is @bitskiff/client, a separate install. A headless Node process installs @bitskiff/host-node.

This page is the reference. The guides cover the rest: quickstart · channels · lifecycle · node hosts

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

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

Hello world

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

const host = new BitskiffHost({ key: 'bsk_pk_...' });
const press = host.channel('press');                          // declare every channel before open
const readout = host.channel('readout');                      // reliable by default

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

const session = await host.open();                            // open last: it seals the channels
console.log(session.joinCode.code, session.joinCode.url);     // show these, or draw the QR

session.on('clientJoined', () => {                            // a broadcast reaches whoever is
  readout.broadcast('ready');                                 // listening when it is sent
});
import { BitskiffHost } from '@bitskiff/host';

const host = new BitskiffHost({ key: 'bsk_pk_...' });
const press = host.channel('press');                          // declare every channel before open
const readout = host.channel('readout');                      // reliable by default

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

const session = await host.open();                            // open last: it seals the channels
console.log(session.joinCode.code, session.joinCode.url);     // show these, or draw the QR

session.on('clientJoined', () => {                            // a broadcast reaches whoever is
  readout.broadcast('ready');                                 // listening when it is sent
});

new BitskiffHost(options?)

Synchronous, and it touches no network. Every option is optional, so new BitskiffHost() is a complete call. A host built without a key can resume but not open.

OptionTypeWhat it is
keyHostKeyThe one credential slot; the prefix says which credential (table below). A string, or a function returning one, which is how an unattended host reads a fresh key on every attempt
reconnectPartial<ReconnectPolicy>base, cap, jitter, minSpacing, stable, joiningBudget, openBudget
e2ee'required'This host mints its own identity key and encrypts every payload

e2ee is this host's own requirement. The project has one of its own, in the console's Settings panel, and that is the switch the service holds every session to.

The two must agree. A required host against a project that is off, or a plain host against a project that is required, is refused: open() rejects with validation and details.option of e2ee. The console guide is where the project's switch lives.

There is no endpoint option: the published package talks to the service it was built for. Three more options—platform, transport and api—reach under the SDK itself and are under Advanced. Those three and the three above are the whole type.

Host credentials

The prefix on key says which of the three you passed:

PrefixWhat it isWhere it belongs
bsk_pk_publishable keya page you serve, on an allowed origin
bsk_sk_secret keya server or a device you control, never a browser bundle
bsk_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 a reload resumes on that, so you do not have to fetch a second token for the reload.

BitskiffHost

The host's members:

MemberTypeWhat it does
channel(name, spec?)HostChannelDeclare a channel before open()
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
platformPlatformThis host's platform, shared by every session it opens
reconnectResolvedReconnectPolicyThis host's resolved reconnect policy, shared by every session. Its members are the milliseconds the loops compute with, so they carry Ms: baseMs, capMs

open() rejects when the service refuses. A key it will not take, a blocked origin, a spent quota and an exhausted retry budget all arrive as a thrown BitskiffError. A refused open never becomes a session, so catch the rejection.

A refusal waiting can clear—concurrency_limit, rate_limited, unavailable, a network that never reached the service—is not the rejection: open() waits and asks again until openBudget runs out, 2m by default, and open({ openBudget: '0s' }) makes one attempt instead. Every code one can carry, which ones wait, and what to do about each, is in open() rejections:

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

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

try {
  const session = await new BitskiffHost({ key: 'bsk_pk_...' }).open();
  draw(session.joinCode.url);          // your own function
} catch (error) {
  if (!isBitskiffError(error)) throw error;
  // The lifecycle guide's "open() rejections" table is every `error.code` this catch can see,
  // with what to do about each. `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);
}
import { BitskiffHost, isBitskiffError } from '@bitskiff/host';

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

try {
  const session = await new BitskiffHost({ key: 'bsk_pk_...' }).open();
  draw(session.joinCode.url);          // your own function
} catch (error) {
  if (!isBitskiffError(error)) throw error;
  // The lifecycle guide's "open() rejections" table is every `error.code` this catch can see,
  // with what to do about each. `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 BitskiffError carries code, message, retryable, clears, source, retryAfterMs, resetAtUnixMs, status, limit, details, requestId, closeCode and cause.

clears is waiting when waiting alone can make the same call succeed, and never when something else has to change first. source is api, sdk or edge. edge is a 429 or 403 the edge in front of the service answered with no body at all. BitskiffErrorInit is what the constructor takes: the same shape minus clears and resetAtUnixMs, which the constructor derives from code.

A link that drops after the session opened is not an error. The session goes reconnecting, then suspended if the budget runs out, and session.retry() starts it again. The lifecycle guide 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.

A Duration is a string with its unit on it: 500ms, 90s, 5m, 1h30m. The units are ms, s, m and h, largest first, with no spaces, no sign and no bare number: Durations has the grammar.

FieldTypeWhat it is
maxClientsnumberHow many clients the session holds at once. There are no per-role ceilings
idleTimeoutDurationNobody joined for this long ends the session; 0s is unlimited
maxSessionTimeDurationThe session's own time limit; 0s is unlimited
hostResumeWindowDurationThe host grace: how long the service holds the session open for a host that dropped. A host that never arrives is a different clock: the session waits 60 s, the ticket's life, and then ends host_resume_expired
reconnectBudget, backgroundBudgetDurationWhat a client gets to reconnect, and to sit in the background
joinCodeRotationDurationThe join code's rotation step. The floor is 15s, and it rotates on a whole number of seconds, so a step that is not one is refused
warningLeadDurationHow long before a terminal deadline on('warning') fires
inactivityDurationPer-client inactivity window, kept by this SDK and restamped by client.markActive()
terminationRulesTerminationRule[]When the service ends a client, beyond the two budgets. Omit it and the session takes the project's default list; [] is no rules. At most 16. A rule outside its bounds is a validation error, never clamped: import @bitskiff/host/expressions to get it in the call rather than from the service
persist{ in?: PersistMedium; as?: string }Where the host token is kept and which saved host this is. { in: 'none' } keeps nothing
defaultGrantsGrantsWhat a client joins with. Omit it and a client joins as CONTROLLER: it sends input and receives broadcasts
label, metadatastring, Record<string, string>Your own labels, echoed back on the session
openBudgetDurationWhat bounds the retry loop inside this one open() call and nothing else; 0s is one attempt
idempotencyKeystringThe Idempotency-Key this one open() call sends, so a retried call can attach to the response of one that already went through

A string the duration grammar refuses is not clamped: it is a validation error whose details.reason is duration. An in-grammar value above the project's own limit is clamped to it. Read session.state.config to see what you got.

Session

What open hands back.

MemberTypeWhat it is
idstringThe session id
stateSessionStateThe snapshot: phase, changed, sessionId, config, limits, resumed, grants, epoch, transport, suspended, ended
lifecycleSessionLifecycleThe last transition and the reason for it
joinCodeJoinCode{ code, url, expiresAt }. Always there on a session you hold
capacityCapacityNoticeYour project's standing against its usage limits: { status, limits, at }, ok over an empty list until a limit is close or spent
clientsClientRosterEvery client in the session, in join order: get(id), has(id), size, keys(), values(), entries(), iterable
channelsReadonlyMap<string, HostChannel>The host's channels, by name
hostTokenstring | undefinedThe rotating host token a reload resumes on
deadlinesreadonly Deadline[]Every armed deadline, session-scoped and client-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)HostChannelLook up a channel the host declared
retry()Promise<Session>Start the attach loop again from suspended; resolves to this session, as the client's retry() resolves to its client
close()Promise<void>End the session and tell every client
dispose()voidLocal teardown: the session runs out its host grace waiting for a restart

The per-client verbs are on the Client, not here: client.setGrants(grants), client.boot(reason?) and client.markActive().

Events all go through session.on(event, handler, options?), which returns a () => void that removes that one listener: state, lifecycle, joinCode, capacity, clientJoined, clientChanged, clientLeft, deadline, warning, error, diagnostic.

Pass { signal } to close a group of listeners with one AbortController. session.off(event, handler) stands beside it for a handler you already hold; the stop on handed back is what the samples use. There is no session.on('message'): every message is heard on a channel.

Client

What session.clients holds and what every handler is handed. One object per client, the same instance from clientJoined to clientLeft, across a reconnect.

MemberTypeWhat it is
idstringStable for the life of this client; never reused
stateClientStateA snapshot replaced whole: attested, grants, number, connection, presence, presenceSince and connectionSince (monotonic), deadlines, transport, joinedAt (service clock), deviceClass, networkClass, channels, subject, e2ee, left
state.subjectstring | undefinedThe subject of the join token this client presented: your own user id, as your backend minted it. Absent for a client that joined with no token
on(event, handler, options?)() => voidchange and left, each handed the same event object the session-wide handler gets (change is { client, change, previous, at, stopPropagation() } and left is { client, reason, at, stopPropagation() }); they run first and can stop it
off(event, handler)voidDrops a handler on added
setGrants(grants)Promise<void>Change what this client may do
boot(reason?)Promise<void>Remove this client. reason is a BootReason—'booted' (what an omitted reason reads as), 'idle' or 'policy'—and the client reads it on state.ended.bootReason. The REST boot route takes a fourth value, BOOT_REASON_KEY_UNVERIFIED, which reaches the client as state.ended.reason === 'keyUnverified' and not as a bootReason
markActive()voidRestamp this client's inactivity window

Read the snapshot first, then subscribe. Subscribing does not replay, and the first join code and the first state are already on the session when open resolves.

Grants

What a client may do. Five booleans, all of them required.

FieldTypeWhat it allows
sendInputbooleanSend on an input channel
receiveInputbooleanReceive every input message in the session
broadcastbooleanBroadcast on a channel
receiveBroadcastbooleanReceive broadcasts
managebooleanCall setGrants, boot and close

An input channel is a lossy one. sendInput gates lossy sends and nothing else, so a client without it still sends on its reliable channels and its lossy sends come back { dropped: 'noGrant' }.

The sets below are exported, so a call reads as a name and not as five flags:

ExportTypeWhat it is
CONTROLLERGrantsSends input, receives broadcasts. What a client joins with
VIEWERGrantsReceives broadcasts and nothing else
HOSTGrantsWhat the host itself holds: receives input, broadcasts, manages. Not a set to hand a client
NO_GRANTSGrantsEvery field false
BUNDLE_GRANTS{ [name]: Grants }The sets by name: controller, viewer, host
import { BitskiffHost, VIEWER, type Session } from '@bitskiff/host';

const session: Session = await new BitskiffHost({ key: 'bsk_pk_...' }).open();

for (const client of session.clients) {
  await client.setGrants(VIEWER);            // this one can watch and nothing else
}
import { BitskiffHost, VIEWER } from '@bitskiff/host';

const session = await new BitskiffHost({ key: 'bsk_pk_...' }).open();

for (const client of session.clients) {
  await client.setGrants(VIEWER);            // this one can watch and nothing else
}

Pass an object of your own for anything else: { ...CONTROLLER, manage: true }.

JoinCode

The string a client joins on.

FieldTypeWhat it is
codestringThe whole string a client passes to join(code). Under e2ee: 'required' it carries the host key fingerprint after a .
urlstringThe project's join URL with code in the fragment, <joinUrl>#<code>, ready for a QR
expiresAtnumberAbsolute Unix ms the service stops accepting this code

A new code is rendered at each rotation step, and the steps are on the service's clock rather than on your session's: a session opened part-way through a step rotates at the end of that step, so the first code can be shorter-lived than joinCodeRotation.

The service accepts one step either side of the one that rendered a code, so a code a client sees stays accepted for at least one full step and at most two—30 to 60 s at the 30s default.

expiresAt is the end of the step after the code's own, which is the instant that window closes, so a client that scanned just before a rotation still joins. Read the code off the joinCode event rather than holding the first one.

The code rides in the fragment so it never reaches a server, a proxy or an access log: a joinUrl of https://play.example.com/join gives https://play.example.com/join#1CAbCdEfG1HPWtg.

Build your own link with buildJoinUrl(session.joinCode, { base }), which builds the same shape over a base of your own, or put code in a string of your own. The page it lands on hands that string straight back to join. Never split the code: under encryption, the half you drop is what verifies the host.

qrMatrix(text, opts?) encodes to a boolean grid and qrSvg(text | matrix, opts?) to an SVG string. Put the SVG on the page yourself: qr.innerHTML = qrSvg(session.joinCode.url).

Channels

host.channel(name, spec?), before open(), hands back the object every message goes through, with its own send, its own broadcast, its own on('message'), its own counters on channel.counters and its own congested reading. spec is { delivery?: 'reliable' | 'lossy'; ordered?: boolean; lifetime?: Duration; maxHz?: number; maxBytes?: number; latestOnly?: boolean }. Omit it and the channel is reliable and ordered.

lifetime and latestOnly require delivery: 'lossy'. On a channel declared reliable—the default—either is a validation throw at channel(). ordered says whether a receiver is handed this channel's messages in the order they were sent; omitted, it is true on a reliable channel and false on a lossy one.

Every client is handed the row. A lossy channel with no maxHz is paced at 30 a second. On a direct link a lossy channel may run at channel.linkMaxHz, never below maxHz. On a reliable channel maxHz defaults to 20 and paces nothing: the service only reserves it. open() seals the table, and a later host.channel() throws validation. After the open, session.channel(name) looks a channel up.

broadcast(data, opts?) reaches every client that named the channel. send(client, data, opts?) reaches the client you name, and takes an array of them; there is no target-less send. data is a string, an ArrayBuffer, or any view over one (Sendable, exported from this root). Both verbs return 'sent' or { dropped } with one of the reasons below, and never throw for anything the network did.

ResultWhat happened
{ dropped: 'noClient' }every client you named has left
{ dropped: 'notListening' }they are here but have not named this channel. This is why a client page names its channels before it joins
{ dropped: 'size' }the message is over the channel's maxBytes
{ dropped: 'rate' }past the channel's maxHz, or past the project's bytes a second
{ dropped: 'pressure' }the send buffer is behind, or a reliable queue is full
{ dropped: 'noGrant' }this host's grants do not carry what the topic needs. HOST grants never carry sendInput, so a send the wire routes as input is this, in every phase
{ dropped: 'notOpen' }the session is not open

A lossy channel has no unicast at all: send(client, ...) on one throws validation at the call site, because the downlink is one packet to every client on the channel. There is no ack and no request-reply, so send a reply on a channel of your own.

on('message', handler, options?) returns a () => void that removes that one listener. Pass { from: client } to hear one client alone: those handlers run before the unfiltered ones, and m.stopPropagation() in one keeps the unfiltered ones from running. In a handler, m.client is the client that sent the message.

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

Peer and relay

Every client starts on server, the Bitskiff connection. Two more transports carry a client's data over a WebRTC link to the host. Declare each one you want before open:

  • peer(): direct. The bytes go from the client to the host and never through Bitskiff, so they are logged, never metered.
  • relay(): through Bitskiff's relay. Neither end sees the other's address. Relayed bytes are metered to your project.
JavaScript
import { BitskiffHost, peer, relay } from '@bitskiff/host';

const host = new BitskiffHost({ key: 'bsk_pk_...' });
host.transport(peer());
host.transport(relay());
const session = await host.open();
  • A client moves onto a transport only when both ends declared it. A host that declares neither serves every client on server alone.
  • Leave relay() out and no relayed bytes are ever billed to your project.
  • peer() shows each client's network address to the host and the host's to each client. An IP address is personal data in most privacy regimes, so name it in your privacy notice.
  • A client on peer or relay sends straight into the host's WebRTC stack. For a public venue with open joins, pass allowed: false and call client.allow(settings) for each client you trust.
  • Both take the same options: allowed, maxConnections, silenceMs, idleCloseMs, allocate, release, and peerConnection for a Node host.

Release and rejoin

By default every client and the host keep their Bitskiff connection for the whole session. Release is an option you turn on per transport. Once a client's data rides that transport, the client can let its Bitskiff connection go, and the host serves it over the transport until it rejoins. Nothing releases on its own.

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

const host = new BitskiffHost({ key: 'bsk_pk_...' });
const bt = host.transport(bluetooth({ release: true }));
const session = await host.open();
// Start Bluetooth from a tap: c.connect(bt) on a client the session holds.

session.on('clientChanged', (e) => {
  if (e.change === 'released') console.log(`client ${e.client.state.number} released`);
  if (e.change === 'rejoined') console.log(`client ${e.client.state.number} is back`);
});
  • release: true is taken by peer(), relay() and bluetooth(). A transport that cannot carry control throws validation.
  • A released client reads connection: 'released', and its server record reads unavailable with reason released. The host runs its background budget and reports its end to Bitskiff.
  • session.release() lets the host's own connection go once every client the session holds is released. It resolves released or refused. session.rejoin() takes it back, and the host rejoins on its own when a client does.
  • session.on('release', fn) hears each step of the host's own release.
  • While the host is released, a new join is refused host_released. A host that expects late joiners does not release.

Released time is logged, never metered.

Advanced

Three more options on new BitskiffHost() reach under the SDK and replace a piece of it. Leave them unset unless you are running the host somewhere the defaults do not fit.

OptionTypeWhat it is
platformPartial<Platform>The browser pieces the SDK stands on, merged member by member: storage, persistence, presence, crypto, random, timers, logger, now, wallNow
transportEngineTransportFactoryThe real-time transport; the default is this package's own
apiHostApiThe client for the service's HTTP routes; the default is this package's fetch client

An EngineTransportFactory hands back an EngineTransport: the connection object it drives, with its own state, path, limits and the events it raises.

The default platform is built from four exports on this root, so an app that replaces one member can keep the rest:

ExportWhat it is
browserPlatform(overrides?)The whole default: sessionStorage, the browser's presence signals, crypto.subtle, the page's timers and clock. Anything you pass is merged over it member by member
browserStorage()The sessionStorage store on its own, for the storage member
browserPresence()document visibility and window online: six of the eight PresenceEvent members. freeze and resume come from a runtime that reports them
browserPersistence(logger?)Where the host token is kept: sessionStorage, so this tab's session survives this tab's reload

Subpaths

Three imports sit beside the package root:

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 node-datachannel, an optional peer
@bitskiff/host/unattendedopenForever(bitskiff, { session, policy }), the reopen-on-end loop below, and DEFAULT_UNATTENDED_POLICY, its defaults
@bitskiff/host/expressionsThe local checker for terminationRules. Import it for its effect and open() refuses a bad rule in the call, with the checker's message; without it the service sends the same refusal. It also exports checkExpression and checkTerminationRules

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.

openForever(bitskiff, { session, policy }) takes one options object: session is what open takes, and policy is the loop's own. Both are optional; an unknown key in that object, or a third argument, is a synchronous throw.

FieldDefaultWhat it is
initialBackoff'2s'The first doubling step. With full jitter the first wait is 1 to 3 s
maxBackoff'5m'The ceiling that doubling stops at
maxRetryAfter'5m'The ceiling on a wait the server named. An edge ban is the one wait honoured in full
attentionBackoff'5m'The floor under a wait whose last refusal was one that waiting does not clear
stableAfter'10m'How long one serving run must last before the attempt count goes back to zero
recoveringWithin'30s'How long a host may go without serving before health.status reads down
reopenAfterClose'1m'The wait after a session a person or this application closed
jitter'full'Drawn over the step and added above the floor
minSpacing'1s'The floor between two opens
reachabilityTimeout'5s'How long one reachability probe may wait for an answer
reopenOn() => trueA predicate over the end reason. Every reason it refuses ends the loop

The defaults in that table are exported together as DEFAULT_UNATTENDED_POLICY, so a policy of your own can start from them and change one field. It comes from the same subpath as the loop:

import { BitskiffHost } from '@bitskiff/host';
import { openForever, DEFAULT_UNATTENDED_POLICY } from '@bitskiff/host/unattended';
import type { UnattendedLoop } from '@bitskiff/host/unattended';

const bitskiff = new BitskiffHost({ key: 'bsk_pk_...' });
const loop: UnattendedLoop = openForever(bitskiff, {
  policy: { ...DEFAULT_UNATTENDED_POLICY, reopenAfterClose: '30s' },
});
import { BitskiffHost } from '@bitskiff/host';
import { openForever, DEFAULT_UNATTENDED_POLICY } from '@bitskiff/host/unattended';

const bitskiff = new BitskiffHost({ key: 'bsk_pk_...' });
const loop = openForever(bitskiff, {
  policy: { ...DEFAULT_UNATTENDED_POLICY, reopenAfterClose: '30s' },
});

The loop carries session, health, stop(), detach() and five events: open, reopen, openRefused, health and stopped. It never gives up: a refusal that waiting cannot clear waits longer before the next attempt, then is tried again, because the loop is not the judge of what a person has fixed.

It has three ends and three only—stop() or detach(), a reopenOn that returned false, and a disposed host. It acts on ends only and never reconnects, because the SDK already has its own loop for a dropped transport.

stop() is the shutdown: it closes the session in hand, so the slot frees now. detach() is the restart: it disposes the handle, so the session runs out its host grace and a restarted process resumes it on the same join code.

A host like that is usually headless, so the worked example is on the @bitskiff/host-node page: the same loop and the same events, with the secret key coming from the environment rather than a bundle or source.

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.

Reference

The rest of the package root. Nothing else is exported, so what autocomplete offers inside import { } is this page.

Constants

ExportValueWhat it is
MAX_CTL_PAYLOAD_BYTES14080The largest payload one reliable send carries
MAX_CTL_ENVELOPE_BYTES14336That payload plus its framing, which is what the transport sees
MAX_INPUT_PAYLOAD_BYTES1152The largest payload one lossy send carries
MAX_INPUT_ENVELOPE_BYTES1200That payload plus its framing
MAX_CHANNEL_NAME_BYTES64The longest a channel name may be, in UTF-8 bytes
DEFAULT_RECONNECT_POLICY{ base: '500ms', cap: '10s', jitter: 'full', minSpacing: '250ms', stable: '5s', joiningBudget: '2m', openBudget: '2m' }The curve reconnect starts from
INFRA_JITTER_MS250The spread on the one fast attempt after an infrastructure close
JOINING_BUDGET_CEILING_MS600000The ceiling a reconnect budget is clamped to
BUNDLE_NAMES['controller', 'viewer', 'host']The three bundle labels
JOIN_BUNDLE_NAMES['controller', 'viewer']The two a client may join with
HOST_RECORD_KEY'bitskiff:host'The prefix the host token's storage key is built from, below
HOST_PACKAGE_NAME'@bitskiff/host'This package's own name, for a log line or a user agent

The key itself is one per slot, spelled bitskiff:host:<endpoint host>:<key fingerprint>:<as>—HOST_RECORD_KEY and three segments. The <as> part is the persist.as name you passed, and default when you passed none. What sits under it is a small JSON record holding the endpoint, the key fingerprint and the token.

Functions

ExportWhat it does
msLeft(deadline, apiNowMs)The milliseconds left on a deadline, floored at 0. Pass session.apiNowMs(); the answer is undefined before the service clock has landed
asBitskiffError(value, fallback?)Whatever a call threw, as a BitskiffError
resolveReconnectPolicy(partial?)A ResolvedReconnectPolicy from the fields you set, defaulting the rest
parseDuration(written)A duration string as milliseconds, or null when the grammar refuses it
formatDuration(ms)Milliseconds as a duration string, largest unit down
bundleNameOf(grants)The bundle label a grant set reads as
isBundleName(value)Whether an untrusted string is one of the three labels
isJoinBundle(value)Whether an untrusted string is a label a client may join with
grantsEqual(a, b)Whether two grant sets allow the same things
grantsAreEmpty(grants)Whether a set allows nothing at all

Session types

TypeWhat it is
BitskiffOptionsThe argument to new BitskiffHost()
HostKeyThe key option's type: a string, or a function returning one (string | Promise<string>). The function form is read on every open and every ticket mint, so a replaced key lands on the next attempt with no restart
DurationA duration a developer writes, as a string: 500ms, 90s, 5m, 1h30m
SessionConfigReadWhat session.state.config holds: maxClients and the seven durations the service resolved, each duration spelled the way it came back
ResolvedReconnectPolicyA ReconnectPolicy with every field filled in and every duration parsed into a reading, so the members are baseMs, capMs, minSpacingMs, stableMs, joiningBudgetMs and openBudgetMs beside jitter. That is what the schedule runs on
SessionPhase'opening', 'open', 'reconnecting', 'suspended', 'ended'
SessionChangeWhy state was replaced: opening, open, reconnecting, resumed, config, transport, budget, suspended, ended
SessionEventThe event names session.on() takes
SessionListenerOptionsWhat a session-level on takes beside its handler: an AbortSignal
SessionLimitsThe caps the service hands this session when it opens or re-attaches
CapacityNoticeWhat session.capacity holds and on('capacity') hands over: status, one CapacityLimit per limit that is not ok, and the service instant it was read at
CapacityLimitOne limit that is close or spent: limit, scope, scopeId, status, value, ceiling, resetAt
CapacityStatus'ok', 'warning', 'exceeded'
CapacityScopeWhich scope a limit bound at: project, key, subject, ip, session, client, org, address
SessionEndReasonWhy a host session ended; the lifecycle guide tabulates them
EndedInfoThe end itself: the reason, and the error fields when one refused it
EndedErrorFieldsThose error fields on their own: errorCode, limit, requestId, retryAfterMs, resetAtUnixMs and tokenReason. The registry code of the refusal is errorCode; EndedInfo.code is the numeric close code, and stays on EndedInfo with reason and message
HostResumeOptionsThe one SessionConfig field resume() can still set: inactivity
HostDirectPeerConfig, HostDirectPeerConnection, HostDirectDataChannel, HostDirectCandidateInitWhat peerConnection takes and returns: (config) => new RTCPeerConnection(config), typed by the part of RTCPeerConnection the host uses, so the browser's own and werift's both fit. A browser host needs none. A Node host bundles no WebRTC and offers only with it set
HostPersistenceWhere this host's token is kept, and which saved host it is
Persistence, PersistSlot, PersistSlotIdThe persistence seam behind it: the media this runtime has, the slot one session holds, and which saved host that slot is
E2eeInfoEncryption on one client: bound, generation and the six confirmation digits
ClientTransport, ClientTransportsOne transport's record for a client, and the set keyed by name (server, peer, relay, a declared transport's name), on client.state.transports: status, reason, nextAt
TransportReason, TransportStatusWordWhy a record reads unavailable (notAttached, notAllowed, limit, failed), and the four statuses
TransportSettingsA transport's settings handle, what host.transport(peer()) returns, the argument of client.allow and client.deny
PeerOptionsWhat peer(options) takes: allowed, peerConnection, maxConnections, silenceMs, idleCloseMs, allocate, release. peer is direct only and never relayed
RelayOptionsWhat relay(options) takes: the same as PeerOptions. The relay is Bitskiff's, and relayed bytes count against the project's turnBytesPerMonth
TransportFactoryWhat peer(), relay() and bluetooth() return, passed to host.transport() before open
TransportInfoDeprecated for one release: use ClientTransports. The leg a snapshot is carried on: the path, and the round trip when the engine reports one
LifecycleBudgetWhich budget bounds the loop now, and when it runs out
LifecycleDetailThe close or error code behind a lifecycle reason
LifecycleListenerThe handler on('lifecycle') takes
DiagnosticWhat on('diagnostic') hands over: the reason, the wire topic, the bytes and the cap in force
DiagnosticReasonThe ten reasons a diagnostic names
DeadlineKindWhich deadline a row in deadlines is
DeadlineInstantA deadline reduced to its instant, which is all msLeft needs
DeadlineWarningWhat on('warning') hands over: the deadline that is close, and the lead
TerminationRuleOne rule of a SessionConfig.terminationRules list: name, when, a CEL condition over that client with no clock in it, and for, how long when must hold continuously before the rule fires; omitted, for is 1s
TokenReasonWhy a token cannot be presented: expired, revoked, unknown
UnsubscribeThe () => void every on returns

Client types

TypeWhat it is
ClientRostersession.clients: iterable in join order, with get(id), has(id), size, keys(), values() and entries()
ClientChangeWhy a client's state was replaced: disconnected, reconnected, away and the rest
ClientChangedThe event object a change handler is handed
ClientLeftThe event object a left handler is handed, and the last word about that client
ClientListenerOptionsWhat a client-level on takes beside its handler
ClientPresence'present' or 'away'
OriginSourceHow a client's origin arrived: 'header', which the browser enforced, or 'declared', which the client named
ClientConnectionWhat the engine says about this client's link
ClientTargetWho a send is for: a Client, or the id you kept
LeftReasonWhy a client's life ended
GoneReasonThe same vocabulary under the name the wire gives it
BundleName'controller', 'viewer' or 'host'
JoinBundleThe two of those a client may join with
HolderKindWhat keeps a client's place, on state.holder: 'page' or 'app'
ClientHandoffThe place's last handoff, on state.handoff: phase, since, deadline, stage and hint
HandoffHintThe transport class the page was on when it handed off, and its readings

Channel types

TypeWhat it is
ChannelSpecThe second argument to host.channel(name, spec?)
ChannelWhat client.channel() hands back on the client side; HostChannel is written as the difference from it
ChannelCountersOne channel's monotonic counters, read off channel.counters when you paint
ChannelListenerOptionsWhat a message listener takes beside its handler
HostChannelListenerOptionsThe same, plus the { from: client } filter a host can pass
MessageThe one object a message handler is handed
SendOptionsThe third argument to send and broadcast
SendResult'sent', or the one reason it did not go
DropReasonThose reasons on their own
Delivery'reliable' or 'lossy'

QR types

TypeWhat it is
QrMatrixOptionsThe second argument to qrMatrix
QrSvgOptionsThe second argument to qrSvg
EcLevelThe four error-correction levels: 'L', 'M', 'Q', 'H'

Platform types

The types behind the members a platform option may carry. Platform itself is exported, and that is the whole list of members: now and wallNow are () => number and need no name of their own, persistence is the Persistence above, and the six below have one. Pass the members you replace; every other one stays the browser default.

TypeWhat it is
CryptoThe crypto member: one function per primitive the end-to-end encryption uses—X25519, Ed25519, HKDF, SHA-256, AES-256-GCM, JWS verification and the key store. None of them takes an algorithm, a curve or a mode
KeyValueStoreThe storage member
LoggerThe logger member
TimersThe timers member
RandomSourceThe random member
PresenceSourceThe presence member
PresenceEventThe eight signals a PresenceSource raises

Service vocabulary

TypeWhat it is
ErrorCodeEvery BitskiffError.code in the system
LimitNameWhat BitskiffError.limit carries when a refusal named a limit
NetworkClassWhat ClientState.networkClass carries
DeviceClassWhat ClientState.deviceClass carries: mobile, tablet, desktop, other
EntryKindWhat ClientState.entry carries: code for a rotating join code, fixed for a fixed code
LinkGradeHow a link reads: good, fair or poor

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/

The whole host SDK, in a page that needs no build step:

HTML
<script type="importmap">
{
  "imports": { "@bitskiff/host": "https://docs.dev.bitskiff.com/sdk/host@0.3.0.js" },
  "integrity": { "https://docs.dev.bitskiff.com/sdk/host@0.3.0.js": "sha384-t4tevb6IaP3UssGRlkmHoQscYfON1BN2JyGY3zsoEGm3KmG2W3Mbe4icXcXQ7uGA" }
}
</script>

<script type="module">
  import { BitskiffHost, qrSvg } from '@bitskiff/host';

  const host = new BitskiffHost({ key: 'bsk_pk_...' });   // your host key: the key from bitskiff.json
  const press = host.channel('press');                    // declare every channel before open
  press.on('message', (m) => press.send(m.client, `got ${m.data}`));   // m.client is the client that sent it
  const session = await host.open();                      // open last: it seals the channels
  document.body.innerHTML = qrSvg(session.joinCode.url);
</script>

The .d.ts files in this package 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.