Changelog
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.environmentholds its OS family, whether it is a browser, a webview or a native app, and its SDK and version. The types areClientEnvironment,OsFamilyandClientRuntime. A client back from another environment, such as the app a handoff opened, raisesclientChangedwithenvironment. client.state.metadatais the developer's own map the client joined with.client.state.grantsCeilingis the most its join token lets the client hold.setGrantsabove it is clamped to it.client.state.keyThumbprintis 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 withkeyUnverified.
Client (@bitskiff/client)
join,joinWithHandoffandresumetakemetadata, the developer's own map the host reads.resumetakes it asResumeOptions.client.appsis filled after aresumetoo, 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/sessionstakesclientSubject: the sessions where one of the developer's users is a client.POST /v1/sessions/boottakes asubjectand boots that user from every session in the project. It answersbooted, the places it freed; a repeat answers 0.- Client records from
GET /v1/project/ledgercarrymessagesReported, the host's count, anddataMessagesUp,dataMessagesDown,dataBytesUpanddataBytesDown, the service's own.
CLI
bitskiff tokens mint --grants-ceiling receive_broadcastmints a token with a ceiling. It takes a comma-separated list.bitskiff sessions list --client-subjectfinds one user's sessions, andbitskiff sessions boot --subjectcuts 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
clientLeftcarries 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 doctorchecks a setup in one run and names the one thing to fix.- Each app directory gets its own project.
bitskiff initmakes it without asking, and every command uses the project of the directory it runs in. - The CLI installs from
npxwith 5 packages instead of 100.bitskiff mcpfetches the MCP SDK the first time it runs. bitskiff initends 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 doctorchecks 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, eachok,failorskip, then the fix for the first failure, and exits 0 only when every check isok. It asks the api whether the credential holds.--format jsonprints one JSON object, andbitskiff mcphas it as a tool.- Every project-scoped command resolves its project in one order:
--project, thenBITSKIFF_PROJECT, then the nearestbitskiff.json(searched up from the current directory), then the profile, then the machine default.whoamianddoctorsay which one it came from.bitskiff initin a directory with nobitskiff.jsonmakes 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 jsonends withsessionUrl,todoandnext: 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}answersmessagesToHostandmessagesToClient, the messages the host counted each way.bitskiff sessions getshows 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, andlist,replace,relabelanddeletemanage them over/v1/project/fixed-codes. The code is shown once, at create and at replace. A host claims a code at open withhost.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 refusednothing_open, and the hosted join page says "Nothing is running here" with Try again. A replaced or deleted code isinvalid_code. A fixed code carries no host fingerprint, so a client does not join an end-to-end encrypted session by one: its inspect answerse2eeRequired. The host reads how each client came in asentryon 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 recordslimit.hit. - Claiming a fixed code with a host key takes
sessions:claimbesidesessions:open: pickfixed-code-hostwhen you make the key, in the console or withbitskiff keys create --preset fixed-code-host. A secret key, an organization key and a join token claim undersessions:open. Without it the open is refusedforbiddenwithmissing_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 takesorigin, and with no browserOriginheader 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:
BitskiffHostandSessionover 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 hearclientJoined,clientChanged,clientLeftanderror.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 mcploads 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,updateandrevokesay 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.jsin 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 a0.1.0tarball 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
peeror 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 tonullwhen there is no place to go back to, and anullleaves the client new forjoin(code). In an encrypted place the stored host fingerprint is checked against the nextWelcome, and a host that answers unencrypted ends ithostUnverified. A record written at a handoff redeem is now marked pending until itsWelcome.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 resolvesnull; 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 holdingproject: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 --capacityprints it, andload-devsizes itself from it before falling back to opening until the firstunavailable. 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 ascause, is not retryable and does not clear by waiting.internalis now only ever the api's own 500, and the host and client SDKs reportsdk_internalfor their own failures. - The
load-devvalidation run now sizes itself to the capacity the engine has: it opens hosts until the firstunavailableand 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 ticketclient.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 ende2ee_required.POST /v1/handofftakese2eebeside the ticket, refuses a build that cannot encrypte2ee_requiredwithout spending the ticket, and answerse2ee. - 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 readsh=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.holderreads'page'or'app'and moves once, when the app takes the place;client.state.handoffreads the move (pendingatmintedorredeemed, thendoneorfailedwith the step it failed at, and the page's transport as a hint), each change astateevent withchanged: 'handoff'or'holder'.client.handoff({ data })carries up to 4096 bytes to the app, which reads them onclient.handoffData, and theHandoffit resolves hascancel(). The page endshandedOffwhen the app takes its place, neverreplaced. On the host the client readsconnection: 'handingOff'from the app's redeem to the commit, withclientChangedhandoffandholder, and neverdisconnectedorreconnected. In an encrypted session the host holds the app's new key until the app'scnfarrives. An app's hop policy re-takes the page's transport at once on the newhandofftrigger. - 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 (DELETEon 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 toldHandoffStatefrom the redeem on (redeemed, thendoneorfailedwith its stage), never at the mint, and the evicted page's resume readshanded_off(orhandoff_pendingwhile the engine cannot be read). Six mints a minute per place (handoff_mints_per_minute_client), and each handoff ends in oneclient.handoffmeter event. - An agent can now drive Bitskiff from its MCP client:
bitskiff mcpis 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"] } } }.initandloginrun as--no-wait, and a refusal is the tool's error result carrying the api's error body,detailsincluded. - An organization owner can now set how much authority its agents have, or switch them off:
orgKeyMinting: consolestops any key making or rotating an organization key (a person in the console still can),keyMaxLifetimecaps how long an organization key lives, keys already live and the CLI's login key included, anddeviceSignIn: refusedstopsbitskiff loginandbitskiff initacting in the organization while browser sign-in to the console stays. Set them withbitskiff orgs policies --org-key-minting keys|console --key-max-lifetime <duration>|none --device-sign-in allowed|refusedor on the console's Policies page, overPUT /v1/orgs/{orgId}/policies; a key holdingprojects:readreads them onGET. A refusal isforbiddenwithdetails.reason = "policy"anddetails.policynaming the policy. - An agent can now sign in without its tool call waiting on a person:
bitskiff init --no-waitandbitskiff login --no-waitprint{ verificationUri, userCode, expiresAt, next }as JSON and exit at once. Show the person the URL and code, then runnext(init --continueorlogin --continuewith 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-waitreplaces it. bitskiff loginnow refuses a staff key and stores nothing: the api answersstaffonGET /v1/orgsto the key's own holder, and the one line points atnpm 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, andclient.platformEvent('freeze')and'resume'are there for an app that knows better (powerMonitor: falseturns the automatic one off). The Unreal subsystem follows the engine's background and foreground delegates, with a BlueprintPlatformEventnode andSetFollowsApplicationLifecycle(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_eventfrom the C ABI, orclient.OnPlatformEvent(bitskiff::PlatformEvent::kFreeze)andkResumein C++, from the app's lifecycle delegates. A frozen client closes itspeerorrelayconnection and reads itunavailableuntil it resumes, thenavailable, with presence untouched, as a frozen web page does. In the same pass the native core'sorderedHopPolicystays offpeerafter an attempt that found no direct path, as the TypeScript one does, and the Unreal plugin readsbRepairing(bRestartingstays for this release with the same value) and declaresrelaywithDeclareRelay. - A hop policy can now read its base from the context:
client.hops.context().baseisclient.serverby default and the declaredrelayhandle when the installed policy has one, and a policy names it with abaseproperty (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
relayinstead ofserver, so the host's address stays hidden and Bitskiff's engine never reads its data: declarerelay()at both ends and installorderedHopPolicy({ base: viaRelay }), whereconst viaRelay = client.transport(relay()). The data moves ontorelayat the join and back onto it after a fallback, andpeerstays the upgrade from there. A host that declaresrelaynow asks for each client's relay credential as the client arrives, so the firstrelayconnect waits on no extra round trip. Relayed bytes are metered to your project, and the defaultturnBytesPerMonthis now 64 GiB, sized for a session that relays all its data. A client that floods arelayconnection is now booted at once rather than only moved back toserver, because every byte it sends through the relay is billed. The defaultorderedHopPolicy()now triespeeralone; putrelayinorderyourself 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 withGET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/invoices,.../invoices/{invoiceId}and.../invoices/{invoiceId}/pdfunderbilling:read, on the console's billing page, or withbitskiff invoices list,showandpdf. A billing account can takeinvoice.issued,invoice.paidandinvoice.voidedwebhooks on its own endpoint, set withPUT /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/webhooks. No payment is collected yet.bitskiff billing accounts list,createandupdateare now verbs of their own, each with its own route inbitskiff commands. - A client whose project requires a token can now sign in for as long as it takes:
POST /v1/join/inspectwithreserve: { holderHash }turns the fresh join code into a join grant good for 10 minutes and holding no place, andPOST /v1/joinredeems it once withgrant: { grant, holder }in the code's place, the token inAuthorizationas 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 isinvalid_codewithdetails.reasongrant_expired,grant_usedorgrant_holder_mismatch. A project holds at most 1,000 unredeemed grants and a session twice itsmaxClients(at least 20); past either the reservation is refusedrate_limitedinreservation_refusedwhile 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 })answersreservation, a join grant good for 10 minutes, andclient.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'ssessionStorage, so the load that comes back from a redirect readsclient.heldReservation()and redeems with no second scan. A grant that does not redeem isinvalid_codewithdetails.reasongrant_expired,grant_usedorgrant_holder_mismatch; a full ceiling isinspection.reservationRefused, arate_limitedwith its wait. The reference join page takes the sign-in as onebeforeJoinseam 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 appclient.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 endsreplaced, andclient.appscarries 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, orbitskiff --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, andbitskiff --helpreads as before. - An agent can now prove a project works with no host code and no device:
bitskiff initwritesbitskiff-prove.mjsand apackage.jsonbeside the host page, andnpm install && node bitskiff-prove.mjsopens 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 fromBITSKIFF_SECRET_KEYand a client key fromBITSKIFF_CLIENT_KEY, and prints neither. - A project can now be read, renamed and archived by itself:
bitskiff projects get,rename <name>,archiveandunarchiveoverGETandPATCH /v1/projects/{projectId}andPOST /v1/projects/{projectId}/archiveand/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 withforbidden,details.reasonproject_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.gonesets where events are delivered and which usage records ride along, and prints the signing secret the one time it is shown;bitskiff webhooks getreads the config back without it;bitskiff eventsreads the event stream a receiver that was down catches up on, paged by--cursorand--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 })orpeer({ release: true })at the host, then callclient.server.release()and laterclient.server.rejoin();ifLostsays whether a lost link rejoins (the default) or ends the place. The host serves a released client over that transport, shows itreleased, and can release its own connection withsession.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}/releasemarks the place released,.../release-reportreports the released link lost (the place waits under its reconnect budget) or the place ended, andPOST /v1/sessions/{sessionId}/host-releasereleases the host once every client is released; a client reads its own place withGET .../clients/{clientId}/placeand 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 refusedhost_released, and a session whose host stays released for 24 hours endsreleased_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 inorderthat fits every channel's rows, waits 20 s between hops, goes back toserverfrom a link that stays poor, skips a relayed link unlesskeepRelayed, and backs off from 30 s to 5 min over 6 tries, reset by a network change or a return. With noorderit prefers the declaredpeer.@bitskiff/client/testingadds fixtures for iOS Safari, the Android wrapper and a native iOS app, withactive(t),available(t),unavailable(t, reason)andctx({ 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 callclient.hops.use(policy), andclient.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,disconnectorwaitMs, each with awhy), andnextRetryDelayMs(ctx)says how long to wait after a failure.client.hops.step()runs it once andclient.hops.apply(step)takes any step you computed. A step taken shows as ahoporofferevent bypolicy, and one not taken as adecision. Your pins and blocks, the 30 s retry wait, 6 attempts a minute, a 1000 ms floor onwaitMs, the row-size check and one ask at a time hold over any policy, and a raised delay is reported. Each transport now readsretryAtandasked, and the context carriesnetworkandpowerwhere the platform reports them. - A client can now pin, block and clear a transport.
t.pin()keeps the data ontwhile it is active and ends whentfails;t.block()keeps any hop offt; both last for the client's place through away spells and resumes, andt.clear()ends either.t.connect()pins andt.disconnect()blocks. A hop onto a transport whoseprofile.maxRowis below a channel'smaxBytesnow resolvesfailedbefore any attempt, at the client'sconnect()and the host'sclient.connect(settings); pass{ oversizeRows: 'refuse' }to hop anyway and have each oversize row refused withsize. 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
peeroff and on mid-session:settings.disable()moves every client back toserverwith causehostOffand readsnotAttached, and a host reload keeps it off;settings.enable(options?)readsavailableagain and connects no one. - A client now reports every hop:
client.on('hop')fires on each step (started,done,failedwith the stage it reached,failingwhen a link is noticed dead andfellBackwithstallMswhen the data is back onserver),client.on('decision')on a step not taken, andclient.on('ask')when the ask hook is called and answers.client.hops.historykeeps the last 64,on(name, fn, { replay: true })hands them over first, andclient.hops.context()andclient.hops.on('trigger')work with no policy. At the host,clientChangedwithchange: 'transport'carries the step ashop, andclient.hops.historykeeps them per client.client.disconnect(settings)moves one client back once with causehostDisconnect.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;idleCloseMscloses an unused connection with causeidle(the reconnect budget by default,nullfor never), andallocate(waiting)picks which waiting client gets a freed connection, kept for it 30 s. A hidden host page now closes connections with causehostHidden. - 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 installsorderedHopPolicyor 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
peerover a real WebRTC connection on the stack's own network and back toserver, 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::Releasein C++) while its data rides a transport that carries control, andclient.server.rejoin(). Each step is areleaseevent, and a transport's profile readscarriesControl. In the C ABI,client.serverhas noconnect, the state carries every transport record, andclient.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()besidepeer(): 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 withhost.transport(relay())andclient.transport(relay()); leave it out and no relayed bytes are ever billed to you.orderedHopPolicy()triespeerfirst andrelaynext, and moves a client onrelaytopeerwhen the network allows. The hosted join page and the Demo host declare both, and their transport lines name each with its cost:peerdirect, not billed;relaythrough Bitskiff's relay, relayed bytes metered. - An organization can now manage its members, not only invite them:
GET /v1/orgs/{orgId}/memberslists them with their role,PATCH /v1/orgs/{orgId}/members/{userId}changes a member's organization role, andDELETEon 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 confirmedRemove, and the CLI hasbitskiff members list,update <userId> --role <role>andremove <userId>; the invitation list moved tobitskiff 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.
GETandPOST /v1/orgs/{orgId}/billing-accounts,GETandPATCH /v1/orgs/{orgId}/billing-accounts/{billingAccountId}(rename, the billed party's details, make it the default) andPOST /v1/projects/{projectId}/billing-account(attach a project) manage them, andGET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/usage?month=YYYY-MMreads 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 takebilling:readon 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 hasbitskiff billing accounts list|create|update,bitskiff billing attach <project> <billId>andbitskiff 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-raiseslists 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/approveapplies one when the plan's ceiling now holds it, and refusesquota_above_ceilingnaming the ceiling when it does not, leaving it pending;POST /v1/orgs/{orgId}/limit-raises/denyclears 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 kindslimits.raise_approvedandlimits.raise_denied. The console lists the raises on the organization's limits page,/orgs/{orgId}/limits, with Approve and Deny, and the CLI hasbitskiff limits raises list|approve|deny.GET /v1/project/limitsnow answerstokensMaxTtlfor 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), withPOST /v1/project/limits/raise; it is lowered only, never raised. The register rows forclient.heldReservation(),Inspection.reservationRefusedandClientCoreOptions.holderStoredocument 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:
HandoffStateonbitskiff.ctl(pending,redeemedwith the successor'scnf,done,failedwith its stage, and the page's transport hint),ParticipantView.holder(the holder's kind,pageorapp, and the successor while a handoff is under way), the participant statehandingOff, and the error codeshanded_offandhandoff_pendinga 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-errorsrow on one, and the soak's minute line counts them.
Changed
- A session's
reconnectsmeter can count a return the host never read asdisconnected, 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 forlinkDeadAfterHidden(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 nodisconnected; the device dark for under 10 s counted none (metering.md3.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
providerSecondFactorpolicy, 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
validationnaminghandoffLink, 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_internalrather thaninternal, and is reported once: one log line aterrornaming the step, and on the host the session'serrorevent. The host raises noerroronce the session has ended, and a client raises nolifecycleevent after its end beyond the one that says why it ended.setGrantsandbooton a host withoutmanagenow rejectforbiddenrather thanvalidation. 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 aHandoff(ticket,expiresAt,cancel()); theHandoffTickettype is gone. It no longer refuses an away page or one onpeeritself: whether a place may hand off is the api's to say, and a page onpeerhands off as one onserverdoes.- The engine's participants read now carries each connection's ticket attributes,
bitskiff.jtiamong them, asparticipant_joinedcarries 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-nodeand@bitskiff/clino 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 initandbitskiff loginsendlogin: trueonPOST /v1/orgs/{orgId}/admin-keys, and that key alone lasts until revoked. A key you or an agent make withbitskiff keys create --scope orgover the same stored sign-in gets the 90-day default unless--expiresnames another.loginfrom any other credential is refusedvalidationwithdetails.fieldlogin. - A key that makes a key now learns its own expiry instead of getting less than it asked for: an
expiresAtlater than the making key's is refusedvalidationwithdetails.fieldexpiresAt, 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'skeyMaxLifetime) 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 newbitskiff orgs showtake a key holdingprojects:read, andPOST /v1/orgs/{orgId}/limit-raises/approveand/deny(bitskiff limits raises approve|deny) take a key holdingproject:manage, so an agent signed in withbitskiff loginno longer needs a console sign-in for them. The plan still bounds an approval: a value above it is refusedquota_above_ceilingand stays pending, and the audit row names the key. - The organization key
bitskiff initandbitskiff loginmake 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 asUntil revokedand names who approved it, andKeyViewcarriesapprovedByon 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
ghkeeps 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_KEYstill outranks a stored key,bitskiff logoutdeletes 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 orgnow works under a stored key, so an agent signed in once withbitskiff loginmints organization keys as it drives the rest of the CLI. The stored key must holdkeys:manageand name no project allowlist, and the new key holds only permissions the stored key holds (elseforbidden,missing_permission), never outlives it, and is revoked with it.POST /v1/orgs/{orgId}/admin-keystakes 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 organdbitskiff keys rotate <keyId> --scope orgsend on the stored key when no console sign-in is stored, with no step-up. The key must holdkeys:manageand name no project allowlist, and it acts only on an organization key it made or that one made, elseforbiddenwithnot_descendant(orown_linefor 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 (elsevalidationonexpiresAt).DELETE /v1/orgs/{orgId}/admin-keys/{keyId}andPOST /v1/project/keys/{keyId}/rotatetake an organization key under the same rule. - A transport record now says
repairing, notrestarting, while a transport repairs its connection without a hop: an ICE restart onpeerorrelay, or the engine transport's reconnect onserver.restartingstill 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 hashost.server, theservertransport's settings handle: the same shape as a declared one, always on, itsenable()anddisable()throwvalidation, and on a client's handleallow(host.server)does nothing whiledeny,connectanddisconnectthrowvalidation. - 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.downand.up,project_control_messages.downand.upandproject_control_bytes.downand.upbesideproject_data_bytes; a session's meters (GET /v1/sessions/{sessionId}/meters) show them asengine:data_msgs,engine:data_bytes,engine:ctl_msgsandengine:ctl_bytesfields ofsession,upanddown. Themessages.*andbytes.*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 undercontrol) 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.serveris a transport record of the common shape at both ends:client.server.connect()moves the data back onto it and pins,disconnect(),block()andwithhold()throwvalidation, and its record readsrepairingwhile the session connection's reconnect re-makes the data path, which is a transport drop and never a hop. A connectedpeerorrelaylink rides out that reconnect forreconnectBudgetand closes with causesessionLost(wasserverLost) only past it. The engine keeps the old path behind-ctl-data-channelfor 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.relayOnlyis gone at both ends, and passing it throwsvalidationnamingpeer.relayOnly; declarerelay()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 longerseat_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 onseat_spent. - Bitskiff's hosted join page now hops by itself: it declares
peer, installsorderedHopPolicy(), and moves onto a direct connection wherever the host declaredpeerand 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:
relayis its own transport (bitskiff_client_relay,bitskiff::Relay()) besidepeer, and a client declares one of the two;relay_onlyandkeep_relayedare gone, and a transport's profile readsmay_relay(mayRelayin Electron),neveroralways, in place ofrelay. A record carriesrepairingand, for this minor release,restartingbeside it with the same value; a hop causesessionLostreplacesserverLost; 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
peeralone by default, as the TypeScript client'sorderedHopPolicydoes:relayis an upgrade only where yourorderplaces it. Apeerattempt whose link found no direct path now keeps the policy offpeeruntil the network changes or the client comes back. @bitskiff/hostnow 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 readsfaileduntilnextAt, 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: 0readsavailableat once. Before, it readavailablestraight away and a policy could hop the client right back. EngineTransport.flush()now takes{ signal }, asconnectdoes: 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_internalwith exit 1, rather thaninternalwith exit 9, which means the service failed. bitskiff initandbitskiff loginnow 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) assdk_internalwith exit 1 too, rather thaninternalwith 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 withenabledand 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
reconnectBudgetwithseat_spentwhen the place is gone, and an api that does not answer leaves itsuspendedwithbudgetSpentat the end of the joining budget. Before, a connect orHellothat went unanswered after the ticket kept itjoiningwith 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 endsreconnectBudget(orbackgroundBudget) within about two seconds of the instant the host releases the place. Before, a start that sent nothing stayed onjoiningwith no word for the rest of the two-minute joining budget. npm run admin-keynow 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 orgwith "an organization key is created in the console", although a key withkeys:managemakes 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 toservera few seconds into eachpeerconnection 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
presentagain on its return and its background budget stops, where before the host kept counting and ended the placebackgroundBudgetwith the client back. - When the host moves a client off
peerbecause the link stopped delivering its rows (a passedhopRetainBytes,hopHoldBytesorhopAckWait), the host'sclientChangedand the client's transport status now give the causestalledrather thanfailed. - That same move now reads
stalledin the client's hop history too (client.hops.history, itsfailingandfellBackevents), rather thansilence: 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
stalledin 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 withbudgetSpentandresume()rejects, instead of sitting onjoiningand 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 answersrate_limitedwithhandoffAppReadsPerIpand 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
joiningand 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
budgetSpenton that budget, the same lifecycle row its own clock gives, rather thanbye. - The credentials doc now says what
deviceSignIn: refuseddoes 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 withcreatedBynaming the login key. Revoking the login key ends them with it. npm run admin-keynow revokes its key however the run ends, a thrown error and SIGINT, SIGTERM or SIGHUP included, and its key lives one hour unless--hoursnames 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 readsclient.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
validationonhandoffLink. 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
bufferPressureonly 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()endshostClosedeven when the api'sByeor a scheduled reconnect arrives during it, always ends the session, and rejects only with aBitskiffError. 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. Arejoin()that races a released session's end readsfailed, 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 secondjoin()while the first is still starting is refused withvalidation, and a secondleave()sends no secondBye.dispose()now fires astateevent 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 firstWelcomesuspends the client rather than ending it. A failure in work the client does not wait on (a presence post, a rejoin underdisconnect()) 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 appkey_unverifiedinstead 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 noclientLeft, and a clientchangeorleftlistener that throws now raiseslistener_erroron the session, as a session listener does. - An end-to-end encrypted client no longer sits unbound when its
KeyConfirmcannot be sealed or its confirmation digits cannot be computed: the confirm is retried and, past the third retry, the client endshostUnverifiedasprotocol.md10.4 says. A key mint that failed is tried again on the nextHellorather than failing every one after it, and a fresh join after a spent place never presents the old place's key. A committed handoff staysdonewhen a lateredeemedorfailedarrives, and an app that finds at itsHellothat 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, andjoin()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
KeyConfirmthat 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 bootedkey_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.handoffreadsfailedwith 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 newhandoff_failed), and the join page's offer comes back with a fresh ticket. The host now reads a refused redeem asclientChangedhandofffailedatrefused, with its connection unchanged. An app that refuses the place on its own check after its redeem ends with lifecycle reasonlocalRefusaland reads its handofffailedatrefused. - 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
hostUnverifiedbefore 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
hostUnverifiedbefore 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
sizeorratewhile the seal backlog is full, neverpressure. - 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 reachessession.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
noClientbefore a missing grant readsnoGrant, and a session that is not open readsnotOpenbefore a full seal backlog readspressure. 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 onsession.on('error')with codeinternal; the client, which has noerrorevent, logs it aterror. 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.clientswhenopen()(orresume()) resolves, and their return to the new page raises noclientJoined. 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 newclientJoined. - 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 fromhost.channel()held each session it had served so itscounterscould 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
DELETEcross-origin. Before, an app that refused the host before it connected left the host readinghandingOffand the pagepending, where now they readfailed{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'sHello: the key is bound under itscnf, as it is when the api'sdonecomes first. - A host that resumes its session from
persistonopen()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
Welcomeverified, the redeem answers it to the app, andjoinWithHandoffchecks it against the fingerprint in the ticket before it connects. On a mismatch the app does not connect: it endshostUnverified, cancels the handoff, and the host readshandingOff, thenfailedat stagecancelled, 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.
joinWithHandoffnow sends the thumbprint of the key itsHellowill present on the redeem (HandoffRedeemRequest.key_thumbprint), and api compares it with the token'scnfbefore anything moves: a token with nocnf, or one that names another key, is refusedkey_mismatch(403), the ticket is spent, the app endsrefusedwith that code, and the page keeps its place. Before, the app connected, the place moved to it, and the host then booted itkeyUnverified. The host still checks the key theHellobrings. @bitskiff/host-nodenow exports theLinkGradetype, as@bitskiff/hostdoes.- 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
clientChangedwithchange: '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 nodisconnectedchange and no budget running. Before, the host read itdisconnectedand losthandingOff. If the handoff fails, the host now reads the pagedisconnectedand 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 arequiredproject endsrefusedwitherrorCodee2ee_unsupported. Before, it could endbootedwith no reason. A client that ends itself on its own check now reports the lifecycle reasonlocalRefusalinstead ofbye. - Our own tools no longer ask the owner to sign in:
npm run demoandnpm run walk:devrun under the CLI's stored credential throughnpm 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
peerthe way a fresh join does. The host SDK tells the app's client its transport records once the app takes the place, and offers itpeerwhen it asks. Before, the app stayed onserverfor the rest of the session. A page whose handoff fails gets itspeerback the same way. - An app that takes a client's place by a handoff and stops before it sends anything is now read
disconnectedby the host within about 3 s, and leaves at the end ofreconnectBudget, where before the host read it connected until the session ended. A handoff the app redeemed and never attached now endsfailedat stageredeemedwhen its 60 s window ends: a mint from the page while the app holds the redeemed ticket is refusedhandoff_pending(409,retry_after_msthe 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
peerlink stays up and its transport records hold until the app takes the place. Before, the host queued its messages to the page and closed itspeerlink from the redeem, so the page's data fell back toserverand 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 readdisconnectedabout 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 encryptioninspectanswerse2eeUnsupportedandjoinrejects withe2ee_unsupportedbefore the join request;joinWithHandoff, which cannot know first, joins with no key and the host turns it away. Either way, and when aHello's key cannot be made, the client endsrefusedwithended.errorCodee2ee_unsupported, the join rejects, the place is given back, andstate.handoffreadsfailedatrefusedon 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
cnfnames it and boots itkeyUnverifiedotherwise, and a host opened withe2ee: '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
cnfon 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 sameIdempotency-Keygets 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, statehandingOff); a boot or the session's end in the window cancels the handoff, so the app's redeem readsseat_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_offon the client, the lifecycle reasonhandoff_pending). - A prompt can no longer steer the CLI's stored credential through
bitskiff mcp: theconfigverbs andlogoutare no longer tools, so no tool call moves the endpoint the key is sent to or drops the key, and--endpointand--profilestay 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: refusednow stops every machine already signed in, not only new sign-ins: a login key minted before the switch is refusedforbiddenwithdetails.reason = "policy"anddetails.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
subjectsare inside its own: a key holdingkeys:manageand no session word is refusedforbiddenmissing_permissionon a key holdingsessions: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:manageand no project allowlist to list the organization's keys onGET /v1/orgs/{orgId}/admin-keys, the organization admin relation the writes already check: a key holding onlyproject:readorprojects:readis refusedforbiddennaming the permission, akeys:managekey bounded to some projects is refusedforbiddennaming 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: 2is refusedquota_above_ceilingon a key that names noconcurrentSessions, 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 readsnever. - A handoff ticket is now spent only by the registered app: a redeem with any other client key is refused
wrong_clientand 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 readhandoff_invalidspent. 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, withBITSKIFF_ENDPOINTandBITSKIFF_ORG, before its config, andnpm run admin-keyputs the key it mints in the environment of the shell it starts, or of the command--execnames, instead of handing it tobitskiff 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
bootedat once: the host announces the boot withBootbefore 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, endedreconnectBudget. - A client the host boots for exceeding a rate (ten seconds over the message rate, or past twice its channel's row) now reads
bootedat once: a host holdingmanageannounces the boot withBootbefore the boot route closes the client 4001, where before the client read the rate close 4100, backed off and could endreconnectBudget. A host withoutmanagestill 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
tokensMaxTtlnow binds: a token minted on a key holding a shorter value than its project's is held to the key's, both atPOST /v1/tokensand 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 refusedvalidationonttlwithdetails.limittokensMaxTtlanddetails.scopenaming which of the two bound. - An organization key now needs
members:inviteto list, send or revoke invitations: a key without it is refusedforbiddennaming the permission, as the members routes already did, and a console session is unchanged. - A local engine run stays on loopback:
-loopbacknow rewrites an empty or0.0.0.0host in-listen,-internal-addrand-metrics-addrto127.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
linkMessagesPerSecondup to its ceiling of 120 instead of every raise being held pending; asamplePageSessionsrefusal now recordsclears: neverinopen.refused, because a limit's ownclearswins 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
unsupportedinGetTransportRecordat 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 demoandnpm run walk:devmoved under the stored credential. The device-approval routes,bitskiff loginandbitskiff initare 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,peerand each declared transport with its status and reason, itssupportword, 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
asktonew BitskiffClient({ ask }), or to a transport's ownpeer({ ask })orbluetooth({ ask, name }), and callt.prepare()when you are ready to ask: the hook answersgranted,refusedorlater, andprepare()resolves the newsupport. Without a hook nothing is ever asked and the transport staysneedsAsk.client.askingnames 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:
supportreadsready,needsAsk,asking,refused,unsupportedorunknown, and every transport handle has a read-onlyprofile, readable beforejoinoropen. Callwithhold()on a transport beforejointo keep this client off it; the host reads itunsupported. - Each transport record now says whether it is connected and how good its link is, at both ends:
connected, and while connected alinkwith its grade, round trip, jitter, loss, whether it is relayed and, onserver, the path. The client grades its own links and the host shows the reading the client sent. A record also says when a connection isrestartingand, once it readsavailableagain,lastFailedAt, andt.on('status')and the host'sclientChangedsay who moved it (by) and why (cause).state.transportandTransportInfoare gone: read a client's engine path asstate.transports.server.link.pathand its peer connection asstate.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 undertransports.peer; the options types arePeerOptionsandClientPeerOptions. Nothing else changes in how it behaves.p2pis no longer read anywhere: a host's saved allow and deny lists from an earlier release start empty, and api counts onlypeertraffic rows as the client-to-host transport. peeris declared at both ends likebluetooth():host.transport(peer(options))beforeopenorresumereturns the settings handle you pass toclient.allowandclient.deny, andclient.transport(peer({ relayOnly }))beforejoinreturns the client's handle withconnect()anddisconnect(). A host or client that declares nothing has onlyserver. The host'speer(options)takesallowed,relayOnly,maxConnectionsandpeerConnection.open({ peer }),resume(token, { peer }),session.peer,client.peerandnew BitskiffClient({ peer })are gone, and from JavaScript passingpeertoopen,resumeor the constructor throwsvalidationnaming the option. Declarepeer()again beforeresumeand the saved allow and deny lists come back. The hosted join page and the Demo host declarepeer().
Removed
- The names 0.2.x kept for one release are gone, and the relay is Bitskiff's alone. Set relay-only with
relayOnlyonpeer, at the host and the client: thedirectoption,HostDirectOptionsandClientDirectOptionare removed. Your own STUN and TURN servers (iceServers) are no longer accepted, so relayed bytes are always Bitskiff's and metered to your project. PassingdirectoriceServersis a type error, and from JavaScriptopenandnew BitskiffClientthrowvalidationnaming the option.Transportnow names only the transport handle (client.server,client.peer); the engine seam isEngineTransport.createNodeTransport()takes no argument andNodeTransportOptionsis gone, as areTransportPath(useEnginePath) and the exportedCLIENT_RECORD_KEY(a page reads its record underclientRecordKey(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) andclient.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)orclient.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.serverandclient.p2preport 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 withsize. - 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, withClient.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
dataBytesfields (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 limitsshow them, andGET /v1/project/limitsreturns 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.codeholds 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 withdisconnect(). - 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
declinedclose 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
nextAttime, notAllowed after a deny, unavailable while the client or the host is away, or available afterdisconnect()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.
TransportFactorynow names only the factory of a transport you attach, such asbluetooth(). Code that imports it for the engine must useEngineTransportFactory, because the old name has no alias.- The other engine connection types are renamed
EngineTransportandEnginePath. 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.tokensPerMinutesets 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/clientbundles 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-nodenow 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
Transportin@bitskiff/hostand@bitskiff/host-nodestill works as an alias ofEngineTransportuntil 0.3.0.createNodeTransport()takes no options.NodeTransportOptionsremains only as an ignored empty option until 0.3.0.- The
directoption is deprecated for one release and maps ontop2p. Turn p2p on with thep2poption on the host's open and resume and on the client's constructor. TransportInfoandTransportPathare deprecated for one release. In their place, readstate.transportsandclient.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_texttakes a length, and every native binding reports its device class. - A session opened with
defaultGrantsnow 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 asvalidation, 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_limitedreads 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
unavailableinstead 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
subjectfilters 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 withoutpersist.asnow 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_lostafter 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_shutdownfirst, 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_awayandclient.reconnecting, instead of putting the host away silently. bitskiff initno 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 initmakes now holds the project's session permissions, sobitskiff keys create --type secretwith the open, read and manage session permissions works right after sign-in. bitskiff usagenow 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-nodeprocesses 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:
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 asX-Bitskiff-Network-Class, where they readNetwork-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 withusage: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,backgroundBudgetMsorjoinCodeRotationS) fails its open. Upgrade the host and pass each as a duration string under its new name, such asidleTimeout: '10m'(step 4). - A no-build page that loads the hosted module
/sdk/host@0.1.0.js(orhost@0.1.0/unattended.jsorclient@0.1.0.js) under the import map'sintegrityhash 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 fromd321fd70c(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 areclient.*; since its roll at 2026-09-26 05:09Z an unwritableAcceptis 406; and since its roll at 2026-09-27 07:59Z a request with no body and noAcceptis 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/64as one address. It already counts only your project's traffic on dev. A venue whose clients share one/64is refusedrate_limitedpast 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 sbackgroundBudget, 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 (endedbackgroundBudget) and a client dropped in the foreground after 10 s (endedreconnectBudget). To keep the old hold, passreconnectBudget: '15s'andbackgroundBudget: '5m'on open, or, from the roll, set them as the project's session defaults. - The byte row:
bytesPerSeconddefaults 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 withbitskiff limits set bytesPerSecond 16384from the 0.2.0 CLI (/downloads/0.2.0/). requireTokenis 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 itleftand does not tell the host. - A join token bound to another session is refused
forbidden(details.reason: 'token_not_for_session'), where dev answerssession_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.
@bitskiff/client: pass a client key askey, 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 (expiresAtinbitskiff keys list --scope org), or 90 days after its own making if that key has none.--expiresonly 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--originper origin; a native client names its scheme, such ascapacitor://localhost. With no--origina key allows onlyhttp://localhost:*andhttp://127.0.0.1:*, and any other origin is refusedorigin_not_allowed(reasonmismatch). A client that sends noOrigin(Node, or an app shell'sOrigin: null) passesorigin, such asnew BitskiffClient({ key, origin: 'app://my-player' }), and the key lists it, or it is refusedorigin_not_allowed(reasonabsent). An expired key's joins are refusedinvalid_key. A client key admits 300 joins a minute (joinsPerMinuteKey), which 0.1.0 did not cap, and refuses the restrate_limited. From the roll, per-address limits count a whole IPv6/64as one address, andsessionOpensPerMinuteIp(30) andhostTicketsPerMinuteIp(30) count only your project's traffic, asjoinsPerMinuteIp(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 asbitskiff limits set joinsPerMinuteIp 200. Today the joins and session-opens raises apply at once, buthostTicketsPerMinuteIpstays at most 30 until the roll brings its 300; a raise past a ceiling is refusedquota_above_ceiling. From the roll, on theunlimitedplan no raise waits; underexperimentalone past the default is held for approval. Every organization made before the roll, under 0.1.0 included, is onunlimited, 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; andbadCodeAttempts: past ten wrong codes from a/64in a ten-minute window, its joins, valid codes included, are refusedrate_limitedwhile a back-off runs (2 s, doubling to 60 s), and past 100 until the window ends. The constructor throwsvalidationwithout a key (details.option: 'key') and on atokenoption ('token'; pass it tojoin, as above). The host's publishable key is not a client key: its joins are refusedorigin_not_allowed, orforbidden(details.reason: 'missing_permission',details.permission: 'sessions:join') when its origins allow the page. A REST caller of join or inspect sends the key asX-Bitskiff-Keyandoriginin the body (Changed, below). An early 0.1.0 client refuses every 0.2.0 code itself asinvalid_code. 0.1.0'sstart(code, { bundle: 'viewer' })is dropped: every client joins with the host'sdefaultGrants, so a view-only client needs the host to set its grants.- Webhooks and REST: rename every header; the api ignores the old names, with no alias. A webhook
receiver reads
X-Bitskiff-SignatureandX-Bitskiff-Signature-Kid, where it readX-MRC-SignatureandX-MRC-Signature-Kid, or it rejects every delivery. A receiver answers 2xx to a kind it does not handle:open.refusednow reaches every project with a webhook, and a delivery answered with any other status is retried for 24 hours. Under Changed, below: alimit.warningorlimit.exceededreceiver reads camelCasedataand renamedlimitandscopevalues; alimits.raise_requestedreceiver and a caller ofPOST /v1/project/limits/raiseuse the renamed limit ids; a REST caller sendsX-Bitskiff-Projectand readsX-Bitskiff-Request-Id; a usage reader queries the renamed meter ids, and sums old and new ids as Usage meters says. - Join codes: stop checking or reformatting them. A code is 15 case-sensitive characters under
e2ee: off; undere2ee: requireda client joins on the wholesession.codestring,<code>.<fingerprint>, so pass it as it is. Never upper-case what a person types. A 16-character code, printed or stored, isinvalid_code. @bitskiff/host,@bitskiff/client: rename every duration option and pass a duration string, such as'30s'(Changed, below). Onlyopen()refuses an old name (validation,details.optionnames it), andopenForeverthrows aTypeErroron an unknown top-level key.ReconnectPolicy, theopenForeverpolicy,resume()options and the client options ignore an old name and keep the default; only the TypeScript compiler catches those.@bitskiff/host: declare channels withhost.channel(name, spec)beforeopen(), and give the client only the name,client.channel(name). A lossy channel that needs more than 30 messages a second declaresmaxHz.@bitskiff/host: replacesession.participantsandon('participant')withsession.clientsand theclientJoined,clientChangedandclientLeftevents.session.clientsis no longer aMap: it iteratesClientobjects, not[id, participant]pairs, and has noforEach(get,has,keysandentriesremain). A client's fields are read fromclient.state, such asclient.state.deviceClasswhere you readp.deviceClass;seatisstate.number, andbundleis gone. On both SDKs a message handler takes oneMessage:(m, meta)is(m), and the host reads the sender asm.client, aClient, where it read the idmeta.from.send(to, data)takes aClient, a client id or an array of them, andbroadcast(data)replaceseveryone: a literal'*'target, which waseveryone, is read as a client id and returns{ dropped: 'noClient' }. Anon()oroff()for an event the handle does not have now throwsvalidation(Changed, below), so a leftoversession.on('participant', ...)orclient.on('gone', ...)throws at that line. AParticipantChangehandler is rewritten, not renamed (step 13).@bitskiff/host,@bitskiff/client:send()returns'sent'or{ dropped: reason }. Code that compares the result with'dropped'or'queued'never matches now; testresult !== 'sent'and readresult.dropped.@bitskiff/host/unattended: callopenForever(host, { session, policy }), listen foropenRefusedandstoppedwhere you listened foropenFailedandgaveUp, and dropmaxStrikes. Read theloop.sessionproperty where you calledcurrent(). Both events hand new shapes (Changed, below):nextAtis absent when the loop endsdisposed, and astoppedreasonis the loop's, not a session end reason. A leftoverloop.on('openFailed', ...)orloop.on('gaveUp', ...)throwsvalidation(details.option: 'event') at that line from 0.2.0 on; the served-0.1.0.tgzbuild runs it as astoppedlistener. A host you pass toopenForeverneedsopen,platformandreconnect, where it needed onlyopen: pass theBitskiffHostitself, since a wrapper exposing onlyopenthrows aTypeErrorat the call. The defaults changed (Changed, below). Rename the options insidesessiontoo (step 4): asessionthat still carriesidleTimeoutMsormaxControllersnever opens, since every open is refusedvalidationand the loop retries it every 300 to 600 s forever, so watchopenRefusedon first boot.@bitskiff/host-node/operator:createDrainnow detaches onSIGTERMas well asSIGHUPwhendetachis wired, so a plain stop leaves the session running out its host grace; passrestartOn: ['SIGHUP']to close onSIGTERMas before.processSignals(process)hears onlySIGTERMandSIGINT, so a process sentSIGHUPpasses it in the list.@bitskiff/host,@bitskiff/host-node: aPersistenceof 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 withnodeStateStore(backend)instead.- 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 noAcceptis answered JSON, not protobuf, and an unwritableAcceptis 406, not 400, with codevalidationas before. @bitskiff/ui-kit: import a component's constants and helpers from its own subpath, such asDIRECTIONSfrom@bitskiff/ui-kit/dpad.- 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
mrcnames (Changed, below), nor a client record saved before early 2026-09-27 (undermrc:client, or the barebitskiff:clientfrom 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 undermrcmust sign in again. @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:idleis gone, and a reconnect isjoining, where it wasreconnecting.- End reason
seatsFullisclientsFull; error codeseats_fullisclients_full. client.state.deviceClass:'phone'is'mobile'.Channel.pressure(0 | 1) isChannel.congested(a boolean).- Lifecycle reasons
hiddenandvisibleareawayandpresent(a client reports them only once its page callssetPresence, step 15); transport pathturnisrelay. ParticipantChange, an object with akind, isClientChange, a string, handed aschangeinClientChanged { client, change, previous, at }onclientChanged.joinedand the firstconnectedare oneclientJoinedevent, andgoneisclientLeft(itsreasonon the event);connectedafter a drop is'reconnected',grantsChangedis'grants'(the old grants onprevious.grants, where they werefrom) ande2eeChangedis'e2ee';disconnected,awayandpresentkeep their words;'channels'and'network'are new. A portedc.kind === 'connected'never matches: teste.change === 'reconnected'.- Deadline kind
seatis reported asreconnect, andDeadline.participantIdisclientId, on both SDKs. - Host end reason
leftis gone; one the SDK does not know isunknown, where it wasapiClosed. BitskiffError.limitanddetails.limit:concurrentParticipantsisconcurrentClients, and the rest as listed under Changed. The rename reaches a server on a secret or organization key, and every caller'srate_limitedandconcurrency_limitrefusals. Aquota_exceededorturn_quota_exceededrefusal 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 oncodeand readsresetAtUnixMs(details.resetAton REST), where it comparedlimit.session.setGrants(id, grants),session.boot(id)andsession.markActive(id)aresetGrants(grants),boot()andmarkActive()on theClient; the old calls throw aTypeError, as the session no longer has them.- Session options
maxControllersandjoinGrantsaremaxClientsanddefaultGrants;open()refuses the old namesvalidation.maxViewersis gone andopen()refuses it too: setmaxClientsto the oldmaxControllersplusmaxViewers, up to 500, or the former viewers are refusedclients_full. A host that kept the default still holds 4 clients.
- 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 warnsEBADENGINEand fails underengine-strict. @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 itsbackgroundBudgetnever starts, until it wires its own signal:
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.
- CLI scripts:
whoami --format jsonprints 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 jsonanswer is the REST body, so the REST renames and the 200limitcap (Changed, below) reach scripts that parse CLI output; thekeysverbs butcreateandrotateleave outmaskedandsecret, so a script names a key bykeyId.bitskiff keys rotate --overlapandbitskiff tokens mint --ttltake a duration, such as--overlap 1hwhere a script passed--overlap 3600, and exit with a usage error on a bare number. The--limitnames onbitskiff keysandbitskiff tokens mintare renamed (Changed, below), and an old name exits with a usage error. The CLI ignoresBITSKIFF_ADMIN_KEY, so a script that sets it runs under the machine's sign-in, or fails for want of one: setBITSKIFF_ORG_KEY.bitskiff deadlines parkedandunparkare gone (Removed, below; step 19). - Keys and project: clear
requireTokenon 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 refusedforbidden(details.reason: 'token_required') without the token the stricter of the project's word and the opening key's asks for (token, ortoken_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 asbitskiff limits set concurrentClients 50,clientMinutesPerDay,clientResumesPerMinuteortokensMaxTtl 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 refusedquota_above_ceilingwhole,requireTokenincluded, and the key's clients are refusedtoken_requiredat the roll. Then, until the roll a key patch,bitskiff keys updateincluded, replaces the key's wholelimits, andGET /v1/project/keysleaves out a limit stored under a 0.1.0 name, so a patch copied from the GET erases it. SendPATCH /v1/project/keys/{keyId}withlimits: { 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:concurrentParticipantsasconcurrentClients,participantMinutesPerDayasclientMinutesPerDay, andtokensMaxTtlS(seconds) astokensMaxTtl(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, runbitskiff keys update <keyId> --limit requireToken=offfrom the 0.2.0 CLI (/downloads/0.2.0/); today's CLI exitsunknown limit. Either way the key keeps every limit. The project's ownrequireTokencan be neither read nor cleared before the roll: dev's limits read does not return it, and its raise refuses itvalidation. 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, runbitskiff limits set requireToken offfrom the 0.2.0 CLI; until then every tokenless client is refusedtoken_required. Eachtoken_requiredrefusal counts toward the sharedbadCodeAttemptswindow (step 1), so a venue's/64of tokenless clients soon has its valid joins refused too. - Keys: read
ancestrybefore 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 orgfor the organization's), find those whoseancestrynames the key you revoke, and replace the ones that must keep working from a key you keep before you revoke it. - Keys, now: finish the rotations 0.1.0 left open; it never retired a rotated key.
In
bitskiff keys list, a key withstatusrotatingnames its new key insuccessorKeyId. One whoseretireAtis still ahead at the roll retires then: move its hosts and clients to the new key first, or push it out withbitskiff 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 itsretireAtretires, 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 withPOST /v1/project/deadlines/{kind}/{member}/unpark(kindrotation,memberthe key id), called with an organization key holdingproject:manageandX-Bitskiff-Projectnaming the project, and it retires at the next sweep.GET /v1/project/deadlines/parkedanswers at most 200 entries (50 by default), with no cursor, so trustkeys 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>.tgzfor every package, and/downloads/latest/<scope>-<name>.tgzfor the newest release. @bitskiff/host: the direct path.direct: truein 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: falsedeclines 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 reporteddirectNotOfferedwith causenoPeerConnection.@bitskiff/host: each channel row carriesmaxHz,maxBytes,latestOnly,orderedand a lifetime (lifetimeon the spec, read back aslifetimeMson the row). The api clamps the row (maxHzto the class rate,maxBytesto 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 65thhost.channel()throwsvalidation.@bitskiff/client:client.setPresence(): presence is the app's to set, and the host reads it on theClient.@bitskiff/host:session.capacityandon('capacity'), so a live session learns its cap.@bitskiff/host:open()takesopenBudget(a duration string, such as'2m') andidempotencyKey, andBitskiffOptions.keymay be a function.@bitskiff/host: the termination policy:terminationRuleson the session, the@bitskiff/host/expressionssubpath, and thepolicydeadline.@bitskiff/host,@bitskiff/client:BitskiffErrorcarriesclearsandsource.@bitskiff/host,@bitskiff/client,@bitskiff/host/unattended: everyontakes{ signal }, and every handle hasoff; the unattended loop'sonoptions are the new typeUnattendedListenerOptions.@bitskiff/host,@bitskiff/client:parseDurationandformatDuration.@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 reportshealthanderrorevents, andUnattendedLoop.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 exportHostKey, the type ofBitskiffOptions.key.@bitskiff/client:resolveReconnectPolicyandJoinToken, the type ofjoin(code, { token }).@bitskiff/host,@bitskiff/client:ResolvedReconnectPolicy, whatresolveReconnectPolicyreturns; its fields are milliseconds and keep theMsnames (baseMs,capMsand so on).- Error codes:
plan_expired(the organization's plan passed its expiry),overloaded(the platform is at its admission ceiling; retry afterretryAfterMs),replaced(a second join as the same token subject took the client's place) andrelay_not_configured(the environment runs no relay). - Error reasons:
forbiddenwithtoken_required(the project requires a join token) andtoken_not_for_session(the join token is bound to another session);invalid_keywithdetails.reasonabsent,malformed,unknown,revokedorexpired. - Webhooks:
open.refused, delivered to every project with a webhook; the opt-in kindssession.endedandclient.gone, one usage record each, chosen withWebhookConfig.kinds. A client's record carriessubjectUnknown, true when itssubjectandkeyIdare empty because they could not be read, not because the client is anonymous. - REST:
GET /v1/project/ledgerandSessionView.usage, the usage a developer bills on. A CSV page carries its next cursor inX-Bitskiff-Next-Cursor. - REST:
GET /v1/project/events, which reads back refusals such asopen.refused. It pages bycursorandlimit. From the roll a page carriestrimmed, which dev's build does not:truewhen thecursorasked for is older than the oldest event the stream keeps, and the page starts at the oldest kept. - REST:
GET /v1/sessionsfilters bylabel,subjectandhostState. - REST: project session defaults, so an open that names no
idleTimeouttakes 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/approveandGET /v1/auth/device/{userCode}. - REST:
GET /v1/orgs/{orgId}/plan, the organization's plan and when it expires (the plan behindplan_expired).PUT /v1/orgs/{orgId}/plan,GETandPOST /v1/platform/invitations, andDELETE /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 .../budgetandPOST .../closing; andPOST /v1/bench/tickets, which the connection benchmark page uses. - REST: a
concurrency_limitrefusal onconcurrentSessionscarriesdetails.sessionIds, the sessions in the way, to a caller that may list them. - Limits:
bootCooldown,10mby 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; andjoinsPerMinuteIpmay 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 holdingsessions:join. - CLI:
bitskiff initsigns in, picks a project and writes a hello world. Its page loads the versioned host file the CLI was staged against, with itsintegrityhash, 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,--hostand--subject,bitskiff sessions close --subject, and--host-keyand--subject-prefixonbitskiff keys createandupdate, and--clear-host-keysand--clear-subjectsonbitskiff keys update. - CLI:
bitskiff settings setalso writes the project's session defaults andterminationRules. - Usage meters:
api_requests.{route_class},egress_bytes_attributedandengine_bytes_unattributed.
Changed
@bitskiff/client: every client is built with a client key,new BitskiffClient({ key }); the constructor throwsvalidationwithout one. The api answers a join with no keyinvalid_key(reasonabsent), unless the session serves the platform's sample client page.@bitskiff/client: theoriginoption, andoriginin 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 noOriginheader orOrigin: null, and then requires it: a Node client, an app-shell page or a server passes it, or its join is refusedorigin_not_allowed(reasonabsent).@bitskiff/host,@bitskiff/client: the session's, the host's per-clientClientobjects' and the client'son()andoff()throwvalidation(details.option: 'event') for an event the handle does not have, where they accepted it and the handler never ran (aClientregistered it on'left'). The unattended loop'sondoes too, where an unknown name ran its handler as astoppedlistener. A channel'son(), 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)isclient.join(code, opts?), no alias, and it resolves to the client once it has joined. A join token isjoin(code, { token }), not a constructor option.@bitskiff/client:ClientPhaseis'joining' | 'connected' | 'suspended' | 'ended':idleis gone and a reconnect inside the budget isjoining, notreconnecting. TheClientChangevaluereconnectingis gone too.@bitskiff/host,@bitskiff/client:retry()resolves to the session or the client, where it resolved to nothing (host) or theClientState(client).- Join code: 15 case-sensitive characters over
A-Z,a-zand0-9, where it was 16 upper-case characters. A 16-character code, or one whose case was changed, isinvalid_code. @bitskiff/host:session.channel(name, spec)ishost.channel(name, spec), called beforeopen(), no alias;open()seals the table, a call after it throwsvalidation, andsession.channel(name)returns the declared channel.@bitskiff/client:client.channel(name, spec)isclient.channel(name); the host's row is the only spec.@bitskiff/host: a lossy channel with nomaxHzis 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' }; declaremaxHz: 60for a client sending at 60 Hz.@bitskiff/host,@bitskiff/client:SendResultis'sent' | { dropped: reason }, where it was'sent' | 'queued' | 'dropped'. The host'sDropReasonisrate,size,pressure,noClient,notListening,noGrantornotOpen; the client'sClientDropReasonisrate,size,pressure,noGrant,notOpen,hostAwayornotDeclared.@bitskiff/host,@bitskiff/client: a message handler takes oneMessage, where it took(message, meta);seq,receivedAt,sentAtandchannelare on theMessage, and the host's sender ism.client, aClient, where it was the idmeta.from.meta.lagMshas no counterpart on theMessage; readreceivedAt - sentAt.@bitskiff/host:channel.send(to, data)takes aClientTarget(aClientor a client id) or an array of them, andchannel.broadcast(data)sends to every client.@bitskiff/host,@bitskiff/client:Channel.pressure(0 | 1) isChannel.congested(a boolean), no alias.@bitskiff/host:BitskiffOptions.e2eetakes'required'or'off'and refuses anything elsevalidation.@bitskiff/host/unattended:openForever(host, session, policy)isopenForever(host, { session, policy }), and an old call with a third argument or a flat session config throws aTypeError(openForever(host)runs unchanged). It returns anUnattendedLoop, where it returned anUnattendedHost; theopenFailedevent isopenRefusedandgaveUpisstopped, no alias; the policy'smaxStrikesis gone.current()is thesessionproperty;OpenFailedInfo { strikes, retryInMs }isOpenRefusedInfo { attempt, nextAt };GaveUpInfo { strikes, error, reason }isLoopStopped { at, reason }, whosereasonisstop,reopenOnordisposed; andOpenableHostrequiresplatformandreconnectbesideopen. The defaults changed:maxBackoffis5m, wheremaxBackoffMswas 30 s; the loop never gives up, wheremaxStrikesgave up after 20; andreopenOnaccepts every end reason, where it refuseddisposed.@bitskiff/host,@bitskiff/client: every duration is a duration string, such as'30s', and its option lost the unit suffix, no alias:- session options:
idleTimeoutMsisidleTimeout,maxSessionTimeMsismaxSessionTime,hostResumeWindowMsishostResumeWindow,warningLeadMsiswarningLead,reconnectBudgetMsisreconnectBudget,backgroundBudgetMsisbackgroundBudget,joinCodeRotationSisjoinCodeRotation,inactivityMsisinactivity(also onresume()). ReconnectPolicyandDEFAULT_RECONNECT_POLICY:baseMsisbase,capMsiscap,minSpacingMsisminSpacing,stableMsisstable,joiningBudgetMsisjoiningBudget,openBudgetMsisopenBudget.@bitskiff/client:returnProbeMsisreturnProbe,linkDeadAfterHiddenMsislinkDeadAfterHidden.openForeverpolicy:initialBackoffMsisinitialBackoff,maxBackoffMsismaxBackoff.@bitskiff/host: session optionjoinGrantsisdefaultGrants, no alias, and an empty value is refusedvalidation.@bitskiff/host:maxControllersismaxClients, no alias;maxViewersis gone.@bitskiff/host: the roster isClientobjects.session.participants, aReadonlyMap<string, Participant>, issession.clients, aClientRosterthat iteratesClientvalues and has noforEach. A client's fields (deviceClass,grants,presence,connection,transport,networkClass,joinedAt,deadlines,e2ee,attested) are onclient.state, where they were on theParticipant;seatisstate.number, andbundleis gone.on('participant')isclientJoined,clientChangedandclientLefton the session andchangeandlefton theClient, andsession.setGrants,session.bootandsession.markActivearesetGrants,bootandmarkActiveon theClient. No alias.@bitskiff/host: a client'sdeviceClassis'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 israte.@bitskiff/host: end reasons:leftis gone andunknownis new, and an end reason the SDK does not recognise isunknown, where it wasapiClosed.@bitskiff/host,@bitskiff/client:Deadline.participantIdisclientId, and the SDK deadline kindseatis reported asreconnect.@bitskiff/host:ParticipantChange, an object union onkind, isClientChange, a string union handed inClientChanged { client, change, previous, at }:connectedisreconnected,grantsChangedisgrants,e2eeChangedise2ee,joinedandgoneare theclientJoinedandclientLeftevents, andchannelsandnetworkare new.@bitskiff/host-node/operator:createDrain'srestartOndefaults to['SIGTERM', 'SIGHUP'], where it was['SIGHUP'], so aSIGTERMdetaches whendetachis 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 withclient.setPresence, and aPresenceSourceraisinghiddenorvisibleon a client is ignored.@bitskiff/client: the end reasonseatsFullisclientsFull, no alias.@bitskiff/host,@bitskiff/client: the lifecycle reasonshiddenandvisibleareawayandpresent, and the transport pathturnisrelay(TransportPath,TransportInfo.path).Diagnostic.topicis gone.@bitskiff/host,@bitskiff/client:BitskiffError.limitnames clients:concurrentParticipantsisconcurrentClients,participantMinutesPerDayisclientMinutesPerDay,seatResumesPerMinuteisclientResumesPerMinute,tokensMaxTtlSistokensMaxTtl, andmaxControllersismaxClients.maxViewers,monthlySpendCap,deviceCodesPerHourPerIp,invitationsPerHourPerOrg,labSessionReadsPerIp,latencyProbesPerIp,mailPerDayGlobal,signInLinksPerHourPerEmail,signInLinksPerHourPerIpandsignInVerifiesPerHourPerIpare gone;bootCooldown,joinsPerMinuteKey,linkMessagesPerSecond,relayCredentialsPerMinuteandsessionOpensPerMinuteIpare new, and a per-address open refusal namessessionOpensPerMinuteIp, where it namedsessionOpensPerMinute.@bitskiff/host,@bitskiff/host-node: a customPersistencechanged contract.PersistSlot.readandwritetake and return a host record (hostToken,generation,sessionIdand the host's key pair), where they took and returned the host token string;takereceives the sealing secrets as a third argument and may return a promise; and the optionalseals(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.channelsmaps each name to itsChannelRow, where it mapped to aChannel;ClientState.bundleis gone andClientState.numberis new;Inspection.verdictgainswrongClient;ClientEndReasongainsrate.@bitskiff/client:CLIENT_RECORD_KEYis'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 underclientRecordKey(key)(Deprecated, below).@bitskiff/ui-kit: the root export keeps the elements, thedefine*functions, their types andTHEME_TOKENS. The tag constants (BITSKIFF_*_TAG),DIRECTIONS,READOUT_COLORS,TRANSPORTS,BANNER_STATES,BACK_CLEAR_MS,LEAVE_ACTIONand the scanner's names (SCANNER_STATES,SCANNER_FOUND_EVENT,SCAN_INTERVAL_MS,createCodeDetector,nativeCodeDetector,ponyfillCodeDetector,wasmLocator,joinUrl,QR_FORMAT,WASM_FILEand the detector types) are imported from their component's subpath, such as@bitskiff/ui-kit/scanner. The status element's transportturnisrelay.@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 isbitskiff/host(under%APPDATA%,~/Library/Application Supportor$XDG_DATA_HOME), where it wasmrc/host; a browser host's keys arebitskiff:host:..., where they weremrc:host:...; a browser client's record isbitskiff:client:<the last 16 characters of the client key>(clientRecordKey(key)) and its key databasebitskiff-client-crypto-keys, where they weremrc:client(then the barebitskiff:client) andmrc-client-crypto-keys; its per-session keys arebitskiff:client:key:<sessionId>andbitskiff:client:seq:<sessionId>, where they weremrc:client:key:andmrc:client:seq:. The CLI's config stays underbitskiff; a config left under an oldermrcdirectory is no longer read.- Webhooks:
X-MRC-SignatureisX-Bitskiff-Signature, no alias. - Webhooks:
X-MRC-Signature-KidisX-Bitskiff-Signature-Kid, no alias. - HTTP:
X-MRC-ProjectisX-Bitskiff-Project, no alias; the api ignores the old name. - HTTP:
X-MRC-SDKisX-Bitskiff-SDK, no alias. - HTTP:
X-MRC-Request-IdisX-Bitskiff-Request-Id, no alias. - HTTP:
POST /v1/auth/bootstrapreadsX-Bitskiff-Bootstrap, where it readX-MRC-Bootstrap, no alias. - HTTP: the join routes read the optional network hint as
X-Bitskiff-Network-Class(orSec-CH-Network-Class), where they readNetwork-Class, no alias. - REST: a request with no body and no
Accept,*/*orapplication/*is answered JSON, where it was answered protobuf; a request with a body is still answered in its body's format. AnAcceptthe api cannot write isvalidationwith HTTP status 406, where it was 400. - REST: the session read fills each client's
transportPathwithudp,tcp,turnorunknown, where it always readunknown. The REST read spells the relayed pathturn, where the SDKs sayrelay. - REST: the per-client routes moved from
/v1/sessions/{sessionId}/participants/{participantId}to/v1/sessions/{sessionId}/clients/{clientId}, no alias:PATCH, andPOST .../boot,.../leaveand.../presence. - REST:
SessionConfig:maxControllersismaxClients(maxViewersis gone),joinGrantsisdefaultGrants, and each duration field is a duration string without the unit:idleTimeoutMsisidleTimeout,maxSessionTimeMsismaxSessionTime,joinCodeRotationSisjoinCodeRotation,hostResumeWindowMsishostResumeWindow,warningLeadMsiswarningLead,reconnectBudgetMsisreconnectBudgetandbackgroundBudgetMsisbackgroundBudget. No alias. - REST:
InspectResponse:maxControllersismaxClientsandcontrollerSeatsFreeisclientsFree(maxViewersandviewerSeatsFreeare gone), no alias. - REST:
ListSessionsResponse.cursorisnextCursor;MintTokenRequest.ttlS(seconds) isttl, a duration string;KeyLimits.tokensMaxTtlS(seconds) istokensMaxTtl, a duration string. No alias. - REST: limit fields on
LimitsResponse,LimitsPatch,KeyLimitsandTokenLimits:concurrentParticipantsisconcurrentClients,maxConcurrentParticipantsismaxConcurrentClients,participantMinutesPerDayisclientMinutesPerDay,seatResumesPerMinuteisclientResumesPerMinute, andLimitsResponse'sjoinsPerMinutePerSessionisjoinsPerMinuteSession, the nameLimitsPatchalready 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,seatResumesPerMinuteortokensMaxTtlSare read underconcurrentClients,clientMinutesPerDay,clientResumesPerMinuteandtokensMaxTtl(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
limitdefaults to 50 and refuses more than 200validation, and a stale or foreigncursorisvalidation(details.field: 'cursor'), where it was an empty page. - REST: a key reading a session it cannot reach gets
not_foundwithdetails.named, where it gotsession_not_found; an organization key naming another organization getsnot_found, where it gotforbidden. A resume keepssession_not_found. - Sessions:
reconnectBudgetdefaults to10s, where it was15s, andbackgroundBudgetto30s, where it was5m(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 releasedleftand 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 wassession_not_found. - REST:
SessionView.projectIdis 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_fullisclients_full(the session is atmaxClients), no alias. - Error codes:
wrong_clientchanged meaning. It was the origin mismatch; it is now a client key that does not fit the session, withdetails.reasonother_projectorother_host. An origin the client key does not allow isorigin_not_allowed(reasonmismatch). - Error details: a limit refusal's
details.scopevalueparticipantisclient, and itsdetails.limitvalues are renamed and removed exactly asBitskiffError.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,.snapshotand.goneareclient.joinedthroughclient.gone, no alias.GET /v1/project/eventsand the webhook kindclient.goneare new in 0.2.0 and carry theclient.*names from the start; 0.1.0's webhooks carried onlylimit.warning,limit.exceededandlimits.raise_requested. - Usage meters: four ids name clients, no alias:
limit_hits.concurrent_participantsislimit_hits.concurrent_clients,limit_hits.daily_participant_minutesislimit_hits.daily_client_minutes,limit_hits.seat_resumes_per_minuteislimit_hits.client_resumes_per_minuteandjoins.refused.seats_fullisjoins.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 newlimit_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 onlimit_hits.session_opens_per_minute_ip, where 0.1.0 counted it onlimit_hits.opens_per_minutewith the project's own open refusals. - Webhooks:
limit.warningandlimit.exceededdeliverdatain camelCase,orgId,keyId,limit,scope,scopeId,value,ceilingandrequestId, where it was the snake_case meter body.valueis the observed count, where it was a copy of the cap;usedandperiod_endare gone, and so isproject_id: read the envelope'sprojectId. The day-minutes quota'slimitvalue isclient_minutes_per_day, where it wasparticipant_minutes_per_day.scopemay be the neworg, for an organization cap, whose notice goes to every project of the organization, and a per-clientscopeisclient, where it wasparticipant.keyIdis left out, not empty, when no key is involved.requestIdis the event's ownid, since a threshold notice answers no request, so it never matches a refused call'sX-Bitskiff-Request-Id. - Webhooks: every delivery's envelope carries
orgIdbesideprojectId. - 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.v1andbitskiff.metering.v1, where they weremrc.protocol.v1andmrc.metering.v1. A renamed field keeps its number. - Wire:
Payload.channel(9, string) isPayload.channel_id(10, uint32), andInputEnvelope.channel(12, string) isInputEnvelope.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_ttland theSessionConfigdurations changed type from integer to string as well as name. - Wire: in the meter event, the
participant_*body members areclient_*(numbers 6 to 13),DEVICE_CLASS_PHONEisDEVICE_CLASS_MOBILE,TRAFFIC_SOURCE_PHONEisTRAFFIC_SOURCE_CLIENT, and theLIMIT_NAME_*PARTICIPANT*andLIMIT_NAME_SEAT_RESUMES_PER_MINUTEvalues name clients. The meter event'sSessionConfigfieldmax_controllers(1) ismax_clients, andmax_viewers(2) is reserved. - Wire: the data-plane topics are
bitskiff.ctl,bitskiff.inandbitskiff.out, where they weremrc.ctl,mrc.inandmrc.out; atransportof 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) mergeslimitsper field, where it replaced the whole object and erased every limit it did not name: a named field is set, a field sent asnullis 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.permissionsandoriginsstill replace the whole list. - Limits: the limit ids
POST /v1/project/limits/raisetakes and every webhookdata.limitcarries,limits.raise_requestedincluded, are renamed, no alias:concurrent_participantsisconcurrent_clients,participant_minutes_per_dayisclient_minutes_per_dayandseat_resumes_per_minuteisclient_resumes_per_minute. A raise naming an old id is refusedvalidation. - Limits: every per-address limit counts a whole IPv6
/64as 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 + 25deliveries 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 reserved2 x maxHz x maxClientsper row, their sum capped atmaxClients x messagesPerSecond, against 2850. The rows declare2 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,maxClients4, 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.maxClientsstays 4,concurrentClientsdefaults to 40, where it was 25,concurrentSessionsstays 5, andjoinsPerMinuteSessionstill 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 refusedoverloaded, as before, and that may be another project's load: retry afterretryAfterMs. - Limits:
bytesPerSeconddefaults 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 onexperimental, none onunlimited) withPUT /v1/project/limitsorbitskiff 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
concurrentClientsreads 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 aconcurrentClientsset 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
retryAfterMsandresetAtup 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.updatedper key. Rotations that 0.1.0 left overdue are parked at the 0.2.0 roll, each auditedkey.rotation_parkedin 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, orvalidation(no such key) forrotation. - 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
--limitnames onbitskiff keysandbitskiff tokens mintareconcurrentClientsandclientMinutesPerDay, where they wereconcurrentParticipantsandparticipantMinutesPerDay; onbitskiff keys,tokensMaxTtlS(seconds) istokensMaxTtl(a duration). Neither old spelling, camelCase or snake_case, is accepted. - CLI:
bitskiff tokens mint --ttltakes a duration, such as10m, and refuses a bare number, where it took seconds. - CLI:
bitskiff keys rotate --overlaptakes a duration, such as--overlap 1hwhere it was--overlap 3600, and exits with a usage error on a bare number. - CLI:
--orgis a global flag on every verb. - CLI:
whoami --format jsonprints 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 jsonprints the REST body unchanged, so the REST field renames and the 200limitcap above apply to scripts that parse CLI output. The one exception:keys list,updateandrevokeleave outmaskedandsecret, where early 0.1.0 builds printed them; a key is named bykeyId.
Deprecated
@bitskiff/client:CLIENT_RECORD_KEY, which no client writes under any more; removed in 0.3.0. Read a page's record underclientRecordKey(key). The export carries no deprecation mark of any kind in 0.2.0: no@deprecatedtag, 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.retryableandClientEnded.retryable(deprecated in 0.1.0, where they always readfalse). An end is always an end; a stopped attempt isphase: 'suspended', anderrorCodeandBitskiffError.clearssay 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; setBITSKIFF_ORG_KEY(step 16).
@bitskiff/host,@bitskiff/host-node:Participant,ParticipantChange,ParticipantConnectionandParticipantPresence; useClient,ClientChange,ClientConnectionandClientPresence.@bitskiff/host,@bitskiff/host-node,@bitskiff/client:everyone; usechannel.broadcast().@bitskiff/host,@bitskiff/host-node:HostMessageandMessageMeta; a handler is handed oneMessage.@bitskiff/host,@bitskiff/host-node:TOPIC_CTL,TOPIC_INPUT,TOPIC_OUT,seatClassOf,qrCapacityBytesandisTrustedPeer, with no replacement.@bitskiff/host:BitskiffOptions.joinTrust, with no replacement.@bitskiff/host,@bitskiff/host-node: the internal helpersapiErrorToBitskiffError,httpFailureToBitskiffError,errorCodeFromWire,bundleFromProto,bundleToProto,grantsFromProto,grantsToProto,ReconnectScheduler,ReconnectSchedulerOptions,ReconnectRange,ScheduleOutcomeandScheduleRequest.@bitskiff/host/unattended,@bitskiff/host-node/unattended:UnattendedHost,GaveUpInfoandOpenFailedInfo; useUnattendedLoop,LoopStoppedandOpenRefusedInfo.@bitskiff/client:StartOptions(useJoinOptions),ChannelSpec(the host's row isChannelRow),SessionConfigValues(useSessionConfigRead),JoinBundle,BundleNameandMessageMeta.@bitskiff/ui-kit:privateNamefrom the root, andfakeCodeDetector,FakeCodeDetectorandDetectionfrom the root and from@bitskiff/ui-kit/scanner.@bitskiff/host:maxViewers; every client is a client.- REST:
JoinRequest.bundle,InspectResponse.maxViewers,InspectResponse.viewerSeatsFreeandSessionConfig.maxViewers. - CLI:
bitskiff dev(up,down,seed), which was the repository's own tool. - CLI:
bitskiff deadlines parkedandbitskiff deadlines unpark, public 0.1.0 verbs that calledGET /v1/project/deadlines/parkedandPOST /v1/project/deadlines/{kind}/{member}/unpark, with no alias: a script that runs them exits with a usage error. The routes remain, andbitskiff 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
clientMinutesPerDayholds 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.