Lifecycle

A session has five phases, and only one of them is an end. open() rejects when the session could not be opened, so everything below the await is a session that exists:

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

try {
  const session = await new BitskiffHost({ key: 'mrc_pk_...' }).open();
  session.on('state', (s) => console.log(s.phase, s.ended?.reason ?? ''));
} catch (err) {
  if (isBitskiffError(err)) console.log(err.code);      // `origin_not_allowed`, `invalid_key`, ...
}
phaseWhat it is
openingthe open is in flight
openthere is a join code and phones can take seats
reconnectingthe link dropped and the SDK is getting it back; the seats are held and the phones see the host as away
suspendedthe SDK stopped trying, with the session not known to be gone; session.retry() is the way out, and it is what a Try again button calls
endedterminal, with a reason

state is a frozen snapshot replaced whole, and on('state') fires on every change to it.

A reload is not an end. A browser host keeps its session for the tab it is in, so reloading resumes the same session with the phones still seated.

Why an open was refused

The rejected open() carries a code:

codeWhat it is
origin_not_allowedthe origin the page is served from is not on the publishable key's allowlist. Add it in the console; a page opened straight off disk has no origin to add
invalid_keythe key is not the whole string, or it was revoked
secret_key_in_browseran mrc_sk_... reached a page. A page holds a publishable key or a token, never that one
invalid_tokenthe token your backend handed the page is expired, revoked or unknown. Ask your backend for another; the same one answers the same way

Why a session ended

session.state.ended.reason is the row to key on. These thirteen are every reason a host handle can carry:

ended.reasonWhat it is
hostClosedyou called session.close(), here or in another tab
idleTimeoutidleTimeoutMs passed with nobody joined. Off unless you set it
maxSessionTimemaxSessionTimeMs passed. Off unless you set it
hostResumeExpiredthe host went away for longer than the host grace, 10 s by default
replacedanother handle presented this host's identity and took the session
keyRevokedthe key that opened it was revoked
apiClosedit was closed from outside this page
leftthis host's own seat left the session, its own goodbye or the leave route
ticketRejectedthe ticket this handle presented was invalid, expired or already used
sessionNotFoundthe session id is unknown; its record is gone
protocolthe two sides disagree on the protocol version, or one sent repeated malformed envelopes
refusedthe api refused for a project-side reason it does not spell out; ended.errorCode carries its own code
disposedyou called session.dispose(): local teardown, nothing on the wire

Key on the ones your host acts on and keep a fallback anyway: a switch with no default is a session that ended with nothing said to whoever is standing at the host.

Who is seated

session.participants is the roster, by id, and on('participant') is every change to it:

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

const session = await new BitskiffHost({ key: 'mrc_pk_...' }).open();
const paint = (id: string, live: boolean) => console.log(id, live);
const drop = (id: string, why: string) => console.log(id, why);

session.on('participant', (p, change) => {
  switch (change.kind) {
    case 'connected':                      // welcomed; messages may flow
    case 'present':                        // back in the foreground
      return paint(p.id, true);
    case 'disconnected':                   // link down, seat held
    case 'away':                           // phone hidden, seat held
      return paint(p.id, false);
    case 'gone':                           // the last event for this id, ever
      return drop(p.id, change.reason);
    default:                               // `joined`, `grantsChanged`, `e2eeChanged`
      return;
  }
});

A participant is an immutable value: each change hands you a new one, and session.participants holds the current set.

A phone in the background

A phone that is locked or switched away from does not lose its seat. Its presence goes to away and the host gets an away change; the seat is held for five minutes, and a phone that comes back inside that gets a present change with the same participant id. Past it the seat ends with backgroundBudget.

The phone's own page sees the same thing from the inside: client.state.presence is present or away, and client.state.returning is true from the moment the page runs again until the seat is confirmed still held, which is when to take the greyed-out look off rather than the moment the tab is visible.

Ending

CallWhat it ends
await session.close()ends the session for everyone; the phones end with hostClosed
await session.boot(id)ends one seat; that phone ends with booted
await client.leave()gives up this phone's seat; the host sees gone with left

Nothing runs on your behalf when a page goes away. A host that navigates or closes its tab is a dropped link: the session is held for the host grace, the phones sit on the host being away, and then it ends with hostResumeExpired and they are told hostGone. Call close() when the session is over and you save everyone that wait.

Why a seat ended

client.state.ended.reason is the phone's own row:

ended.reasonWhat it is
invalidCodethe code rotates every 30 s, and this one is past it. Scan the current one
hostGonethe host reloaded, slept or lost its link for longer than the host grace
seatsFullfour controller seats by default: raise maxControllers, or set maxViewers above 0 for a watch seat
wrongClientthe session's join URL points at a different page than the one this phone is on
bootedthe host ended this seat
leftthis page called leave()
reconnectBudgetthe link was down too long, with the phone in the foreground
backgroundBudgetthe phone was away too long
hostClosedthe host closed the session

Turn the ones you recognise into your own words and keep a fallback: a reason your page has never heard of still has to say something true.