Bitskiff

@bitskiff/client

On this page

The client SDK. BitskiffClient joins a session a host has opened, and gives the page a state machine, a send queue and a reconnect policy. The host side is @bitskiff/host, a separate install.

This page is the reference. The guides cover the rest: quickstart · channels · lifecycle · client pages

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

To check a download, compare sha256sum bitskiff-client.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 { BitskiffClient, type Message } from '@bitskiff/client';

const code = location.hash.slice(1);                    // your page read the link; see below
const client = new BitskiffClient({ key: 'bsk_pk_...' });
const press = client.channel('press');                  // the host's row sets delivery and rate
const readout = client.channel('readout');              // the names are the host's, spelled its way
readout.on('message', (m: Message) => console.log(m.data));   // only `readout` reaches this

await client.join(code);                                // name first, then join: see below
press.send('down');                                     // a message is a string or bytes
import { BitskiffClient } from '@bitskiff/client';

const code = location.hash.slice(1);                    // your page read the link; see below
const client = new BitskiffClient({ key: 'bsk_pk_...' });
const press = client.channel('press');                  // the host's row sets delivery and rate
const readout = client.channel('readout');              // the names are the host's, spelled its way
readout.on('message', (m) => console.log(m.data));      // only `readout` reaches this

await client.join(code);                                // name first, then join: see below
press.send('down');                                     // a message is a string or bytes

new BitskiffClient({ key })

Synchronous, and it touches no network. key is the one required option: the client key your page or app was built with. Without it, or with anything but a client key, the constructor throws validation naming key. Every other option is optional.

OptionTypeWhat it is
keystring | () => Promise<string>Required. The client key (bsk_pk_...), sent on inspect and join. The service admits a join only when the key belongs to the session's project and, when the key names host keys, to one of them; otherwise the verdict is wrongClient. A function is called on every inspect and join
originstringWhere this client is, a URL or a custom scheme, for a native app, an app shell or a process that sends no Origin header. The service reads it against the key's allowed origins only when there is no Origin header. A page in a browser never needs it
platformPartial<Platform>Merged member by member over the browser default: storage, persistence, presence, crypto, random, timers, logger, now, wallNow
transportEngineTransportFactoryThe transport adapter; the default fetches its WebRTC bundle on the first connect
reconnectPartial<ReconnectPolicy>base, cap, jitter, minSpacing, stable, joiningBudget, openBudget
returnProbeDurationHow long a returning page waits for the host's Pong before it stops believing the link. Default 2s, refused outside 500ms to 10s
linkDeadAfterHiddenDurationHow long this page can have been hidden before the link is presumed dead and the return skips the probe. Default 10s, refused outside 2s to 2m

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.

There is no endpoint option: the published package talks to the service it was built for. A value outside its range is refused, not pulled to the nearest end: the constructor throws BitskiffError{ code: 'validation' } naming the option in details.option, before anything reaches the network.

loadDefaultTransport() is the same transport adapter, exported so a page can await the fetch when it chooses and pass the result as transport.

BitskiffClient

The client's members:

MemberTypeWhat it does
stateClientStateThe whole snapshot: phase (joining, connected, suspended, ended), changed, clientId, sessionId, number, config, grants, host, hostEverAttached, presence, returning, transport (path and rttMs), attempt, e2ee, lastError, suspended, ended
lifecycleLifecycleThe last transition and the reason for it
channelsReadonlyMap<string, ChannelRow>The host's channel table, name to row, once the welcome arrives; empty before
deadlinesreadonly Deadline[]Every armed deadline this client knows about
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)ChannelName one of the host's channels, or read back the one under that name
inspect(code, opts?)Promise<Inspection>What that code would do, without joining. { reserve: true } also reserves a join grant, see Sign in after the scan
join(code, opts?)Promise<BitskiffClient>Join the session. Resolves to this client on the welcome, rejects with the BitskiffError that refused it; once per client. opts.token is a join token, see Join tokens. Takes a Reservation in the code's place
heldReservation()Promise<Reservation | undefined>The join grant this tab reserved and has not redeemed, for the load that comes back from a sign-in
retry()Promise<BitskiffClient>Start the join loop again from suspended; resolves the same way
leave()Promise<void>Leave the session
dispose()voidLocal teardown; calling it twice is calling it once

Events all go through client.on(event, handler, options?), which returns a () => void that removes that one listener: state, lifecycle, diagnostic, deadline, warning. client.off(event, handler) stands beside it for a handler you already hold. Pass { signal } to close a group of listeners with one AbortController. Read client.state first and then subscribe: subscribing does not replay.

join() rejects when the service refuses. Four refusals are the common ones: a code past its 30 to 60 s window, a session already at maxClients, a client key that is not for that session, and a token the service will not take. Each arrives as a thrown BitskiffError. The handle is left at phase: 'ended' with state.ended. A refused join never becomes a session, so catch the rejection:

import { BitskiffClient, isBitskiffError, type Channel } from '@bitskiff/client';

const code = location.hash.slice(1);                    // your page read its own link
const status = document.querySelector('#status');       // your page's own line of text

const client = new BitskiffClient({ key: 'bsk_pk_...' });
const press: Channel = client.channel('press');

try {
  await client.join(code);
  press.send('down');
} catch (error) {
  if (!isBitskiffError(error)) throw error;
  // `error.code` is one of `invalid_code`, `clients_full`, `wrong_client`, `invalid_token` and
  // `rate_limited`. `client.state.ended.reason` carries the same refusal as one word.
  if (status) status.textContent = error.message;
}
import { BitskiffClient, isBitskiffError } from '@bitskiff/client';

const code = location.hash.slice(1);                    // your page read its own link
const status = document.querySelector('#status');       // your page's own line of text

const client = new BitskiffClient({ key: 'bsk_pk_...' });
const press = client.channel('press');

try {
  await client.join(code);
  press.send('down');
} catch (error) {
  if (!isBitskiffError(error)) throw error;
  // `error.code` is one of `invalid_code`, `clients_full`, `wrong_client`, `invalid_token` and
  // `rate_limited`. `client.state.ended.reason` carries the same refusal as one word.
  if (status) status.textContent = error.message;
}

The one-word form is the list under end reasons, which is where to look when your page writes its own message for each of them.

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.

"Try again" is join on a new BitskiffClient, which is the same line the page ran the first time. A link that drops after the join is not a refusal: the client goes suspended and retry() starts the loop again on the same handle.

Join codes

Read the code out of your own page's link and pass it to join. A page reached by scanning a QR usually reads its own fragment with location.hash.slice(1), in whatever shape the host built the link. That line is yours to write: this SDK never looks at location, never parses a link, and exports nothing that does.

Pass the string the host handed out (session.joinCode.code) unchanged. Under end-to-end encryption it carries the host key fingerprint after a ., so a page that passes only the first half joins with nothing to verify the host against.

inspect(code) takes the same string and joins nothing. Call it to tell "this session is full" from "this code is wrong" before the page commits.

Join tokens

A join code is anonymous admission: whoever holds it gets in. To make the client carry one of your own users, pass a join token your backend minted for them:

import { BitskiffClient } from '@bitskiff/client';

const code = location.hash.slice(1);                   // your page read its own link

// your backend: POST /v1/tokens with a secret key holding tokens:mint
// { "subject": "user_8123", "permissions": ["sessions:join"], "sessionId": "s_..." }
const { token } = (await fetch('/api/join-token').then((r) => r.json())) as { token: string };

const client = new BitskiffClient({ key: 'bsk_pk_...' });
await client.join(code, { token });                    // the code admits, the token names who
import { BitskiffClient } from '@bitskiff/client';

const code = location.hash.slice(1);                   // your page read its own link

// your backend: POST /v1/tokens with a secret key holding tokens:mint
// { "subject": "user_8123", "permissions": ["sessions:join"], "sessionId": "s_..." }
const { token } = await fetch('/api/join-token').then((r) => r.json()); // bsk_at_...

const client = new BitskiffClient({ key: 'bsk_pk_...' });
await client.join(code, { token });                    // the code admits, the token names who

The client still joins with the join code; the token rides on that call and names who is joining. It is a join option and not a constructor option: new BitskiffClient({ key, token }) throws validation naming token. token may also be a function that returns one, called at each join request, so a token your backend replaced lands on the next attempt.

The service meters, limits and attributes the client under that subject, and the usage rows read by it. The host reads it too: the client's row on the host's roster carries it as client.state.subject, so the host knows which of your own users this is. A client that joined with no token carries no subject.

Set sessionId on the mint once your backend knows which session this person is joining, so a token captured on its way to a page cannot be redeemed in any other.

A token the service refuses ends the client with state.ended.errorCode === 'invalid_token', under the coarser ended.reason of refused; ask your backend for a new token rather than retrying with that one. The credentials guide has the mint call and the permissions.

A code a client sees stays accepted for at least one full step and at most two—30 to 60 seconds at the default. A sign-in that takes longer reserves a join grant first.

Sign in after the scan

inspect(code, { reserve: true }) turns the fresh code into a join grant that lasts 10 minutes, and join(reservation, { token }) redeems it once the person has signed in:

import { BitskiffClient, type Reservation } from '@bitskiff/client';

const client = new BitskiffClient({ key: 'bsk_pk_...' });
let reservation: Reservation | undefined = await client.heldReservation();
if (reservation === undefined) {
  const inspection = await client.inspect(location.hash.slice(1), { reserve: true });
  if (inspection.reservationRefused) throw inspection.reservationRefused; // too many outstanding
  reservation = inspection.reservation;
}
if (reservation === undefined) throw new Error('no join grant');

// your sign-in, then your backend's mint of a sessions:join token for that person
const { token } = (await fetch('/api/join-token').then((r) => r.json())) as { token: string };
await client.join(reservation, { token });
import { BitskiffClient } from '@bitskiff/client';

const client = new BitskiffClient({ key: 'bsk_pk_...' });
let reservation = await client.heldReservation();      // set when this load is the sign-in's return
if (reservation === undefined) {
  const inspection = await client.inspect(location.hash.slice(1), { reserve: true });
  if (inspection.reservationRefused) throw inspection.reservationRefused; // too many outstanding
  reservation = inspection.reservation;
}
if (reservation === undefined) throw new Error('no join grant');

// your sign-in, then your backend's mint of a sessions:join token for that person
const { token } = await fetch('/api/join-token').then((r) => r.json());
await client.join(reservation, { token });

The grant is useless alone: it redeems only beside a holder secret the SDK made, which stays in this tab's sessionStorage and never reaches your code. A sign-in that redirects away and back comes back to a fresh load, and heldReservation() hands that load the reservation to redeem, with no second scan and no Join button. The token reaches your page in a response body or a URL fragment, never a query string. A project that does not require a token needs none of this.

A grant that does not redeem is invalid_code, with details.reason naming why: grant_expired, grant_used or grant_holder_mismatch. The way on is another scan. A refusal before the grant is spent, such as a missing token, keeps the reservation for a join that can pass.

Channels

The host declares the channels. client.channel(name) names one and hands back the object every message goes through, with its own send, its own on('message'), its own counters on channel.counters and its own congested reading. It takes a name and nothing else: a second argument is a validation throw with details.option: 'channel'.

The welcome binds each channel to the host's row. From then on the channel reads that row—delivery, ordered, lifetimeMs, maxHz, maxBytes, latestOnly—and channel.declared says whether the host declared the name:

declaredWhen
undefinedbefore the welcome: nothing is known yet
truethe host's table has this name
falsethe host's table does not. Sends answer { dropped: 'notDeclared' } and one unknownChannel diagnostic names the channel

client.channels is the host's table, name to row. Read it to decide what your own page shows, such as a joystick only when the host declared move.

Name your channels before join. A name held on the handle first rides the hello, so the channel is live the instant the host sees you arrive and a greeting sent on that line reaches you. A host unicast on a channel you have not named is dropped at the host with { dropped: 'notListening' }; nothing is buffered waiting for a subscriber.

Before the welcome, a send is held and answers 'sent', and the welcome delivers it or drops it notDeclared.

send(data, opts?) takes a string, an ArrayBuffer, or any view over one (Sendable, exported from this root). It returns 'sent' or { dropped } with one of the seven reasons below. A client speaks only to its host, so send takes no target and there is no broadcast on this side. It never throws for anything the network did.

ResultWhat happened
{ dropped: 'size' }the message is over the channel's maxBytes. A tooLarge diagnostic fires with it
{ dropped: 'rate' }the lossy pace held it back
{ dropped: 'hostAway' }the host is not attached
{ dropped: 'noGrant' }this client's sendInput grant does not cover a lossy send. A lossy channel is what "input channel" means, and sendInput gates nothing else
{ dropped: 'pressure' }the send buffer is behind, or a reliable queue is full
{ dropped: 'notOpen' }the session is not open
{ dropped: 'notDeclared' }the host never declared this name

There is no ack and no request-reply, so send a reply on a channel of your own.

channel.on(event, handler, options?) returns a () => void that removes that one listener, the same as every other on in both SDKs. { from } is a host-side option and means nothing here; { signal } works on either side.

Join the host's channels by naming them, spelled the way the host spells them. You set no rates: a lossy send is paced to the row's maxHz, and maxBytes is the row's on send and on receipt.

Peer and relay

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

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

const client = new BitskiffClient({ key: 'bsk_pk_...', hopPolicy: orderedHopPolicy() });
const direct = client.transport(peer());
client.transport(relay());
await client.join(location.hash.slice(1));
  • The client moves onto a transport only when the host declared it too. orderedHopPolicy() tries peer first and relay next, and moves a client on relay to peer when the network allows.
  • Without a policy, nothing moves by itself: call direct.connect() to hop and direct.disconnect() to go back to server.
  • peer() shows this client's network address to the host. Name it in your privacy notice.
  • Both take silenceMs (250 to 3000 ms, default 750), after which a quiet link falls back to server, and ask, the transport's own ask hook.

Release and rejoin

By default a client keeps its Bitskiff connection for the whole session. When the host allows release on a transport and your data rides it, you can let the connection go and run on that transport alone. Nothing releases on its own: only your call does.

JavaScript
import { bluetooth } from '@bitskiff/client';

const viaBt = client.transport(bluetooth({ name: 'Racer' }));
await client.join(code);

client.on('hop', async (e) => {
  if (e.outcome === 'done' && e.to === viaBt) await client.server.release();
});
client.on('release', (e) => console.log(e.outcome, e.cause, e.boundMs));
  • client.server.release({ ifLost }) resolves released or refused and never throws. A refusal says why in the release event's stage: notActive, notAllowed, noControl, hop or host.
  • ifLost: 'rejoin' (the default) rejoins at once when the transport is lost, inside the reconnect budget. ifLost: 'end' ends the place instead.
  • client.server.rejoin() takes the connection back. It resolves rejoined or failed, and the data stays on its transport.
  • While released, the hop policy takes no step: each one is a decision with vetoedBy: 'released'. Disconnecting or connecting another transport rejoins first.

Content Security Policy

Serve your client page with a Content-Security-Policy response header. The client page guide has the policy and the reason a <meta http-equiv> copy is not enough.

@bitskiff/client/node

Import from this subpath for a load generator, a test harness or an embedded controller. It exports the same class, taking the same options, with the runtime-neutral platform and the custom engine's transport over node-datachannel. Its exports are BitskiffClient and createNodeTransport; BitskiffClientOptions and the rest of the types come from the package root.

That peer is optional, so a page's install pulls no native binary. Only the import line changes between a page client and a process one.

import { BitskiffClient } from '@bitskiff/client/node';

const client = new BitskiffClient({ key: 'bsk_pk_...' });
const code: string = process.argv[2] ?? '';   // the code the host's display is showing
await client.join(code);
import { BitskiffClient } from '@bitskiff/client/node';

const client = new BitskiffClient({ key: 'bsk_pk_...' });
const code = process.argv[2] ?? '';        // the code the host's display is showing
await client.join(code);

Its store is in memory, so the client record lives as long as the process and a restart joins as a new client. To keep it across restarts, pass your own storage through platform. createNodeTransport() is exported too, for a caller that builds its own transport stack.

The stored record

A page that reloads can say where it stands before it reconnects. The SDK keeps one record per client key in storage, under the name clientRecordKey(key) answers, and two functions read it without a BitskiffClient:

import { clientRecordKey, isRecordExpired, parseClientRecord } from '@bitskiff/client';
import type { StoredClientRecord } from '@bitskiff/client';

const key = 'bsk_pk_...';
const record: StoredClientRecord | undefined = parseClientRecord(
  localStorage.getItem(clientRecordKey(key)) ?? undefined,
);
const returning = record !== undefined && !isRecordExpired(record, Date.now());
import { clientRecordKey, isRecordExpired, parseClientRecord } from '@bitskiff/client';

const key = 'bsk_pk_...';
const record = parseClientRecord(localStorage.getItem(clientRecordKey(key)) ?? undefined);
const returning = record !== undefined && !isRecordExpired(record, Date.now());

clientRecordKey(key) is synchronous: bitskiff:client: and the last 16 characters of the key string, so the page names its record before the SDK boots.

parseClientRecord answers a StoredClientRecord—sessionId, clientId, the resume token, expiresAt, and the public half of this client's key—or undefined when there is nothing to read. isRecordExpired(record, nowUnixMs) says whether the place the record names has run out; an expiresAt of 0 means the service named no deadline, so such a record never expires here.

Neither function touches storage and neither joins. What they answer is provisional until inspect(code) matches the record against the session that code names.

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_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
RETURN_PROBE_MS2000The default returnProbe, as a millisecond reading. formatDuration(RETURN_PROBE_MS) is the string the option takes
RETURN_PROBE_MIN_MS / RETURN_PROBE_MAX_MS500 / 10000The range returnProbe is refused outside of, as millisecond readings
LINK_DEAD_AFTER_HIDDEN_MS10000The default linkDeadAfterHidden, as a millisecond reading
LINK_DEAD_AFTER_HIDDEN_MIN_MS / LINK_DEAD_AFTER_HIDDEN_MAX_MS2000 / 120000The range that option is refused outside of, as millisecond readings
CLIENT_PACKAGE_NAME'@bitskiff/client'This package's own name, for a log line or a user agent

Beyond the record, the SDK also keeps its end-to-end-encryption keys in an IndexedDB database named bitskiff-client-crypto-keys.

Functions

ExportWhat it does
msLeft(deadline, apiNowMs)The milliseconds left on a deadline, floored at 0. Pass client.apiNowMs(); the answer is undefined before the service clock has landed
clientRecordKey(key)The storage name of the record a client built with that key keeps, synchronously
parseClientRecord(raw)The stored record, or undefined
parseDuration(written)A duration string as milliseconds, or null when the grammar refuses it
formatDuration(ms)Milliseconds as a duration string, largest unit down
resolveReconnectPolicy(partial?)A ResolvedReconnectPolicy from the fields you set, defaulting the rest
isRecordExpired(record, nowUnixMs)Whether that record has run out
browserPlatform(overrides?)The default platform, with anything you pass merged over it member by member
browserStorage()The localStorage store on its own, for the storage member—a camera-app scan opens a new tab, so the client keeps its record per browser, not per tab
browserPresence()document visibility and window online: six of the eight PresenceEvent members. freeze and resume come from a runtime that reports them

Client types

TypeWhat it is
ClientPhase'joining', 'connected', 'suspended', 'ended'
ClientChangeWhy state was replaced: joining, connected, away and the rest
ClientEventThe event names client.on() takes: state, lifecycle, diagnostic, deadline, warning
ClientEndedThe end itself, on state.ended
ClientEndReasonWhy a client ended; the lifecycle guide tabulates them
ClientSuspendedOn state.suspended: the loop stopped itself and the place is not known to be gone
ClientLastErrorWhat the page can see of a refusal the SDK is still retrying
BootReasonWhy the host removed this client: booted, idle, policy. The REST boot route takes a fourth value, BOOT_REASON_KEY_UNVERIFIED, and it arrives as state.ended.reason === 'keyUnverified' with no bootReason set
VerdictWhat inspect(code) decided: fresh, resume, replace, expired, invalid, wrongClient, e2eeUnsupported, e2eeRequired
WrongClientReasonWhy a verdict is wrongClient, on its details.reason: other_project (the code is another project's) or other_host (this client key does not serve that host)
JoinOptionsThe second argument of join(code, opts): { token }, the join token a project that requires a signed-in person takes beside the code
JoinTokenThe token itself: a bsk_at_... string, or a function that returns one, read at each join request
InspectOptionsThe second argument of inspect(code, opts): { reserve }, to reserve a join grant
ReservationA join grant and its expiresAt on the service's clock, from inspection.reservation; join(reservation, { token }) redeems it
StoredClientRecordThe record above
HandoffWhat client.handoff(opts?) resolves: the ticket, its expiresAt and cancel()
HandoffOptionsWhat client.handoff(opts?) takes: data for the app, and hint to replace the SDK's own
HandoffHintThe transport class this page is on when it hands off, and its readings
ClientHandoffThis place's last handoff, on state.handoff: phase, since, deadline, stage and hint
HolderKindWhat keeps this place: 'page' or 'app'
GrantsWhat this client may do, on state.grants
SessionConfigReadThe session's resolved settings, on state.config, each duration spelled the way it came back
DurationA duration a developer writes, as a string: 500ms, 90s, 5m, 1h30m
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
E2eeInfoEncryption on this client: bound, generation and the six confirmation digits
ServerTransportclient.server: a Transport that also has release(options?) and rejoin()
ReleaseEventOne step of a release or a rejoin: outcome, via, by, cause, stage, boundMs
TransportA transport on the client, client.server or what client.transport(peer()) returns: status, reason, nextAt, connect(), disconnect() and on('status'), a view over its record in state.transports
ClientTransport, ClientTransportsOne transport's record, and the set keyed by name
TransportReason, TransportStatusWord, TransportStatusEventWhy a record reads unavailable, the four statuses, and what on('status') hands over
TransportFactoryWhat peer(), relay() and bluetooth() return, passed to client.transport() before join
ClientPeerOptions, ClientRelayOptionsWhat peer(options) and relay(options) take: silenceMs and ask
BluetoothOptionsWhat bluetooth({ ask }) takes: ask, the app's own nearby-devices prompt, called when the host first reaches for the link and resolving 'granted' or 'refused'
TransportInfoDeprecated for one release: use ClientTransports and client.link. The leg this client is carried on. In a browser the path is udp, tcp or turn, read off the selected candidate pair, with rttMs beside it. A Node client reports unknown and no rttMs
EnginePathThe leg a client is carried on: 'udp', 'tcp', 'relay' or 'unknown'
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
DeadlineListener / DeadlineWarningListenerThe handlers on('deadline') and on('warning') take
ListenOptionsWhat every on takes beside its handler: an AbortSignal
UnsubscribeThe () => void every on returns

Channel types

TypeWhat it is
ChannelRowOne row of the host's channel table, as client.channels maps each name to it
ChannelCountersOne channel's monotonic counters, read off channel.counters when you paint
MessageThe one object a message handler is handed
ClientMessageThe payload on its own: { data }, a string or the bytes, and none of the channel, seq or clock fields Message adds
SendOptionsThe second argument to send
SendResult'sent', or the one reason it did not go
ClientDropReasonThose reasons on their own
Delivery'reliable' or 'lossy'

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, and the seven 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
PersistenceThe persistence member: which media this runtime has, which one it uses by default, and the slot a session takes in one
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
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/

This is the client side of the session above; the host side is @bitskiff/host, a separate install. A client page joins with its client key and the code its own link carries:

JavaScript
import { BitskiffClient } from '@bitskiff/client';

const client = new BitskiffClient({ key: 'bsk_pk_...' });   // your client key
const press = client.channel('press');          // name the channels first, then join
await client.join(location.hash.slice(1));       // your page read the code off its own link
press.send('down');                              // a message is a string or bytes

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.