Accounts and connections — the backup, the device, connected services
An account is a verified email — the one on a sign-in, never a field a page fills in. It holds what belongs to the person rather than to one room: the list of their rooms and its backup, their devices, the services they connect, and their Siri inbox. Signing in is on People.
Part of the SDK · every call: the reference.
Account backup and the recovery code
A person's rooms are in their links, and a lost device loses the links. wb.backup keeps them in the same encrypted
account backup the Witbitz app uses, opened by a short recovery code:
const wb = new Witbitz({ backup: JSON.parse(localStorage.getItem('wb-backup') || 'null') })
wb.backup.onState = (s) => localStorage.setItem('wb-backup', JSON.stringify(s))
const { code } = await wb.backup.enable() // e.g. 44YW-572I-… — show it, for the person to keep
// on a new device:
const { rooms } = await wb.backup.restore({ code })
// → [{ room, link, kind: 'space' | 'lane', label?, shared? }]: reopen with wb.openSpace(link) / wb.openLane(link, { space })Once it is on, every room this client creates or opens is recorded.
- Witbitz cannot open it. The code never leaves the device. It unlocks a random master secret, which seals the list — each room's link, and so its key. The server stores ciphertext, under ids derived from the secrets.
- One backup per app. Each
enable()makes its own backup and code, so your users' backups never mix with their Witbitz app account or another app's. - The email as a second factor. If the person is signed in when the backup is turned on (or you pass
{ email }), its recovery gate also records that address — as an HMAC under a key derived from the code, so the server cannot read it. From then on the code alone does not restore:restore({ code })emails a six-digit code to that address, and only that one, and throwsemail_code_requiredwith aticket;restore({ code, ticket, emailCode })finishes. A backup made with no email is opened by the code alone. How recovery works end to end: accounts and recovery. - By hand:
wb.backup.add(spaceOrLane, { label? }),.remove(room)and.save();wb.backup.roomslists what is kept (up to 500), andwb.backup.keyFor(room)returns a kept room's key. - The invite key is kept too.
wb.backup.inviteis the account's invite key, so a restored device can be added to rooms again;wb.invitehands it over by itself, andwb.backup.setInvite(state)puts one in by hand. wb.backup.stateholds the master secret. Store it as you store room links, and never send it anywhere. It lets a device keep saving without the code.- Older backups.
formatis'account'for every new backup;upgrade()rewrites an older'legacy'one in the account format, with the same code and rooms — after which an older SDK can no longer restore it.
One account on every device
The backup brings a person's rooms back on a new device. wb.account keeps the devices already running in step: a
room made on the phone appears on the laptop, and archiving, renaming or forgetting a room reaches every device. It
turns on with the backup and needs nothing else.
const wb = new Witbitz({ backup: saved, deviceName: 'Dana’s phone' })
wb.account.onChange = ({ rooms, devices }) => render(rooms)
await wb.account.sync() // any time → { ok, rooms, devices }
wb.account.rooms // [{ room, link, kind, label?, shared?, archived? }], archived ones included
wb.account.archive(room) // archive(room, false) undoes it
wb.account.rename(room, 'Kyoto trip')
wb.account.forget(room) // off the account on every device; the room itself is not deleted
wb.account.devices // [{ id, name, seenAt, signedOut, current }]; wb.account.deviceId is this one
wb.account.signOut(id) // that device drops the account at its next sync
wb.account.onSignedOut = () => { localStorage.removeItem('wb-backup') }- Changes arrive in about a second. In a browser it holds a socket on the account's own
live stream. A sync every minute while the page is visible, and whenever it comes back into
view, covers a socket that cannot open; your CSP needs
wss://code-relay.witbitz.chatinconnect-src.wb.account.stop()ends the background syncs andstart({ every: 60_000 })turns them back on; it starts by itself. - Witbitz cannot read it. The list lives in a Witbitz room whose id and key every device derives from the backup's master secret, and its content is sealed on the device under a third derived key before it is sent.
- Devices agree entry by entry. Each room, removal and device keeps its own newest version, so devices converge in whatever order they sync. A device whose read fails writes nothing, so a new or offline device can never replace the account's list with its own.
- Forget is not delete.
forgettakes the room off the account, and opening its link later brings it back. To erase a room's content, useadmin.rooms.deleteon your server. - Signing a device out is the SDK's part only. The device drops
wb.backup.stateand callsonSignedOut; clearing whatever else your app keeps is yours, in that callback. wb.account.enabledis true once the backup is on;wb.account.lastErroris the last background sync's error (unreadable,unopenable,save_failed, …) ornull;wb.account.linkslists the linked lanes.
What the account used
Every reply costs something, and in a shared room someone else may pay: the room's admin, your app, or Witbitz.
wb.account.usage() answers what this account's turns cost in a month, across all its rooms, whoever paid:
const u = await wb.account.usage() // this month (UTC); or usage({ month: '2026-10' })
u.replies; u.costUsd // the replies this account's turns got, and their cost
u.byPayer // { you, admin, app, witbitz }: micro-dollars each paid
u.byDay // { 23: 1234, … }: micro-dollars by day of the month- Recorded on every turn. Each turn carries a receipt address only the account's devices can derive; the server
seals the reply's cost to it, in the room, and
usage()collects and adds them up. - The same on every device. The usage key travels in the account's list, and the monthly totals are kept with it, sealed the same way.
- In a vault, the vault holds the usage key, adds the receipt to each turn and answers
usage().
Protect what the app keeps on the device
Your app keeps things on the device: the sign-in session, the backup state, room snapshots and links (which carry room
keys), and the Siri and A2A state (private keys). In localStorage, any infostealer that copies the browser profile
can read them. wb.protect seals them under a key a platform passkey derives — Windows Hello, Touch ID, Android —
and that key is nowhere on disk:
import { Witbitz, Protect } from 'https://witbitz-spaces.pages.dev/witbitz-sdk.js'
const protect = new Protect() // first: the session and the backup are kept there
if (protect.state.locked) await protect.unlock() // one Face ID / Touch ID / Windows Hello prompt
const wb = new Witbitz({ protect, session: await protect.load('wb:session'), backup: await protect.load('wb-backup') })
wb.backup.onState = (s) => wb.protect.save('wb-backup', s)
await wb.protect.save('wb:session', wb.session)
if (wb.protect.available() && !wb.protect.state.on) await wb.protect.enable({ label: 'My app' })
wb.protect.autoLock(5) // lock after five minutes away
wb.protect.on('locked', () => showYourLockScreen()) // the SDK draws nothingsave,loadandremovekeep values in aStorage—localStorageunless you passprotect: { storage }. While protection is off they store values as they are, so an app can route its storage through them from day one;enable()seals what was saved before, anddisable()writes it back in the clear.seal(value)andopen(stored)do the same for a store of your own, such as IndexedDB or a file.- A lost passkey loses what it sealed. So
enable()refuses withbackup_requiredwhile the backup is off — the backup is what brings the rooms back on a new device. Pass{ withoutBackup: true }if you keep nothing you cannot get again. - While locked,
loadandsaverefuse withlocked, so nothing is ever written in the clear by mistake.unlock()refuses a passkey that opens nothing here withwrong_passkey. - What it does not cover: malware that reads the page's memory while it is unlocked, keyloggers, screen capture. The away timer bounds that window.
- Browsers: a passkey that can hold a key (PRF or largeBlob) — Chrome, Edge and Safari can.
available()answers for the browser, andenable()throwsprf_unsupportedfor a passkey that cannot. In an iframe, the frame needsallow="publickey-credentials-create *; publickey-credentials-get *". - The passkey is your app's. It is made for your page's site (
protect: { rpId }for a parent domain) under its own user handle, so it never replaces another passkey your app made for the same site.
Connections
A connection is a credential the room's assistant can spend but never see: an API key, an OAuth grant, an MCP
server. It belongs to the account. The Sentinel — an attested, always-on enclave — holds the secret; a room's assistant
gets a tool that calls through it, and each call is a proposal a member
approves — except an MCP tool granted to run on its own (auto).
const gh = await wb.connections.attach({ label: 'GitHub', host: 'api.github.com', secret: token })
await space.connections.grant(gh) // this room's assistant may now propose connection_call
await wb.connections.list() // [{ id, label, host }]: never a secret, never a callKey
await wb.connections.detach(gh.id, { spaces: [space] }) // everywhere at once, and out of each room you pass- The page keeps only what a room needs.
attachchecks a fresh attestation of the Sentinel against the pinned measurement before it seals anything; a Sentinel running any other image is refused, and nothing is sent. It answers{ id, label, host, callKey, account }, and the secret is gone from the page. - Grants go only to gated rooms (otherwise
gate_required). In an open room the link is the whole key: whoever holds it can start a turn, and the assistant would spend the connection. - A lane of a gated room is gated to its member by itself: it takes the room's gate, narrowed to the signed-in
email. With nobody signed in it refuses
sign_in_required, and anadmissionthat would open it refuseslane_downgrade. - A lane of an open room is open unless you gate it, so gate one that holds a connection:
space.privateLane({ admission: { emails: [me] } }). - What the model sees of a granted
connection_callis the reply's status, content type and body; never the secret or the call key. The hosts a key may be used for are the Sentinel's allow-list — GitHub, Google APIs, Tinfoil and the package registries today, plus the hosts you declare. See Connections. - A signed webhook. A
whsec_secret with the webhook's wholehttps://address ashostmakes the Sentinel sign each approved request (Standard Webhooks), and only that one path can be called.attach({ webhook: true, job })makes an agent started by a web address — a Claude Code routine's trigger — a connection: see Outside agents. - Your own OAuth app. File it from your server with
admin.providers.attach({ kind: 'google', clientId, clientSecret, redirectUri, scopes }); then, in the page,wb.connections.startConnect({ tenant, providerId })→{ authUrl, state }sends the person to the consent screen, andwb.connections.finishConnect({ state, code })with the callback's values returns a connection likeattach's. The tokens stay in the enclave.
MCP servers
The Spaces app's ⋯ → Connections → Other MCP server, for your page. The server's own sign-in runs inside the Sentinel, which registers an app with the server and keeps the PKCE verifier; your page gets a URL and a state, and the code it receives goes back to the Sentinel sealed. The server's token never reaches your page.
const st = await wb.connections.startMcp({ url: 'https://mcp.linear.app/mcp', label: 'Linear' })
let conn
if (st.needsSignIn) {
location.assign(st.authUrl) // the member consents; your callback page receives ?code=&state=
conn = await wb.connections.finishMcp({ state, code, label: 'Linear', host: st.host }) // on the callback page
} else {
conn = await wb.connections.attachMcp({ url: 'https://mcp.deepwiki.com/mcp', label: 'DeepWiki' }) // or { token }
}
await wb.connections.mcpTools(conn.id) // { tools: [{ name, title, description, readOnly, … }], server }
await space.connections.grant(conn) // its recommended tools: reads run on their own, changes ask
await space.connections.grant(conn, { tools: ['list_issues', 'create_issue'], auto: ['list_issues'] }) // or your choice- The callback page is
<your origin>/mcp-callbackunless you passredirectUritostartMcp.attachMcptakes atokenfor a server you already hold one for. - Each tool reaches the room's assistant as
<label>__<tool>, and is a card the member approves unless it is inauto. With notoolsorauto,grantoffers the server's recommended tools and lets the ones the server marks read-only run on their own —readOnlyis the server's own claim, which Witbitz does not verify, so nameautoyourself where it matters. Anautocall still goes through the Sentinel: the credential stays there. - A server that registers apps itself — most do: Linear, Notion, Sentry, DeepWiki — works as shown. One that needs a
registered app (GitHub, Asana, Box) signs in with Witbitz's own app for now;
admin.providers.attachMcpfiles yours, for when the SDK uses it. - In a vault, the same calls run through the vault and the
callKeystays there: grant by id.
A new Sentinel build
When Witbitz moves the standing Sentinel to a new build, the SDK follows it: a build Witbitz publishes at
/sentinel-claim.json, on its own site and checked against the attestation like the pinned one, is accepted.
new Witbitz({ sentinel: { follow: 'published' } })is the default.follow: 'ask'callssentinel.onNewBuild(pcr0)and accepts only ontrue; otherwise the call fails withdeclinedand nothing is sent.follow: 'off'keeps this release's pins, so connections, Siri and outside agents stop working once that build is retired.- Releases before 2.2.3 cannot follow a new build; move to a later one before the build they pin is retired.
- A tenant running its own standing Sentinel pins its image with
new Witbitz({ sentinel: { pins } })(and its Lambda'sSENTINEL_HOP_PCRS); its own pins are never followed.loginBasepoints the sign-in endpoints at its own origin.
The Siri inbox
"Hey Siri, Witbitz": a Shortcut posts the dictated text to an endpoint whose TLS ends inside the enclave; the words are sealed there to the room's inbox key, and a device that holds the key collects them.
const { key, endpoint, alias } = await space.siri.enable({ alias: 'groceries', makeDefault: true })
// show the member the Shortcut: Dictate Text → Get Contents of URL, POST `endpoint`, JSON { key, text }
const st = wb.siri.state // persist it: without it, drops can never be opened
const { delivered, elsewhere } = await space.siri.collect() // posts each drop as the member (🎙-marked)
await space.siri.collect({ deliver: async (text, meta) => myInbox.add(text, meta) }) // or take each one yourself- Collect when the room opens and every time the page comes back to the foreground.
collect()opens this room's drops, posts each one as the member and acknowledges them;elsewherenames other rooms holding drops. - Keep the state.
wb.siri.stateholds the Shortcut key and each room's inbox key pair. Pass it back asnew Witbitz({ session, siri: st }), orwb.siri.restore(state)on a running client.wb.siri.shortcutis{ key, endpoint }, what the Shortcut posts with (keyisnulluntil Siri is on). - Account-wide:
wb.siri.rotateKey()invalidates the old Shortcut key as soon as the record is pushed;wb.siri.revoke()turns Siri off for the account;wb.siri.status()reads the account's record. deliver, for an app with its own inbox. Without it each drop is posted as the member, which starts an assistant turn. With it your function takes the drop instead: the words as spoken (no 🎙 — that mark tells a room something was said rather than typed) andmeta = { id, room, alias, at }, wherealiasis the word actually said andatis when Siri took it, not when you collected. To put it in the room as well, callspace.send()from yourdeliver.- At least once, not exactly once. A
deliverthat throws is not acknowledged, so a failed write never loses a drop — the next collect sees it again. The other side of that: a write that succeeded but whose acknowledgement failed is delivered again. Dedupe onmeta.id, which is stable for the life of the drop.
Machine-readable source: sdk-accounts.md · Generated 2026-10-27T07:05:42Z · build f301388c · every doc in one fetch: llms-full.txt (HTML)