Bitskiff

Changelog

On this page

Every Bitskiff release, newest first, and what it changed in the SDK, the REST api, the webhooks and the CLI. A release is tagged after its entry here is written, and every package carries its version.

From 0.2.0 on, each release's packages are at /downloads/<version>/<scope>-<name>.tgz, such as /downloads/0.2.0/bitskiff-host.tgz, written once and never replaced. Pin a version in an app you ship; /downloads/latest/ follows the newest release once it has rolled and is for trying things.

[Unreleased]

What's new

  • A developer can cap what a client may do, and cut one user off everywhere in one call.
  • The host sees where each client runs and the developer's own data about it.
  • The REST ledger counts each client's messages and data.

Host (@bitskiff/host, @bitskiff/host-node)

  • The host sees where each client runs: client.state.environment holds its OS family, whether it is a browser, a webview or a native app, and its SDK and version. The types are ClientEnvironment, OsFamily and ClientRuntime. A client back from another environment, such as the app a handoff opened, raises clientChanged with environment.
  • client.state.metadata is the developer's own map the client joined with.
  • client.state.grantsCeiling is the most its join token lets the client hold. setGrants above it is clamped to it.
  • client.state.keyThumbprint is the key the developer's backend bound into the client's join token. Under end-to-end encryption, a client that presents another key is booted with keyUnverified.

Client (@bitskiff/client)

  • join, joinWithHandoff and resume take metadata, the developer's own map the host reads. resume takes it as ResumeOptions.
  • client.apps is filled after a resume too, so a page that takes back its place on a fresh load can still offer the developer's app.

REST api

  • A join token can carry grantsCeiling, the most its client may hold. A token minted with ["GRANT_RECEIVE_BROADCAST"] joins watching only, and every later grant change is held to it.
  • GET /v1/sessions takes clientSubject: the sessions where one of the developer's users is a client.
  • POST /v1/sessions/boot takes a subject and boots that user from every session in the project. It answers booted, the places it freed; a repeat answers 0.
  • Client records from GET /v1/project/ledger carry messagesReported, the host's count, and dataMessagesUp, dataMessagesDown, dataBytesUp and dataBytesDown, the service's own.

CLI

  • bitskiff tokens mint --grants-ceiling receive_broadcast mints a token with a ceiling. It takes a comma-separated list.
  • bitskiff sessions list --client-subject finds one user's sessions, and bitskiff sessions boot --subject cuts that user off.

Changed

Host (@bitskiff/host, @bitskiff/host-node)

  • A reopening host keeps its stored session through a refusal that does not name it, such as a 403, a 429 or a timeout. It opens a fresh session only when the stored one is gone or has ended.
  • The host re-sends what it tells a client until the client confirms it: a boot, a grant change, a peer change, a deadline, a new end-to-end encryption key and its goodbye.
  • A booted client's clientLeft carries the reason its boot gave.

Sessions

  • Peer connections look for a direct path with Bitskiff's STUN server on both ends.
  • A client's reliable messages are held to 40 a second with a burst of 80 at the engine, on by default. A client within the host's own limit never meets it.
  • A pause no longer costs reliable messages. A host or client back from a stall reads the reliable messages that queued during it without a rate limit; lossy messages still drop past the burst.
  • A receiver too far behind on reliable messages is disconnected and resumes, and the messages wait for it. Before, the engine dropped them.

Hosted join page

  • Back on the join page after opening the developer's app, the offer reads "Open the app" on every platform. Returning from the app's link page takes the place back with no code, and other tabs say "Continued in another tab".

Fixed

  • Reliable messages are no longer lost when a link ends: a resume delivers every frame the old link did not confirm, in order and once, however many there were.
  • A client back from a long background receives every message the host held for it, in order.
  • A host that restarts inside host grace keeps its clients, and the reliable messages they sent meanwhile reach it once.
  • Under end-to-end encryption, the first reliable message after a transport hop is no longer dropped as a replay.
  • A host's close() no longer reports its last usage window twice.
  • A host's end-to-end encryption drops on a relay link are counted under relay.
  • A reconnecting client's fresh ticket can be used once only.

[0.3.0] - 2026-11-06

0.3.0 rolled to dev on 2026-11-07, and /downloads/latest/ names it. Four earlier rolls of it were rolled back while the test runs that check a roll were being fixed; none of them found a fault in 0.3.0.

What's new

  • bitskiff doctor checks a setup in one run and names the one thing to fix.
  • Each app directory gets its own project. bitskiff init makes it without asking, and every command uses the project of the directory it runs in.
  • The CLI installs from npx with 5 packages instead of 100. bitskiff mcp fetches the MCP SDK the first time it runs.
  • bitskiff init ends with a report of what it set up, what is left and the next command.
  • A fixed code is a join code that never changes, for a printed QR code, a tag or a sign. Each join through it reaches the session that claimed it last.
  • A host key can allow a native app build, by its bundle id or package name, in place of a web origin.
  • An organization can be renamed in the console.

Before you upgrade

Two changes can move what a 0.2.3 setup does. The CLI now takes the project from the nearest bitskiff.json before the profile and the machine default, and init no longer sets the machine default: in an app directory, every command acts on that directory's project. Pass --project or set BITSKIFF_PROJECT to pick another. And a session's data bytes now count message payloads only, not the framing around them, so they read lower for the same traffic: about a fifth lower on dev's test traffic.

Added

  • bitskiff doctor checks the CLI build, the stored credential (its kind, label and where it is stored, never its value), the project, the host key's origins, the join URL and the api. It prints one line per check, each ok, fail or skip, then the fix for the first failure, and exits 0 only when every check is ok. It asks the api whether the credential holds. --format json prints one JSON object, and bitskiff mcp has it as a tool.
  • Every project-scoped command resolves its project in one order: --project, then BITSKIFF_PROJECT, then the nearest bitskiff.json (searched up from the current directory), then the profile, then the machine default. whoami and doctor say which one it came from. bitskiff init in a directory with no bitskiff.json makes a new project named for the directory, however many projects the organization has, and adds four characters to the name when it is taken.
  • bitskiff init --format json ends with sessionUrl, todo and next: where to watch the first session, what is left to do and the command to run next. It names keys by id, never by the key itself.
  • GET /v1/sessions/{sessionId} answers messagesToHost and messagesToClient, the messages the host counted each way. bitskiff sessions get shows them, and the console's session view says the session works end to end once a client joined and a message went each way.
  • Fixed codes. A project makes one with bitskiff fixed-codes create --label <label> or on the console's Fixed codes page, and list, replace, relabel and delete manage them over /v1/project/fixed-codes. The code is shown once, at create and at replace. A host claims a code at open with host.open({ fixedCode }), which takes the code's id, never the code, and the newest open wins. A client joins with the fixed code as with any join code. With no session holding the code the join is refused nothing_open, and the hosted join page says "Nothing is running here" with Try again. A replaced or deleted code is invalid_code. A fixed code carries no host fingerprint, so a client does not join an end-to-end encrypted session by one: its inspect answers e2eeRequired. The host reads how each client came in as entry on the client's state, 'code' or 'fixed'. Joins through one fixed code are limited to 30 a minute for each client key, and a refusal records limit.hit.
  • Claiming a fixed code with a host key takes sessions:claim beside sessions:open: pick fixed-code-host when you make the key, in the console or with bitskiff keys create --preset fixed-code-host. A secret key, an organization key and a join token claim under sessions:open. Without it the open is refused forbidden with missing_permission.
  • A host key's origins can name a native build instead of a web page: its bundle id or package name as a custom scheme, such as com.studio.racer://host. A key lists one shape only, web origins or native builds. The open body takes origin, and with no browser Origin header the api matches it against the key's native builds only. The console marks such a key as a native build.
  • The C++ SDK has a host: BitskiffHost and Session over the C ABI's host half. Declare channels before the first open, open a session, read its join code and clients, send to one client or broadcast, and hear clientJoined, clientChanged, clientLeft and error. Pump() runs every listener on the caller's thread.
  • An organization owner or admin can rename the organization on the console, over PATCH /v1/orgs/{orgId}.

Changed

  • The CLI's install no longer carries the MCP SDK. bitskiff mcp loads it where Node finds it, else from a copy beside the CLI's config file, else fetches the pinned version there once and says so on stderr. The install is 5 packages and 5 MB, down from 100 and 20 MB.
  • A session's data bytes count each message's payload, as the metering reference defines them, not the frame around it. The engine's node total still counts whole frames.
  • Mailed sign-in links open on the console's own address.
  • bitskiff keys rotate, update and revoke say which project they looked in, and where that project came from, when the key is not there.

Fixed

  • A console account whose address is off the sign-up list now gets its sign-in link. The list admits new accounts only; it never locks out an account that already exists.
  • The package READMEs name the current release in every install line, CLI command and browser module URL. A known issue of 0.2.3: its published tarballs are not replaced, so the host and cli READMEs in them still name host@0.2.2.js in the no-build import map, the cli README installs /downloads/0.2.0/bitskiff-cli.tgz, and the host, client, host-node and ui-kit READMEs name a 0.1.0 tarball path that is not served. Use the 0.2.3 lines on the downloads page.

[0.2.3] - 2026-11-03

What's new

  • Hand a client's place from your web page to your own Android app, with no second scan. The host sees the move, and an encrypted place stays encrypted in the app.
  • An app the OS killed or stopped takes its place back with client.resume().
  • A client moves between transports by itself under a hop policy you install (orderedHopPolicy()), and you can pin or block a transport. Every move is an event at the client and a step at the host. The hosted join page moves onto a direct link by itself.
  • relay() is a transport of its own: a link through Bitskiff's relay, so neither end sees the other's address. peer() is now direct only and never billed.
  • A client can let its server connection go while peer or Bluetooth carries it, and take it back.
  • C, C++, Unreal and Electron clients have the same transports, and freeze and resume with the platform.
  • A page can sign its person in after the scan: Bitskiff holds the join for 10 minutes while they do.
  • Agents drive Bitskiff from an MCP client (bitskiff mcp), sign in without waiting on a person, read every CLI command as JSON, and prove a project works with no device.
  • Organizations manage members and billing accounts, get a monthly invoice, approve limit raises and set how much authority their agents have.
  • The console's sign-in pages look finished and offer Google and Microsoft sign-in where the deployment turns them on.
  • Security: a client can no longer skip the host's message rate with made-up hop markers, and an encrypted host holds each client to the key it first bound.

Before you upgrade

Four changes can break a 0.2.2 app. peer({ relayOnly }) is gone: declare relay() for a link through the relay. A spent place now reads seat_spent, not seat_released. client.handoff() resolves a Handoff (ticket, expiresAt, cancel()), and the HandoffTicket type is gone. A failure inside the SDK itself reads sdk_internal, not internal. Match on the new names, and upgrade the host and client packages together.

Added

  • client.resume(): an app that was killed, crashed or stopped by the OS takes back its place from its own stored record, with no code, inside the place's budget. It resolves to the client once it is back in its place and to null when there is no place to go back to, and a null leaves the client new for join(code). In an encrypted place the stored host fingerprint is checked against the next Welcome, and a host that answers unencrypted ends it hostUnverified. A record written at a handoff redeem is now marked pending until its Welcome.
  • joinWithHandoff(ticket) with the link a stored place came by, as a relaunch from the recent apps list after a kill brings back, resumes that place rather than redeeming the spent ticket again, so an app keeps no handled-link state across a kill; the stored record keeps a SHA-256 digest of the ticket, never the ticket. client.resume() of a record written at a redeem before its attach cancels that handoff first, so the page keeps its place at once, and resolves null; a place that committed before the app stopped resumes as any.
  • joinWithHandoff(ticket) with any other ticket than the one the stored place came by now leaves that stored place before it redeems, so the old place's host reads the client leave at once rather than holding it for its reconnect budget.
  • Your backend or an agent can now read what a project can open before it opens: GET /v1/project/capacity (a secret or organization key holding project:read) answers the project's capacity standing and how many more sessions of the shape you name, and the clients they hold, would be placed now. bitskiff usage --capacity prints it, and load-dev sizes itself from it before falling back to opening until the first unavailable. It is a reading, not a reservation, and no SDK exposes it, so a page never learns a limit.
  • A new error code, sdk_internal, for a failure inside the SDK itself where no request was refused, such as an end-to-end encryption step that throws. It carries the throw as cause, is not retryable and does not clear by waiting. internal is now only ever the api's own 500, and the host and client SDKs report sdk_internal for their own failures.
  • The load-dev validation run now sizes itself to the capacity the engine has: it opens hosts until the first unavailable and runs its load inside the capacity it found, then records the hosts and clients placed and the refusal that set the ceiling in its report, the matrix row's result and the ledger line. A refusal at the ceiling is the expected shape and no longer fails the row.
  • A place in an end-to-end encrypted session can now move to your app sealed: await client.keyThumbprint() gives your backend the thumbprint to mint the app's join token with (cnf: { jkt }), and the ticket client.handoff() returns now carries the host fingerprint after a ., so the app checks it reached the same host. Pass the ticket whole; a link parser that drops the . makes the app end e2ee_required. POST /v1/handoff takes e2ee beside the ticket, refuses a build that cannot encrypt e2ee_required without spending the ticket, and answers e2ee.
  • When a handoff link opens in the browser on Android with your app installed, the sample handoff host page now offers "Open in the app": a link that opens the package you name in its meta tag, whatever the link verification reads, with the store listing as its fallback. It carries the ticket as ?h=<ticket> in the link's query, since an Android intent cannot carry a fragment, so your app's link handler reads h= from the query as well as the fragment. The page keeps the ticket in memory only and puts it in no link until the tap.
  • The client and host SDKs now show the handoff as it happens. client.state.holder reads 'page' or 'app' and moves once, when the app takes the place; client.state.handoff reads the move (pending at minted or redeemed, then done or failed with the step it failed at, and the page's transport as a hint), each change a state event with changed: 'handoff' or 'holder'. client.handoff({ data }) carries up to 4096 bytes to the app, which reads them on client.handoffData, and the Handoff it resolves has cancel(). The page ends handedOff when the app takes its place, never replaced. On the host the client reads connection: 'handingOff' from the app's redeem to the commit, with clientChanged handoff and holder, and never disconnected or reconnected. In an encrypted session the host holds the app's new key until the app's cnf arrives. An app's hop policy re-takes the page's transport at once on the new handoff trigger.
  • A client's place can now move from the page to the developer's registered app as the same client: the page mints a handoff ticket (POST /v1/sessions/{sessionId}/clients/{clientId}/handoff, with an optional transport hint and up to 4096 bytes of handoff data, string or bytes), the app redeems it (POST /v1/handoff, answered with its own token and ticket, the hint and the data), and the place commits to the app when the engine reports the app's connection, never before. The page can cancel (DELETE on the same path). Until the commit the page keeps its place, its token and its budgets; a redeem pauses a running budget once per stable spell; a failure after the redeem leaves the page where it was. The host is told HandoffState from the redeem on (redeemed, then done or failed with its stage), never at the mint, and the evicted page's resume reads handed_off (or handoff_pending while the engine cannot be read). Six mints a minute per place (handoff_mints_per_minute_client), and each handoff ends in one client.handoff meter event.
  • An agent can now drive Bitskiff from its MCP client: bitskiff mcp is a stdio MCP server with one tool per CLI verb, generated from the command table, running under the key or sign-in the CLI already stored. Add it with { "mcpServers": { "bitskiff": { "command": "npx", "args": ["--yes", "<CLI tarball URL>", "mcp"] } } }. init and login run as --no-wait, and a refusal is the tool's error result carrying the api's error body, details included.
  • An organization owner can now set how much authority its agents have, or switch them off: orgKeyMinting: console stops any key making or rotating an organization key (a person in the console still can), keyMaxLifetime caps how long an organization key lives, keys already live and the CLI's login key included, and deviceSignIn: refused stops bitskiff login and bitskiff init acting in the organization while browser sign-in to the console stays. Set them with bitskiff orgs policies --org-key-minting keys|console --key-max-lifetime <duration>|none --device-sign-in allowed|refused or on the console's Policies page, over PUT /v1/orgs/{orgId}/policies; a key holding projects:read reads them on GET. A refusal is forbidden with details.reason = "policy" and details.policy naming the policy.
  • An agent can now sign in without its tool call waiting on a person: bitskiff init --no-wait and bitskiff login --no-wait print { verificationUri, userCode, expiresAt, next } as JSON and exit at once. Show the person the URL and code, then run next (init --continue or login --continue with the same flags), which waits for the approval and finishes the setup. The pending code stays in the CLI's owner-only config file and is never printed; a second --no-wait replaces it.
  • bitskiff login now refuses a staff key and stores nothing: the api answers staff on GET /v1/orgs to the key's own holder, and the one line points at npm run admin-key, which hands the key to a command's environment instead.
  • Electron and Unreal clients now freeze and resume with their platform. The Electron client follows the machine's suspend and resume (powerMonitor) by itself, and client.platformEvent('freeze') and 'resume' are there for an app that knows better (powerMonitor: false turns the automatic one off). The Unreal subsystem follows the engine's background and foreground delegates, with a Blueprint PlatformEvent node and SetFollowsApplicationLifecycle(false) for a game that raises them itself. Presence does not move. A resumed native client now treats its session connection as dead and reconnects at once with no probe, as a web page does.
  • A native app can now tell its client that the platform froze or resumed it: call bitskiff_client_platform_event from the C ABI, or client.OnPlatformEvent(bitskiff::PlatformEvent::kFreeze) and kResume in C++, from the app's lifecycle delegates. A frozen client closes its peer or relay connection and reads it unavailable until it resumes, then available, with presence untouched, as a frozen web page does. In the same pass the native core's orderedHopPolicy stays off peer after an attempt that found no direct path, as the TypeScript one does, and the Unreal plugin reads bRepairing (bRestarting stays for this release with the same value) and declares relay with DeclareRelay.
  • A hop policy can now read its base from the context: client.hops.context().base is client.server by default and the declared relay handle when the installed policy has one, and a policy names it with a base property (orderedHopPolicy({ base: viaRelay }) does). turnBytesPerMonth's default reads 64 GiB in the limits guide, as the limits page has it.
  • A client can now start on relay instead of server, so the host's address stays hidden and Bitskiff's engine never reads its data: declare relay() at both ends and install orderedHopPolicy({ base: viaRelay }), where const viaRelay = client.transport(relay()). The data moves onto relay at the join and back onto it after a fallback, and peer stays the upgrade from there. A host that declares relay now asks for each client's relay credential as the client arrives, so the first relay connect waits on no extra round trip. Relayed bytes are metered to your project, and the default turnBytesPerMonth is now 64 GiB, sized for a session that relays all its data. A client that floods a relay connection is now booted at once rather than only moved back to server, because every byte it sends through the relay is billed. The default orderedHopPolicy() now tries peer alone; put relay in order yourself if you want it as an upgrade.
  • Invoices now exist: early each month every billing account gets one invoice for the month before, a line item for each attached project and owned meter, at that meter's rate card parameter, plus the monthly fee parameter once on the organization's default billing account. Every parameter is zero today, so every invoice totals zero and is issued already paid. An issued invoice is numbered in its billing account's own sequence with no gap (Q2XM9PBZ-0001), never changes, and keeps its PDF beside it. Read them with GET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/invoices, .../invoices/{invoiceId} and .../invoices/{invoiceId}/pdf under billing:read, on the console's billing page, or with bitskiff invoices list, show and pdf. A billing account can take invoice.issued, invoice.paid and invoice.voided webhooks on its own endpoint, set with PUT /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/webhooks. No payment is collected yet. bitskiff billing accounts list, create and update are now verbs of their own, each with its own route in bitskiff commands.
  • A client whose project requires a token can now sign in for as long as it takes: POST /v1/join/inspect with reserve: { holderHash } turns the fresh join code into a join grant good for 10 minutes and holding no place, and POST /v1/join redeems it once with grant: { grant, holder } in the code's place, the token in Authorization as before. A copied grant is useless without its holder secret, a repeat within 30 s gets the same ticket and seat, and one that does not redeem is invalid_code with details.reason grant_expired, grant_used or grant_holder_mismatch. A project holds at most 1,000 unredeemed grants and a session twice its maxClients (at least 20); past either the reservation is refused rate_limited in reservation_refused while the inspect still answers.
  • A page can now sign its person in after the scan with the client SDK alone: client.inspect(code, { reserve: true }) answers reservation, a join grant good for 10 minutes, and client.join(reservation, { token }) redeems it beside the join token your backend minted once the sign-in is done. The holder secret behind the grant stays inside the SDK, in this tab's sessionStorage, so the load that comes back from a redirect reads client.heldReservation() and redeems with no second scan. A grant that does not redeem is invalid_code with details.reason grant_expired, grant_used or grant_holder_mismatch; a full ceiling is inspection.reservationRefused, a rate_limited with its wait. The reference join page takes the sign-in as one beforeJoin seam and never calls the join routes itself.
  • A client on a web page can now hand its place to your native app with no second scan: register the app on the project (bitskiff settings set registeredApps '[{ "platform": "android", "appId": ..., "handoffLink": "https://...", "clientKeyId": ..., "name": ..., "gain": ... }]'), and the hosted join page offers "Continue in the app" once the client is in. On the page, client.handoff() mints a 60 s single-use handoff ticket for the client's own place (POST /v1/sessions/{sessionId}/clients/{clientId}/handoff); the page opens <handoffLink>#h=<ticket>, and in the app client.joinWithHandoff(ticket) redeems it (POST /v1/handoff, the registered app's client key) and takes the same place as the same client: the host sees one client, the page ends replaced, and client.apps carries the project's registered apps off the join answer. Refusals: handoff_invalid (expired, spent, unknown), wrong_client (other_app), forbidden (token_required), seat_spent. No handoff in an end-to-end encrypted session.
  • An agent can now read the CLI's whole command table as data: bitskiff commands --format json, or bitskiff --help --format json, prints every verb with its arguments, flags, the routes it calls, the permission each needs, its output shape, whether it pages and whether it needs a sign-in. It needs no sign-in and sends no request, and bitskiff --help reads as before.
  • An agent can now prove a project works with no host code and no device: bitskiff init writes bitskiff-prove.mjs and a package.json beside the host page, and npm install && node bitskiff-prove.mjs opens a session on @bitskiff/host-node, joins one client on @bitskiff/client, sends a message each way, prints each transport's record and the session's usage record as one JSON object per step, and closes, exiting 1 at the step that fails. It reads a secret key from BITSKIFF_SECRET_KEY and a client key from BITSKIFF_CLIENT_KEY, and prints neither.
  • A project can now be read, renamed and archived by itself: bitskiff projects get, rename <name>, archive and unarchive over GET and PATCH /v1/projects/{projectId} and POST /v1/projects/{projectId}/archive and /unarchive, and the console's projects page has Rename, Archive with a confirmation, and an Archived filter. Archive never deletes: an archived project refuses new sessions, keys and tokens with forbidden, details.reason project_archived, keeps its keys, ledgers and usage, and an organization owner can unarchive it for 30 days.
  • The CLI can now wire a project's webhook and read its events: bitskiff webhooks set --url <url> --kind session.ended --kind client.gone sets where events are delivered and which usage records ride along, and prints the signing secret the one time it is shown; bitskiff webhooks get reads the config back without it; bitskiff events reads the event stream a receiver that was down catches up on, paged by --cursor and --all. Each takes --format=json.
  • A client can now let its Bitskiff connection go once its data rides a transport the host allows it on, and take it back at will. Declare bluetooth({ release: true }) or peer({ release: true }) at the host, then call client.server.release() and later client.server.rejoin(); ifLost says whether a lost link rejoins (the default) or ends the place. The host serves a released client over that transport, shows it released, and can release its own connection with session.release() once every client is released. Nothing releases on its own, and released time is logged, never metered or billed.
  • The REST api now knows a released client, one that let its server connection go while another transport carries it, and takes it back: with the host token, POST /v1/sessions/{sessionId}/clients/{clientId}/release marks the place released, .../release-report reports the released link lost (the place waits under its reconnect budget) or the place ended, and POST /v1/sessions/{sessionId}/host-release releases the host once every client is released; a client reads its own place with GET .../clients/{clientId}/place and its resume token. A released place is held, and its span is logged, never metered or billed. A join while the host is released is refused host_released, and a session whose host stays released for 24 hours ends released_session_ceiling. The SDK verbs come in a later release
  • The SDK now ships its own hop policy, orderedHopPolicy(options), as short readable code you can copy or wrap: client.hops.use(orderedHopPolicy({ order: [bt, viaPeer] })) prefers the first ready transport in order that fits every channel's rows, waits 20 s between hops, goes back to server from a link that stays poor, skips a relayed link unless keepRelayed, and backs off from 30 s to 5 min over 6 tries, reset by a network change or a return. With no order it prefers the declared peer. @bitskiff/client/testing adds fixtures for iOS Safari, the Android wrapper and a native iOS app, with active(t), available(t), unavailable(t, reason) and ctx({ active: t }), so a policy is tested with no device.
  • A client can now hop by itself under a policy you install. Pass new BitskiffClient({ hopPolicy }) or call client.hops.use(policy), and client.hops.use(null) to remove it; with none installed nothing hops by itself. A policy is two plain functions: next(transports, ctx) names one step on each trigger (connect, prepare, offer, disconnect or waitMs, each with a why), and nextRetryDelayMs(ctx) says how long to wait after a failure. client.hops.step() runs it once and client.hops.apply(step) takes any step you computed. A step taken shows as a hop or offer event by policy, and one not taken as a decision. Your pins and blocks, the 30 s retry wait, 6 attempts a minute, a 1000 ms floor on waitMs, the row-size check and one ask at a time hold over any policy, and a raised delay is reported. Each transport now reads retryAt and asked, and the context carries network and power where the platform reports them.
  • A client can now pin, block and clear a transport. t.pin() keeps the data on t while it is active and ends when t fails; t.block() keeps any hop off t; both last for the client's place through away spells and resumes, and t.clear() ends either. t.connect() pins and t.disconnect() blocks. A hop onto a transport whose profile.maxRow is below a channel's maxBytes now resolves failed before any attempt, at the client's connect() and the host's client.connect(settings); pass { oversizeRows: 'refuse' } to hop anyway and have each oversize row refused with size. Bluetooth's 3072-byte rows are below the reliable channel default, so a Bluetooth tap needs that option until rows span frames.
  • A host can now turn peer off and on mid-session: settings.disable() moves every client back to server with cause hostOff and reads notAttached, and a host reload keeps it off; settings.enable(options?) reads available again and connects no one.
  • A client now reports every hop: client.on('hop') fires on each step (started, done, failed with the stage it reached, failing when a link is noticed dead and fellBack with stallMs when the data is back on server), client.on('decision') on a step not taken, and client.on('ask') when the ask hook is called and answers. client.hops.history keeps the last 64, on(name, fn, { replay: true }) hands them over first, and client.hops.context() and client.hops.on('trigger') work with no policy. At the host, clientChanged with change: 'transport' carries the step as hop, and client.hops.history keeps them per client. client.disconnect(settings) moves one client back once with cause hostDisconnect. peer({ silenceMs }) sets how long a quiet link is waited on, 250 to 3000 ms at either end (750 by default), and the first end to notice closes it for both; idleCloseMs closes an unused connection with cause idle (the reconnect budget by default, null for never), and allocate(waiting) picks which waiting client gets a freed connection, kept for it 30 s. A hidden host page now closes connections with cause hostHidden.
  • A native client (C, C++, Unreal, Electron) now uses transports as the TypeScript client does: it declares peer, reads each transport's record, pins, blocks and clears a transport, answers an ask only through its own hook, hears every hop, and installs orderedHopPolicy or a policy of its own. Each SDK's own tests prove this until the conformance stack carries a real peer.
  • The conformance stack now carries a real peer link: a client hops onto peer over a real WebRTC connection on the stack's own network and back to server, each reliable row once and in order, for the TypeScript client and the native core alike, so the native core's transport records, hop and link reports are proven on real frames rather than a fake.
  • A native client (C, C++, Electron) can now let its Bitskiff connection go and take it back, as the TypeScript client does: client.server.release({ ifLost }) (Transport::Release in C++) while its data rides a transport that carries control, and client.server.rejoin(). Each step is a release event, and a transport's profile reads carriesControl. In the C ABI, client.server has no connect, the state carries every transport record, and client.hops.on('trigger') is a listener.
  • The racing demo now has a client that is both a web page and an Android app from one build (apps/race-app): scan the race code and play in the browser, or open the same controller in its Capacitor shell, where it is also offered Bluetooth. A details panel shows the upgrade ladder: which rung the client is on, each transport's reading and what it costs, and the one offer it may accept.
  • A host and a client can now declare relay() beside peer(): the same WebRTC link, through Bitskiff's relay only. Neither end sees the other's address, and relayed bytes are metered to your project. Declare it at both ends with host.transport(relay()) and client.transport(relay()); leave it out and no relayed bytes are ever billed to you. orderedHopPolicy() tries peer first and relay next, and moves a client on relay to peer when the network allows. The hosted join page and the Demo host declare both, and their transport lines name each with its cost: peer direct, not billed; relay through Bitskiff's relay, relayed bytes metered.
  • An organization can now manage its members, not only invite them: GET /v1/orgs/{orgId}/members lists them with their role, PATCH /v1/orgs/{orgId}/members/{userId} changes a member's organization role, and DELETE on the same path removes a member, ending their roles on the organization and its projects and their console sessions. The last owner can be neither removed nor demoted, and nobody changes their own role. The console's members page lists the members above the invitations with a role select and a confirmed Remove, and the CLI has bitskiff members list, update <userId> --role <role> and remove <userId>; the invitation list moved to bitskiff members invitations.
  • Billing accounts are now real: an organization has one or more, created with a default one, and every project is attached to exactly one. GET and POST /v1/orgs/{orgId}/billing-accounts, GET and PATCH /v1/orgs/{orgId}/billing-accounts/{billingAccountId} (rename, the billed party's details, make it the default) and POST /v1/projects/{projectId}/billing-account (attach a project) manage them, and GET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/usage?month=YYYY-MM reads a month's roll-up: each attached project's client minutes, session minutes, relayed bytes and engine data bytes sent, for the days it was attached, and their totals. Reads take billing:read on an organization key; writes take a console sign-in. The console's billing page switches between billing accounts, shows the month and attaches a project, and the CLI has bitskiff billing accounts list|create|update, bitskiff billing attach <project> <billId> and bitskiff billing usage --month YYYY-MM. Removing a member now also ends their roles on the organization's billing accounts.
  • A limit raise above your plan's ceiling no longer waits forever: an organization owner now approves or denies it. GET /v1/orgs/{orgId}/limit-raises lists every pending raise across the organization's projects with the value asked for, the value that applies meanwhile and the ceiling; POST /v1/orgs/{orgId}/limit-raises/approve applies one when the plan's ceiling now holds it, and refuses quota_above_ceiling naming the ceiling when it does not, leaving it pending; POST /v1/orgs/{orgId}/limit-raises/deny clears one with a reason. Approve and deny take an owner's console sign-in, never a key. Both are audited, and a project learns the outcome on its webhook by subscribing to the new opt-in kinds limits.raise_approved and limits.raise_denied. The console lists the raises on the organization's limits page, /orgs/{orgId}/limits, with Approve and Deny, and the CLI has bitskiff limits raises list|approve|deny. GET /v1/project/limits now answers tokensMaxTtl for the calling key, the shorter of the key's own and the project's.
  • A project can now lower join_grants_outstanding, the ceiling on unredeemed join grants (1,000 by default), with POST /v1/project/limits/raise; it is lowered only, never raised. The register rows for client.heldReservation(), Inspection.reservationRefused and ClientCoreOptions.holderStore document what the client half of the join grant already shipped in the bullet above.
  • The protocol now carries the handoff's state, ready for the build that uses it: HandoffState on bitskiff.ctl (pending, redeemed with the successor's cnf, done, failed with its stage, and the page's transport hint), ParticipantView.holder (the holder's kind, page or app, and the successor while a handoff is under way), the participant state handingOff, and the error codes handed_off and handoff_pending a resume can answer. Nothing sends them yet, and every message a 0.2.x SDK reads decodes as before.
  • An unhandled rejection or an uncaught error now fails the test it happens in, with its own message and stack, across the SDK packages, the api and the conformance suite; a page the local conformance tier, the walks or the soak drive fails its scenario, walk or page-errors row on one, and the soak's minute line counts them.

Changed

  • A session's reconnects meter can count a return the host never read as disconnected, and that is the meter working: it counts each time the backend took a client back after its link was detached, and a client closes its own link when it returns from being out for linkDeadAfterHidden (10 s by default) or more, or from a freeze. A return over a link that held counts nothing. On a Note9, the device going dark for about 11 s in a half hour counted one reconnect with no disconnected; the device dark for under 10 s counted none (metering.md 3.2).
  • Our dev environment keeps a short list of test addresses that are never mailed, so our own checks still read their sign-in links and invitations once dev sends real mail; every other address is mailed as before, and no environment that serves customers can carry such a list.
  • The console's sign-in steps now look finished: each step (sign in, check your email, the two-factor setup and code, approving a terminal sign-in, accepting an invitation, signed out) is one card under the Bitskiff name, in light and dark and at narrow widths. Google and Microsoft sign-in show as coming soon; email sign-in works as before.
  • The console now shows its own typeface, Instrument Sans, served from the console itself with no request to another site, and its whole stylesheet now applies under its content policy.
  • You can sign in to the console with Google or Microsoft, on the sign-in page and so on the terminal approval and invitation pages too. One verified email is one account whichever way you sign in; an address its provider does not vouch for, or one nobody has invited yet, is told so. An organization owner can let a Microsoft or Google second factor stand in for Bitskiff's own with the new providerSecondFactor policy, which is off by default. Each environment turns the two buttons on once its Google and Microsoft clients are set up.
  • When a Google or Microsoft sign-in finds that your address already signs in another way, the console now offers "Send link" to add the provider to that account; when the Google or Microsoft account already signs in to a different Bitskiff account, it says so, links nothing, and offers an email link.
  • The sample host page now opens its session when you press "Open a session", not when the page loads, so a visit that never presses spends nothing against the project's sample-page bound. A reload after the press opens on load, as every load did before.
  • A registered app's handoff link on a host Bitskiff serves (the join host, the handoff host, the docs host or any other name in the deployment's zone) is now refused validation naming handoffLink, and so is a link with userinfo, a port or no host: the link belongs on your own link host, so another project's tickets can never land in an app that is not yours.
  • The sample handoff host page names no app of its own: it reads the registered apps of the ticket's project from the new GET /v1/handoff/{ticketHash}/apps, which takes the ticket's SHA-256 and no credential, and offers only the record whose handoff link is the page's own address, so "Open in the app" and the store listing are always your project's app. Our handoff host is a site of its own that serves only that page, at each of our apps' own path prefix, and its association files; the join host serves no /handoff/.
  • A failure inside the host or client SDK itself, such as an end-to-end encryption step that throws, now reads sdk_internal rather than internal, and is reported once: one log line at error naming the step, and on the host the session's error event. The host raises no error once the session has ended, and a client raises no lifecycle event after its end beyond the one that says why it ended. setGrants and boot on a host without manage now reject forbidden rather than validation. A host whose key for the current epoch failed to generate tries again on the next client rather than failing every client the same way.
  • Your app's handoff link stays on your own host, registered with the app: Bitskiff hosts no link host or join host per project, and handoff.<env> serves only our sample handoff host page. That page now says what to do on Android and iOS when the app is missing or the link opened in the browser: install, then scan the code in the app. The hosted join page now serves a web app manifest listing our demo apps, so on Android it can tell whether one is installed.
  • client.handoff() now resolves a Handoff (ticket, expiresAt, cancel()); the HandoffTicket type is gone. It no longer refuses an away page or one on peer itself: whether a place may hand off is the api's to say, and a page on peer hands off as one on server does.
  • The engine's participants read now carries each connection's ticket attributes, bitskiff.jti among them, as participant_joined carries them, on the per-session read and the bulk read alike. A client the reconciler attaches from that read is now held to the ticket's single use as the webhook's attach is: a connection presenting a ticket another connection already used is logged and attached to nothing. A row from an engine built before the field attaches as before.
  • The published packages are smaller and carry only the wire they speak: @bitskiff/host, @bitskiff/client, @bitskiff/host-node and @bitskiff/cli no longer include the metering schema, the meter catalogue or the ledger, organization key, billing and invoice message types, about a quarter less protocol code in each. Nothing on the wire changed and no name your code imports changed.
  • Only the sign-in now makes the login key: bitskiff init and bitskiff login send login: true on POST /v1/orgs/{orgId}/admin-keys, and that key alone lasts until revoked. A key you or an agent make with bitskiff keys create --scope org over the same stored sign-in gets the 90-day default unless --expires names another. login from any other credential is refused validation with details.field login.
  • A key that makes a key now learns its own expiry instead of getting less than it asked for: an expiresAt later than the making key's is refused validation with details.field expiresAt, and under a making key with no expiry, such as the login key, a requested expiry is kept (up to one year and the organization's keyMaxLifetime) rather than cut to 90 days. Naming none still takes the making key's expiry, or 90 days.
  • An organization key now reads its organization and resolves its projects' limit raises. GET /v1/orgs/{orgId} and the new bitskiff orgs show take a key holding projects:read, and POST /v1/orgs/{orgId}/limit-raises/approve and /deny (bitskiff limits raises approve|deny) take a key holding project:manage, so an agent signed in with bitskiff login no longer needs a console sign-in for them. The plan still bounds an approval: a value above it is refused quota_above_ceiling and stays pending, and the audit row names the key.
  • The organization key bitskiff init and bitskiff login make after you approve the sign-in no longer expires after 90 days: it lasts until revoked, as gcloud's stored credential does. It is revoked when the person who approved it loses the organization's admin role or leaves the organization, together with every key it made. The console's Keys page shows it as Until revoked and names who approved it, and KeyView carries approvedBy on it.
  • The CLI now keeps your organization key in the OS credential store (Windows Credential Manager, the macOS Keychain, the Secret Service on Linux), with only a marker in its config file, as gh keeps its token. Where no store answers, such as a headless Linux box or a container, the key stays in the config file, owner-only, as before. BITSKIFF_ORG_KEY still outranks a stored key, bitskiff logout deletes the key from wherever it rests, and a key an older CLI wrote to the file moves to the store the first time the CLI reads the file.
  • bitskiff keys create --scope org now works under a stored key, so an agent signed in once with bitskiff login mints organization keys as it drives the rest of the CLI. The stored key must hold keys:manage and name no project allowlist, and the new key holds only permissions the stored key holds (else forbidden, missing_permission), never outlives it, and is revoked with it. POST /v1/orgs/{orgId}/admin-keys takes an organization key under the same rule, and a console sign-in no longer steps up to mint one.
  • A stored key now revokes and rotates the organization keys it made: bitskiff keys revoke <keyId> --scope org and bitskiff keys rotate <keyId> --scope org send on the stored key when no console sign-in is stored, with no step-up. The key must hold keys:manage and name no project allowlist, and it acts only on an organization key it made or that one made, else forbidden with not_descendant (or own_line for itself and the keys above it); a rotation's successor carries no permission or limit beyond the stored key's and expires no later than it (else validation on expiresAt). DELETE /v1/orgs/{orgId}/admin-keys/{keyId} and POST /v1/project/keys/{keyId}/rotate take an organization key under the same rule.
  • A transport record now says repairing, not restarting, while a transport repairs its connection without a hop: an ICE restart on peer or relay, or the engine transport's reconnect on server. restarting still reads the same value for this minor release and is then removed, with the router's flag for SDKs that send control on the data channel; no outside app is live yet, so the old name stays only that long. The host now has host.server, the server transport's settings handle: the same shape as a declared one, always on, its enable() and disable() throw validation, and on a client's handle allow(host.server) does nothing while deny, connect and disconnect throw validation.
  • The server transport is now metered from the engine's own count, never from your host's report. The engine counts each session's data messages and bytes and its control frames and bytes on the session connection, each way, and your usage carries them as project_data_messages.down and .up, project_control_messages.down and .up and project_control_bytes.down and .up beside project_data_bytes; a session's meters (GET /v1/sessions/{sessionId}/meters) show them as engine:data_msgs, engine:data_bytes, engine:ctl_msgs and engine:ctl_bytes fields of session, up and down. The messages.* and bytes.* usage meters, which came from your host's stats report, are gone from every usage answer, and what your host reports (its traffic by topic and path, and its control counts by kind under control) is still shown on the session's meters as your host's figures, never metered.
  • Control now rides the session connection only, and the engine's data path is one transport like the others. Every control body (presence, heartbeats, grants, the key handshake, hop marks and acks, transport setup, Bye) travels on the signalling WebSocket as a frame of its own beside the signalling JSON, and the engine forwards it as signalling; the engine's data channels carry your channels and nothing else. server is a transport record of the common shape at both ends: client.server.connect() moves the data back onto it and pins, disconnect(), block() and withhold() throw validation, and its record reads repairing while the session connection's reconnect re-makes the data path, which is a transport drop and never a hop. A connected peer or relay link rides out that reconnect for reconnectBudget and closes with cause sessionLost (was serverLost) only past it. The engine keeps the old path behind -ctl-data-channel for an SDK before this release; the flag is off by default and goes in the cut after.
  • peer() is now direct only: it never goes through Bitskiff's relay and is never billed. relayOnly is gone at both ends, and passing it throws validation naming peer.relayOnly; declare relay() for a link through the relay.
  • Time a client or a host spends released is now only logged, never metered: your usage shows no released minutes, a released client adds no client minutes until it rejoins, and a released host's session minutes stop at its release. The release and rejoin events still record that the session moved to Bluetooth or peer. Bitskiff meters and bills only what its backend serves and can verify: connected time, joins, and traffic through the engine and TURN.
  • Relayed bytes are now metered only from Bitskiff's relay's own count, never from what a host or client reports, and your usage shows them as their own lines, "Relayed bytes (TURN)", beside engine bytes and never summed with them. A direct peer link is never metered, because Bitskiff carries none of it, and it no longer appears in your usage; its client's connected time still counts as client minutes.
  • Traffic on peer, Bluetooth or any other transport you declare is now only logged, never metered: it no longer counts toward a session's or client's reported bytes, its usage record or its ledger traffic. What your host reported for it is logged on the client's rejoin, the client's end and the session's end events, and the session's meters still list it by transport beside server.
  • The error a client reads when its place is over (its reconnect or background budget is spent) is now seat_spent, no longer seat_released, in the REST api, the close code 4003 and the TypeScript SDK, so "released" means only a client that let its server connection go. Match on seat_spent.
  • Bitskiff's hosted join page now hops by itself: it declares peer, installs orderedHopPolicy(), and moves onto a direct connection wherever the host declared peer and has it on. The Connect directly button and its messages are gone; a closed Details panel shows one line naming the transport and its cost (Connection: server (connected time metered), Connection: peer (direct, not billed)), says a direct connection shares the network address, and opens a Privacy page. Inside the Android wrapper the page asks for the nearby-devices permission once the client has joined, and Not now on the wrapper's words now counts as later rather than a refusal.
  • The console's first page now loads less: the projects and organization pages and the invitation and device sign-in pages load on demand, so the console's initial bundle is 491 kB, down from 513 kB and inside its 500 kB budget. No page looks or behaves differently.
  • The native SDKs (C, C++, Unreal, Electron) now carry the same transport model as the TypeScript client: relay is its own transport (bitskiff_client_relay, bitskiff::Relay()) beside peer, and a client declares one of the two; relay_only and keep_relayed are gone, and a transport's profile reads may_relay (mayRelay in Electron), never or always, in place of relay. A record carries repairing and, for this minor release, restarting beside it with the same value; a hop cause sessionLost replaces serverLost; and the core sends every control body on the session connection.
  • The native SDKs' ordered hop policy (C, C++, Unreal, Electron) now upgrades onto the declared peer alone by default, as the TypeScript client's orderedHopPolicy does: relay is an upgrade only where your order places it. A peer attempt whose link found no direct path now keeps the policy off peer until the network changes or the client comes back.
  • @bitskiff/host now ships only the route rows it calls, not the whole route table: a bundled host page is about 18 kB smaller (the racing demo's is now 1,039,626 bytes), and a client page built beside it no longer carries the table in the chunk the two share (the racing demo's client page went from 390,058 to 372,979 bytes).
  • The host's client.disconnect(s) now takes { retryAfterMs }, and the record of that transport reads failed until nextAt, 30 seconds later by default, at the host and the client alike, so an installed hop policy waits that long before moving the client back; retryAfterMs: 0 reads available at once. Before, it read available straight away and a policy could hop the client right back.
  • EngineTransport.flush() now takes { signal }, as connect does: the caller's signal is the bound, and with none the drain stops at 300 ms by itself. The SDK hands its own 300 ms bound in as that signal, so a drain stops when its caller stops waiting.
  • The CLI now reports its own faults, such as an answer it could not read or a route it has no registry row for, as sdk_internal with exit 1, rather than internal with exit 9, which means the service failed.
  • bitskiff init and bitskiff login now report an answer missing a field they need (a key made with no key string, a new project with no slug, a sign-in with no refresh credential) as sdk_internal with exit 1 too, rather than internal with exit 9.

Fixed

  • The console's Continue with Google and Continue with Microsoft buttons are now live only where that sign-in is turned on for the deployment, on the sign-in page and so on the terminal approval and invitation pages too. A provider that is not turned on shows its button disabled with a sentence saying so, rather than a live button that fails. The console learns which are on from GET /v1/auth/sign-in/providers, which answers each provider with enabled and no client id.
  • Signing in to the console with Google or Microsoft now reaches the account that Google or Microsoft account was first linked to, never another account that shares its email. Before, a provider account whose email was changed could sign in to the account at the new address without that account's second factor. An email that matches an existing account now only offers the link, which the person makes from that account with its own sign-in and factor. A refused sign-in now removes only the sign-in record it just created.
  • A native app's first join that gets its ticket but never hears from the session is now told, as a web page's is: at the place's own deadline it asks api once more and ends reconnectBudget with seat_spent when the place is gone, and an api that does not answer leaves it suspended with budgetSpent at the end of the joining budget. Before, a connect or Hello that went unanswered after the ticket kept it joining with no word at all.
  • An app started again after a stop that cannot take its place back is now told inside the place's own budget: client.resume() rejects and the client ends reconnectBudget (or backgroundBudget) within about two seconds of the instant the host releases the place. Before, a start that sent nothing stayed on joining with no word for the rest of the two-minute joining budget.
  • npm run admin-key now refuses to run a CLI build older than the CLI's source and says to rebuild. Before, an old build in the checkout refused --scope org with "an organization key is created in the console", although a key with keys:manage makes organization keys under the stored credential.
  • A handoff redeemed in the instant a client's reconnect or background budget runs out is now refused seat_spent, as the place is over. Before, the redeem could hold the spent budget for the rest of the handoff window and keep the place alive.
  • A client can no longer skip the host's message rate by sending made-up hop markers: the host now reads a hop marker only for a transport it is actually connecting or connected to that client on, so a client flooding messages is closed after ten seconds as before.
  • A client can no longer earn free re-sent messages by repeating a hop to a connection and back without the connection failing: the host frees re-sent messages once per connection, so a client flooding that way is closed after ten seconds as before.
  • The sample host's "Too many session opens" message now says whether this key, this project or this network opened too many sessions, as the refusal names it, instead of always blaming the key.
  • An app that stopped and resumed its place now keeps every reliable message on peer, both ways. Before, the resumed app never read the host's messages on the direct link, so the host fell back to server a few seconds into each peer connection and the messages of that connection were lost; and the host dropped the resumed app's first messages on the link as repeats.
  • The C++ SDK and the Unreal plugin now do the same: a native app that stopped and resumed its place keeps every reliable message on peer, both ways, instead of waiting on messages from before it stopped.
  • A client back from away inside its background budget is no longer released when it returns over a resumed link: the host reads it present again on its return and its background budget stops, where before the host kept counting and ended the place backgroundBudget with the client back.
  • When the host moves a client off peer because the link stopped delivering its rows (a passed hopRetainBytes, hopHoldBytes or hopAckWait), the host's clientChanged and the client's transport status now give the cause stalled rather than failed.
  • That same move now reads stalled in the client's hop history too (client.hops.history, its failing and fellBack events), rather than silence: when the client's link closes under it, the client waits up to a second for the host to say why before it writes the move.
  • The C++ SDK and the Unreal plugin now do the same: that move reads stalled in a native client's hop history too, after the same wait of up to a second for the host's word.
  • client.resume() no longer waits forever when the device's storage does not answer while the client writes its key and record before reconnecting: the joining budget now covers that wait, so the client is suspended with budgetSpent and resume() rejects, instead of sitting on joining and sending nothing.
  • GET /v1/handoff/{ticketHash}/apps, the handoff host page's keyless read, is now limited per client address like the other keyless routes: past 30 reads a minute from one address it answers rate_limited with handoffAppReadsPerIp and the wait, and the page shows the page-only path.
  • A client whose link went down no longer ends on its reconnect or background budget while the resume it asked is still unanswered: the budget is held, the client stays joining and keeps asking, and api's answer moves the budget or confirms the end, as a host's grace already was.
  • A client whose place api ends on its reconnect or background budget now ends budgetSpent on that budget, the same lifecycle row its own clock gives, rather than bye.
  • The credentials doc now says what deviceSignIn: refused does to the keys a login key already made: they keep working until revoked or expired, as the keys a GCP user created stay after the user's access is revoked, and each is listed with createdBy naming the login key. Revoking the login key ends them with it.
  • npm run admin-key now revokes its key however the run ends, a thrown error and SIGINT, SIGTERM or SIGHUP included, and its key lives one hour unless --hours names a longer life, never the api's 90-day default.
  • An app that takes back a place a handoff gave it, with client.resume(), the link the place came by or a scan of the code, now reads client.state.holder 'app', not 'page'.
  • A client whose reconnect budget has passed can no longer resume its place in the moment before the service releases it: the resume is refused seat_spent, so a host that read the client leave never reads it join again.
  • A handoff your app has redeemed is no longer lost when the page asks for a new handoff ticket at the same moment: that request is refused handoff_pending, and the move to the app finishes. A refused redeem of the same ticket no longer ends one that passed.
  • A handoff's commit now moves the place and ends the handoff in one step, so the host never reads a place that is neither the page's nor the app's, even when the page's resume lands during the commit.
  • An app whose handoff was cancelled, lapsed or lost its place while its connection was arriving is now disconnected, and that connection can never be used again: the page keeps the place. Before, the app could connect as an ordinary client while the place was still the page's.
  • A handoff redeem of a place whose reconnect or background budget has passed is refused seat_spent, as a resume is, so a client that ran out of time is not brought back by the app.
  • A handoff path on our handoff host now belongs to one project: a registered app's link there must be the path our apps table gives that app, and a path another project registered is refused validation on handoffLink. A record saved before this is refused the same way when a handoff reads it.
  • The handoff data and the stored redeem answer now leave the service when the place ends during the handoff, not up to a minute after the handoff's window.
  • A host no longer moves an away client's data back onto the server transport while the client is away: the move waits until it is back, as its sends do.
  • A browser host or client on the server transport now gets its held control messages out as soon as a short network blip ends, not only after a full reconnect; a stale answer from a connection already let go no longer closes the one that replaced it; waiting for the reliable send queue to empty stops after 300 ms rather than polling until the connection closes; and a dropped lossy send is reported as bufferPressure only when the send buffer was full, with drops while the link is not carrying logged under their own reason.
  • The host session now holds together when two things happen at once. A session that ends while it waits on the api or a connect no longer attaches again afterwards. Its own close() ends hostClosed even when the api's Bye or a scheduled reconnect arrives during it, always ends the session, and rejects only with a BitskiffError. An engine resume that gives up is followed by a reconnect rather than the wait for the host grace, and one reconnect runs at a time. A rejoin() that races a released session's end reads failed, and a persistence slot that refuses a write no longer fails an open or a reconnect.
  • A client now runs one join attempt at a time. A join request or connect that never answers suspends the client once its joining budget is spent, where before it waited forever; an attempt overtaken by a suspend or a newer attempt connects nothing and sends no Hello. A second join() while the first is still starting is refused with validation, and a second leave() sends no second Bye. dispose() now fires a state event and calls off a leave still retrying, a suspended client stops its probe, stats and deadline-warning timers, and a background budget spent before the first Welcome suspends the client rather than ending it. A failure in work the client does not wait on (a presence post, a rejoin under disconnect()) is now logged as an error instead of being lost.
  • The host keeps each client's state straight through a handoff, a reload and its own absence. A host page that reloads still holds each bound client to its key, so a client that returns with another key is booted key_unverified. A handoff that has been committed never goes back to pending on a late copy, and a handoff that fails while the app's key waits for its token boots that app key_unverified instead of leaving it unanswered. A client that leaves takes its handoff with it, a client that leaves while its key is being agreed leaves no key behind, a spent budget's question to the api waits while the host is away instead of ending the place with no clientLeft, and a client change or left listener that throws now raises listener_error on the session, as a session listener does.
  • An end-to-end encrypted client no longer sits unbound when its KeyConfirm cannot be sealed or its confirmation digits cannot be computed: the confirm is retried and, past the third retry, the client ends hostUnverified as protocol.md 10.4 says. A key mint that failed is tried again on the next Hello rather than failing every one after it, and a fresh join after a spent place never presents the old place's key. A committed handoff stays done when a late redeemed or failed arrives, and an app that finds at its Hello that it cannot do the place's encryption withdraws its redeem so the place is the page's at once. A reliable message sealed while the connection dropped is held and sent on the connection that comes back, a sealed message lost after it was answered is logged once, and join() rejects rather than resolves when the client ends while its record is being stored. A failure to load the direct path now drops only the waiting work of the client that asked for it, and a direct signalling message that cannot go out is logged.
  • Security: an end-to-end encrypted host now holds a client to the key it was first bound under for as long as the place lasts. A client whose connection dropped and comes back with a different key, a KeyConfirm that names another key, or an app that took a place through a handoff and returns with a key its token did not vouch for, is booted key_unverified. Before, a dropped connection let the host agree a fresh secret with a new key, and only an honest client's own check refused it.
  • Every handoff that ends without the app taking the place is now told to the page: client.state.handoff reads failed with its stage when the app's redeem is refused (refused), when the ticket lapses unredeemed (minted), and when the evicted page's resume finds the app gone (crashed, on the resume's new handoff_failed), and the join page's offer comes back with a fresh ticket. The host now reads a refused redeem as clientChanged handoff failed at refused, with its connection unchanged. An app that refuses the place on its own check after its redeem ends with lifecycle reason localRefusal and reads its handoff failed at refused.
  • A client in an end-to-end encrypted place now keeps the host fingerprint it verified in its stored record, and a reload is checked against it: an entry that names another host for the same place ends hostUnverified before anything connects, leaves the place and deletes the record, never re-binding to the newer host.
  • A client holding a host fingerprint, from its join code, its handoff ticket or its stored record, now refuses a session the api answers as unencrypted: it ends hostUnverified before it connects (an app cancels the handoff first) instead of joining unencrypted. A join code with no fingerprint follows the api's answer as before.
  • A host send checks size, then the byte cap, then pressure, as the client does: a message too large for a client's transport, or over the byte cap, reads size or rate while the seal backlog is full, never pressure.
  • An end-to-end encrypted host whose sealing falls behind its send rate now drops the new send with { dropped: 'pressure' } once 256 sends are waiting to be sealed, the way a full reliable queue does. Before, every waiting send stayed in memory until it was sealed, so a long session sending faster than it could seal grew without end. A seal or an open that fails now reaches session.on('error'), and the sends and messages behind it carry on. Before, a failed open stopped every later seal and open in the session.
  • A host whose reliable send queue is full now logs one warning when the first send is dropped and one line with the count when the queue drains. Before, it logged a warning for every dropped send, which at a hundred sends a second stalled the page itself.
  • A host send now answers in the order the reference gives: a client that is not in the session reads noClient before a missing grant reads noGrant, and a session that is not open reads notOpen before a full seal backlog reads pressure. A reliable send to an away client is held in its queue of 64 and goes when the client is back; before, it was published to a client that was not reading it.
  • Under end-to-end encryption a host send now goes where the session and the client are when its seal finishes, not where they were when it was sent, so a send sealed while a client came back is no longer left in a queue nobody flushes. A sealed send dropped after it was answered 'sent' (a full queue, a client with no key yet) is logged once per run of drops, with the count. A rekey that was running when the host came back on a new epoch now installs nothing, and a client leaving while a rekey starts no longer causes a second one.
  • The host's direct-path signaling no longer leaves an unhandled rejection: a signaling step, an offer or an ICE restart that fails reaches session.on('error'), the attempt closes as a failed connection, and the steps behind it carry on. A sealed direct-path body no longer overtakes a message sent after it, which the client used to drop. A relay credential call that fails, and a late close that does not open, now log why.
  • An end-to-end encrypted client now drops a new send with { dropped: 'pressure' } once 256 sends are waiting to be sealed, the same budget as the host. Every other end-to-end encryption step that fails, on either end, now reports it and the steps behind it still run: a key agreement, a key confirmation, a rekey, a direct-path signaling body, or a seal or an open on the client. The host reports it on session.on('error') with code internal; the client, which has no error event, logs it at error. Before, one such failure was an unhandled rejection that could stop every later seal and open in the session.
  • A host page that reloads and resumes its session now has its clients in session.clients when open() (or resume()) resolves, and their return to the new page raises no clientJoined. Before, the reloaded page's roster was empty until each client said hello again, and then every client already in the session arrived as a new clientJoined.
  • The route profile a soak run reads now has its frames again on the session's default transport: the transport stamps each frame when it hands it over and samples the connection's round trip, so the route split and its per-frame segments are measured. Before, every frame read as never handed over and the split was empty. Nothing changes for an app: the profile is off unless a test harness turns it on.
  • A host that opens session after session, such as an unattended host under openForever, no longer keeps every ended session in memory. A channel object from host.channel() held each session it had served so its counters could sum them; it now adds an ended session's counters in once and lets the session go. The counters read the same.
  • A host whose connect to a node was answered and then dropped before it carried now asks for a new host ticket and comes back. Before, it presented the spent ticket again, the api removed that connection as a replay, and the host ended participant_removed, leaving its session with no host and its clients waiting (seen once after a node loss on dev).
  • A handoff cancel from a browser or an app's web view now reaches the api: its preflight was refused because the api did not allow DELETE cross-origin. Before, an app that refused the host before it connected left the host reading handingOff and the page pending, where now they read failed{cancelled}. In an encrypted session the host also no longer boots the app's key when it reads the commit off a roster view before the app's Hello: the key is bound under its cnf, as it is when the api's done comes first.
  • A host that resumes its session from persist on open() now counts each client's messages and bytes once. Before, every restart counted again everything each client had sent since it joined, so a host restarted five times metered about twice its traffic. resume(hostToken) was not affected.
  • In an end-to-end encrypted session, a handoff link whose host fingerprint was changed no longer costs the page its place. The page's mint now sends the host key its Welcome verified, the redeem answers it to the app, and joinWithHandoff checks it against the fingerprint in the ticket before it connects. On a mismatch the app does not connect: it ends hostUnverified, cancels the handoff, and the host reads handingOff, then failed at stage cancelled, with the page still holding the place. Before, the app connected first, the place moved to it, and the app then ended.
  • In an end-to-end encrypted session, an app whose join token was minted for another key no longer costs the page its place. joinWithHandoff now sends the thumbprint of the key its Hello will present on the redeem (HandoffRedeemRequest.key_thumbprint), and api compares it with the token's cnf before anything moves: a token with no cnf, or one that names another key, is refused key_mismatch (403), the ticket is spent, the app ends refused with that code, and the page keeps its place. Before, the app connected, the place moved to it, and the host then booted it keyUnverified. The host still checks the key the Hello brings.
  • @bitskiff/host-node now exports the LinkGrade type, as @bitskiff/host does.
  • In an end-to-end encrypted session the host now reports the app's key when the app takes a place by a handoff: a clientChanged with change: 'e2ee' carries the app's confirmation digits. The race demo app now opens an encrypted place's handoff link, whose ticket carries the host fingerprint after a ..
  • A page whose connection drops while its handoff is pending now keeps connection: 'handingOff' on the host until the app takes the place or the window ends, with no disconnected change and no budget running. Before, the host read it disconnected and lost handingOff. If the handoff fails, the host now reads the page disconnected and its reconnect budget runs from then.
  • A client the host refuses before it is welcomed now ends with the host's reason. A client whose key the host cannot verify ends keyUnverified, and one that cannot run end-to-end encryption on a required project ends refused with errorCode e2ee_unsupported. Before, it could end booted with no reason. A client that ends itself on its own check now reports the lifecycle reason localRefusal instead of bye.
  • Our own tools no longer ask the owner to sign in: npm run demo and npm run walk:dev run under the CLI's stored credential through npm run admin-key, with no device code and no second factor, and the demo makes the projects and keys its hosted pages run on under the stored credential itself. A deploy whose bench run reads no readout now runs it once more a minute later before it rolls dev back.
  • After a handoff, the app now moves up to peer the way a fresh join does. The host SDK tells the app's client its transport records once the app takes the place, and offers it peer when it asks. Before, the app stayed on server for the rest of the session. A page whose handoff fails gets its peer back the same way.
  • An app that takes a client's place by a handoff and stops before it sends anything is now read disconnected by the host within about 3 s, and leaves at the end of reconnectBudget, where before the host read it connected until the session ended. A handoff the app redeemed and never attached now ends failed at stage redeemed when its 60 s window ends: a mint from the page while the app holds the redeemed ticket is refused handoff_pending (409, retry_after_ms the window's remainder) instead of cancelling the move.
  • While a client's handoff is pending, the host SDK now serves the page as it did before: its messages go out at once, its peer link stays up and its transport records hold until the app takes the place. Before, the host queued its messages to the page and closed its peer link from the redeem, so the page's data fell back to server and its acks stalled. The host SDK also counts the app's silence from the moment it takes the place, so an app that stops before it sends anything is read disconnected about 3 s later.
  • A join no longer hangs on a browser or WebView whose WebCrypto lacks X25519 or Ed25519 (Chrome before 133, Samsung Internet 23, an old Android System WebView). The client SDK now tries both once when the client is made. On a session with encryption off such a browser joins as any other does, with no key on its Hello. On a session that requires encryption inspect answers e2eeUnsupported and join rejects with e2ee_unsupported before the join request; joinWithHandoff, which cannot know first, joins with no key and the host turns it away. Either way, and when a Hello's key cannot be made, the client ends refused with ended.errorCode e2ee_unsupported, the join rejects, the place is given back, and state.handoff reads failed at refused on an app that arrived by a handoff.
  • A handoff in an end-to-end encrypted session now stays encrypted: the app's client presents a key of its own when it joins with the handoff ticket, the host binds that key only when the app's join token cnf names it and boots it keyUnverified otherwise, and a host opened with e2ee: 'required' boots any client that presents no key instead of serving it plain.
  • A client that takes a subject at a handoff now counts against that subject's one place, so the same person cannot join a second session while the app holds the first; and a handoff that pauses a client's budget can no longer put that budget back on a client the host has just let go.
  • A handoff now holds up end to end: the commit puts the app's token cnf on the client and gives a client that joined with no subject the app token's subject, so the page's old token is no longer accepted; an app that repeats its redeem with the same Idempotency-Key gets the same token and ticket back instead of new ones; the host's client list shows who holds a client and the app it is handing off to (ParticipantView.holder, state handingOff); a boot or the session's end in the window cancels the handoff, so the app's redeem reads seat_spent; a budget that starts during the window is no longer pulled early when the handoff ends. The page's end and its wait have reason rows of their own (handed_off on the client, the lifecycle reason handoff_pending).
  • A prompt can no longer steer the CLI's stored credential through bitskiff mcp: the config verbs and logout are no longer tools, so no tool call moves the endpoint the key is sent to or drops the key, and --endpoint and --profile stay the server's own.
  • A config file an older CLI wrote no longer keeps the organization key in it until some command writes the file: the first command that reads it moves the key into the OS credential store, rewrites the file owner-only with only the marker, and says so once on stderr. Where no store answers, the file stays as it is.
  • An owner who sets deviceSignIn: refused now stops every machine already signed in, not only new sign-ins: a login key minted before the switch is refused forbidden with details.reason = "policy" and details.policy = "deviceSignIn" on every request in that organization, and acts again when the policy is lifted. A key a person made for CI or a server is not affected.
  • A key now patches, rotates or revokes a project key only when that key's permissions, limits and subjects are inside its own: a key holding keys:manage and no session word is refused forbidden missing_permission on a key holding sessions:open, and a refused rotate hands out no successor, where before the check read only the words the request named.
  • An organization key now needs keys:manage and no project allowlist to list the organization's keys on GET /v1/orgs/{orgId}/admin-keys, the organization admin relation the writes already check: a key holding only project:read or projects:read is refused forbidden naming the permission, a keys:manage key bounded to some projects is refused forbidden naming the relation, and a console session is unchanged.
  • A key now patches, rotates or revokes a project key that leaves a limit empty only when its own value is no lower than the project's, since the empty row inherits the project's value: a key holding concurrentSessions: 2 is refused quota_above_ceiling on a key that names no concurrentSessions, where before the empty row was not compared.
  • The console's Keys pages now show when each key was last used: every key's use stamps lastUsedAt, at most once a minute per key, so a login key no longer reads never.
  • A handoff ticket is now spent only by the registered app: a redeem with any other client key is refused wrong_client and leaves the ticket unspent, so the app's own redeem still takes the place, where before the refused redeem had already spent the ticket and the app read handoff_invalid spent. The ticket stays single use: of two redeems that race, one wins.
  • The CLI now protects the organization key it stores: the config file is written owner-only on every platform, with an owner-only ACL on Windows, and says so when it cannot restrict the file.
  • A staff key never touches the CLI config: the CLI reads BITSKIFF_ORG_KEY, with BITSKIFF_ENDPOINT and BITSKIFF_ORG, before its config, and npm run admin-key puts the key it mints in the environment of the shell it starts, or of the command --exec names, instead of handing it to bitskiff login.
  • A developer's bill no longer carries a booted client's relayed bytes: a relay credential cannot be revoked, so a client the host boots (the relay flood rule boots one at once) can keep relaying until its credential expires, and what Bitskiff's relay counts on it from the boot on is now kept as relay-measured usage billed to no project, counted on mrc_relay_bytes_total{kind="booted"}. The bytes before the boot bill as any relayed byte.
  • A client the host boots for flooding its relay connection now ends booted at once: the host announces the boot with Boot before it removes the client, as any boot is announced, where before the client read a rate close, backed off and, under the default 10 s reconnect budget, ended reconnectBudget.
  • A client the host boots for exceeding a rate (ten seconds over the message rate, or past twice its channel's row) now reads booted at once: a host holding manage announces the boot with Boot before the boot route closes the client 4001, where before the client read the rate close 4100, backed off and could end reconnectBudget. A host without manage still closes such a client with the rate close 4100.
  • A booted client's relay allocation now ends at the boot, not when its credential expires: the boot route has Bitskiff's relay cancel every live allocation of the booted client on every relay node, without the boot waiting on it, so nothing more is relayed toward the host on it. The credential itself still cannot be revoked, so an allocation the client makes afresh is billed to no project, as above.
  • A key's own tokensMaxTtl now binds: a token minted on a key holding a shorter value than its project's is held to the key's, both at POST /v1/tokens and every time the token is presented, so lowering the key's value ends the longer tokens it allowed. The tighter of the project's and the key's value wins, and a mint past it is refused validation on ttl with details.limit tokensMaxTtl and details.scope naming which of the two bound.
  • An organization key now needs members:invite to list, send or revoke invitations: a key without it is refused forbidden naming the permission, as the members routes already did, and a console session is unchanged.
  • A local engine run stays on loopback: -loopback now rewrites an empty or 0.0.0.0 host in -listen, -internal-addr and -metrics-addr to 127.0.0.1 (an explicit address is kept), so the engine no longer listens on a LAN interface or prompts the desktop firewall.
  • Three limits fixes: an experimental organization can now raise linkMessagesPerSecond up to its ceiling of 120 instead of every raise being held pending; a samplePageSessions refusal now records clears: never in open.refused, because a limit's own clears wins over its code's; and the sample join page shows "This page is a sample" for a sample-page refusal instead of the usage-limit message.
  • The console's organization pages can be reached from the console: the projects page and every organization page carry a row of links to members, organization keys, limit raises and billing, the current page marked and a link back to the projects. The console's first load is also lighter, down from 500 kB to about 445 kB, because the message codec and the pages' words now load with the pages that use them.
  • The Demo host's Connect over Bluetooth tap works again: each of its channels now declares the row size it really sends, so the row-size check against Bluetooth's smaller rows passes without an option.
  • In the Unreal plugin, a transport's record now reads the support its handle reads: a peer withheld before Join reads unsupported in GetTransportRecord at once, where before the record could keep its old support until the session restated it.

Removed

  • The owner tools' device-code sign-in helpers, which nothing has called since npm run demo and npm run walk:dev moved under the stored credential. The device-approval routes, bitskiff login and bitskiff init are unchanged.

[0.2.2] - 2026-10-30

Added

  • The Demo host's Transports line now shows every record of each client as the host sees it: server, peer and each declared transport with its status and reason, its support word, whether it is connected, the link's grade, round trip and whether it is relayed, and who moved it last and why (by, cause). It reports only; it asks the person nothing.
  • The SDK now asks only your ask hook before anything a person would meet. Pass ask to new BitskiffClient({ ask }), or to a transport's own peer({ ask }) or bluetooth({ ask, name }), and call t.prepare() when you are ready to ask: the hook answers granted, refused or later, and prepare() resolves the new support. Without a hook nothing is ever asked and the transport stays needsAsk. client.asking names the ask in flight. A Bluetooth client whose permission is not yet granted no longer advertises when the host taps; its host's tap fails until the page's hook grants it.
  • Each transport record now says whether this client can carry it and in which role, at both ends: support reads ready, needsAsk, asking, refused, unsupported or unknown, and every transport handle has a read-only profile, readable before join or open. Call withhold() on a transport before join to keep this client off it; the host reads it unsupported.
  • Each transport record now says whether it is connected and how good its link is, at both ends: connected, and while connected a link with its grade, round trip, jitter, loss, whether it is relayed and, on server, the path. The client grades its own links and the host shows the reading the client sent. A record also says when a connection is restarting and, once it reads available again, lastFailedAt, and t.on('status') and the host's clientChanged say who moved it (by) and why (cause). state.transport and TransportInfo are gone: read a client's engine path as state.transports.server.link.path and its peer connection as state.transports.peer.connected.
  • The protocol now carries each transport's lifecycle between the client, the engine and the host: which transports each side supports and any change to that support, the health of each link in the heartbeats, who set a transport up, and why one closed. The SDKs can now show a developer what each transport is doing and why it ended.

Changed

  • The Android wrapper's Bluetooth asks for nothing on its own: starting Bluetooth no longer raises the nearby-devices permission, and fails as not allowed until it is granted. Only the join page's bluetooth({ ask }) hook raises it, with the wrapper's own words first and then Android's dialog.
  • The connection from a client straight to the host is now called peer, since the server path is WebRTC too and only the far end differs. Read each record under transports.peer; the options types are PeerOptions and ClientPeerOptions. Nothing else changes in how it behaves. p2p is no longer read anywhere: a host's saved allow and deny lists from an earlier release start empty, and api counts only peer traffic rows as the client-to-host transport.
  • peer is declared at both ends like bluetooth(): host.transport(peer(options)) before open or resume returns the settings handle you pass to client.allow and client.deny, and client.transport(peer({ relayOnly })) before join returns the client's handle with connect() and disconnect(). A host or client that declares nothing has only server. The host's peer(options) takes allowed, relayOnly, maxConnections and peerConnection. open({ peer }), resume(token, { peer }), session.peer, client.peer and new BitskiffClient({ peer }) are gone, and from JavaScript passing peer to open, resume or the constructor throws validation naming the option. Declare peer() again before resume and the saved allow and deny lists come back. The hosted join page and the Demo host declare peer().

Removed

  • The names 0.2.x kept for one release are gone, and the relay is Bitskiff's alone. Set relay-only with relayOnly on peer, at the host and the client: the direct option, HostDirectOptions and ClientDirectOption are removed. Your own STUN and TURN servers (iceServers) are no longer accepted, so relayed bytes are always Bitskiff's and metered to your project. Passing direct or iceServers is a type error, and from JavaScript open and new BitskiffClient throw validation naming the option. Transport now names only the transport handle (client.server, client.peer); the engine seam is EngineTransport. createNodeTransport() takes no argument and NodeTransportOptions is gone, as are TransportPath (use EnginePath) and the exported CLIENT_RECORD_KEY (a page reads its record under clientRecordKey(key)).

Fixed

  • The engine's metrics are no longer reachable from the internet; they were labelled with session ids, client ids and channel names. The rtc. address now answers only the signalling path, and every other path there answers 404.

[0.2.1] - 2026-10-29

What's new

  • See which key let each client in and the origin it came from, so a leaked key shows up.
  • Turn p2p on or off for each client. A client page starts p2p only when the person asks for it.
  • p2p carries reliable messages as well as lossy ones.
  • Attach another transport beside the server and p2p. Bluetooth is the first, from an Android app.
  • Open your own Android app from the host's join link.
  • Sessions carry on when an engine node is lost, and the host shows how long each place is held.
  • The join page loads less on its first visit.
  • A client can no longer flood the host or another client, or guess join codes past the limits.

Before you upgrade

Four changes can break a 0.2.0 app. A client page must now call client.p2p.connect() to start p2p, because p2p no longer starts by itself. A session whose defaultGrants gives every client a host-only grant is now refused. Code that imports TransportFactory for the engine must use EngineTransportFactory, and token mints are now limited per project as well as per key.

Added

  • The host now sees which client key let each client in and the origin it came from, so you can tell your join page's clients from your own app's, and a key seen from an origin you do not own has leaked. Read them in client.state.keyId (the key's id, never the key itself) and client.state.origin.
  • The client's state also says whether the browser sent that origin or a native app declared it, and a declared origin is never treated as one the browser enforced. The console's session roster shows the key id and that source beside each client.
  • Turn p2p on or off for one client with client.allow(session.p2p) or client.deny(session.p2p). A deny wins over an allow, and the host keeps both in its saved state, so they survive a reload.
  • Each client's state now lists its transports, the server and p2p, at both the host and the client. On the client page, client.server and client.p2p report each one's status as it changes.
  • Attach another transport before open or join, with transport(factory) on the host or the client, the same way channels are declared. It reads and closes the way p2p does, and a message larger than its link carries is refused with size.
  • Bluetooth is the first such transport. bluetooth() carries data between a client in an Android app, which advertises over Bluetooth Low Energy, and a browser host page, which connects to it over Web Bluetooth when the person taps, with Client.connect(settings).
  • The Bluetooth transport reports not attached in every other browser and in Node. It is tested in a single process, with the run on real devices still to come.
  • Your own app shows the nearby-devices prompt through bluetooth({ ask }), and the SDK asks only when the host first reaches for the link. The host sees asking, then advertising or refused, and a refusal ends the attempt at once while the session carries on over the engine.
  • Your own Android app can open from the host's join link. Claim your join domain as a verified App Link with /.well-known/assetlinks.json, and the link opens the app when it is installed and the browser when it is not.
  • Usage records for a session and for a day now carry the data bytes the engine measured each way, in three dataBytes fields (down, up and grade). They read 0 until graded, and the webhook and the CSV export carry them too.
  • See your project's sample page session count and both its bounds without opening a session. The console's limits panel and bitskiff limits show them, and GET /v1/project/limits returns them.
  • A script can write a new key straight to a file. bitskiff keys create --only secret (or --only id) prints that one value and a newline, without the JSON body.
  • A client page can show its join code, or compare a link against it, without reading storage. client.code holds it.
  • The session's meters count each kind of control message a client sends, and are never billed. Oversized hello and ping messages are refused.
  • The hosted sample client page shows a Connect directly button when the host offers p2p, with the consent notice before the tap and Connecting, then Direct, while it switches. It has a Disconnect action, says in words why the host refused or the link failed, and never connects on its own.
  • The console's session meters show the messages and bytes each transport carried in and out (server, p2p or your own), as the host reported them and never billed. The session meters route and the traffic snapshot event carry the same figures as transports.
  • When an engine node is lost, the SDK notices within about 2.5 s even if no connection closes, and moves to the session's new node on its own. The host shows each missing client as disconnected, with how long the api holds its place.
  • That held time is the one the api enforces. The host and each client keep their place until the move's deadline, and a client that never reaches the new node is released at that instant.
  • The server benchmark page reads like a speed test at a venue. It shows one live figure while it runs, then the typical and 95th-percentile times and a rating graded on what your connection adds, beside the distance to the engine. It says why a result is below Good, what could be installed here and what refused a run, keeps Wi-Fi, Data and Wired runs side by side, and sends only what a default session can send.
  • The benchmark page's Two devices mode has a second device scan a QR code, then measures the client-to-host leg both on the venue's network and through the engine. Copy results puts the result on the clipboard as version 5 JSON.

Changed

  • Every docs page now carries a notice that the pages describe the SDK as of 0.2.0 and are stale until the transport rework ships.
  • p2p no longer starts by itself. The client page starts it with client.p2p.connect(), which resolves connected or failed and never throws, and ends it with disconnect().
  • The host makes a p2p offer only when a client asks for one, and limits the attempts, the size of setup messages and the number of p2p connections it holds. The declined close reason is reserved and never sent.
  • When p2p fails, the client falls back to the server by itself and does not try again on its own. The p2p record says why it is not in use: failed until its nextAt time, notAllowed after a deny, unavailable while the client or the host is away, or available after disconnect() or an idle connection.
  • A network change restarts the path of a live p2p connection in place, instead of ending it.
  • p2p carries an ordered, reliable channel beside its lossy one, so reliable messages survive ordinary packet loss without falling back to the server. The p2p record reads active only once both are open, and messages on the link count on the p2p traffic row.
  • When a p2p link's relay credential is about to expire, the link closes as failed and the client page connects again. The host no longer offers again unasked.
  • TransportFactory now names only the factory of a transport you attach, such as bluetooth(). Code that imports it for the engine must use EngineTransportFactory, because the old name has no alias.
  • The other engine connection types are renamed EngineTransport and EnginePath. The old names answer for one release (see Deprecated).
  • Token mints are now limited per project as well as per key, as the limits table says. limits.tokensPerMinute sets the project's rate (default 600), and each key is held to the lower of 120 and that rate.
  • The join page's first load is smaller. @bitskiff/client bundles only the api routes it calls, loads the QR scanner only when a client taps Scan, and loads the engine connection lazily.
  • The p2p code loads only once a client's p2p record reads available or the page calls connect(), so a page whose host never offers p2p never downloads it.
  • Installing @bitskiff/host-node now brings its WebRTC binding, node-datachannel (MPL-2.0), as its own dependency. The package no longer asks for a browser peer it never loads.

Deprecated

  • Transport in @bitskiff/host and @bitskiff/host-node still works as an alias of EngineTransport until 0.3.0.
  • createNodeTransport() takes no options. NodeTransportOptions remains only as an ignored empty option until 0.3.0.
  • The direct option is deprecated for one release and maps onto p2p. Turn p2p on with the p2p option on the host's open and resume and on the client's constructor.
  • TransportInfo and TransportPath are deprecated for one release. In their place, read state.transports and client.link.

Removed

  • Bitskiff runs one engine, its own, behind every session. No SDK, REST or CLI setting names it, and nothing you write changes with it.
  • The SDK packages no longer depend on the 0.2.0 engine's client libraries. An app that installed those libraries only for the SDK can remove them.

Fixed

  • A native client built on the core now hears its host: the core names the binding's channels when it joins and announces any it adds later, a text holding a zero byte arrives whole because bitskiff_client_send_text takes a length, and every native binding reports its device class.
  • A session opened with defaultGrants now gives each joining client exactly that grant set, instead of the full controller set. An open whose default set holds a host-only grant is refused as validation, naming the field.
  • A client can no longer fill another client's backlog or its download meter, even if it learns that client's id. A client's messages reach only the host, at a fixed rate.
  • A client the host grants receiveInput during the session now hears the clients that send input.
  • A host or resume token used on another session is refused as session_not_found, so a stolen token cannot tell which sessions are live.
  • A client can no longer flood the host with control messages or the api with presence changes. The host allows 20 control messages a second per client and closes a sustained flood, and presence changes are held to one a second and 60 a minute per client, even when the client re-sends hello.
  • A client the host closes for sending too fast is removed at once and cannot come back, whether or not it honours the close.
  • Wrong join codes sent in a parallel burst from one address are held to the per-address bad-code allowance, back-off and hard cap. A guessed join token counts against the same allowance as a wrong code.
  • A page refused as rate_limited reads the limit's name, scope and wait, but never the allowance.
  • While the api rebuilds its quota counters after losing its cache, a sample-page join waits with a retryable unavailable instead of passing the sample-page session bound. Session opens are no longer refused with 503 in that window, and a project's sample page session count survives the loss.
  • A token's subject may now be any Unicode letters, digits and marks, up to 128 code points, as the credentials page says. The mint used to refuse anything but printable ASCII.
  • A subject is stored in its NFC form, so two spellings of one accented name are one subject, in the subject filters on the ledger and the token list too. A subject accepted before is unchanged.
  • A host page the browser freezes keeps its session and says no goodbye, and is reported frozen rather than gone. It closes its p2p links while frozen, then offers p2p again as soon as it resumes, as a visible page does.
  • The away deadline a client sets as it goes to the background now counts only the background time it has left, not the whole background budget.
  • A client that goes to the background and quickly returns is no longer left away on the host. An app killed in the middle of a presence update no longer holds a place with no deadline.
  • A client no longer receives one host message twice when its p2p link closes, or p2p is denied, in the middle of a broadcast.
  • A host that suspends no longer leaves a client's presence change waiting for good.
  • A client no longer sees its host as attached again after the host went away, so its p2p record cannot read available while the host is gone.
  • After a host page reloads during a live p2p connection, the client's p2p record reads available as soon as the host is back. It used to read failed with a 30 s countdown for a failure that never happened.
  • After a node loss, a host that meets a 502 reaching the new node now retries inside its host grace, after 250 ms and doubling to 2 s. An expired ticket is replaced once, and if the replacement is refused too, the session ends as rejected.
  • A client and its host that reach a new engine node at the same moment after a node loss now find each other. The client is no longer released after waiting 10 s for an answer that never came.
  • A second open() on one host without persist.as now opens a session of its own, instead of taking over the first one and ending it as replaced.
  • After a node crash, a client whose rejoin got no answer tries again after 1 s, instead of closing as network_lost after 5 s.
  • A reloaded client page keeps its place while the person is looking at it. The old page's unload no longer releases the client 30 s later.
  • After an engine node is lost, a session with a host grace of 0s now waits for its host on the new node, instead of ending a moment after the move.
  • Losing one engine node no longer ends the hosts and clients of sessions on other nodes.
  • An engine node stopped on purpose now tells its hosts and clients server_shutdown first, so the SDK no longer mistakes the stop for a lost node.
  • A host or client the engine ends for good now learns why, instead of seeing a lost link. That covers being removed or replaced, the session closing, and its node being lost or stopped, and a host replaced by a second connection with the same host token ends as replaced.
  • After an engine node is lost, the api now records and sends session.host_away and client.reconnecting, instead of putting the host away silently.
  • bitskiff init no longer fails when another project already uses your folder name as its slug. It takes the slug the server gives instead and prints it.
  • The key bitskiff init makes now holds the project's session permissions, so bitskiff keys create --type secret with the open, read and manage session permissions works right after sign-in.
  • bitskiff usage now says that day D's usage appears at D+2 00:30 UTC, and nothing is final before then.
  • A client that joins within two seconds of its host attaching no longer reports the host as back when it never left.
  • When an engine node stops, the api now releases every client of its sessions, not only the host. A session that ends through host grace releases its clients before the end is recorded.
  • The client page shows the host as disconnected while it is gone.
  • A client the api released is never announced to the host as joined again. It ends on seat_released.
  • The host CLI, stopped with Ctrl+C while the session is still opening, now closes that session instead of leaving it to run out its host grace.
  • The CLI's usage and ledger commands refuse a month outside 01 to 12 instead of rolling into another year, and limits set refuses a negative value. Whoami with no stored credential tells you to run bitskiff login.
  • The CLI says when the api refuses a stored organization key as expired or revoked, and where to make a new one. A 401 with no JSON body, such as from a proxy, is treated as a sign-in problem and exits 3 instead of 8.
  • Two @bitskiff/host-node processes racing for the same stale slot can no longer both take it.
  • Closing a session twice at once sends one goodbye and one close request. The second call waits for the first.
  • A client that retried its hello before the answer arrived now reports connected once.

[0.2.0] - 2026-09-28

The first tagged release: every change since the first published version, 0.1.0, to the development head at the cut.

Upgrading from 0.1.0

Every 0.1.0 client fails against 0.2.0, and a host may. Upgrade both:

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

const client = new BitskiffClient({ key: 'bsk_pk_...' }); // a client key, now required
await client.join(code); // was client.start(code, opts); resolves to the client
// a join token moved from the constructor to the join: client.join(code, { token })

A 0.1.0 app on dev is already broken. Dev, the only environment, runs untagged development heads, and these breaks are live:

  • Since dev's roll at 2026-09-24 17:01Z, the api reads and writes the signature, project, SDK and request-id headers only under their X-Bitskiff- names, and since its roll at 2026-09-25 23:28Z the joins read the network hint only as X-Bitskiff-Network-Class, where they read Network-Class (step 2). A webhook receiver checking the old signature header rejects every delivery, and each is retried for 24 hours; a REST caller's old project header is ignored, and a caller reading the old request-id header reads nothing. Rejected deliveries are not all lost: since dev's roll at 2026-09-25 18:56Z, GET /v1/project/events, on a secret key with usage:read, serves the same events for seven days. A delivery rejected before that roll is gone. Fix the receiver, then read back what it rejected before 2026-10-02 18:56Z, when the first of them age out.
  • Since dev's roll at 2026-09-25 00:04Z, a project or key limit stored under a 0.1.0 name (concurrentParticipants, participantMinutesPerDay, seatResumesPerMinute, tokensMaxTtlS) reads as never set, until the 0.2.0 roll reads it under its new name. Set a project's again now (step 17).
  • Since a dev roll on 2026-09-26 (UTC), the api reads a session's durations as strings (Wire, under Changed), so a 0.1.0 host that sets any duration option (idleTimeoutMs, maxSessionTimeMs, hostResumeWindowMs, warningLeadMs, reconnectBudgetMs, backgroundBudgetMs or joinCodeRotationS) fails its open. Upgrade the host and pass each as a duration string under its new name, such as idleTimeout: '10m' (step 4).
  • A no-build page that loads the hosted module /sdk/host@0.1.0.js (or host@0.1.0/unattended.js or client@0.1.0.js) under the import map's integrity hash is refused the module by the browser, so its script never runs. Dev site publishes rebuilt those files in place, the first by 2026-09-24 07:52Z, so a page broke at the first publish after its hash was copied. A dev publish from d321fd70c (2026-09-28) on rebuilds no versioned file and carries each byte for byte, but a hash copied before the last rebuild stays wrong. Move the page as the no-build paragraph below says.
  • Since about 08:00Z on 2026-09-27, a join without a client key is refused (step 1).
  • Since dev's roll at 2026-09-27 20:57Z, codes are 15 characters (step 3).
  • Step 10 is live in three parts: since dev's roll at 2026-09-25 01:02Z the routes over one client are /v1/sessions/{sessionId}/clients/{clientId} and the metering event kinds are client.*; since its roll at 2026-09-26 05:09Z an unwritable Accept is 406; and since its roll at 2026-09-27 07:59Z a request with no body and no Accept is answered JSON.

Do steps 1 to 19 now, in order, and ship host and client together: holding the build only lengthens the outage.

To do them now, install the -0.1.0.tgz files the site serves today: bitskiff-host, bitskiff-host-node, bitskiff-client and bitskiff-ui-kit, such as /downloads/bitskiff-client-0.1.0.tgz, and the CLI with npm i -g https://docs.dev.bitskiff.com/downloads/bitskiff-cli-0.1.0.tgz. Despite the name they are build 86e32464d (2026-09-27 22:48Z), the last site publish before the bump and later than a2d809e51, the build dev runs (rolled 2026-09-27 21:28Z). They carry every rename in the steps, setPresence, hostResumeWindow and host.channel included, but not what landed after them: a host Client's and a channel's refusal of an unknown event (step 6, Changed), the unattended loop's refusal (step 8), its { signal }, off and UnattendedListenerOptions (Added), the host's GET .../budget question, or any Fixed line. Those arrive with /downloads/0.2.0/ at the cut. Vendor them in your app. Once 0.2.0 is cut, move to /downloads/0.2.0/, and the CLI to npm i -g https://docs.dev.bitskiff.com/downloads/0.2.0/bitskiff-cli.tgz. /downloads/dev/, the development build, answers 404 until the next site publish writes it, and is never for a live app: every publish replaces it.

A no-build page moves the same way. To try things now, load /sdk/host@dev.js (or host@dev/unattended.js or client@dev.js) with no integrity entry, from the next site publish on; every publish writes them afresh, so they are never for a live app. Once 0.2.0 is cut, load /sdk/host@0.2.0.js with the integrity hash the quickstart shows for it, and the unattended and client modules at 0.2.0 the same way. Only a release publish writes a versioned file, once, and no publish replaces it; from the next publish on, /sdk/published.json lists each versioned file the site serves with its hash.

Dev rolls to 0.2.0 on 2026-10-06 12:00 UTC.

What the roll changes for a live app:

  • Joins per address: joinsPerMinuteIp (20 a minute) counts a whole IPv6 /64 as one address. It already counts only your project's traffic on dev. A venue whose clients share one /64 is refused rate_limited past 20 joins a minute unless you made step 1's raise.
  • The engine: every live session drops, and ends unless its two budgets were raised (below).
  • Client budgets: a session opened after the roll defaults to a 10 s reconnectBudget, where it was 15 s, and a 30 s backgroundBudget, where it was 5 minutes (CALL-1230); the ceilings are unchanged. A 0.1.0 app that relied on the 5-minute hold loses a backgrounded client after 30 s (ended backgroundBudget) and a client dropped in the foreground after 10 s (ended reconnectBudget). To keep the old hold, pass reconnectBudget: '15s' and backgroundBudget: '5m' on open, or, from the roll, set them as the project's session defaults.
  • The byte row: bytesPerSecond defaults to 8 KiB a second, where it was 16 KiB, so a client that sends more is booted by its host after 10 s over it (Changed, below). The row cannot be raised before the roll: at the announced hour, once the roll is done, an app that sends more raises it back with bitskiff limits set bytesPerSecond 16384 from the 0.2.0 CLI (/downloads/0.2.0/).
  • requireToken is enforced: a key or project that sets it refuses a client without a token (step 17).
  • Reloads: a second join as a token subject already in the session ends its earlier client replaced, so the host hears that client leave and the engine drops its connection, where dev releases it left and does not tell the host.
  • A join token bound to another session is refused forbidden (details.reason: 'token_not_for_session'), where dev answers session_not_found.
  • Rotations: a rotated key retires or parks, and an organization key's revoke cascades (steps 18 and 19).

Over a2d809e51 the roll also adds the per-field key patch, limits read under their 0.1.0 names, the host-ticket raise ceiling of 300 (step 1), plans and their read, the Limits changes, the ledger, usage records, session defaults, trimmed on the events read, bootCooldown, and the Fixed lines.

Dev has one engine node, so the new engine starts only after the old one stops. The old one drains for up to 120 s first: from the start of the drain the node is picked for no new session, so opens fail until the new engine runs, and a join to a session still live on it is not promised. Every live session drops when the old one stops. On the budgets a session opened before the roll holds by default (a 10 s hostResumeWindow, a 15 s reconnectBudget) expect sessions to end and hosts to open new ones.

To carry a session across, open it before the roll with hostResumeWindow: '5m' (at most '30m') and reconnectBudget: '2m', its ceiling; both run from the drop, so they need only outlast the new engine's start. That start has not been measured on dev, so 2m is not promised: a client whose budget runs out first leaves the session and joins again with the code.

From 0.2.0 on, a release that changes what a live app sees is announced in this changelog and on the site's changelog page at least seven days before it rolls, with the roll's date and hour; a release that changes nothing a live app sees rolls without notice. The notice counts from the site's publish of the page, not from this file. The 0.2.0 roll gets the same seven days: the site does not serve this page yet, and publishes it with the cut, at least seven days before the roll. Dev's untagged rolls, which carried every break above, end with 0.2.0. The rule stands (CALL-1228), and the release cut and the tagged roll refuse a release that breaks it. There is no feed or mail for releases: read this page before each event you run.

  1. @bitskiff/client: pass a client key as key, allowing each origin your client page is served from. Make a live app's key in the console (no expiry unless you set one), or under an organization key that expires as late as the app needs (at most a year). A key the CLI makes expires when the organization key the CLI holds expires (expiresAt in bitskiff keys list --scope org), or 90 days after its own making if that key has none. --expires only shortens it, and a CLI rotation caps the successor the same way. For development: bitskiff keys create --type publishable --preset client-page --origin https://play.example.com. Repeat --origin per origin; a native client names its scheme, such as capacitor://localhost. With no --origin a key allows only http://localhost:* and http://127.0.0.1:*, and any other origin is refused origin_not_allowed (reason mismatch). A client that sends no Origin (Node, or an app shell's Origin: null) passes origin, such as new BitskiffClient({ key, origin: 'app://my-player' }), and the key lists it, or it is refused origin_not_allowed (reason absent). An expired key's joins are refused invalid_key. A client key admits 300 joins a minute (joinsPerMinuteKey), which 0.1.0 did not cap, and refuses the rest rate_limited. From the roll, per-address limits count a whole IPv6 /64 as one address, and sessionOpensPerMinuteIp (30) and hostTicketsPerMinuteIp (30) count only your project's traffic, as joinsPerMinuteIp (20 a minute) already does on dev. Before a large event raise them, joins per address to at most 200 and the others to 300, such as bitskiff limits set joinsPerMinuteIp 200. Today the joins and session-opens raises apply at once, but hostTicketsPerMinuteIp stays at most 30 until the roll brings its 300; a raise past a ceiling is refused quota_above_ceiling. From the roll, on the unlimited plan no raise waits; under experimental one past the default is held for approval. Every organization made before the roll, under 0.1.0 included, is on unlimited, so its raises never wait. Plans and their read (whoami, GET /v1/orgs/{orgId}/plan) arrive with the roll. Two windows count every project and cannot be raised: before a host ticket names its session, 300 calls a minute per /64; and badCodeAttempts: past ten wrong codes from a /64 in a ten-minute window, its joins, valid codes included, are refused rate_limited while a back-off runs (2 s, doubling to 60 s), and past 100 until the window ends. The constructor throws validation without a key (details.option: 'key') and on a token option ('token'; pass it to join, as above). The host's publishable key is not a client key: its joins are refused origin_not_allowed, or forbidden (details.reason: 'missing_permission', details.permission: 'sessions:join') when its origins allow the page. A REST caller of join or inspect sends the key as X-Bitskiff-Key and origin in the body (Changed, below). An early 0.1.0 client refuses every 0.2.0 code itself as invalid_code. 0.1.0's start(code, { bundle: 'viewer' }) is dropped: every client joins with the host's defaultGrants, so a view-only client needs the host to set its grants.
  2. Webhooks and REST: rename every header; the api ignores the old names, with no alias. A webhook receiver reads X-Bitskiff-Signature and X-Bitskiff-Signature-Kid, where it read X-MRC-Signature and X-MRC-Signature-Kid, or it rejects every delivery. A receiver answers 2xx to a kind it does not handle: open.refused now reaches every project with a webhook, and a delivery answered with any other status is retried for 24 hours. Under Changed, below: a limit.warning or limit.exceeded receiver reads camelCase data and renamed limit and scope values; a limits.raise_requested receiver and a caller of POST /v1/project/limits/raise use the renamed limit ids; a REST caller sends X-Bitskiff-Project and reads X-Bitskiff-Request-Id; a usage reader queries the renamed meter ids, and sums old and new ids as Usage meters says.
  3. Join codes: stop checking or reformatting them. A code is 15 case-sensitive characters under e2ee: off; under e2ee: required a client joins on the whole session.code string, <code>.<fingerprint>, so pass it as it is. Never upper-case what a person types. A 16-character code, printed or stored, is invalid_code.
  4. @bitskiff/host, @bitskiff/client: rename every duration option and pass a duration string, such as '30s' (Changed, below). Only open() refuses an old name (validation, details.option names it), and openForever throws a TypeError on an unknown top-level key. ReconnectPolicy, the openForever policy, resume() options and the client options ignore an old name and keep the default; only the TypeScript compiler catches those.
  5. @bitskiff/host: declare channels with host.channel(name, spec) before open(), and give the client only the name, client.channel(name). A lossy channel that needs more than 30 messages a second declares maxHz.
  6. @bitskiff/host: replace session.participants and on('participant') with session.clients and the clientJoined, clientChanged and clientLeft events. session.clients is no longer a Map: it iterates Client objects, not [id, participant] pairs, and has no forEach (get, has, keys and entries remain). A client's fields are read from client.state, such as client.state.deviceClass where you read p.deviceClass; seat is state.number, and bundle is gone. On both SDKs a message handler takes one Message: (m, meta) is (m), and the host reads the sender as m.client, a Client, where it read the id meta.from. send(to, data) takes a Client, a client id or an array of them, and broadcast(data) replaces everyone: a literal '*' target, which was everyone, is read as a client id and returns { dropped: 'noClient' }. An on() or off() for an event the handle does not have now throws validation (Changed, below), so a leftover session.on('participant', ...) or client.on('gone', ...) throws at that line. A ParticipantChange handler is rewritten, not renamed (step 13).
  7. @bitskiff/host, @bitskiff/client: send() returns 'sent' or { dropped: reason }. Code that compares the result with 'dropped' or 'queued' never matches now; test result !== 'sent' and read result.dropped.
  8. @bitskiff/host/unattended: call openForever(host, { session, policy }), listen for openRefused and stopped where you listened for openFailed and gaveUp, and drop maxStrikes. Read the loop.session property where you called current(). Both events hand new shapes (Changed, below): nextAt is absent when the loop ends disposed, and a stopped reason is the loop's, not a session end reason. A leftover loop.on('openFailed', ...) or loop.on('gaveUp', ...) throws validation (details.option: 'event') at that line from 0.2.0 on; the served -0.1.0.tgz build runs it as a stopped listener. A host you pass to openForever needs open, platform and reconnect, where it needed only open: pass the BitskiffHost itself, since a wrapper exposing only open throws a TypeError at the call. The defaults changed (Changed, below). Rename the options inside session too (step 4): a session that still carries idleTimeoutMs or maxControllers never opens, since every open is refused validation and the loop retries it every 300 to 600 s forever, so watch openRefused on first boot. @bitskiff/host-node/operator: createDrain now detaches on SIGTERM as well as SIGHUP when detach is wired, so a plain stop leaves the session running out its host grace; pass restartOn: ['SIGHUP'] to close on SIGTERM as before. processSignals(process) hears only SIGTERM and SIGINT, so a process sent SIGHUP passes it in the list.
  9. @bitskiff/host, @bitskiff/host-node: a Persistence of your own (Platform.persistence) no longer fits: its slot stores a host record, not the host token string (Changed, below). On Node, keep host state in your own store with nodeStateStore(backend) instead.
  10. REST: rename the JSON fields, routes, error codes and event kinds your code reads (Changed, below). A REST caller reads and writes the proto3 JSON names, such as maxClients. A request with no body and no Accept is answered JSON, not protobuf, and an unwritable Accept is 406, not 400, with code validation as before.
  11. @bitskiff/ui-kit: import a component's constants and helpers from its own subpath, such as DIRECTIONS from @bitskiff/ui-kit/dpad.
  12. Expect a fresh start on each machine and page from an early 0.1.0 build: 0.2.0 does not read state saved under the mrc names (Changed, below), nor a client record saved before early 2026-09-27 (under mrc:client, or the bare bitskiff:client from builds of 2026-09-25 on), so the client joins afresh. Host state from builds of 2026-09-25 on, and the client's record under its key's name from builds of 2026-09-27 on, carry over. Nothing needs deleting. A CLI sign-in stored only under mrc must sign in again.
  13. @bitskiff/host, @bitskiff/client: rename every value your code compares against and every method it calls that moved. A comparison with an old value is not refused; it never matches:
  • ClientPhase: idle is gone, and a reconnect is joining, where it was reconnecting.
  • End reason seatsFull is clientsFull; error code seats_full is clients_full.
  • client.state.deviceClass: 'phone' is 'mobile'.
  • Channel.pressure (0 | 1) is Channel.congested (a boolean).
  • Lifecycle reasons hidden and visible are away and present (a client reports them only once its page calls setPresence, step 15); transport path turn is relay.
  • ParticipantChange, an object with a kind, is ClientChange, a string, handed as change in ClientChanged { client, change, previous, at } on clientChanged. joined and the first connected are one clientJoined event, and gone is clientLeft (its reason on the event); connected after a drop is 'reconnected', grantsChanged is 'grants' (the old grants on previous.grants, where they were from) and e2eeChanged is 'e2ee'; disconnected, away and present keep their words; 'channels' and 'network' are new. A ported c.kind === 'connected' never matches: test e.change === 'reconnected'.
  • Deadline kind seat is reported as reconnect, and Deadline.participantId is clientId, on both SDKs.
  • Host end reason left is gone; one the SDK does not know is unknown, where it was apiClosed.
  • BitskiffError.limit and details.limit: concurrentParticipants is concurrentClients, and the rest as listed under Changed. The rename reaches a server on a secret or organization key, and every caller's rate_limited and concurrency_limit refusals. A quota_exceeded or turn_quota_exceeded refusal to a host or a client, on a publishable key or a token, names no limit at all in 0.2.0 (Changed, below), so a host branches on code and reads resetAtUnixMs (details.resetAt on REST), where it compared limit.
  • session.setGrants(id, grants), session.boot(id) and session.markActive(id) are setGrants(grants), boot() and markActive() on the Client; the old calls throw a TypeError, as the session no longer has them.
  • Session options maxControllers and joinGrants are maxClients and defaultGrants; open() refuses the old names validation. maxViewers is gone and open() refuses it too: set maxClients to the old maxControllers plus maxViewers, up to 500, or the former viewers are refused clients_full. A host that kept the default still holds 4 clients.
  1. Every package: run on Node 22 or later. Each one you install declares engines: { node: ">=22" }, which 0.1.0 did not, so an install on Node 18 or 20 warns EBADENGINE and fails under engine-strict.
  2. @bitskiff/client: set presence from your page. The client platform no longer reads page visibility, so a 0.1.0 page that upgrades never reports away, and its backgroundBudget never starts, until it wires its own signal:
JavaScript
    document.addEventListener('visibilitychange', () =>
      client.setPresence(document.hidden ? 'away' : 'present'),
    );

A custom PresenceSource that raises hidden or visible on a client is ignored, with no error.

  1. CLI scripts: whoami --format json prints one JSON document per line on stdout: the resolution, the api's body, then the organization's plan when one is resolved, so a script reads the api body from the second line. Every --format json answer is the REST body, so the REST renames and the 200 limit cap (Changed, below) reach scripts that parse CLI output; the keys verbs but create and rotate leave out masked and secret, so a script names a key by keyId. bitskiff keys rotate --overlap and bitskiff tokens mint --ttl take a duration, such as --overlap 1h where a script passed --overlap 3600, and exit with a usage error on a bare number. The --limit names on bitskiff keys and bitskiff tokens mint are renamed (Changed, below), and an old name exits with a usage error. The CLI ignores BITSKIFF_ADMIN_KEY, so a script that sets it runs under the machine's sign-in, or fails for want of one: set BITSKIFF_ORG_KEY. bitskiff deadlines parked and unpark are gone (Removed, below; step 19).
  2. Keys and project: clear requireToken on every key that sets it, and plan for the project's own. 0.1.0 stored it and never enforced it; from the roll a client is refused forbidden (details.reason: 'token_required') without the token the stricter of the project's word and the opening key's asks for (token, or token_bound_to_session: one bound to the session). First, now, set again every project limit you raised under a 0.1.0 name, under its new name, such as bitskiff limits set concurrentClients 50, clientMinutesPerDay, clientResumesPerMinute or tokensMaxTtl 1h. The set applies at once, and at the roll the new name wins over the old. Until then such a raise reads at the default, so a key patch sent first that names a key limit above it is refused quota_above_ceiling whole, requireToken included, and the key's clients are refused token_required at the roll. Then, until the roll a key patch, bitskiff keys update included, replaces the key's whole limits, and GET /v1/project/keys leaves out a limit stored under a 0.1.0 name, so a patch copied from the GET erases it. Send PATCH /v1/project/keys/{keyId} with limits: { requireToken: "off", ... } holding every limit the GET shows, and every limit your own records say you set on the key before dev's roll early on 2026-09-25 (UTC), under its new name: concurrentParticipants as concurrentClients, participantMinutesPerDay as clientMinutesPerDay, and tokensMaxTtlS (seconds) as tokensMaxTtl (a duration, such as '1h'). If you cannot list them, patch nothing now: give that host's clients join tokens, and after the roll, whose patch keeps every limit it does not name, old names included, run bitskiff keys update <keyId> --limit requireToken=off from the 0.2.0 CLI (/downloads/0.2.0/); today's CLI exits unknown limit. Either way the key keeps every limit. The project's own requireToken can be neither read nor cleared before the roll: dev's limits read does not return it, and its raise refuses it validation. If the project may have set it, give its clients join tokens before the roll, and at the announced hour, once the roll is done, run bitskiff limits set requireToken off from the 0.2.0 CLI; until then every tokenless client is refused token_required. Each token_required refusal counts toward the shared badCodeAttempts window (step 1), so a venue's /64 of tokenless clients soon has its valid joins refused too.
  3. Keys: read ancestry before you revoke a key. Revoking a project key revokes every key made under it, recursively, keys made on 0.1.0 included, as dev has since 2026-09-21 (before, only the key). From the roll an organization key's revoke does the same, so revoking the one a CLI signed in with also revokes every key that CLI made (Changed, below). List the keys (bitskiff keys list, and --scope org for the organization's), find those whose ancestry names the key you revoke, and replace the ones that must keep working from a key you keep before you revoke it.
  4. Keys, now: finish the rotations 0.1.0 left open; it never retired a rotated key. In bitskiff keys list, a key with status rotating names its new key in successorKeyId. One whose retireAt is still ahead at the roll retires then: move its hosts and clients to the new key first, or push it out with bitskiff keys update <keyId> --retire-at <time> (at most 30 days out, cut with no error). Nothing sweeps before the roll, so a past time set now is parked at the roll and the key keeps working: revoke a key that must stop now, after step 18. At the roll one up to ten minutes past its retireAt retires, and one further past is parked (Changed, below) and keeps working: move its hosts and clients, then re-arm it the same way, or unpark it with POST /v1/project/deadlines/{kind}/{member}/unpark (kind rotation, member the key id), called with an organization key holding project:manage and X-Bitskiff-Project naming the project, and it retires at the next sweep. GET /v1/project/deadlines/parked answers at most 200 entries (50 by default), with no cursor, so trust keys list.

The steps lead from any 0.1.0 build: the 0.1.0 files were rebuilt in place until the bump, so a step whose old name your code does not use is already done.

Only the CLI names its build (bitskiff --version, and build in /downloads/manifest.json, from 2026-09-27 on); an SDK tarball carries none, and the manifest's sha256 matches only the last 0.1.0 build, kept at /downloads/<scope>-<name>-0.1.0.tgz. From 0.2.0 on, a version names one set of bytes.

0.2.0 was cut without the release checklist's previous-release conformance run or smoke join, so what it says a 0.1.0 host or client meets is read from the 0.1.0 code, not from a run, and the earlier the build, the more of it applies.

From 0.2.0 on, nothing breaks in place: a renamed or removed name keeps working for one minor release. This release removes names with no such window, since that rule starts at the first tagged release. The Deprecation and Sunset headers and the SDK diagnostic for an old name are not built yet, and nothing on the wire is deprecated in 0.2.0.

Added

  • Downloads: /downloads/0.2.0/<scope>-<name>.tgz for every package, and /downloads/latest/<scope>-<name>.tgz for the newest release.
  • @bitskiff/host: the direct path. direct: true in the host's session options offers each capable client a direct link that carries its lossy channels; the engine carries everything else and is the fallback. The relay is Bitskiff's own TURN server for the direct link, metered per credential. Off by default.
  • @bitskiff/client: direct: false declines a direct link; { iceServers, iceTransportPolicy } brings the page's own.
  • @bitskiff/host-node: direct.peerConnection, the peer connection the direct path uses, such as (config) => new RTCPeerConnection(config) from werift. Without it, a client is reported directNotOffered with cause noPeerConnection.
  • @bitskiff/host: each channel row carries maxHz, maxBytes, latestOnly, ordered and a lifetime (lifetime on the spec, read back as lifetimeMs on the row). The api clamps the row (maxHz to the class rate, maxBytes to the wire ceiling), and the channel object reports the row it runs. All reliable rows share one bucket of 20 a second per sender.
  • @bitskiff/host: a session declares at most 64 channels; the 65th host.channel() throws validation.
  • @bitskiff/client: client.setPresence(): presence is the app's to set, and the host reads it on the Client.
  • @bitskiff/host: session.capacity and on('capacity'), so a live session learns its cap.
  • @bitskiff/host: open() takes openBudget (a duration string, such as '2m') and idempotencyKey, and BitskiffOptions.key may be a function.
  • @bitskiff/host: the termination policy: terminationRules on the session, the @bitskiff/host/expressions subpath, and the policy deadline.
  • @bitskiff/host, @bitskiff/client: BitskiffError carries clears and source.
  • @bitskiff/host, @bitskiff/client, @bitskiff/host/unattended: every on takes { signal }, and every handle has off; the unattended loop's on options are the new type UnattendedListenerOptions.
  • @bitskiff/host, @bitskiff/client: parseDuration and formatDuration.
  • @bitskiff/client: clientRecordKey(key), the storage name of the record a client keeps.
  • @bitskiff/host-node: nodeStateStore(backend), for keeping host state in your own store.
  • @bitskiff/host/unattended: the loop reports health and error events, and UnattendedLoop.detach() ends the loop and leaves the session running out its host grace.
  • @bitskiff/host, @bitskiff/host-node: HOST_RECORD_KEY, the storage name of the host record; both packages export HostKey, the type of BitskiffOptions.key.
  • @bitskiff/client: resolveReconnectPolicy and JoinToken, the type of join(code, { token }).
  • @bitskiff/host, @bitskiff/client: ResolvedReconnectPolicy, what resolveReconnectPolicy returns; its fields are milliseconds and keep the Ms names (baseMs, capMs and so on).
  • Error codes: plan_expired (the organization's plan passed its expiry), overloaded (the platform is at its admission ceiling; retry after retryAfterMs), replaced (a second join as the same token subject took the client's place) and relay_not_configured (the environment runs no relay).
  • Error reasons: forbidden with token_required (the project requires a join token) and token_not_for_session (the join token is bound to another session); invalid_key with details.reason absent, malformed, unknown, revoked or expired.
  • Webhooks: open.refused, delivered to every project with a webhook; the opt-in kinds session.ended and client.gone, one usage record each, chosen with WebhookConfig.kinds. A client's record carries subjectUnknown, true when its subject and keyId are empty because they could not be read, not because the client is anonymous.
  • REST: GET /v1/project/ledger and SessionView.usage, the usage a developer bills on. A CSV page carries its next cursor in X-Bitskiff-Next-Cursor.
  • REST: GET /v1/project/events, which reads back refusals such as open.refused. It pages by cursor and limit. From the roll a page carries trimmed, which dev's build does not: true when the cursor asked for is older than the oldest event the stream keeps, and the page starts at the oldest kept.
  • REST: GET /v1/sessions filters by label, subject and hostState.
  • REST: project session defaults, so an open that names no idleTimeout takes the project's.
  • REST: device-code sign-in for a terminal: POST /v1/auth/device/code, POST /v1/auth/device/token, POST /v1/auth/device/approve and GET /v1/auth/device/{userCode}.
  • REST: GET /v1/orgs/{orgId}/plan, the organization's plan and when it expires (the plan behind plan_expired). PUT /v1/orgs/{orgId}/plan, GET and POST /v1/platform/invitations, and DELETE /v1/platform/invitations/{invitationId} are platform staff only.
  • REST: routes the SDKs call themselves, not for app code: POST /v1/sessions/{sessionId}/relay-credentials, and under /v1/sessions/{sessionId}/clients/{clientId} POST .../unheard, GET .../budget and POST .../closing; and POST /v1/bench/tickets, which the connection benchmark page uses.
  • REST: a concurrency_limit refusal on concurrentSessions carries details.sessionIds, the sessions in the way, to a caller that may list them.
  • Limits: bootCooldown, 10m by default: a subject booted by policy, or by its own rate, is held out of the project that long.
  • Limits: joinsPerMinuteKey, joins per minute per client key, 300 by default; and joinsPerMinuteIp may be raised to 200 per project and address.
  • Keys: the subjects condition: a secret key that mints tokens carries subjects, the subject prefixes it may mint for, and a backend key mints join tokens without holding sessions:join.
  • CLI: bitskiff init signs in, picks a project and writes a hello world. Its page loads the versioned host file the CLI was staged against, with its integrity hash, or else the dev build, host@dev.js, with no pin and a comment saying so; it never names a hosted file the site does not serve.
  • CLI: bitskiff ledger, bitskiff limits set <limit> <value>, bitskiff sessions list --label, --host and --subject, bitskiff sessions close --subject, and --host-key and --subject-prefix on bitskiff keys create and update, and --clear-host-keys and --clear-subjects on bitskiff keys update.
  • CLI: bitskiff settings set also writes the project's session defaults and terminationRules.
  • Usage meters: api_requests.{route_class}, egress_bytes_attributed and engine_bytes_unattributed.

Changed

  • @bitskiff/client: every client is built with a client key, new BitskiffClient({ key }); the constructor throws validation without one. The api answers a join with no key invalid_key (reason absent), unless the session serves the platform's sample client page.
  • @bitskiff/client: the origin option, and origin in the join and inspect body, is checked against the client key's origins, where it was matched against the session's join URL. The api reads it only when the request has no Origin header or Origin: null, and then requires it: a Node client, an app-shell page or a server passes it, or its join is refused origin_not_allowed (reason absent).
  • @bitskiff/host, @bitskiff/client: the session's, the host's per-client Client objects' and the client's on() and off() throw validation (details.option: 'event') for an event the handle does not have, where they accepted it and the handler never ran (a Client registered it on 'left'). The unattended loop's on does too, where an unknown name ran its handler as a stopped listener. A channel's on(), the client's and both of the host's (the handle's channel object and a session's), throws the same for any name but 'message', where the host's returned a stop that registered nothing.
  • Every published package declares engines: { node: ">=22" }, where 0.1.0 declared none.
  • @bitskiff/client: client.start(code, opts) is client.join(code, opts?), no alias, and it resolves to the client once it has joined. A join token is join(code, { token }), not a constructor option.
  • @bitskiff/client: ClientPhase is 'joining' | 'connected' | 'suspended' | 'ended': idle is gone and a reconnect inside the budget is joining, not reconnecting. The ClientChange value reconnecting is gone too.
  • @bitskiff/host, @bitskiff/client: retry() resolves to the session or the client, where it resolved to nothing (host) or the ClientState (client).
  • Join code: 15 case-sensitive characters over A-Z, a-z and 0-9, where it was 16 upper-case characters. A 16-character code, or one whose case was changed, is invalid_code.
  • @bitskiff/host: session.channel(name, spec) is host.channel(name, spec), called before open(), no alias; open() seals the table, a call after it throws validation, and session.channel(name) returns the declared channel.
  • @bitskiff/client: client.channel(name, spec) is client.channel(name); the host's row is the only spec.
  • @bitskiff/host: a lossy channel with no maxHz is paced at 30 messages a second, where 0.1.0 held it only by its class bucket, 120 a second. A send over the rate returns { dropped: 'rate' }; declare maxHz: 60 for a client sending at 60 Hz.
  • @bitskiff/host, @bitskiff/client: SendResult is 'sent' | { dropped: reason }, where it was 'sent' | 'queued' | 'dropped'. The host's DropReason is rate, size, pressure, noClient, notListening, noGrant or notOpen; the client's ClientDropReason is rate, size, pressure, noGrant, notOpen, hostAway or notDeclared.
  • @bitskiff/host, @bitskiff/client: a message handler takes one Message, where it took (message, meta); seq, receivedAt, sentAt and channel are on the Message, and the host's sender is m.client, a Client, where it was the id meta.from. meta.lagMs has no counterpart on the Message; read receivedAt - sentAt.
  • @bitskiff/host: channel.send(to, data) takes a ClientTarget (a Client or a client id) or an array of them, and channel.broadcast(data) sends to every client.
  • @bitskiff/host, @bitskiff/client: Channel.pressure (0 | 1) is Channel.congested (a boolean), no alias.
  • @bitskiff/host: BitskiffOptions.e2ee takes 'required' or 'off' and refuses anything else validation.
  • @bitskiff/host/unattended: openForever(host, session, policy) is openForever(host, { session, policy }), and an old call with a third argument or a flat session config throws a TypeError (openForever(host) runs unchanged). It returns an UnattendedLoop, where it returned an UnattendedHost; the openFailed event is openRefused and gaveUp is stopped, no alias; the policy's maxStrikes is gone. current() is the session property; OpenFailedInfo { strikes, retryInMs } is OpenRefusedInfo { attempt, nextAt }; GaveUpInfo { strikes, error, reason } is LoopStopped { at, reason }, whose reason is stop, reopenOn or disposed; and OpenableHost requires platform and reconnect beside open. The defaults changed: maxBackoff is 5m, where maxBackoffMs was 30 s; the loop never gives up, where maxStrikes gave up after 20; and reopenOn accepts every end reason, where it refused disposed.
  • @bitskiff/host, @bitskiff/client: every duration is a duration string, such as '30s', and its option lost the unit suffix, no alias:
  • session options: idleTimeoutMs is idleTimeout, maxSessionTimeMs is maxSessionTime, hostResumeWindowMs is hostResumeWindow, warningLeadMs is warningLead, reconnectBudgetMs is reconnectBudget, backgroundBudgetMs is backgroundBudget, joinCodeRotationS is joinCodeRotation, inactivityMs is inactivity (also on resume()).
  • ReconnectPolicy and DEFAULT_RECONNECT_POLICY: baseMs is base, capMs is cap, minSpacingMs is minSpacing, stableMs is stable, joiningBudgetMs is joiningBudget, openBudgetMs is openBudget.
  • @bitskiff/client: returnProbeMs is returnProbe, linkDeadAfterHiddenMs is linkDeadAfterHidden.
  • openForever policy: initialBackoffMs is initialBackoff, maxBackoffMs is maxBackoff.
  • @bitskiff/host: session option joinGrants is defaultGrants, no alias, and an empty value is refused validation.
  • @bitskiff/host: maxControllers is maxClients, no alias; maxViewers is gone.
  • @bitskiff/host: the roster is Client objects. session.participants, a ReadonlyMap<string, Participant>, is session.clients, a ClientRoster that iterates Client values and has no forEach. A client's fields (deviceClass, grants, presence, connection, transport, networkClass, joinedAt, deadlines, e2ee, attested) are on client.state, where they were on the Participant; seat is state.number, and bundle is gone. on('participant') is clientJoined, clientChanged and clientLeft on the session and change and left on the Client, and session.setGrants, session.boot and session.markActive are setGrants, boot and markActive on the Client. No alias.
  • @bitskiff/host: a client's deviceClass is 'mobile', where it was 'phone'.
  • @bitskiff/host: a client that stays over its byte cap for ten consecutive seconds is booted, where it was ten dropped sends; its left reason is rate.
  • @bitskiff/host: end reasons: left is gone and unknown is new, and an end reason the SDK does not recognise is unknown, where it was apiClosed.
  • @bitskiff/host, @bitskiff/client: Deadline.participantId is clientId, and the SDK deadline kind seat is reported as reconnect.
  • @bitskiff/host: ParticipantChange, an object union on kind, is ClientChange, a string union handed in ClientChanged { client, change, previous, at }: connected is reconnected, grantsChanged is grants, e2eeChanged is e2ee, joined and gone are the clientJoined and clientLeft events, and channels and network are new.
  • @bitskiff/host-node/operator: createDrain's restartOn defaults to ['SIGTERM', 'SIGHUP'], where it was ['SIGHUP'], so a SIGTERM detaches when detach is wired and the session runs out its host grace.
  • @bitskiff/client: the client platform reads no page visibility; presence is the page's to set with client.setPresence, and a PresenceSource raising hidden or visible on a client is ignored.
  • @bitskiff/client: the end reason seatsFull is clientsFull, no alias.
  • @bitskiff/host, @bitskiff/client: the lifecycle reasons hidden and visible are away and present, and the transport path turn is relay (TransportPath, TransportInfo.path). Diagnostic.topic is gone.
  • @bitskiff/host, @bitskiff/client: BitskiffError.limit names clients: concurrentParticipants is concurrentClients, participantMinutesPerDay is clientMinutesPerDay, seatResumesPerMinute is clientResumesPerMinute, tokensMaxTtlS is tokensMaxTtl, and maxControllers is maxClients. maxViewers, monthlySpendCap, deviceCodesPerHourPerIp, invitationsPerHourPerOrg, labSessionReadsPerIp, latencyProbesPerIp, mailPerDayGlobal, signInLinksPerHourPerEmail, signInLinksPerHourPerIp and signInVerifiesPerHourPerIp are gone; bootCooldown, joinsPerMinuteKey, linkMessagesPerSecond, relayCredentialsPerMinute and sessionOpensPerMinuteIp are new, and a per-address open refusal names sessionOpensPerMinuteIp, where it named sessionOpensPerMinute.
  • @bitskiff/host, @bitskiff/host-node: a custom Persistence changed contract. PersistSlot.read and write take and return a host record (hostToken, generation, sessionId and the host's key pair), where they took and returned the host token string; take receives the sealing secrets as a third argument and may return a promise; and the optional seals(medium) is new. nodeStateStore(backend) is the supported way to use your own store.
  • @bitskiff/host-node: nodePersistence(dir, platform) lost its third argument.
  • @bitskiff/client: client.channels maps each name to its ChannelRow, where it mapped to a Channel; ClientState.bundle is gone and ClientState.number is new; Inspection.verdict gains wrongClient; ClientEndReason gains rate.
  • @bitskiff/client: CLIENT_RECORD_KEY is 'bitskiff:client', where early 0.1.0 builds had 'mrc:client', and no client writes a record under it, though 0.1.0 builds of 2026-09-25 to early 2026-09-27 did (step 12); a page reads its record under clientRecordKey(key) (Deprecated, below).
  • @bitskiff/ui-kit: the root export keeps the elements, the define* functions, their types and THEME_TOKENS. The tag constants (BITSKIFF_*_TAG), DIRECTIONS, READOUT_COLORS, TRANSPORTS, BANNER_STATES, BACK_CLEAR_MS, LEAVE_ACTION and the scanner's names (SCANNER_STATES, SCANNER_FOUND_EVENT, SCAN_INTERVAL_MS, createCodeDetector, nativeCodeDetector, ponyfillCodeDetector, wasmLocator, joinUrl, QR_FORMAT, WASM_FILE and the detector types) are imported from their component's subpath, such as @bitskiff/ui-kit/scanner. The status element's transport turn is relay.
  • @bitskiff/host-node, @bitskiff/host, @bitskiff/client: saved state moved, and an early 0.1.0 build's is not read (a later build's already used the new names, step 12). The Node host's state directory is bitskiff/host (under %APPDATA%, ~/Library/Application Support or $XDG_DATA_HOME), where it was mrc/host; a browser host's keys are bitskiff:host:..., where they were mrc:host:...; a browser client's record is bitskiff:client:<the last 16 characters of the client key> (clientRecordKey(key)) and its key database bitskiff-client-crypto-keys, where they were mrc:client (then the bare bitskiff:client) and mrc-client-crypto-keys; its per-session keys are bitskiff:client:key:<sessionId> and bitskiff:client:seq:<sessionId>, where they were mrc:client:key: and mrc:client:seq:. The CLI's config stays under bitskiff; a config left under an older mrc directory is no longer read.
  • Webhooks: X-MRC-Signature is X-Bitskiff-Signature, no alias.
  • Webhooks: X-MRC-Signature-Kid is X-Bitskiff-Signature-Kid, no alias.
  • HTTP: X-MRC-Project is X-Bitskiff-Project, no alias; the api ignores the old name.
  • HTTP: X-MRC-SDK is X-Bitskiff-SDK, no alias.
  • HTTP: X-MRC-Request-Id is X-Bitskiff-Request-Id, no alias.
  • HTTP: POST /v1/auth/bootstrap reads X-Bitskiff-Bootstrap, where it read X-MRC-Bootstrap, no alias.
  • HTTP: the join routes read the optional network hint as X-Bitskiff-Network-Class (or Sec-CH-Network-Class), where they read Network-Class, no alias.
  • REST: a request with no body and no Accept, */* or application/* is answered JSON, where it was answered protobuf; a request with a body is still answered in its body's format. An Accept the api cannot write is validation with HTTP status 406, where it was 400.
  • REST: the session read fills each client's transportPath with udp, tcp, turn or unknown, where it always read unknown. The REST read spells the relayed path turn, where the SDKs say relay.
  • REST: the per-client routes moved from /v1/sessions/{sessionId}/participants/{participantId} to /v1/sessions/{sessionId}/clients/{clientId}, no alias: PATCH, and POST .../boot, .../leave and .../presence.
  • REST: SessionConfig: maxControllers is maxClients (maxViewers is gone), joinGrants is defaultGrants, and each duration field is a duration string without the unit: idleTimeoutMs is idleTimeout, maxSessionTimeMs is maxSessionTime, joinCodeRotationS is joinCodeRotation, hostResumeWindowMs is hostResumeWindow, warningLeadMs is warningLead, reconnectBudgetMs is reconnectBudget and backgroundBudgetMs is backgroundBudget. No alias.
  • REST: InspectResponse: maxControllers is maxClients and controllerSeatsFree is clientsFree (maxViewers and viewerSeatsFree are gone), no alias.
  • REST: ListSessionsResponse.cursor is nextCursor; MintTokenRequest.ttlS (seconds) is ttl, a duration string; KeyLimits.tokensMaxTtlS (seconds) is tokensMaxTtl, a duration string. No alias.
  • REST: limit fields on LimitsResponse, LimitsPatch, KeyLimits and TokenLimits: concurrentParticipants is concurrentClients, maxConcurrentParticipants is maxConcurrentClients, participantMinutesPerDay is clientMinutesPerDay, seatResumesPerMinute is clientResumesPerMinute, and LimitsResponse's joinsPerMinutePerSession is joinsPerMinuteSession, the name LimitsPatch already used; no alias. A duration limit reads back as a duration string.
  • Limits: a limit stored by 0.1.0 under a renamed name holds. A project's or key's limits, a token's limits, a project's pending raise and a seed file stored under concurrentParticipants, participantMinutesPerDay, seatResumesPerMinute or tokensMaxTtlS are read under concurrentClients, clientMinutesPerDay, clientResumesPerMinute and tokensMaxTtl (seconds converted), rewritten under the new names at the object's next write, and logged once per object. A developer re-sets nothing, unless a patch before the roll left them out (step 17).
  • REST: every route that takes limit defaults to 50 and refuses more than 200 validation, and a stale or foreign cursor is validation (details.field: 'cursor'), where it was an empty page.
  • REST: a key reading a session it cannot reach gets not_found with details.named, where it got session_not_found; an organization key naming another organization gets not_found, where it got forbidden. A resume keeps session_not_found.
  • Sessions: reconnectBudget defaults to 10s, where it was 15s, and backgroundBudget to 30s, where it was 5m (CALL-1230); the ceilings are unchanged. A session opened before the roll keeps the budgets it opened with; to keep the old hold, pass them on open or set them as the project's session defaults.
  • Join: a second join as a token subject already in the session ends its earlier client replaced: the host is told, as of any client that leaves, and the engine drops that connection, where the earlier client was released left and the host was not told.
  • Join: a join token bound to another session of the project is refused forbidden (details.reason: 'token_not_for_session', naming neither session), where it was session_not_found.
  • REST: SessionView.projectId is filled only for a secret key, an organization key or the console, and left out for every other caller.
  • REST: the session read keeps its older field names in 0.2.0 (participants, participantId, seatClass, controllersHeld, viewersHeld); a later release renames them with a deprecation window.
  • Error codes: seats_full is clients_full (the session is at maxClients), no alias.
  • Error codes: wrong_client changed meaning. It was the origin mismatch; it is now a client key that does not fit the session, with details.reason other_project or other_host. An origin the client key does not allow is origin_not_allowed (reason mismatch).
  • Error details: a limit refusal's details.scope value participant is client, and its details.limit values are renamed and removed exactly as BitskiffError.limit (above), on the REST api and every other surface that carries them.
  • Event kinds, in the meter events: participant.joined, .connected, .reconnecting, .away, .present, .grants_changed, .snapshot and .gone are client.joined through client.gone, no alias. GET /v1/project/events and the webhook kind client.gone are new in 0.2.0 and carry the client.* names from the start; 0.1.0's webhooks carried only limit.warning, limit.exceeded and limits.raise_requested.
  • Usage meters: four ids name clients, no alias: limit_hits.concurrent_participants is limit_hits.concurrent_clients, limit_hits.daily_participant_minutes is limit_hits.daily_client_minutes, limit_hits.seat_resumes_per_minute is limit_hits.client_resumes_per_minute and joins.refused.seats_full is joins.refused.clients_full. The ids split at dev's first roll to a build from 2026-09-24 on, on 2026-09-25. The daily usage job recomputes the days two to four back, so the older two days of its first run after that roll carry both the old and the new limit_hits.* id, and later days the new id only: for a month, sum the old id before those two days and the new id from them on. joins.refused.* splits at the roll instant: the roll day carries both ids, and a month sums both. A per-address open refusal counts on limit_hits.session_opens_per_minute_ip, where 0.1.0 counted it on limit_hits.opens_per_minute with the project's own open refusals.
  • Webhooks: limit.warning and limit.exceeded deliver data in camelCase, orgId, keyId, limit, scope, scopeId, value, ceiling and requestId, where it was the snake_case meter body. value is the observed count, where it was a copy of the cap; used and period_end are gone, and so is project_id: read the envelope's projectId. The day-minutes quota's limit value is client_minutes_per_day, where it was participant_minutes_per_day. scope may be the new org, for an organization cap, whose notice goes to every project of the organization, and a per-client scope is client, where it was participant. keyId is left out, not empty, when no key is involved. requestId is the event's own id, since a threshold notice answers no request, so it never matches a refused call's X-Bitskiff-Request-Id.
  • Webhooks: every delivery's envelope carries orgId beside projectId.
  • Webhooks: a failed delivery is retried for 24 h (at 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours after the first attempt, then every 6 hours to 24 h, each up to 10% later for jitter), where it was three attempts over about a second and a half; each retry is signed afresh. Delivery is at least once, so a receiver dedupes on the event id.
  • Wire: the changes below are made inside protocol major 1 before the first tagged release, not as recorded exceptions: a field that changes its type or name on its number under the same-number carve-out (CALL-1103), and the two channel fields on new numbers before the freeze CALL-1226 draws at that release. From 0.2.0 on the wire is frozen.
  • Wire: the proto packages are bitskiff.protocol.v1 and bitskiff.metering.v1, where they were mrc.protocol.v1 and mrc.metering.v1. A renamed field keeps its number.
  • Wire: Payload.channel (9, string) is Payload.channel_id (10, uint32), and InputEnvelope.channel (12, string) is InputEnvelope.channel_id (13, uint32): new numbers and a new type, the channel's index in the session's table.
  • Wire: MintTokenRequest.ttl, KeyLimits.tokens_max_ttl and the SessionConfig durations changed type from integer to string as well as name.
  • Wire: in the meter event, the participant_* body members are client_* (numbers 6 to 13), DEVICE_CLASS_PHONE is DEVICE_CLASS_MOBILE, TRAFFIC_SOURCE_PHONE is TRAFFIC_SOURCE_CLIENT, and the LIMIT_NAME_*PARTICIPANT* and LIMIT_NAME_SEAT_RESUMES_PER_MINUTE values name clients. The meter event's SessionConfig field max_controllers (1) is max_clients, and max_viewers (2) is reserved.
  • Wire: the data-plane topics are bitskiff.ctl, bitskiff.in and bitskiff.out, where they were mrc.ctl, mrc.in and mrc.out; a transport of your own (TransportFactory) publishes on the new names.
  • Limits: requireToken (off, token, token_bound_to_session) is enforced at the join, where 0.1.0 stored it and never enforced it (step 17); inspect and the limits read return it.
  • Keys: a key patch (PATCH /v1/project/keys/{keyId}, bitskiff keys update --limit) merges limits per field, where it replaced the whole object and erased every limit it did not name: a named field is set, a field sent as null is cleared back to the value above it (the patching key's, when a key writes the patch and holds one, else the project's), and one left out is kept. Only a JSON body clears a field. permissions and origins still replace the whole list.
  • Limits: the limit ids POST /v1/project/limits/raise takes and every webhook data.limit carries, limits.raise_requested included, are renamed, no alias: concurrent_participants is concurrent_clients, participant_minutes_per_day is client_minutes_per_day and seat_resumes_per_minute is client_resumes_per_minute. A raise naming an old id is refused validation.
  • Limits: every per-address limit counts a whole IPv6 /64 as one address, where 0.1.0 counted each address; step 1 has the rows, their raises and the two windows every project shares. One project's raise never spends another's, and a host-ticket lookup that finds no session spends its own window, held at 30. hostTicketsPerMinuteIp's raise ceiling is 300, where it was 30.
  • Limits: an open reserves 2 x maxClients x (min(lossy maxHz sum, messagesPerSecond) + min(reliable maxHz sum, 20)) + 25 x maxClients + 25 deliveries a second against the engine throughput ceiling, the last 25 for the host, plus one more for each 112 bytes a second its rows declare past what those deliveries carry, where the last 0.1.0 builds reserved 2 x maxHz x maxClients per row, their sum capped at maxClients x messagesPerSecond, against 2850. The rows declare 2 x maxClients x min(sum of maxHz x maxBytes, bytesPerSecond) bytes a second. The ceiling is 7000 a second per engine node (the 2026-09-28 capacity run), one budget shared by every project on the environment, and dev has one engine node. A typical session (one lossy row at 30 and one reliable row at 20, maxClients 4, the 8 KiB byte row) reserves 586, 400 for the rows, 100 for the clients, 25 for the host and 61 for the bytes it declares, so eleven fit on dev in all, and a project's five reserve 2930 of the 7000; a home game of 8 clients on the same rows reserves 1171, and a session with no rows reserves 125. maxClients stays 4, concurrentClients defaults to 40, where it was 25, concurrentSessions stays 5, and joinsPerMinuteSession still never prices a session below 4 joins a minute. A session opened before the roll counts at what it reserved when it opened, until it ends. An open that would pass the ceiling is refused overloaded, as before, and that may be another project's load: retry after retryAfterMs.
  • Limits: bytesPerSecond defaults to 8 KiB a second for each sender in each direction, where it was 16 KiB, so a client of a 0.1.0 app that sends more is booted by its host after 10 s over the row (the roll, above). A project can now raise it from the default up to its plan's ceiling (16 KiB on experimental, none on unlimited) with PUT /v1/project/limits or bitskiff limits set bytesPerSecond; a client key can still set a lower value, and a raised value reserves more per session (1171 at 16 KiB against 586 at the default, above).
  • Limits: a project limit you never set now follows the catalogue default, on projects made under 0.1.0 too: a project that never raised concurrentClients reads 40, not the 25 it was created with; a value you set stays. A limit a 0.1.0 project set to its default value reads as never set; today that changes only a concurrentClients set to 25, so set it again.
  • Licence: every package is licensed Apache-2.0, where 0.1.0 was MIT.
  • Limits: a usage-limit refusal answered to a page, a token or a host names no limit, and rounds retryAfterMs and resetAt up to the hour; a secret key, an organization key and the console still read the full refusal.
  • Keys: a rotated key's predecessor retires at its deadline on the running deployment, where 0.1.0 never retired it. Rotating a key no longer revokes the keys it made: when the overlap ends only the old key is retired, and every key it made moves to the new key, audited as one key.updated per key. Rotations that 0.1.0 left overdue are parked at the 0.2.0 roll, each audited key.rotation_parked in your project's audit log (step 19). The parked-deadline routes see and touch only the caller's project, where 0.1.0's touched every project's: unpark answers another project's member as missing, session_not_found, or validation (no such key) for rotation.
  • Keys: revoking an organization key revokes every key made under it, recursively, as a project key's revoke has on dev since 2026-09-21 (step 18).
  • REST: a boot from outside the session reaches the host and the client as a boot, and a close or a boot from outside names who did it.
  • CLI: the --limit names on bitskiff keys and bitskiff tokens mint are concurrentClients and clientMinutesPerDay, where they were concurrentParticipants and participantMinutesPerDay; on bitskiff keys, tokensMaxTtlS (seconds) is tokensMaxTtl (a duration). Neither old spelling, camelCase or snake_case, is accepted.
  • CLI: bitskiff tokens mint --ttl takes a duration, such as 10m, and refuses a bare number, where it took seconds.
  • CLI: bitskiff keys rotate --overlap takes a duration, such as --overlap 1h where it was --overlap 3600, and exits with a usage error on a bare number.
  • CLI: --org is a global flag on every verb.
  • CLI: whoami --format json prints one JSON document per line on stdout (the resolution, the api's body, then the plan), where stdout was the api's body alone and the resolution went to stderr; and every list answer carries its list field, empty or not.
  • CLI: --format json prints the REST body unchanged, so the REST field renames and the 200 limit cap above apply to scripts that parse CLI output. The one exception: keys list, update and revoke leave out masked and secret, where early 0.1.0 builds printed them; a key is named by keyId.

Deprecated

  • @bitskiff/client: CLIENT_RECORD_KEY, which no client writes under any more; removed in 0.3.0. Read a page's record under clientRecordKey(key). The export carries no deprecation mark of any kind in 0.2.0: no @deprecated tag, so an editor shows nothing, and no notice on its use, as the SDK diagnostic is not built yet.

The session read's older field names (Changed, above) are not deprecated yet: they are deprecated in the release that adds their new names. No route carries the Deprecation or Sunset header in 0.2.0 (Upgrading from 0.1.0).

Removed

Only the first two lines below were deprecated first, in 0.1.0; every other name was removed before the first tagged release with no deprecation.

  • @bitskiff/host, @bitskiff/client: EndedInfo.retryable and ClientEnded.retryable (deprecated in 0.1.0, where they always read false). An end is always an end; a stopped attempt is phase: 'suspended', and errorCode and BitskiffError.clears say what to do next.
  • CLI: BITSKIFF_ADMIN_KEY (deprecated in 0.1.0) is no longer read, and setting it is ignored with no notice; set BITSKIFF_ORG_KEY (step 16).
  • @bitskiff/host, @bitskiff/host-node: Participant, ParticipantChange, ParticipantConnection and ParticipantPresence; use Client, ClientChange, ClientConnection and ClientPresence.
  • @bitskiff/host, @bitskiff/host-node, @bitskiff/client: everyone; use channel.broadcast().
  • @bitskiff/host, @bitskiff/host-node: HostMessage and MessageMeta; a handler is handed one Message.
  • @bitskiff/host, @bitskiff/host-node: TOPIC_CTL, TOPIC_INPUT, TOPIC_OUT, seatClassOf, qrCapacityBytes and isTrustedPeer, with no replacement.
  • @bitskiff/host: BitskiffOptions.joinTrust, with no replacement.
  • @bitskiff/host, @bitskiff/host-node: the internal helpers apiErrorToBitskiffError, httpFailureToBitskiffError, errorCodeFromWire, bundleFromProto, bundleToProto, grantsFromProto, grantsToProto, ReconnectScheduler, ReconnectSchedulerOptions, ReconnectRange, ScheduleOutcome and ScheduleRequest.
  • @bitskiff/host/unattended, @bitskiff/host-node/unattended: UnattendedHost, GaveUpInfo and OpenFailedInfo; use UnattendedLoop, LoopStopped and OpenRefusedInfo.
  • @bitskiff/client: StartOptions (use JoinOptions), ChannelSpec (the host's row is ChannelRow), SessionConfigValues (use SessionConfigRead), JoinBundle, BundleName and MessageMeta.
  • @bitskiff/ui-kit: privateName from the root, and fakeCodeDetector, FakeCodeDetector and Detection from the root and from @bitskiff/ui-kit/scanner.
  • @bitskiff/host: maxViewers; every client is a client.
  • REST: JoinRequest.bundle, InspectResponse.maxViewers, InspectResponse.viewerSeatsFree and SessionConfig.maxViewers.
  • CLI: bitskiff dev (up, down, seed), which was the repository's own tool.
  • CLI: bitskiff deadlines parked and bitskiff deadlines unpark, public 0.1.0 verbs that called GET /v1/project/deadlines/parked and POST /v1/project/deadlines/{kind}/{member}/unpark, with no alias: a script that runs them exits with a usage error. The routes remain, and bitskiff keys update <keyId> --retire-at <time> re-arms a parked rotation (step 16, step 19).
  • 0.1.0's saved-state names are no longer read (Changed, above).

Fixed

  • @bitskiff/client: the transport event fires when the path moves, not on every round-trip sample.
  • @bitskiff/client: a reload after the join code rotated resumes on the stored record.
  • @bitskiff/host-node: a restart after a crash takes the lease as soon as the old one expires.
  • @bitskiff/host, @bitskiff/client: an api outage is charged to no host grace and no client budget, and deadlines moved during it are announced again.
  • @bitskiff/host: a host that runs around the clock resumes after days offline.
  • @bitskiff/host, @bitskiff/client: a replayed ticket no longer ends the client or the host, and a resume whose answer was lost still resumes.
  • Webhooks: a retry is signed afresh with its own t.
  • Limits: a token's clientMinutesPerDay holds on live usage.
  • Limits: an organization-scope refusal is labelled the organization in the console and the CLI.
  • CLI: the "raise it with" hint names bitskiff limits set, which now exists.