# 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](./sdk-people.md#sign-in). Part of [the SDK](./sdk.md) · every call: [the reference](./sdk-reference.md#the-surface). --- ## Account backup and the recovery code {#backup} 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**: ```js 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 throws `email_code_required` with a `ticket`; `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](./accounts.md). - **By hand:** `wb.backup.add(spaceOrLane, { label? })`, `.remove(room)` and `.save()`; `wb.backup.rooms` lists what is kept (up to 500), and `wb.backup.keyFor(room)` returns a kept room's key. - **The invite key is kept too.** `wb.backup.invite` is the account's [invite key](./sdk-people.md#members), so a restored device can be added to rooms again; `wb.invite` hands it over by itself, and `wb.backup.setInvite(state)` puts one in by hand. - **`wb.backup.state` holds 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.** `format` is `'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 {#account-index} 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. ```js 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](./sdk-rooms.md#live). 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.chat` in `connect-src`. `wb.account.stop()` ends the background syncs and `start({ 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.** `forget` takes the room off the account, and opening its link later brings it back. To erase a room's content, use [`admin.rooms.delete`](./sdk-builder.md#delete) on your server. - **Signing a device out is the SDK's part only.** The device drops `wb.backup.state` and calls `onSignedOut`; clearing whatever else your app keeps is yours, in that callback. - `wb.account.enabled` is true once the backup is on; `wb.account.lastError` is the last background sync's error (`unreadable`, `unopenable`, `save_failed`, …) or `null`; `wb.account.links` lists the [linked lanes](./sdk-agents.md#lane-links). ### What the account used {#account-usage} 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: ```js 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 {#protect} 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: ```js 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 nothing ``` - **`save`, `load` and `remove`** keep values in a `Storage` — `localStorage` unless you pass `protect: { 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, and `disable()` writes it back in the clear. - **`seal(value)` and `open(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 with `backup_required` while [the backup](#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,** `load` and `save` refuse with `locked`, so nothing is ever written in the clear by mistake. `unlock()` refuses a passkey that opens nothing here with `wrong_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, and `enable()` throws `prf_unsupported` for a passkey that cannot. In an iframe, the frame needs `allow="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 {#account} 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](./sdk-approvals.md#connection-calls) a member approves — except an MCP tool granted to run on its own (`auto`). ```js 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.** `attach` checks 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 an `admission` that would open it refuses `lane_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_call` is 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](./sdk-builder.md#builder-calls). See [Connections](./connections.md). - **A signed webhook.** A `whsec_` secret with the webhook's whole `https://` address as `host` makes 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](./outside-agents.md). - **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, and `wb.connections.finishConnect({ state, code })` with the callback's values returns a connection like `attach`'s. The tokens stay in the enclave. ### MCP servers {#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. ```js 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 `/mcp-callback` unless you pass `redirectUri` to `startMcp`. `attachMcp` takes a `token` for a server you already hold one for. - Each tool reaches the room's assistant as `