Witbitz docs HomeTrustAll docs

All documentation

Overview — build confidential AI apps around Spaces

Witbitz is the backend for confidential, multiplayer AI apps. You ship a static page; Witbitz runs the rest — the shared room, its members, the AI assistant, storage, sign-in and approvals — sealed so that neither Witbitz nor you can read your users' rooms, and built so that anyone can check that.

html
<script type="module">
  import { Witbitz } from 'https://witbitz-spaces.pages.dev/sdk/3.10.0/witbitz-sdk.js'

  const space = await new Witbitz({ tenant: 't_…' }).createSpace({   // your tenant id
    agent: { name: 'Helper', instructions: 'You help a small group plan and decide together.' }
  })
  space.on('message', (m) => console.log(m.from, m.text))  // everyone's messages, the assistant's too
  await space.send('Plan us a weekend in Kyoto')
  console.log('Share this:', space.shareLink)              // the link is the invitation
</script>

That page is a complete private, multiplayer AI app: no server of your own, no database, no key handling. Creating rooms needs a free tenant and the page's origin declared once (Do I need a tenant?); opening a room someone shared needs neither.


What you get

A room — the Space People, your app and an AI assistant together; one shared thread and shared state; members by link, sign-in or invite.
An assistant that acts Tools, web search, maps and files; results that render as widgets; a private assistant for each member.
A coding agent The person's own cloud computer: a coding agent in an attested enclave only they can open, its sessions and approvals in your app.
Storage The conversation, documents and your app's own records, sealed to the room.
Accounts Sign-in by email, the user's rooms on every device, a backup only they can open.
Connections Your users' API keys, logins and MCP servers, spent by the assistant but never seen — each call approved by a member, unless you let a server's read-only tools run on their own.
Proof Reproducible, logged releases; an attested enclave for confidential rooms; checks anyone can run.

The whole map: the platform.

The guarantee

Privacy you can check. Rooms are sealed to keys made on the user's device; your page has no backend and reaches only Witbitz; every release is a reproducible build of published source. Neither Witbitz nor the app's builder can read the users' rooms — and you can verify it yourself.

Each room runs in one of two tiers, chosen by the app:

Confidential Standard
Where a turn runs A measured enclave your device verifies first Witbitz's server, while a member is active
The model Open-source models on confidential compute Frontier models; the turn reaches their vendor

Details: privacy tiers.

Next

  1. Build — the SDK: the example above, then members, tools, the store and private assistants.
  2. Understand — the platform · the trust model.
  3. Check — verify it yourself.

The platform — what Witbitz provides

Every collaborative AI app needs the same things: a room people share, an assistant inside it, storage, sign-in, permissions, a way to use outside services safely. Witbitz provides them as one platform, so your app can be a static page. This page is the map; each line links to its page.


What the platform provides

Rooms

Spaces A persistent room: members, one shared thread, shared state, an assistant. Opened by link, gated by sign-in or invite. SDK
Members and admission Open rooms, verified rooms (allow-listed emails or @usernames), owners and admins, members changed in place. Identity and admission
Embedding A Space inside another site or app, talking to the page around it. Embed

The assistant

Turns The assistant answers when members post; its reply streams into the room. How a turn runs
Tools and widgets Your app's functions as tools; web search, places, files; results that render as maps, charts and your own widgets. How a turn runs
Private assistants Each member's own assistant beside the shared room, crossing to it only with the member's approval. SDK — private lanes
Approvals An effectful action is proposed; it runs only after a member approves. SDK — proposals
Outside agents Claude Code, Grok and other agents talking to a member's assistant, and taking jobs from it. Outside agents
Cloud computers A coding agent on the person's own computer in the cloud — an attested enclave only they can open, its files sealed, its hours on their key. SDK — cloud computers
Models Frontier models in standard rooms; open-source models on confidential compute in confidential rooms. Privacy tiers

Data

Sealed storage The conversation, documents and files, sealed to the room's key; the server holds ciphertext. Trust model
The room as a store Rows in named collections, sealed like the conversation; the assistant queries them with SQL. SDK — the store

Users

Accounts Email sign-in, the user's rooms on every device, an encrypted backup only they can open. Accounts and recovery
Connections API keys, OAuth grants and MCP servers, kept in an attested enclave and spent through calls a member approves — or, for MCP tools granted auto, on their own. Connections
Voice Press-to-talk dictation and reading replies aloud. SDK — voice input

Trust

The confidential tier Turns inside a measured enclave the device verifies first; answers signed with what produced them. The confidential tier
A verifiable client Reproducible releases logged in a public transparency log; every module pinned by hash. Verifying the client
Checks anyone can run The admission gate, the egress lock, the sealed store, the reproducible build. Verify it yourself

How it is built

In the browser, the app is split into realms the browser keeps apart — the vault that holds the account, the Kernel worker that holds room keys and does the cryptography, the frame that draws the screen — so a bug in the screen cannot reach a key. The app as an operating system.

On the server, a room's turns run in the standard tier or inside the confidential tier's enclave; credentials live in a separate always-on enclave, the Sentinel; storage holds only sealed records. The server can refuse service; it cannot read a room.

Your app is a static page that reaches only Witbitz. It is not backendless — Witbitz is the backend — but it is one backend, shared by every app, constrained and inspectable, instead of an opaque server per app.

The reference app: Spaces

Witbitz Spaces — a multiplayer AI room, on the web and installable on any device — is built on this same public surface. Its guide is the platform as its users see it.

What you can build

The shape of the rooms is often the product:

Shape For example
One governed room — an organisation's assistant serves a person, and the organisation cannot read the sessions customer service, a bank advisor, telehealth intake
A professional with a private back room — a shared room with the client, a private one to confer a teacher, a doctor and a scribe, a lawyer
Two sides, each with its own assistant — each prepares privately, they meet in a shared room negotiation, procurement, mediation
Many people, one assistant group planning, a family council, a community
A personal assistant that travels — your assistant joins others' rooms with scoped permissions a personal assistant, a shopping agent

Access

The SDK is open: any static page can open, read and post in a room. Creating rooms from your own site needs a tenant (SDK — do I need a tenant?); the /v1 platform API is invite-only during the beta. Email hello@witbitz.chat.


How a turn runs — the assistant is a function, not a server

A Space keeps no process running. When a member posts, a turn opens the sealed room, runs the assistant with the room's tools, writes the reply, seals the room again and ends. Between turns a Space is ciphertext on disk, so an idle room costs almost nothing, and there is nothing running to read it.

This page follows one turn from the post to the reply, and then the tools, the approvals and the reads around it. The SDK does all of it for you (the SDK); this is what happens underneath.


One turn, step by step

One turn
A member posts, with proof of the room key
->
The turn checks admission, opens the room, runs the assistant and its tools, seals the room, drops the key
  1. A member posts — { op: 'turn', room, mk, message }.
  2. Proof of the key. The server checks the key against the room's stored commitment — proof that the sender is a member, and that the reply will be sealed to the room's key.
  3. Who wrote it. In a room that requires sign-in, the message is signed by the member's device and carries their sign-in; the turn checks both against the room's sealed allow-list and records only the verified author.
  4. The room opens. The sealed ledger is opened, and the new message appended.
  5. The assistant runs with the room's persona, model and tools.
  6. The result lands — the reply, any widgets and files it made, any proposals waiting for approval.
  7. The room is sealed again, and the key is dropped. Nothing about the turn is kept outside the sealed room.

Where it runs. In a standard room the turn runs on Witbitz's server, which holds the key for the length of the turn. In a confidential room the same turn runs inside a measured enclave the device verified first, and the key is sealed to that enclave alone (privacy tiers, the confidential tier).

When the assistant answers

Every message lands in the room. Whether the assistant replies is a per-room setting:

Mode Behaviour
always Reply to every message.
auto (default) Reply when the room is just you and the assistant, or the message is addressed to it. In a group, a small model decides whether a message not aimed at it wants help.
ondemand Reply only when addressed by name or @-mention.

A turn with no reply returns silent: true, so the page can drop its "thinking" indicator at once. A room made only to observe (agentReplies: false) never replies. If the small model in auto cannot be reached, the assistant answers: an outage must not silently mute a group.

Tools, and what they produce

A room's tools are named in its sealed config — the server cannot read the persona, the tool list or the admission policy. A room gets only what it declares: with no tools, the assistant can talk and nothing else. Your page can add its own tools to a turn (the assistant).

Tool What it does Produces
search_places · show_places · add_place find places, then put the chosen ones on the shared map a places map
search_flights · show_flights find real flight offers, then show the chosen ones a flights card
set_itinerary · add_to_itinerary · read_itinerary · edit_itinerary the day-by-day plan an itinerary
show_chart · show_diagram draw data, or a relationship graph a chart / diagram
show_photo an image in the reply a sealed image
read_file read a shared PDF, Word, Excel, CSV or text file — by its id, by who shared it, or by name text
read_url fetch a public page text
write_pdf make a PDF from Markdown a file on the reply
connection_call spend a service the member connected; the secret is added inside the Sentinel, and the call is proposed — an MCP tool granted to run on its own (auto) excepted (connections) the service's answer

Find, then show. A search_* tool returns candidates to the assistant; a second call puts the ones it chose on the shared view. Members see a curated set, not a raw result dump.

A widget is the room's, not one person's. A map, an itinerary or a chart is a shared document sealed in the room: every member sees the same one, edits reach everyone, and any shared view opens full-screen from the room's ⋯ menu.

Where a rule belongs. The persona says who the assistant is, and it is read at the top of every turn. A tool's description — and each parameter's — is read at the moment the model fills that field, so a rule about a field belongs on the field ("only if the member said one" on due). Rules moved from the persona onto the parameters are followed more reliably. Which tools a turn offers is how a page steers the assistant; what it must never do is enforced by approvals, below.

High-stakes tools become proposals

A tool that spends money, changes an outside system or crosses a policy line does not run in the turn. The turn records a proposal; a member approves or denies it; only an approved proposal runs, and only once. Your own tools can be declared the same way (proposals, end to end).

Reading the room

  • Changes, without reading. A page asks with the last version it saw; the server compares a hash of the ciphertext. Unchanged, it answers at once and opens nothing.
  • Sealed reads. pollsealed returns the new entries still sealed, and the device opens them with the key — the server takes no key and decrypts nothing. The SDK reads this way. The older poll op returns server-decrypted plaintext instead, opening the room with the key the page sends.
  • The reply as it is written. While the model writes, the turn seals the partial reply and publishes it; every read carries it, and the page shows it growing. It is cleared the moment the finished reply lands, so it can never appear twice.
  • Members who are away get a push that carries no content — only "something changed in this room". Their device wakes, reads, and shows the notification itself; the push service learns nothing.
  • Gated reads. In a room that requires sign-in, every read needs an allow-listed sign-in too — the key alone does not open the history.

The sealed ledger

A room's conversation is its ledger, sealed under the room key and stored as ciphertext; there is no operator recovery key. New entries are sealed one by one as they are appended, and long ledgers are stored in segments, so a turn's work grows with what it adds, not with the room's whole history.

The operations

Every room operation is one call to POST /space, chosen by op (HTTP API):

op What it does
create register a room: its key commitment and sealed config. The device makes the key; the server never sees it
turn a member posts; the assistant replies, or proposes
pollsealed · poll read what changed — still sealed, or opened by the server
pending · decide list proposals · approve or deny one
title · state the room's sealed name · a shared widget document
blob sealed images and files, content-addressed and never opened by the server
react an emoji on a message
subscribe · unsubscribe push for members who are away
mirror copy entries between a member's private assistant and the shared room — the device joins them, holding both keys
view · fetchview a scoped, re-sealed slice of the room for an admitted outside agent, which never gets the room key
import start a room from a public ChatGPT share link or a WhatsApp export
policy · attest the room's governance and owner key · the source the running service was built from

In your own boundary

In an on-premises deployment with AIRGAP=1, the tools that reach the internet are refused; chat, files, PDFs, charts and diagrams keep working (deployment options).


Run Spaces as your own tenant

Spaces — the multiplayer-AI room — is the reference app on Witbitz. This page runs it as you: your own origin, your own key vault, your own keys. You write no code; Witbitz builds and hosts it, and you can prove what it deployed.

Get Started: this → Verify it. Reference: HTTP API · SDK.


One paste

Needs curl and python3. Nothing to clone, no Cloudflare account.

bash
# 1) Register (free). The secret is shown ONCE and cannot be reissued — this saves it.
resp=$(curl -sX POST https://api.witbitz.chat/v1/tenants/demo \
  -H 'content-type: application/json' -d '{"name":"My Space"}')
export TENANT_KEY=$(echo "$resp" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["keys"]["secret"])')
export TENANT_ID=$(echo "$resp"  | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["tenant"]["tenant_id"])')
umask 077
{ echo "# $(date -u +%Y-%m-%dT%H:%M:%SZ)"; echo "TENANT_ID=$TENANT_ID"; echo "TENANT_KEY=$TENANT_KEY"; } >> ~/.witbitz-tenants
echo "saved → ~/.witbitz-tenants  (the only copy — lose it and this tenant is unreachable)"

# 2) Ask for your app. Names are first-come, first-served; set SLUG=yourname for a specific address.
SLUG="${SLUG:-myspace-$(od -An -N2 -tu2 /dev/urandom | tr -d ' ')}"
r=$(curl -sX POST https://api.witbitz.chat/v1/tenant/app \
  -H "authorization: Bearer $TENANT_KEY" -H 'content-type: application/json' \
  -d "{\"slug\":\"$SLUG\"}"); echo "$r"

# 3) Watch it — two builds and two deploys, about 90 seconds.
while s=$(curl -s https://api.witbitz.chat/v1/tenant/app -H "authorization: Bearer $TENANT_KEY"); echo "$s"; ! echo "$s" | grep -qE '"status":"(live|failed)"|"error"'; do sleep 20; done

It ends with your address:

json
{"status":"live","slug":"myspace-41287","url":"https://myspace-41287.witbitz.chat", …}

What you got

  • A vault at <slug>.witbitz.chat — the small key-holder that runs sign-in and confines your keys. It is the address you give people and the one they install. The app it frames sits at <slug>-app.witbitz.chat.
  • The app, which is Spaces. You write nothing. Witbitz builds the reference app from a pinned commit and deploys it to your origins. Your copy is pinned to your deploy — it does not change when the reference app updates.
  • Your own keys. A K_eco nobody else holds, so your rooms are cryptographically isolated from every other tenant's; your own app key; your own OTP namespace. Rooms are stamped to you and split-key by default.
  • Your own look. An accent colour and name derived from your slug — the welcome page, the icon and the installed app carry them, so yours is not a second copy of ours on someone's home screen.
  • Sign-in that needs no setup. Google and email codes run through the central Witbitz auth service, minted as your origin. Nothing to register with Google.

While you wait

A workflow runs it one named step at a time: create the projects and the domain, mint your keys, build and deploy the vault, configure it and redeploy, the same for the app, then admit your origins, register your issuers and grant your permissions. If any step fails the ones before it are undone — projects, namespaces, keys and secrets deleted rather than left as debris — and the poll tells you which step failed.

status
queued · provisioning still running — keep polling
live deployed, configured and admitted; url is your address
failed error names the step; everything it created has been undone

When it refuses: refused — the slug is malformed or reserved. conflict — that name is another tenant's. too_many_requests — ten apps per address per day; registering a fresh tenant does not reset it. A refusal queues nothing, so there is no job to poll.

One app per tenant. Re-asking with the same slug re-provisions it, keeping your K_eco so existing rooms still open.

Taking it down

Three levels, because they are three decisions. Each needs the key for that tenant.

bash
# 1. The app: projects, domain, admission, its key. KEEPS K_eco, so your rooms survive.
curl -sX DELETE https://api.witbitz.chat/v1/tenant/app -H "authorization: Bearer $TENANT_KEY"; echo
while s=$(curl -s https://api.witbitz.chat/v1/tenant/app -H "authorization: Bearer $TENANT_KEY"); echo "$s"; ! echo "$s" | grep -qE '"status":"(deleted|live)"|"error"'; do sleep 10; done

# 2. The rooms and everything in them. GET first — it deletes nothing and returns the confirmation value.
curl -s  https://api.witbitz.chat/v1/tenant/rooms -H "authorization: Bearer $TENANT_KEY"; echo
curl -sX DELETE https://api.witbitz.chat/v1/tenant/rooms -H "authorization: Bearer $TENANT_KEY" \
  -H 'content-type: application/json' -d "{\"confirm\":\"$TENANT_ID\",\"slug\":\"$SLUG\"}"; echo

# 3. The account: every key, every row.
curl -sX DELETE https://api.witbitz.chat/v1/tenant -H "authorization: Bearer $TENANT_KEY" \
  -H 'content-type: application/json' -d "{\"confirm\":\"$TENANT_ID\"}"; echo

Taking the app down keeps K_eco, the key your rooms' halves are sealed under — deleting it would not delete the rooms, it would make them permanently unopenable. Put the same slug back and they open again. Step 2 is what removes them, and it takes K_eco last. Step 3 refuses while an app or rooms remain, and tells you what is left.

Steps 2 and 3 are irreversible, so each must name the tenant it is destroying. Ask without the confirmation and the reply gives you the value.

In a new shell: set -a; . ~/.witbitz-tenants; set +a.

Proving what it deployed

Your app ships an asset manifest — every file hashed, stamped with the commit it was built from:

bash
curl -s https://<slug>-app.witbitz.chat/assets-manifest.json | head -3
# → {"v":1,"gitCommit":"…","algo":"sha256", …}

That commit is the platform build your app was made from, and that build is fully verifiable: rebuild the published source and get the hash AWS runs (Verify it, Tests 6–7). The client is served unminified, so you can also just read it.

Your origin also serves its own signed certificate, bound to that origin, committing to the exact bytes above:

bash
curl -s https://<slug>.witbitz.chat/cert.json | python3 -c 'import sys,json;print(json.load(sys.stdin)["signed"])'
# → {"service":"witbitz-vault","env":"tenant","origin":"https://<slug>.witbitz.chat","frontendSha256":"…", …}

So the verified-client extension reports your origin as verified, and builds the policy it enforces from that signed manifest rather than from your page.

Two limits, stated plainly rather than left to be discovered:

  • It is signed by the tenant key, not the platform key. That key lives in the provisioning pipeline, and the verifier accepts it only on tenant origins — so it can attest what it deployed for you and can never speak for spaces.witbitz.chat. The separation is the point; it also means your certificate is only as trustworthy as that pipeline.
  • It commits to the frontend, not to a backend. Your tenant does not run its own render, so the certificate claims the bytes it served and nothing more. The reproducible-source check (Test 7) still runs against the first-party app — the same code at the commit your manifest names.

Everything else on the Verify it page — the admission gate, the egress lock, the sealed store — is checkable against your own Space today.

Keeping it

A demo tenant is real and works immediately, but auto-purges at its expires_at. To make it permanent — same id and keys — upgrade with an author coupon, or sign up non-expiring from the start with POST /v1/tenants/signup:

bash
curl -sX POST https://api.witbitz.chat/v1/tenant/upgrade \
  -H "authorization: Bearer $TENANT_KEY" -H "X-Author-Coupon: <code>"

No coupon? Email hello@witbitz.chat.

Not locked in. Witbitz-hosted is the easy default, not the only one. The same app is portable: point its one backend switch at your own boundary and run the entire stack yourself — same code, same verifiable-privacy properties. To embed a Space in a page you already have, see embed.

Next


Verify It Yourself

Witbitz's claim is verifiable, not promised — so this page is where you check it, in your own terminal, instead of taking our word. It's the honest counterpart to every "enforcement-proven" line in the other docs: here are the exact commands, and here — just as plainly — is what they do not prove yet.

See also: The Verified Room (the model you're checking) · the Space link is the auth (how a real member passes) · the double blind (the privacy claim + its north-star).


What this page proves — and what it doesn't

Be exact about the scope, because "verify it yourself" is itself a claim that can overreach:

Status
The admission gate enforces — reads and writes are refused without an allow-listed identity, even when you hold the room key ✅ checkable now (Tests 1–3)
The app's egress is locked — the app you loaded can reach only a published allowlist of hosts; the browser blocks everything else ✅ checkable now (Test 4)
The store holds only ciphertext, sealed to a key the platform never holds (it can't read what it stores) ✅ checkable now (Test 5)
The deployed render is a faithful build of published source — rebuild it and get the same hash AWS runs ✅ checkable now (Tests 6–7)
The render is unobservable in use (operator can't read the plaintext during the decrypt instant) ✅ checkable now, in a confidential Space — the enclave runs always on; a Space is confidential when its creator picks the tier (the 🔒 ENCLAVE badge), and its attestation and every reply's turn certificate check in your browser (the attested tier)

Today you can check five things on your own traffic: the gate enforces (Tests 1–3), the front-end's egress is locked to a published allowlist (Test 4), the store holds only ciphertext sealed to a key the platform never holds (Test 5), the deployed render is a faithful, reproducible build of published source (Tests 6–7) — and, in a confidential Space, that the render is unobservable in use: the room key is born inside AWS Nitro hardware, the image measurement is reproducible from published source, the enclave runs always on, and the whole check runs in your browser against a fresh nonce (the attested tier). Which Spaces are confidential is chosen per Space when it's created.

Honest position: the code is exactly this — readable, reproducible, and what AWS runs — and "it can't be watched while it runs" is buildable, checkable, and in production for any Space run in the enclave.

On a tenant app of your own. Tests 1–5 and 8 work against any Space, including one you provisioned with one paste. Since 2026-09-07 a provisioned tenant also serves its own signed certificate, bound to its own origin and signed by a key held only by the provisioning pipeline — so Test 6 closes on your origin and the verifier reports it as verified. Test 7 still does not: that certificate commits to the frontend bytes and says nothing about a backend your tenant does not run, so the reproducible-source check goes against the first-party app — the same code your tenant was built from, at the commit its manifest names.


The verifier extension — install it, and read it

Every test below is something you run by hand. The extension does it automatically, on every page load, and enforces the result — you cannot be served an unverified app with it installed.

bash
curl -O https://witbitz.chat/site/verified-client/witbitz-verified-client-ddk9p7JsjS.zip
openssl dgst -sha256 -binary witbitz-verified-client-ddk9p7JsjS.zip | base64
# → ddk9p7/JsjSTRZGs/VUF3ruEb+wniO2rzNqsQF7Qijc=
unzip witbitz-verified-client-ddk9p7JsjS.zip -d witbitz-verified-client
# chrome://extensions → Developer mode → Load unpacked → that folder

The download is the source. Four files, 33 KB of plain JavaScript — no build step, no bundler, nothing minified. Installing it and auditing it are the same act, which is the only honest way to ship something whose job is to be trusted. Read them first: sw.js is all of the logic, config.json is what it points at and the key it pins.

What you should see. Nothing changes on the page — the signal is the toolbar icon, which a page cannot forge:

badge meaning
green ✓ this origin's signed release verified — hover for the environment and commit
red ! enforced, but not verified — the tooltip says why (bad signature, wrong origin, or no certificate)
no badge not an origin this verifier covers

The other visible sign is the address bar: /space becomes /space?vc=1, because verification is forced rather than offered.

What it does on every load:

  1. Forces ?vc=1 on /space, so verification cannot be skipped.
  2. Forces a CSP built from the signed manifest — every approved script named by its hash, 'self' dropped, and the report-only twin stripped. The policy comes from material it verified against a pinned key, never from the page, so the origin cannot supply the hashes it is measured against. Its own JavaScript then executes only if the bytes match what the certificate vouches for.
  3. Clears unapproved service workers, which would otherwise synthesize a response the forced CSP never reaches.
  4. Checks the signed release against a pinned P-256 key and paints a badge the page cannot forge.

Honest limits. It is not in a browser store, so Load unpacked is the only route today. It is Chromium-only (Chrome, Edge, Brave), and on an engine older than Chromium 127 it keeps enforcing but stops pinning script-src by hash — below that the browser does not enforce import-map integrity, and a hash-only policy would refuse the app's own modules rather than protect them. Nothing checks freshness: a compromised origin cannot forge a certificate, but it could serve an older genuine release. And one key-holding Web Worker is no longer loaded by URL at all: the page fetches its bundle, checks the bytes, and runs it from a blob, failing closed if they do not match.


Read the render — the code that touches your plaintext

Verification is only worth as much as your ability to read what you're verifying. Plaintext opens in one place — the render: in a confidential room, inside the always-on attested enclave; in a standard room, in the server's request handler — and that surface is readable: about 1,500 lines for the seal / turn / read core, 2,700 with storage. It's published, readable, Apache-2.0:

→ the published render source

File Lines What it does
envelope.mjs 263 seal/open — a per-write key wrapped for the room under HKDF(mk); no operator recipient
roomKey.mjs 50 where mk lives during a request — per-request memory, cleared after
spaceLedgerV3.mjs 111 one sealed box per entry, each new box bound to its room and position (AAD)
asyncTurn.mjs 825 the turn: open the ledger → add the message → decide whether to reply → model → append the reply → drop mk
spacePoll.mjs 187 reads: content-blind (pollsealed, no mk) or server-opened (poll, the reader sends mk); unchanged = nothing read
sessionStore.mjs 1,040 storage — the ledger and sealed side documents; compare-and-set appends; no mk ⇒ no write
ledgerSegments.mjs 166 the ledger stored in segments — layout only, never a key or plaintext

That repo is a readable mirror; the hash-verified canonical is /source.tar.gz (Test 7), which rebuilds byte-for-byte to the deployed hash. Read the logic there; confirm the bytes here.


Setup (30 seconds)

  1. In the Spaces app, create a Space with "Who can post?" → Only specific Google accounts, and add any email to the allow-list. This is an admission:'email' Space.
  2. From its link …/space?room=sp-XXXX#mk=YYYY&gate=email, copy the room (sp-XXXX) and the mk (YYYY) — the room key that everyone with the link holds.
  3. Discover the API endpoint (it's public; the app fetches it the same way):
bash
SPACE=$(curl -s https://witbitz-spaces.pages.dev/space-config \
  | python3 -c 'import sys,json; print(json.load(sys.stdin)["spaceEndpoint"])')
echo "$SPACE"   # the /space Lambda URL the client posts to

You now hold exactly what a link-holder holds: the room and its key. The tests below show that is not enough.


A token-less poll — the read op — with a valid room key:

bash
curl -sX POST "$SPACE" -H 'content-type: application/json' -w '\n%{http_code}\n' \
  -d '{"op":"poll","room":"<ROOM>","mk":"<MK>","sinceEtag":"","sinceCount":0}'
# → {"error":"not_authorized","reason":"unsigned"}
#   403

Holding mk returns no history. Reads require an allow-listed Google sign-in — the server won't decrypt-and-return without one.

Test 2 — the write gate: you can't post without an allowed identity

A token-less turn — the write op:

bash
curl -sX POST "$SPACE" -H 'content-type: application/json' -w '\n%{http_code}\n' \
  -d '{"op":"turn","room":"<ROOM>","mk":"<MK>","message":{"text":"hi"}}'
# → {"error":"not_authorized","reason":"unsigned"}
#   403

Rejected before anything is written — nothing lands in the Space.

Test 3 — positive control: the key is valid; the 403 is the gate, not a bad key

Create a second Space with "Who can post?" → Anyone with the link (an open Space), and poll it the same way with its room/key:

bash
curl -sX POST "$SPACE" -H 'content-type: application/json' -w '\n%{http_code}\n' \
  -d '{"op":"poll","room":"<OPEN_ROOM>","mk":"<OPEN_MK>","sinceEtag":"","sinceCount":0}'
# → 200  (returns entries)

Same request shape, a valid key — but no admission policy, so it reads. This is the control that proves Tests 1–2 are the admission gate refusing you, not a malformed request or a wrong key. An open Space reads by the link; an email Space does not.

Test 4 — the egress footprint: the app can only talk to a published allowlist

The other three tests check the server. This one checks the app itself: a static page can fetch() anywhere, so "the app doesn't leak your data to third parties" is a real claim — and it's now browser-enforced, not promised. Read the enforced Content-Security-Policy straight off the response header — it is the exhaustive list of hosts the loaded app is permitted to reach:

bash
curl -sI https://witbitz-spaces.pages.dev/space | grep -i '^content-security-policy:'
# default-src 'none'; script-src 'self' 'sha256-…' 'wasm-unsafe-eval' https://accounts.google.com; …
#   connect-src 'self' blob: https://api.witbitz.chat https://accounts.google.com https://kibitz.chat https://auth.witbitz.chat
#     wss://code-relay.witbitz.chat wss://inference.tinfoil.sh https://inference.tinfoil.sh; … frame-ancestors 'self'

Read it directly:

  • connect-src is the data path — where the app may send anything. Each entry, and why it is there:
  • 'self' — the same-origin app and its map/tile proxy; blob: — files the app made itself (a PDF you open).
  • https://api.witbitz.chat — the Space backend (every read and write, sealed).
  • https://accounts.google.com, https://auth.witbitz.chat, https://kibitz.chat — sign-in: Google, the Witbitz email sign-in, and the older issuer some rooms were created under.
  • wss://code-relay.witbitz.chat — the room's live stream: content-free change signals and the reply as it is written, sealed with the room's key (the relay forwards bytes it cannot read).
  • wss://inference.tinfoil.sh, https://inference.tinfoil.sh — press-to-talk and Play aloud: the microphone and the text go straight to Tinfoil's attested speech enclave, not through Witbitz.
  • script-src allows only same-origin files and each inline block by its sha256 — no 'unsafe-inline'. So even injected script can't run, and can't exfiltrate.
  • default-src 'none' means anything not named above is denied by default; form-action and object-src are shut; frame-ancestors 'self' blocks cross-origin framing.

This is the egress-allowlist half of the signed certificate (the double blind §4–6) — now shipped and enforced. You are reading the app's true data footprint off ground truth. (What it does not prove is that the render is unobservable while it runs — that's the attested tier, now live as a per-room option (the attested tier). That the render binary is the published, reproducible build is proven separately by Tests 6–7. This test bounds the front-end; the gate tests bound access.)

Test 5 — the store holds only ciphertext, sealed to a key we never hold

The other tests check access and egress. This one checks what the operator actually holds. The sealed op returns the exact bytes the server stores for a room's chat — no key required, because reading ciphertext reveals nothing:

bash
curl -sX POST "$SPACE" -H 'content-type: application/json' -d '{"op":"sealed","room":"<ROOM>"}' | python3 -m json.tool
# {
#   "alg": "AES-256-GCM · multi-recipient envelope v1",
#   "bytes": 14554,
#   "sha256": "ad7c09…",                                     ← hash of the exact stored bytes
#   "recipients": ["room"],                                  ← WHO can decrypt: only the room key. NOT the operator.
#   "sealed": "{\"v\":1,\"iv\":\"…\",\"ct\":\"…\",\"recipients\":{\"room\":{…}}}"   ← the opaque envelope itself
# }

Two things to read straight off it:

  • recipients is ["room"]. The envelope is sealed to exactly one key — the room key (mk), which lives in your link's #fragment and never reached the server (you didn't send it in the request above). The operator would be a recipient only if "beta" or "admin" appeared in that list. It doesn't. So the operator holds no key that opens this blob — an aws s3 cp, a backup, or a subpoena returns exactly these bytes: noise.
  • sealed is opaque. It's an AES-256-GCM ciphertext; without mk it is indistinguishable from random. Confirm it's genuinely your chat, not a decoy, by decrypting it locally with the mk from your link — the same key the server never saw.

This is content-blindness, made checkable: what the operator stores is ciphertext sealed to a key it does not have. (What it still can't prove: that the render — the one transient moment plaintext exists to run the model — doesn't secretly keep mk. That's declared-program-in-use, addressed next.)

Test 6 — the signed build certificate: what the platform DEPLOYED, committed in public

The last surface is the render code itself — where plaintext opens (to run the model on a turn, and to return the new entries on a changed read). Full proof (that the running binary is a faithful, un-tampered build) needs an attested tier (below). But the first step ships today: the platform publishes a signed certificate at /cert.json binding what it deployed to ground truth — a build identifier (git commit), the running Lambda's AWS CodeSha256, and the egress allowlist — and the running service reports the same identifier via op:attest.

bash
curl -s https://witbitz-spaces.pages.dev/cert.json | python3 -m json.tool
# { "signed": "{\"v\":1,\"service\":\"witbitz-space\",\"env\":\"prod\",\"gitCommit\":\"…\",\"source\":\"…/source.tar.gz\",\"sourceSha256\":\"…\",\n#     \"lambdaCodeSha256\":\"…\",\"egress\":\"…\",\"builtAt\":\"…\",\"frontendSha256\":\"…\",\"frontendCommit\":\"…\",\"origin\":\"…\"}",
#   "sig": "…", "alg": "ES256", "pubkey": {"kty":"EC","crv":"P-256","x":"…","y":"…"} }

curl -sX POST "$SPACE" -H 'content-type: application/json' -d '{"op":"attest"}'
# → { "service":"witbitz-space", "gitCommit":"<same sha>", "runtime":"aws-lambda", "platformTools":{ … } }

Verify the signature yourself — the platform's public key is published here, so compare it to the pubkey in the cert and check the signature over the exact signed string (ES256 / ECDSA P-256):

code
{"kty":"EC","crv":"P-256","x":"TFXrR5DFL3Oo3rBwa6clwJnrfvw0TN2AewDDEcy8hBM","y":"MgLLzEq7g8B86qS7NNxkIW64ytp296d1kkK1wSKBoec"}
javascript
// node — verifies the cert's signature against the published key (WebCrypto)
const c = await (await fetch('https://witbitz-spaces.pages.dev/cert.json')).json()
const ub = (s) => Uint8Array.from(Buffer.from(s.replace(/-/g,'+').replace(/_/g,'/'),'base64'))
const k = await crypto.subtle.importKey('jwk', { ...c.pubkey, ext:true, key_ops:['verify'] }, { name:'ECDSA', namedCurve:'P-256' }, false, ['verify'])
console.log(await crypto.subtle.verify({ name:'ECDSA', hash:'SHA-256' }, k, ub(c.sig), new TextEncoder().encode(c.signed)))  // true

What it proves: the platform has publicly, cryptographically committed to a specific deployed build (a named build id + the Lambda's exact code hash) with a specific egress. The commitment is tamper-evident (a changed cert fails the signature), and the running service reports the same build id — so the operator can't quietly point the certificate at one build while running another without it showing.

On its own, Test 6 is a tamper-evident commitment to a build. Test 7 turns it into a check of the actual code — the cert also carries sourceSha256, the hash of the published, readable source that reproduces lambdaCodeSha256.

Test 7 — the faithful build: rebuild the published source, get the deployed hash

The cert commits to a code hash. Test 7 lets you confirm that hash is a faithful build of source you can read — not a black box. The exact render source is published at /source.tar.gz; the cert's sourceSha256 pins it, and it rebuilds byte-for-byte to lambdaCodeSha256.

Needs only Node + Python 3 — no Docker. The zip is STORED (uncompressed), so the bytes don't depend on a zlib version; npm ci is deterministic for this pure-JS graph (no native binaries).

bash
# 1) download the published render source and confirm it's the one the cert names
curl -s -o source.tar.gz https://witbitz-spaces.pages.dev/source.tar.gz
python3 -c 'import hashlib,base64;print(base64.b64encode(hashlib.sha256(open("source.tar.gz","rb").read()).digest()).decode())'
curl -s https://witbitz-spaces.pages.dev/cert.json | python3 -c 'import sys,json;print(json.loads(json.load(sys.stdin)["signed"])["sourceSha256"])'
#   ↑ these two must match — the source you downloaded IS the source the cert commits to.

# 2) rebuild it and compare to the deployed hash
tar xzf source.tar.gz && bash tools/reproduce-space-build.sh
#   → reproduced CodeSha256: <base64>
curl -s https://witbitz-spaces.pages.dev/cert.json | python3 -c 'import sys,json;print(json.loads(json.load(sys.stdin)["signed"])["lambdaCodeSha256"])'
#   ↑ these two must match — you just rebuilt the exact bytes AWS is running (its CodeSha256).

Both match ⇒ the deployed render is a faithful build of code you just read. No trust in the build server: you reproduced it.

Honest scope — the one thing left. This proves the deployed code is a faithful build of readable source. It does not prove the running process is unobservable in use — that during the transient decrypt-and-render instant, the operator (or the cloud) can't read the plaintext out of memory. That is the attested/TEE tier (a confidential-compute enclave whose remote attestation binds the running measurement to this reproducible build). Production, and independently verifiable: see the attested tier. Everything up to and including "the code is exactly this, and it's readable and reproducible" is checkable today; "and it can't be watched while it runs" is now in production for any Space the app builder runs in the enclave — its per-room choice.


Test 8 — drive the real browser: prove the locks are enforced (the CDP verifier)

Tests 1–7 read a response — a header, a stored blob, a cert. That proves what the server declares, but not what your browser actually enforces on the live app — and a passive fetch is blind to a Service Worker, which can serve tampered bytes from its own cache with no network request at all. To close that, drive a real browser and check the app's claims from the browser's ground truth — below the page, below any Service Worker:

bash
node tools/lumen-verifier.mjs            # drives https://witbitz.chat/lumen  (pass a URL to point elsewhere)

It proves, from the running app rather than from a fetch:

  • the egress-lock is enforced — it runs a forbidden fetch() inside the room and confirms the browser blocks it with a connect-src violation (not merely that the header says so);
  • cross-origin isolation holds — the embedding site gets a SecurityError trying to read the room's DOM ("can't read a word");
  • no rogue Service Worker — it enumerates the browser-owned registration list, which a Service Worker cannot forge; the one thing a load-time or curl check can't see;
  • byte identity — the inline shell's SHA-256 is surfaced and confirmed to be one of the CSP hashes the browser pinned, so you can compare it to the reproducible source (Test 7).

Honest scope. It verifies the browser it drives — so run it yourself, or it's just checking someone else's browser (the "whose fetch" caveat, now at the browser level). It's a desktop tool; a stock mobile browser can't be driven this way — which is exactly what the native attested client (the attested client) is for: it is a controlled, bundled-local browser, so there's no rogue-worker surface and no external driver needed.


Reading the result codes

The gate is fail-closed; every rejection is a 403 not_authorized with a precise reason:

reason Meaning
unsigned no identity presented at all (no idToken + spk)
token: <…> a Google token was presented but failed — e.g. token: expired, token: bad_aud, token: bad_signature (RS256-vs-JWKS)
nonce_mismatch a valid token, but its nonce isn't bound to the signing key it was presented with
not_on_list a valid, key-bound token, but the email isn't on the Space's allow-list
revoked a valid, allowed identity that has since been removed from the Space
(none) → 200 accepted

How a real member passes (the signed path)

An allow-listed member's client sends, with each request, an idToken (a fresh Google ID token) and spk (its device signing public key). The server checks, in order: the token verifies RS256 against Google's JWKS; its nonce equals spkNonce(spk) = base64url(SHA-256(canonical-JWK(spk))) (so the token is bound to this device's key); and the email is on the sealed allow-list. Only then does it decrypt-and-return, or accept the write. Full mechanism: the Space link is the auth §4. This is the async analog of the live call binding an OIDC token to a live DTLS cert — "signed by the keyholder who authenticated as this account, now."


What you can't check here yet — and why

Being explicit, so this page doesn't become the overclaim it's meant to prevent:

  • Unobservable-in-use — the attested/TEE tier. The render decrypts with mk for a transient instant — on an AI turn and on every changed read, the one door plaintext opens. Tests 6–7 now prove the deployed code is a faithful, reproducible build of readable source — you can rebuild it and read it. The last thing they can't prove is that the running process can't be watched while it runs: that during that instant the operator (or the cloud) can't read the plaintext out of memory. That needs a TEE — a confidential-compute enclave whose remote attestation binds the running measurement to this reproducible build (and thereby excludes even the operator + AWS from the render). Live on real AWS Nitro hardware, always on (the attested tier); which Spaces run in it is chosen per Space when it's created. On that tier the model call also closes the last plaintext reader, the model vendor: it leaves the measured image only through an attested no-log gateway into confidential-compute inference on an open-weights model (fail-closed if that gateway does not attest) — shipped, with the frontier model kept as a per-room choice; your enclave check transitively covers that gateway's attestation.

This page grew "verify the gate" → "footprint" → "store" → "the build." The gate is live (Tests 1–3), the footprint is live (Test 4), the store is sealed to a key we never hold (Test 5), the signed build commitment is live (Test 6), and the faithful, reproducible build of published source is live (Test 7). Everything up to "the code is exactly this — readable, reproducible, and what AWS runs" is now checkable. What remains is only "and it can't be watched while it runs" — the attested/TEE tier, live and always on — create the Space confidential and verify it against the running enclave's published measurement (above).


The SDK — build an app on Witbitz

The SDK is one ES module a static page imports. With it your page creates and opens Spaces, sends and reads messages, runs the room's assistant, keeps your app's data in the room, signs people in and checks where a room runs — while the room key, the sealing and the polling stay inside it. Your server, if you have one, uses a second module, WitbitzAdmin, for your tenant's own settings.

Why your users can trust what you build with it: the overview and the trust model.

Status: public beta. Usable today from any static page. The core — create a room, open it, send, listen — is small and meant to stay stable; around it the surface has grown with the platform (lanes, the store, sign-in, connections, voice, certificates), and any change that can break your code is listed under Changes. It wraps the exact reference client the production Spaces app runs on — nothing is forked or reimplemented.

The whole thing

Creating a room needs your tenant id on the page and the page's origin declared once (Do I need a tenant?); opening or posting in a room someone shared needs neither.

html
<script type="module">
  import { Witbitz } from 'https://witbitz-spaces.pages.dev/sdk/3.10.0/witbitz-sdk.js'

  const wb = new Witbitz({ tenant: 't_…' })   // your tenant id

  // A private, agent-backed room. The link IS the auth — share it to let someone in.
  const space = await wb.createSpace({
    agent: { name: 'Helper', instructions: 'You help a small group plan and decide together.' }
  })

  space.on('message', (m) => console.log(m.from, m.text))   // yours, others', and the agent's — all arrive here
  await space.send('Plan us a weekend in Kyoto')            // the agent answers on its own turn → via on('message')

  console.log('Share this:', space.shareLink)
</script>

That page is a complete multiplayer AI app, private to its members: no backend of your own, no key handling, no database.

Try it

Three example apps run live; each one's source is beside it on the same origin.

Example What it shows
A shared assistant room (sdk-example.js, about 40 lines) Create or join a room. Open its share link in a second tab to see the same sealed room as another member.
The Confidential Room An advisor you can tell what you would never paste into an ordinary chatbot, beside a live panel that calls space.sealedProof() and shows the server holds only a sealed envelope, under a key it never receives.
The Ops Desk A private assistant that drafts real actions — refunds, emails — but never runs one itself: each is a card you approve, and only then does it run, on your device, from your handler. privateLane({ actions }), on('proposal') and approve().

The pages

Start with Rooms and messages and The assistant; the rest you need when your app needs it.

Page What it covers
Rooms and messages Create and open a room, read and send messages, replies, reactions, attachments, the room's name, live updates
The assistant Its name, instructions and model, when it answers, a turn you can hold, the assistant as a function, tools, voice
Approvals Proposals from end to end: what is parked, who decides, what runs on approval
The room as a store Your app's rows in the room, schemas, conditional writes
People Sign-in, gated rooms, admins and owners, direct chats, changing members, @usernames
Private and outside agents Each member's private assistant, assistants that ask each other, outside agents over A2A and MCP, AG-UI
Cloud computers A coding agent on the person's own computer in the cloud: make one, open it, its sessions, approvals and files
Embed a Space A room inside your site with one script tag, isolated in its own origin; your page's functions as its tools
Accounts and connections The backup and recovery code, one account on every device, device protection, connections, the Siri inbox
Where the keys live The confidential tier and its proofs, split-key rooms, vault mode
Your server WitbitzAdmin: keys, origins, rooms, customers and billing
Reference Every call, the configuration, the error codes, the limits
HTTP API The /v1 endpoints under the SDK, for when you want the wire
Changes What changed between versions, newest first

Load it

Versions

Load a version in production: https://witbitz-spaces.pages.dev/sdk/<version>/witbitz-sdk.js (and witbitz-admin.js, witbitz-sdk.d.ts, witbitz-admin.d.ts beside it; from 2.2.4 also witbitz-admin.mjs, the admin client in one file for your server). A released version never changes, so our next release reaches your app only when you move the number. Each version's manifest.json lists a sha384 integrity hash for every file it contains, if you want the browser to check them too. The versions so far: /sdk/versions.json.

Path What it is
/sdk/3.10.0/witbitz-sdk.js A release: fixed bytes. Use this in production. (/sdk/3.9.0/, /sdk/3.8.1/, /sdk/3.8.0/, /sdk/3.7.0/, /sdk/3.6.1/, /sdk/3.6.0/, /sdk/3.5.0/, /sdk/3.4.0/, /sdk/3.3.0/, /sdk/3.2.0/, /sdk/3.1.0/, /sdk/3.0.0/, /sdk/2.2.4/, /sdk/2.2.3/, /sdk/2.2.2/, /sdk/2.2.1/, /sdk/2.2.0/, /sdk/2.1.2/, /sdk/2.1.1/, /sdk/2.1.0/, /sdk/2.0.0/, /sdk/1.1.0/ and /sdk/1.0.0/ stay as they were.)
/sdk/latest/witbitz-sdk.js Redirects to the newest release. For demos and experiments.
/witbitz-sdk.js The live build of the Spaces app, between releases. Not for production.

Versions follow semver: a change that can break your code comes in a new major version, listed under Changes (3.0.0 retired fork() — members change in place). An older release keeps working at its fixed address until what it depends on is retired, and Changes says when. An npm package is coming.

Your page's CSP

The SDK is a single ES module; importing it from the address above pulls its other files from the same origin. Your page's Content Security Policy must allow:

For Add
The SDK and the Space API script-src and connect-src for the SDK's origin and your endpoint
Live updates and one account on every device connect-src wss://code-relay.witbitz.chat
Voice input connect-src wss://inference.tinfoil.sh
Reading aloud connect-src https://inference.tinfoil.sh and media-src blob:

A link reads <viewer>/space?room=<id>#mk=<key> — viewer is new Witbitz({ viewer }) (default: the reference app), so links open on your page if you set it to your origin and serve /space there. Variants in the fragment: #s=<share> (a shared-key room), #us=<half>&spc=<commit> (a split-key room), plus &enc=1 (confidential) and &gate=email (email-gated). The fragment never reaches a server. openSpace does not read the page's URL for you; to check it:

javascript
function isSpaceLink(href) {
  try {
    const u = new URL(href)
    const frag = new URLSearchParams(u.hash.slice(1).split('?')[0])
    return !!u.searchParams.get('room') && (frag.has('mk') || frag.has('s') || frag.has('us'))
  } catch { return false }
}
if (isSpaceLink(location.href)) space = await wb.openSpace(location.href)

The reference viewer is the fastest way to see a Space; a production app usually draws its own with on('message') and send, as the example does.

Do I need a tenant?

You want to From Needs
Open, read, post in, approve in an existing room any page the link — nothing else
Create a room your own origin new Witbitz({ tenant: 't_…' }) and that origin declared by your tenant (admin.origins.declare, server side). Otherwise the create answers 403 tenant_required.
Create a split-key room your own origin the above, plus appShare (your half's function) or tenant
Builder calls (origins, customers, billing, room deletes) your server new WitbitzAdmin({ tenantKey }) (wsk_…, or a restricted key wrk_…) — never in a page

So "any static page, no backend" holds for everything except creating rooms, which needs a tenant id on the page and a one-time origin declaration — no key on the page, still no backend.

Get a tenant (free; the secret key is shown once — keep it off your page), then declare the origin your page runs on:

shell
curl -sX POST https://api.witbitz.chat/v1/tenants/demo -H 'content-type: application/json' -d '{"name":"My app"}'
# → data.tenant.tenant_id (t_…, for the page) and data.keys.secret (wsk_…, for your terminal or server only)

curl -sX PUT https://api.witbitz.chat/v1/tenant/webhooks -H "authorization: Bearer $WSK" \
  -H 'content-type: application/json' -d '{"origins":["https://app.example.com"]}'

A demo tenant expires unless you upgrade it, keeping the same id and keys (Keeping it). The origin rules: Your page origins.


Rooms and messages — what a Space holds

A Space is a room: its members, their messages and the assistant's, a name, and the files people share. This page is how your page makes or opens a room, reads what is in it and writes to it.

Part of the SDK · every call: the reference.


Create and open a room

javascript
const space = await wb.createSpace({ agent: { name: 'Helper', instructions: 'You help a small group plan together.' } })
const same = await wb.openSpace(link)
await space.ready
const saved = space.snapshot()
space.close()
  • wb.createSpace(opts) makes a room and returns it, live. agent is { name, instructions, tools?, model? }: the name up to 80 characters, the instructions up to 4,000. tools, immediateTools, tier and key are fixed when the room is made, and admission changes only through changeMembers(); everything else changes in place. An option the SDK does not know is refused.
  • wb.openSpace(link, { resume?, tier? }) joins a room from its link. The key is in the link's fragment, which never reaches a server. openSpace does not read the page's own address; to open it, pass location.href (invite links).
  • space.ready resolves once the room's whole history has been read — a long room loads in pages. From then, messages and data.rows() hold everything the room had. It rejects only when the key can never open the room (cannot_open) or the handle was closed first (closed).
  • space.snapshot() returns { count, etag, messages, rows } as JSON. Pass it back as resume and the next open continues from it instead of reading the whole room again.
  • space.shareLink is the link to give someone: holding it lets them in. space.room is the room's id.
  • space.close() stops listening and releases the handle.

Temporary rooms

javascript
const desk = await wb.createSpace({ agent, tier: 'enclave', temporary: { days: 7 } })   // kept 7 days after its last question
const quick = await wb.createSpace({ agent, temporary: true })                          // wiped about 30 minutes after it goes quiet
  • temporary: true makes a room Witbitz wipes once it has been idle for about 30 minutes, like the app's temporary Spaces. temporary: { days } (1 to 7) keeps it that many days after its last question, then wipes it: a help desk on a public page that a visitor comes back to. Each question pushes the date out again.
  • A temporary room is never in the account backup. space.temporary is true for one.
  • Where a deployment cannot wipe rooms, the call is refused (temporary_unavailable) rather than making a room that stays. A value other than true or { days: 1–7 } is refused (invalid_temporary). Not in a vault yet (vault_unsupported).

Messages: who sent what

space.on('message', cb) hears every entry in the room — your messages, other members', the assistant's replies, store writes and system lines — and space.messages is the list so far. On a room you open, the history is replayed to cb first; pass { replay: false } to hear only what arrives after it. Every on() returns a function that removes the listener.

A message is the room's entry as stored, decrypted on the device, with from taken from name when it has none. Its fields depend on its kind (Message types them all):

kind What it is Notable fields
'text' A member's message, an assistant's reply, a private lane's message into the room, or a system line from, text; replies: self, sources?, usage?, cert? (confidential rooms); from a lane: via: 'bridge', draft.by; system lines: sys
'media' A member's message with images or files the bytes: space.attachment(att)
'data' A write to the store (also fires 'data') ops; self when the assistant wrote it
'mcp' A call from your app to the assistant over the embed bridge mcpName, mcpArgs
'fork' A move an older client made (also fires 'moved') handled for you

Any message may also carry verified, user and spk (the sender passed the room's gate), replyTo, md, and voice (it was dictated). One copied in by an older client's move carries carried — its marks from the room it was written in — and carriedBy.

self: true means the room's assistant produced it. The server sets it on the assistant's replies and data writes and on nothing else; it never means "I sent this".

javascript
space.on('message', (m) => {
  if (m.sys) return renderSystemLine(m)                    // a settings change, a connection call
  if (m.self && m.kind === 'text') return renderReply(m)   // the assistant
  render(m, { mine: m.from === MY_NAME })                  // MY_NAME: the `from` you send with
})

In a gated room, from is only a name the sender typed. What the server verified is m.user, the sender's @username when they have one, and m.spk, the signing key of the device that sent it — each device has its own. So "is this mine" is m.user against your own @username, or, for a member with none, m.spk against this device's key, which reads your other devices' messages as someone else's. The Spaces app checks both:

javascript
const me = await wb.usernames.mine().catch(() => null)   // throws when signed out
const isMine = (m) => !!(me && me.username && m.user === me.username) ||
  !!(m.spk && session.sign && m.spk.x === session.sign.pub.x && m.spk.y === session.sign.pub.y)

Send

javascript
const r = await space.send('Plan us a weekend in Kyoto', { from: 'Dana' })
if (!r.ok) showError(r.error)
  • The answer comes separately. The assistant decides whether to reply, on its own turn, and the reply arrives on on('message'). To wait for the answer to this message, use space.turn().
  • send() never throws. It answers { ok: true } when the message was posted, and { ok: false, status, error } for a refusal; every case is in Error codes.
  • instructions (up to 8,000 characters) reach the assistant for this reply only — today's date, "answer in Hebrew" — and are never stored. mode overrides the reply mode for this turn.
  • images (up to 4) and files (up to 2) are { b64 | bytes, mime, name? }, stored sealed in the room. Over the limits, the call is refused before anything is sent.

Replies and reactions

javascript
await space.send('Saturday works for me', { replyTo: m })
await space.react(m, '👍')
await space.react(m, '👍', { off: true })
space.on('update', ({ message, fields }) => rerender(message))

A reply is shown with the message it answers quoted beside it, as in the Spaces app; it carries replyTo: { key, name?, excerpt? }. A reaction is written on the message itself — no new entry, no assistant turn — and reaches every device as an 'update', the event for any message changed in place (a reaction, an image's caption, a reply's cards). A reacted message carries reactions, emoji → the names that reacted; in a verified room reactKeys maps each name to its key.

Attachments

The images and files on a message are stored apart from it, sealed under the room key. await space.attachment(att) fetches one — message.images[i] or message.files[i] — and opens it on the device: { b64, mime, name? }.

Safe media

In a room with Safe media on, a photo or a PDF someone else posted is never opened on a member's device. What is shown is a safe copy: JPEG images made once, when the file is posted, by an attested Witbitz enclave (the tools enclave). It opens the file in a one-shot process with no network and no keys, and keeps nothing. A PDF's copy is its first page. The copy is stored beside its original, and each device checks the enclave's hardware attestation over the copy's exact bytes before it draws anything. A copy that does not check out is never shown. The same setting is Safe media in the Spaces app (⋯ → This Space).

javascript
const space = await wb.createSpace({ agent: { name: 'Desk' }, safeMedia: true })   // or, later:
await space.safeMedia.set(true)                    // = space.configure({ safeMedia: true }); a line says who turned it on

space.on('message', async (m) => {
  if (!m.images?.length || m.self) return
  const copy = await space.safeMedia.copy(m)       // { ok, versions: [{ w, h, b64 }] }, or { ok: false, why }
  if (copy.ok) draw(copy.versions.at(-1).b64)      // the largest version, image/jpeg
  else showCard(m)                                 // no verified copy (yet): show a card with the name instead, never the original
})
space.on('safeCopy', ({ for: f }) => redraw(f.id)) // a copy arrived after its message
  • The setting. createSpace({ safeMedia: true }), space.configure({ safeMedia }) or space.safeMedia.set(on). In a room that names admins, only an admin may change it. In a room without admins, any member may. Every member sees a line saying who did, and 'safeMedia' fires ({ on, from, ts }). space.safeMedia.on is what this handle knows (null before await space.safeMedia.refresh()).
  • Making the copies. While the setting is on, send() makes the copies of the photos and PDFs it sends, and posts each one beside its message once the room has stored it. This needs a signed-in session (Sign in): it is counted to the account (300 files an hour). If the page is signed out or offline, or a file is over 3 MB, the message goes without a copy. Anyone may then call await space.safeMedia.make(message, { at, i }) (→ { ok, error? }).
  • Reading. await space.safeMedia.copy(message, { at, i }) returns the first copy whose every version verifies here. at is 'images' or 'files', i the attachment's index; the default is the first image, else the first file. Copies are kept beside their message: they never appear in messages.
  • Opening the original is the reader's choice, and only after asking. Fetch it with space.attachment(…). Before you open it, await space.safeMedia.isOriginal(copy, b64) checks that it is the file the copy was made from.
  • Your own files are yours: draw them as you sent them.

What the enclave sees: the file, sealed by the posting device to the enclave's attested key. The Space server carries it but cannot read it. The enclave answers with the copies and a signed statement that names the original's hash and each copy's hash; the hardware attests that statement. In a vault (vault mode), the setting and copy() work, make() is refused (vault_unsupported), and send() makes no copies.

The room's name

space.title is the room's name, the same on every member's device and in the Spaces app; null until it has one. space.rename(name) changes it: every member sees the new name and is notified as for a message, and the Spaces app shows "Alon renamed this Space to “Kyoto”".

javascript
space.on('title', ({ title, manual, by }) => { document.title = title })
await space.rename('Kyoto in September', { by: 'Alon' })   // → { ok: true, title: 'Kyoto in September' }

While a room holds one person and the assistant, its name follows the conversation: one is made after the first exchange and revised as the subject changes, with no line and no notification. Once anyone renames the room, or a second person joins, no name is made again.

  • A name is 1–80 characters; whitespace is folded and control characters dropped.
  • A name is not a message: it is never in messages and never fires message. snapshot() keeps it.
  • When two renames arrive at nearly the same moment, every device keeps the one the server stamped last.
  • Refusals: an empty name throws bad_request. In a room with owners or admins only they may rename it (not_admin); in a room without them, any member may. A direct chat is named after the other person and is never renamed (direct_chat). In a gated room a rename is a write like any other: not_authorized with e.reason not_on_list (the signed-in email is not on the list) or unsigned (no one is signed in; a sign-in that did not verify says token: …).
  • unavailable: the room did not answer, and nothing was stored — try again. ok: false means an older server stored the name without announcing it; members see it the next time they open the room.

Live updates

In a browser, each open room holds one socket on the Witbitz relay (wss://code-relay.witbitz.chat), and what happens in the room arrives down it at once: the assistant's reply grows on partial as it is written, and a new message is fetched the moment it lands. It works in every room — open, gated and confidential.

  • The relay learns nothing. The socket's channel is derived from the room key and every frame is sealed under it. A gated room's channel also needs a short-lived grant that only a read that passed the gate hands out, so a member you remove stops hearing the room from the next message on.
  • Every write, not only replies. A store write, a reaction, an approved card, a copied message or a settings change sends a signal that carries no content, so other open devices fetch it at once.
  • Polling is still the record. If the socket cannot open, the room reads every 0.9 s after activity and every 3 s when idle; a lost frame costs one read, never a message.
  • Cards come with the reply. A card the assistant proposed during a turn is fetched as that reply lands, not at the next 1.5 s check, whenever you listen for proposal.
  • Your CSP needs wss://code-relay.witbitz.chat in connect-src. new Witbitz({ live: false }) turns the socket off.

The assistant — who it is, when it answers, what it may use

Every Space has an assistant. This page is how you set it up and change it, when it answers, how your code gets its answer — or runs it as a function — which tools it may use, and voice.

Part of the SDK · every call: the reference.


Its name, instructions and model

You name the assistant when you make the room (createSpace({ agent: { name, instructions, model? } })), and change it later, in place, with configure:

javascript
await space.configure({ agent: { instructions: 'Never invent a value. Never create a project unasked.' } })
  • What changes in place: the assistant's name, instructions and model; the store's collections (data; data: null removes them); which platform tools it gets (platformTools); the builder framing; and whether it may write rows (dataWrites). configure answers { changed }, the fields it applied.
  • Every change is visible. The room's settings are sealed again with the change, and a system line — “⚙️ You changed the assistant's instructions” — lands in the room for every member, and in the assistant's own context. The next turn runs on the new settings. A room's assistant never changes silently.
  • What does not change in place: the room's tools (tools, immediateTools, agent.tools), its tier and its key are fixed when it is made; its members change only through changeMembers(). For a different setting, make a new room and copy what you need into it.
  • Refusals, made by configure before anything is sent: create_time (a field fixed at creation), use_fork (admission — use changeMembers), lane (on a lane), unknown_field (anything else). A room whose owner signed its policy refuses every change in place with governed_room; another client that tries gets config_immutable from the server.

The model and its reasoning effort

model is a model id — 'kimi-k3' — or an object that also sets how hard the model reasons before it answers:

javascript
const models = await wb.models({ tier: 'enclave' })   // [{ id, label, wire, efforts, defaultEffort, default }]
await space.configure({ agent: { model: { catalog: 'kimi-k3', params: { reasoning_effort: 'high' } } } })

The levels run none · minimal · low · medium · high · xhigh · max, and each model takes only some of them — the list differs by model, and for some models by tier. wb.models() returns each model's efforts, measured against the live provider rather than copied from its documentation; an empty list means the model takes none, so leave the field out.

  • A room that names no effort gets defaultEffort, which is low. A room that names no model gets the tier's default: gpt-6-sol on the standard tier, deepseek-v4.1-flash on the confidential one.
  • createSpace, privateLane and configure refuse a level the model does not take, before anything is made, with invalid_effort and a message naming the levels it does take. A stored room whose model no longer takes its level (after switching models) runs at low rather than failing.
  • Higher levels cost more tokens and time. On most models low is right for a conversational assistant; reach for high or above in a room whose job is analysis.

The table is generated from the live catalog; when they differ, wb.models() is right.

Model Tiers reasoning_effort
gpt-5.5 standard none low medium high xhigh
gpt-5.1 standard none low medium high
gpt-6-sol standard none low medium high xhigh
gpt-6-luna standard none low medium high xhigh
gpt-6-astra standard low medium high xhigh
gpt-5.6-sol standard none low medium high xhigh
gpt-5.6-luna standard none low medium high xhigh
gpt-5.6-terra standard none low medium high xhigh
claude-opus-5 standard none low medium high xhigh max
claude-opus-5.5 standard low medium high xhigh max
claude-sonnet-4.6 standard low medium high max
claude-sonnet-5.5 standard low medium high xhigh max
claude-sonnet-5 standard none low medium high xhigh max
claude-fable-5.1 standard low medium high xhigh max
claude-haiku-4.5 standard low medium high
gemini-2.5-pro standard low medium high
gemini-2.5-flash standard none minimal low medium high
deepseek-v4-pro standard low high xhigh max
deepseek-v4-flash standard low high xhigh max
deepseek-v4.1-flash both low high xhigh max
grok-4.6 standard minimal low medium high xhigh
kimi-k3 both none low high max
glm-5.3-flash both low high max
glm-5.3 both low high max
gpt-oss-120b both low medium high
gemma-4-31b both none minimal low medium high xhigh max
llama-3.3-70b both none — omit the field
mistral-large standard none — omit the field
qwen-3.6-plus standard none minimal low medium high xhigh
nemotron-3-ultra standard none minimal low medium high xhigh max
gemma-4-uncensored standard none — omit the field

When it answers

javascript
await space.setReplyMode('ondemand')
await space.replyMode()                             // → 'ondemand'
await space.send('Thoughts?', { mode: 'always' })   // this turn only
Mode The assistant answers
'auto' (default) when the room is one person and the assistant, or a message is addressed to it; in a group, a small model decides whether a message wants help
'always' every message
'ondemand' only when it is named or @-mentioned

It is the same setting as the Spaces app's reply chip, and a turn re-reads it once it is 20 s old. How the choice is made: how a turn runs.

A turn you can hold

send() behaves like a room: you post, and whatever happens next arrives on on('message') — the assistant may answer or stay quiet, and other people may speak first. When your code needs the answer to this message, use turn():

javascript
const t = space.turn('Plan the trip', { timeoutMs: 120_000 })
t.on('partial', ({ text }) => draft.textContent = text)
const { status, reply, error } = await t.finished
if (status === 'done') show(reply)
else if (status === 'silent') hint('The assistant stayed out of it.')
else if (status === 'failed') warn(error)
  • finished resolves { status, reply, text, proposed, usage?, error? } and never rejects. status is 'done', 'silent' (the assistant chose not to answer), 'failed' (with the server's error, such as not_admitted), 'aborted' or 'timeout'. reply is the reply as a message — the same object on('message') receives.
  • partial streams this reply as it is written; t.text is the text so far, and t.result is send()'s answer once the server gave it.
  • t.abort() stops the reply and keeps what was written as the reply. Only the device that started a turn can stop it. timeoutMs (default 180 s) stops it the same way.
  • One reply at a time. A message sent while another reply is running is held and sent again, as send() does; a held message is not sent after abort() or the timeout. The turn's reply is the first assistant message after the attempt that ran.

What a turn used

send()'s answer and finished carry usage — the turn's model calls added up, from the provider's own figures — so you can measure a turn without waiting for the day's billing:

javascript
const { usage } = await space.turn('Summarise the week').finished
// { tokensIn: 18240, tokensOut: 612, tokensCached: 15872, costMicros: 2140, costUsd: 0.00214,
//   calls: 2, models: ['deepseek/deepseek-v4.1-flash'], source: 'provider' }
  • costMicros is in millionths of a dollar. source: 'provider-partial' or 'none' means some calls reported no cost, and the total counts only those that did. No usage means the turn made no model call.
  • A reply longer than about 30 s reaches send() as pending: true, with no usage; space.turn() then reads it from the stored reply. Every assistant message in messages carries the server's record as usage (in, out, cached, costMicros, models).

The assistant as a function

space.run(input, opts) runs the room's assistant once and gives you its answer. Nothing is written to the room — no message, no reply, no card, no notification. Use it for work that is not a conversation: pull fields out of a text, classify a request, draft something your app shows its own way.

javascript
const r = await space.run('Invoice 2291 from Acme, total 1,240.50 EUR, due 30 Nov', {
  instructions: 'Extract the invoice fields.',
  schema: { vendor: 'string', total: 'number', due: 'string' },
})
if (r.ok) console.log(r.output)   // { vendor: 'Acme', total: 1240.5, due: '30 Nov' }
else console.log(r.error, r.detail)
  • The room is the container. It admits the call (its gate and its key) and pays for it, as for a turn; its store is readable to the assistant through data_query. The assistant sees input, not the room's conversation.
  • Your prompt only. A run gets the room's instructions plus instructions, with no Spaces framing — as in builder mode, whatever the room is set to.
  • Only tools that need no approval. A card is a write, and a run has no member to approve it, so tools that would propose — data writes, effectful app tools, a lane's message into the room — are not offered.
  • schema asks for structured output: { field: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object' } (every field required, no others) or a JSON Schema object. The assistant hands its result to a final_answer tool, the result is checked, and output always fits.
  • Text and images (images, up to 4). Files are read through the room, with read_file.
  • Who pays: your user's own key (a user token), your app's key, or a funded room — never Witbitz.
  • Limits, per room: 4 runs at once and 60 a minute.
error Meaning
too_slow the run did not finish within the endpoint's ~30 s, so its answer could not be returned (nothing was kept). Shorten the task or lower the effort.
no_structured_output schema was given and the assistant never produced an answer that fits it (its text comes with the error)
bad_schema the schema is neither form above
run_needs_payer no user token, app key or room grant pays for it
rate_limited more than 4 at once or 60 a minute in this room; wait retryAfter seconds
run_unavailable the server cannot run it here (a confidential room on a server whose enclave does not run them)
run_unsupported the server is older than runs
unavailable (or another transport code, with detail) the request got no answer — the network, or an enclave that could not be reached; a run keeps nothing, so it is safe to try again

Tools

Name the tools the assistant may use when you make the room. The ones in immediateTools it calls directly; the rest become proposals a member approves before they run.

javascript
const space = await wb.createSpace({
  agent: { name: 'Travel', instructions: 'Plan trips; put options on the shared map and list.' },
  tools: ['search_places', 'show_places', 'add_place', 'search_flights', 'show_flights', 'set_itinerary'],
  immediateTools: ['search_places', 'show_places', 'set_itinerary'],
})

The whole catalogue — places, flights, the itinerary, charts, photos, read_file, read_url, write_pdf — and what each produces: How a turn runs. Your page's own functions become tools as a private lane's actions, or for one question in any room (below).

Your page's tools, for one question

send(text, { tools }) offers the assistant your page's own functions for that question only, in the shape a lane's actions take. An await tool runs on this page while the turn waits and its result goes back to the assistant; a propose tool becomes a proposal a member approves.

javascript
await space.send(question, { tools: [
  { name: 'search_guide', description: 'Search the help guide', input: { topic: 'string' }, mode: 'await',
    run: async ({ topic }) => searchGuide(topic) },
  { name: 'where_am_i', description: 'The screen the person is on', mode: 'await', run: async () => currentScreen() },
] })
  • Nothing is stored in the room: offer the tools again with each question that may need them.
  • At most 20 tools a question; the call is refused before anything is sent over that.

Choosing the platform tools

Every assistant also gets Witbitz's platform tools: web_search, fetch_url, read_file, write_pdf, look_at_image, show_photo, show_chart, show_diagram, search_places, show_places and add_place; connection_call in a room with an API-key connection; and cross_to_shared in a private lane. Their descriptions go with every model call — about 4k tokens, plus about 1k of guidance for places and photos, paid again on each round of a turn's tool loop — so if your assistant uses only some, name them:

javascript
await wb.createSpace({ agent, platformTools: ['web_search', 'read_file'] })   // only these two
await wb.createSpace({ agent, platformTools: false })                         // none
await space.configure({ platformTools: true })                                // all again (the default)
  • Your own tools, a lane's actions and the store's tools are not platform tools; this setting leaves them alone.
  • The guidance for places, photos and shared files goes to the model only when those tools are offered.
  • cross_to_shared is how a private lane posts into its shared room, after the member approves. A lane whose list leaves it out cannot post there, and its assistant says so when asked to.

Builder mode: only your prompt

By default the assistant is framed as a member of a Spaces group chat: a line saying so, rules for formatting and place links, and, in a private lane, how the member's shared room works and how cross_to_shared reaches it. That suits a chat app; in an app of your own it can get in the way. framing: 'builder' leaves it all out: the model gets your instructions, the conversation, the message and the tools you chose, and nothing else.

javascript
const lane = await space.privateLane({
  agent: { name: 'Keep', instructions: 'You are Keepwarden\'s assistant. Change things only through our actions.' },
  framing: 'builder',
  data: { tasks: { title: 'string', done: 'boolean' } },
  dataWrites: false,              // it may read the store (data_query) but proposes no data_put / data_delete
  platformTools: ['web_search'],
})
await space.configure({ framing: null, dataWrites: null })   // back to the defaults

framing works on createSpace, privateLane and configure. Tool descriptions are unchanged — they describe each tool, not your app — and a shared file is still announced to the model when read_file is offered.

See the prompt. send(text, { showPrompt: true }) (and turn() with the same option) answers with prompt: the exact system text, user text, model and tools that turn sent. Nothing extra is stored. A turn longer than about 30 s answers pending without it; send a quick message to read it.

javascript
const { prompt } = await lane.send('explain how to use the app', { showPrompt: true })
console.log(prompt.system, '\n---\n', prompt.user, prompt.tools.map((t) => t.name))

Voice input

wb.dictation() is the press-to-talk the Spaces app uses: words appear as the person speaks, and the final text arrives about half a second after they let go.

javascript
const mic = wb.dictation()
button.onpointerdown = () => mic.start({ onPartial: (text) => (input.value = text) })
button.onpointerup = async () => { input.value = await mic.stop() }

mic.cancel() throws a press away, mic.close() hangs up at once (otherwise after 15 quiet minutes), and mic.listening is true while it records.

  • The audio never reaches Witbitz. The page streams it straight to Tinfoil's confidential realtime enclave (Voxtral Mini 4B Realtime); Witbitz only gives the page a short-lived key, capped at a few cents.
  • Who may use it: a signed-in person (wb.signIn) or one your server vouched for (wb.useUserToken, see billing, per user). With neither, start() throws sign_in_required before it asks for the microphone.
  • Languages: 13, among them English, French, Spanish and German. Not Hebrew — Hebrew speech comes back in Latin letters.
  • Cost: one connection stays open across presses, about $0.002 each. Today Witbitz pays; it is not yet billed to your tenant.
  • Your CSP needs wss://inference.tinfoil.sh in connect-src; the browser needs a secure context and the person's permission for the microphone.
  • Errors (code): sign_in_required (not signed in, or the sign-in or token was refused), stt_rate_limited (a few keys per person per hour), stt_unavailable, voice_unattested (the speech service did not prove what it runs — nothing was sent to it), mic_denied, already_listening.

Reading aloud

wb.speak(text) reads text aloud with the Spaces app's Play aloud player, and resolves when it has finished:

javascript
button.onclick = () => wb.speak(lastReply.text)
stopButton.onclick = () => wb.speech.stop()

wb.speech.playing is true while it plays, and wb.speech.play(text) is the same call.

  • The text never reaches Witbitz. The page sends it straight to Tinfoil's attested speech model and plays what comes back; Witbitz only gives the page a short-lived, capped key.
  • Who may use it: as for voice input — a signed-in person or one your server vouched for; with neither, speak() rejects with sign_in_required before anything is sent.
  • Long text is read in pieces cut at sentence ends: a short first piece, so sound starts in about two seconds, and each next piece fetched while the one before plays. A new speak() stops the one playing.
  • Languages: not Hebrew — neither speech model has it.
  • Cost: about $0.002 a request whatever its length; a paragraph is two to four. Today Witbitz pays.
  • Your CSP needs https://inference.tinfoil.sh in connect-src and blob: in media-src. Most browsers play sound only after the person has tapped something on the page.
  • Errors (code): no_hebrew (refused before anything is spent), empty, sign_in_required, tts_rate_limited, tts_unavailable, voice_unattested, http_<status>, audio / play (the browser would not play it).

Approvals — nothing effectful runs until a person says yes

The assistant can propose; only a member makes it happen. A tool not in immediateTools, a write to the store, a private lane's message into the shared room, a call on a connected service: each is parked as a proposal — a card a member approves or denies — and nothing happens until they do. The one exception is an MCP tool a room was granted to run on its own (connection calls). This page is that round trip.

Part of the SDK · every call: the reference.


Proposals, end to end

A proposal is { id, kind, action, args, text, approve(opts?), deny(opts?) }. kind is 'action' — a tool call — or 'message', a private lane's message into its shared room; action is the tool's name, and args the whole parked call, type included. data_put and data_delete are always proposals.

In a shared room, the server runs the action on approval:

javascript
const space = await wb.createSpace({ agent: { name: 'Ops', instructions: 'Track tickets.' }, data: { tickets: { title: 'string' } } })
space.on('proposal', (proposal) => showCard({
  title: proposal.action, args: proposal.args,
  onApprove: () => proposal.approve({ from: 'Ann' }),   // your card's buttons
  onDeny: () => proposal.deny({ from: 'Ann' }),
}))
space.on('resolved', (e) => hideCard(e.id))
await space.send('Open a ticket: printer on 3 is down')   // the assistant calls data_put: a card

showCard and hideCard are your page's own interface.

The approved write then arrives as a message (kind 'data') and a 'data' event. 'resolved' carries { id, approved, action, result }, where result is what the server did; result.ok === false means it did not go through (a message the room refused, for one).

In a private lane, your page runs the action on approval:

javascript
const lane = await space.privateLane({
  agent: { name: 'My assistant' },
  actions: [{ name: 'book_room', description: 'Book a meeting room', input: { room: 'string', at: 'string' },
              run: async (act) => api.book(act.room, act.at) }],   // act = { type: 'book_room', room, at }
})
lane.on('proposal', (proposal) => showCard({
  title: proposal.action, args: proposal.args,
  onApprove: () => proposal.approve({ from: 'Ann' }),   // the server records the approval, then the SDK calls run(act)
  onDeny: () => proposal.deny({ from: 'Ann' }),
}))
lane.on('resolved', (e) => console.log(e.result))
await lane.send('Book Kyoto for 3pm')

run()'s return value comes back as result, on 'resolved' and in approve()'s answer ({ error } if it threw); it is not sent to the assistant. A lane's message into the shared room (kind: 'message') is posted when approved.

  • Listening starts the check. on('proposal') starts a check for waiting cards every 1.5 s, in any room; a lane looks at once, as soon as your listener is attached, so a card already waiting when the app reopens arrives straight away. A card proposed during a turn arrives with that turn's reply.
  • A refusal keeps the card. If the room refuses the decision, approve() and deny() throw and the card stays pending.

Actions your page runs

A private lane's actions are your page's own functions, offered to its assistant as tools. The assistant can only propose them; when the member approves, the runtime records the approval and the SDK runs your run() handler on the member's device — the runtime never runs it, so the credential and the side effect stay with you. That is the part that is hard to build safely by hand: the propose → approve → run state machine, the record of who approved what, and the guarantee that the assistant cannot act alone.

An action can also be a read. Give it mode: 'await' and the assistant may call it during the turn for something only the device knows; the SDK runs your handler and hands its return value back to the turn (within 25 s). It needs no approval, because it changes nothing.

How to offer actions for one turn only, or change the standing set: private lanes. The limits on actions: Reference.

Deciding by id

javascript
const waiting = await space.pending()                 // checks now → the waiting cards
await space.approve(waiting[0].id, { from: 'Ann' })   // the same as the card's own approve()
await space.deny(id, { from: 'Ann' })

approve(id, { recordResult: false }) is for a connection call whose answer you hand the assistant yourself: the room records the call — who approved it, what it was, how it went — but not the answer.

Who may decide

In a room with admins, a member who is not one cannot approve or deny a card that would do something they may not do themselves: an admin-only row, or a connection call that spends the room's connected account. The room refuses with not_admin, and the card stays pending for an admin. A member's own lane is theirs to decide.

Connection calls

A call through an API-key or OAuth connection is always a proposal a member approves. An MCP tool is too, unless it is in the connection's auto set — chosen by whoever grants the connection to the room (the member in the Spaces app, or your page with grant(conn, { auto })); by default it is the tools the server itself marks read-only, a claim the server makes and Witbitz does not verify. An auto call still goes through the Sentinel: the model never sees the credential, and the connection's host and scope still apply.

The Sentinel adds the secret and makes the call; see connections and MCP servers.


The room as a store — your app's data, sealed with the conversation

A Space can hold your app's data, not only its conversation: rows in named collections, sealed like everything else, ordered by the server without reading them, and readable by the assistant. An app can keep all its state in its rooms, with no database of its own.

Part of the SDK · every call: the reference.


Declare and write

Declare the collections when you make the room, or later with configure. Give each field one line: what it is, and the rule for it. Those lines go into the assistant's tool descriptions on every turn and are read at the moment it fills the field, so a rule about a field belongs on the field; who the assistant is belongs in its instructions.

javascript
const space = await wb.createSpace({
  agent: { name: 'Planner', instructions: 'Keep my week honest.' },
  data: {
    tasks:    { description: 'things to do', fields: { title: 'short text', due: 'ISO date — only if I said one', done: '0 or 1' } },
    projects: { fields: { name: 'short text' } },
  },
})

await space.data.put('tasks', { title: 'Buy milk', due: '2026-09-22', done: 0 })
await space.data.put('tasks', { title: 'Buy oat milk', done: 1 }, { id: 't1' })   // with an id: replace that row
await space.data.delete('tasks', 't2')
space.data.rows('tasks')   // [{ id, title, due, done, _updated, _by, _v }, …]
space.data.get('tasks', 't1')
space.on('data', ({ ops, from, self }) => rerender())   // every write, yours or the assistant's

space.data.batch(ops) writes several rows as one entry. A write answers { ok: true, id, ops } and throws on a refusal.

The store is the room's history. Every write is one entry in the room — ▦ Dana saved tasks “Buy milk” — so every member sees it, two devices writing different rows never collide, and any device holding the key rebuilds the store by reading the room. There is no second copy to drift. A row is at most 16 KB.

What the assistant gets, in a room that declares collections:

Tool Runs What it does
data_query in the turn, at once SQL over one table, rows(collection, id, row JSON, updated, by) — SELECT … WHERE json_extract(row,'$.due') < ? — so it answers "what is due this week" without being handed everything. Read-only, enforced by the engine; at most 200 rows. A query that runs longer than a few seconds is stopped, and the assistant is told to narrow it.
data_put proposes Add or replace one row. It is written when a member approves, never during the turn.
data_delete proposes Delete one row by id, the same way.

dataWrites: false lets the assistant read the store without proposing writes (builder mode). id and every field name starting with _ are the room's own: a write that sets one is refused (reserved_field) — what they carry is in who did what.

Row schemas

Field descriptions guide the assistant; a schema is enforced. Give a collection schema — JSON Schema for one row — and the room checks every write against it, so a row that does not fit is never stored:

javascript
const space = await wb.createSpace({
  agent: { name: 'Ops' },
  data: {
    tasks: {
      description: 'things to do',
      schema: {
        type: 'object',
        properties: {
          title:    { type: 'string', minLength: 1, maxLength: 80, description: 'what to do' },
          due:      { type: 'string', format: 'date' },
          priority: { enum: ['low', 'normal', 'high'] },
          estimate: { type: 'number', minimum: 0, maximum: 40, description: 'hours' },
        },
        required: ['title'],
        additionalProperties: false,
      },
    },
  },
})

try { await space.data.put('tasks', { title: 'Call', priority: 'urgent' }) }
catch (e) { e.code === 'invalid_row' /* tasks: priority must be one of "low", "normal", "high" */ }
  • Your writes: a row that does not fit is refused with invalid_row, naming the field. In a batch, one bad row refuses the whole batch.
  • The assistant's writes: a data_put that does not fit is never proposed — the assistant is told why and tries again, so no member is asked to approve a row that would fail. A card is checked again when it is approved, in case the schema changed meanwhile.
  • The assistant reads the rules: each field's type, allowed values, required and description appear in its data tools. A schema's descriptions take the place of fields.
  • Supported: type, properties, required, additionalProperties (true/false), enum, const, minLength/maxLength, minimum/maximum/exclusiveMinimum/exclusiveMaximum, items, minItems/maxItems, and format (date, date-time, email, uri); description, title, default and examples are kept as notes. Anything else — pattern too, since our servers do not run regular expressions from a room's settings — is refused when you declare it (invalid_schema), never silently ignored. Up to 8 KB a schema, nested 6 levels.
  • New writes only. Changing a schema with configure({ data }) does not touch the rows already stored, and rows an older client's move carried are not checked again. Confidential rooms run the same checks.

Conditional writes

Two devices editing the same row is the one case a room's history cannot settle by itself — the last write would win — so a write can be made conditional. Every row carries _v, the id of the entry that last wrote it; pass it back, and the write is refused if the row has changed since:

javascript
const t = space.data.get('tasks', 't1')
try {
  await space.data.put('tasks', { ...t, done: 1 }, { id: 't1', ifVersion: t._v })
} catch (e) {
  if (e.code === 'row_changed') { /* e.conflicts: [{ collection, id, version }] — read again, merge, retry */ }
  else throw e
}
await space.data.put('tasks', { title: 'first' }, { id: 't9', ifVersion: null })   // only if t9 does not exist yet
await space.data.delete('tasks', 't1', { ifVersion: t._v })
  • The check and the write are one step on the server: it reads, compares, and appends only if the room has not grown in between, retrying under contention, so the losing device gets a clean 409 row_changed and never a silent overwrite.
  • snapshot() keeps _v, so a conditional write after a resume compares against the right version.
  • Without ifVersion, a write is unconditional.

Collections only admins write

Declare a collection write: 'admins' and only the room's admins may write it — see admins. The limits on collections and fields: Reference.


People — sign-in, who may enter, who runs the room

A room is open to anyone with its link, or gated: only signed-in people on its list may read or write it. This page is signing people in, gating a room, its admins and owners, direct chats, and changing who is in it.

Part of the SDK · every call: the reference.


Sign in

Your app needs no accounts of its own. The SDK signs people in the way the Spaces app's Email me a code does: a six-digit code to any address, from any origin.

javascript
const wb = new Witbitz()
const { ticket } = await wb.signIn({ email })      // a code is emailed
const session = await wb.signIn({ email, code })   // → { email, idToken, exp, sign }
localStorage.setItem('wb:session', JSON.stringify(session))
const renewed = await wb.refreshSession()          // before session.exp, with no new code
  • Keep the session. Pass it back as new Witbitz({ session }), or wb.useSession(session) on a running client (null drops it). wb.signOut() forgets it.
  • Renew it before it expires (session.exp) with refreshSession(), the renewal the Spaces app runs. It throws refresh_failed when the person has to sign in again, and no_session when there is nothing to renew.
  • A sign-in on your site is your app's, not the person's whole Witbitz account. The token names the site that asked for it. Connections, the Siri inbox, outside agents and speech keys made on your page live under an account of your app's own; the person's own Witbitz account — their connections elsewhere, their @username, the account itself — cannot be changed from your page. It still proves the email, so room gates work as for any sign-in. This is the builder's half of the double blind: your page cannot spend or erase what the person did in other apps.
  • Your server can check a sign-in on its own: knowing who is signed in.

Gated rooms

A room's gate is chosen when it is made, with wb.createSpace or space.privateLane; a room with none is open to anyone holding its link.

javascript
const space = await wb.createSpace({
  agent: { name: 'Warden' },
  admission: { emails: ['dana@example.com'], domains: ['keepwarden.com'] },
})
  • Every operation is gated, reads included. The SDK sends the signed-in identity with each request, from the session wb.signIn() returned — so sign in before opening a gated room, or its reads come back refused.
  • The list is sealed with the rest of the room's settings: the platform can tell that a room is gated, never who is on its list.
  • A list takes emails and domains (acme.com). The Spaces app's own form also admits by @username — see usernames, discovery, and who is in the room.
  • A list that admits nobody is refused at the call: it would lock you out of your own room.

Reading the gate

A member holds the room key, so your app can read the gate the server enforces instead of keeping a list of its own:

javascript
await space.admission()
// → { gated: true, emails: ['lead@keepwarden.com'], domains: ['keepwarden.com'], hidden: 3,
//     admins: ['lead@keepwarden.com'], owners: [] }
await space.admits('dana@example.com')   // → true / false, against the sealed list
  • Addresses are hidden by default. Each is stored as a hash salted with the room id, so a member can check an address but not read the list; hidden counts them. To show some to members, list them in advertise when you make the room (admission: { emails, advertise: ['lead@keepwarden.com'] }), or pass true for all.
  • "Hidden" hides the list, not the people. A member cannot enumerate who was invited, but a signed-in member's messages carry their @username (unless the room set usernames: false), their rows carry their email, and the room's member list shows who has signed in.
  • The link alone reads nothing. A gated room's sealed settings — the list, the admins, the connection grants — are served only to a signed-in member on the list; anyone else learns only that the room is gated.
  • A member admitted only by @username cannot be checked on the device: that match needs the server's key.
  • Errors: config_unavailable when the settings cannot be read (the SDK refuses to guess), and sign_in_required before the member signs in.

Who did what

In a gated room every write needs an allowed sign-in, and the server marks what a signed-in member wrote:

On The server sets
a message verified; user, their @username (absent when the room set usernames: false); spk, the key of the device that sent it
a row _verified; _email, their verified email; _user, their @username (absent when the room set usernames: false)

A message does not carry the email, so its author's address is not readable by every member. from and a row's _by stay the display name the writer chose; lane.approve(id, { from }) sets the one a lane's message shows.

  • A lane's message into the room is verified only when the member who approved it signed in — to the room it lands in or, when that room is open, to the gated lane it came from. A message into an open room in another region carries no mark: that region never sees the lane's sign-in.
  • A row's own fields cannot pose as these marks. id and every name starting with _ are the room's: data.put refuses them (reserved_field), and a row is always read without them.
  • Copied history keeps its old marks apart. Rows and messages an older client's move carried keep the marks they had as carried ({ from, verified, user? }), never as this room's own verified, which was not earned here, and name who carried them (carriedBy on a message, _carriedBy on a row). A lane's mirror copies messages only (rows as lines, with mirror: { data: true }), with no marks.

Admins

A gated room can name admins, and the room itself — not your app's interface — enforces what only they may do:

javascript
const team = await wb.createSpace({
  admission: { emails: ['lead@keepwarden.com', 'dana@example.com'], admins: ['lead@keepwarden.com'] },
  data: {
    tasks:  { fields: { title: 'text', done: 'boolean' } },                    // every member
    budget: { fields: { item: 'text', amount: 'number' }, write: 'admins' },   // admins only
  },
})
await team.isAdmin('dana@example.com')   // → false

In a room with admins, the room refuses a member who is not one (not_admin) when they:

  • change the assistant (configure);
  • write a collection declared write: 'admins';
  • approve or deny a card that would do something they may not do themselves — an admin-only row, or a connection call, which spends the room's connected account. The card stays pending for an admin.

Posting, reading, the other collections and a member's own lane stay open to every member.

  • Admins must be admitted by the gate: an address in emails, or one in a listed domain.
  • They are sealed in the room's settings, so members can see who they are (isAdmin, admission().admins) and the platform cannot.
  • They are set when the room is made and changed in place with changeMembers().
  • A collection declared write: 'admins' in a room with no admins or owners is refused when declared (admins_required): nobody could be held to it.

Owners

Admins are equal, and any admin can change the room's members — so on its own, any admin could change the list to one without the other admins, or without the person who made the room. Owners are a level above, enforced by the room too:

javascript
const team = await wb.createSpace({
  admission: { emails: ['founder@keepwarden.com', 'lead@keepwarden.com', 'dana@example.com'],
               owners: ['founder@keepwarden.com'], admins: ['lead@keepwarden.com'] },
})
  • An owner is an admin: whatever an admin may do, an owner may.
  • Only an owner can change the owners, or lower or remove an admin. Admins still add and remove members and raise members to admin.
  • The server refuses a member change by someone who is not an owner that breaks this rule (not_owner); the SDK checks before it changes anything.
  • space.isOwner(email), admission().owners. Owners must be admitted by the gate, like admins.

Direct chats

A direct chat is the Spaces app's one-to-one chat: a gated room for exactly you and one other person. Each of you sees it under the other's name, nobody renames it, and the assistant is in it but answers only when @-mentioned.

javascript
const chat = await wb.createSpace({ agent: { name: 'Helper' }, direct: 'sam@example.com' })   // needs wb.signIn
// give Sam chat.shareLink; Sam opens it signed in as sam@example.com
await chat.directPeer()   // → { email: 'sam@example.com' } (on Sam's side: you)
  • direct takes the other person's email in place of admission: the room admits exactly the two of you (both addresses visible to members), you are its owner, and direct: true is sealed in its settings.
  • directPeer() reads the other person from the room's sealed list — never from an invite or a link — so it is the name to show. It is null for any other room.
  • You can make several direct chats with the same person. A room for two that you give a name is an ordinary gated room, made with admission.
  • Refusals: no sign-in (sign_in_required); direct together with admission, or with your own address (invalid_argument); rename() (direct_chat).

Changing members in place

A gated room changes who is in it without becoming a new room: the same id, link, history, store and lanes, and, from that moment, a new key that only the people still on the list receive.

javascript
const r = await team.changeMembers({ emails: ['lead@keepwarden.com', 'dana@example.com'], admins: ['lead@keepwarden.com'] })
// r → { inPlace: true, epoch, link, removed: ['eve@example.com'], added: [], unreachable: [] }
await team.rotateKey()   // the same members, a new key — for a lost phone, or a link sent to the wrong place

team.on('rekeyed', ({ epoch, link }) => saveLink(link))
team.on('removed', () => showGone())
team.on('pending', ({ reason }) => askForTheLink())

changeMembers takes the whole new list. Every member's open copy of the room takes the new key by itself, on its next request: 'rekeyed' gives the new link (null in a vault), 'removed' means this account is no longer on the list, and 'pending' means it is on the list but has no key this device can open yet.

  • How the key reaches each member. When a person signs in, their account publishes an invite key for your app (wb.invite) in Witbitz's directory, under your app's own scope — the origin your page signs in from; no other app can write there. A change seals the room's new key to each remaining member's invite key. Their devices fetch it when the old key is refused (stale_key), check it against the room's commitment, and carry on.
  • Keep wb.invite.state like the session. It holds a private key: persist it and pass it back as new Witbitz({ invite }). With the account backup on, it is kept there too, so a restored device follows. Signing in publishes the key by itself; await wb.invite.enroll() publishes it now and answers the status.
  • A second device of the same account that has neither the state nor the backup never takes the published key over — that would cut the first device off. wb.invite.status reads held_elsewhere, and its rooms say pending with reason: 'no_invite_key', until you wb.invite.restore(state). In vault mode the vault keeps and publishes the key: status reads vault, and restore is refused (vault_unsupported).
  • unreachable lists members with no invite key for your app — they never signed in to it, or only to another app, which has its own scope. Their devices are told pending; send them link.
  • Who may change it, or rotate its key: a signed-in owner or admin (not_admin otherwise), within the owners' rules. A rotation goes to everyone still on the list. A room you make names you its owner when you are on its list. A room with no owners or admins is changed by its creator, in the Spaces app.
  • A removed member keeps what they had read, and reads nothing written after: the room refuses their old key.
  • Only the roles changed (the same people, new admins or owners): the room keeps its key and nobody's key changes; the answer says roles: true.
  • Members added by @username in the Spaces app are on the list as a tag that no email names. A change keeps them; removing a person drops their @username entry too, once they have signed in to the room.
  • A room without a gate whose members the Spaces app changed seals its new key to the app's own member keys, which the SDK does not hold: its rooms say pending with reason: 'link_changed'. Ask for the link again.
  • plan() answers in_place for a new list, with call: 'space.changeMembers(admission)'. A lane is one member's own room: changeMembers on a lane throws lane.
  • In a vault, changeMembers and rotateKey go through the vault, which asks the person in its own screen and makes and keeps the new key; the page sees neither key and gets no link.

@usernames

javascript
await wb.usernames.claim('dana', { publish: true, name: 'Dana' })   // → { username, published }
await wb.usernames.mine()                                          // → { username, published }; throws when signed out
await wb.usernames.resolve('dana')                                 // → { username, exists }; no sign-in needed
await wb.usernames.release()

One @username means one person across all of Witbitz, not per app. Verified messages and rows carry it (user, _user), and the Spaces app can admit a person to a room by it; the SDK's admission takes emails and domains.

  • claim refuses a name that is taken (taken) or was released in the last 30 days (held).
  • publish: true also lists the person in the public directory, under the display name when you pass one. It is off by default.
  • Releasing a name does not change what the person already wrote.

Rooms an older client moved

A room changes its members only in place (above). space.fork() makes nothing and throws use_change_members, and plan() refuses a change that names a history with the same code. For a fresh start, make a new room with wb.createSpace({ admission }); nothing is carried into it.

An older client (a 2.2.x page) could still move a room to a new one, and the SDK follows such a move:

javascript
team.on('moved', ({ room, link }) => switchTo(wb.openSpace(link)))
team.on('moveRefused', ({ reason }) => showWhy(reason))
  • 'moved' fires only for a notice the server stamped and sealed to this device's key: in a room with admins, an admin's; in a room with owners, one whose new room keeps exactly the owners and admins it declares. Anything else is 'moveRefused', with the reason.
  • A confidential move is taken into the enclave before its settings are read.
  • History such a move carried keeps its marks as carried on a message and _carried on a row: { from, verified, user? / email? }, the server's marks from the room it was first written in, plus carriedBy.
  • In a vault, the vault reads the notice from the room itself and follows it by the same rules; the page gets 'moved' with { room, link: null } — open it with wb.vault.openRoom(room). Following a confidential room's move is refused there (vault_unsupported).

Private assistants and outside agents

Each member can have a private assistant beside the shared room; a member's assistants can ask each other; outside agents can reach a member's assistant over A2A or MCP; and a Space can sit behind any AG-UI front end.

Part of the SDK · every call: the reference.


Private lanes

A private lane is a member's own room beside a shared one, with its own assistant. That assistant reads the shared room's messages — and its store, read-only, through shared_data_query — and works with the member in private. It can post into the shared room only as a message the member approves, and it can only propose the lane's actions, which run on the member's device when approved (actions). It never holds a credential.

javascript
const shared = await wb.createSpace({ agent: { name: 'Team' } })

const lane = await shared.privateLane({
  agent: { name: 'Ops assistant', instructions: 'Help me run the store. Propose refunds and emails; I approve.' },
  actions: [
    { name: 'issue_refund', description: 'Refund a customer',
      input: { type: 'object', properties: { customer: { type: 'string' }, amount: { type: 'number' } }, required: ['customer', 'amount'] },
      run: (a) => myBackend.refund(a.customer, a.amount) },   // runs only after the member approves
  ],
})

lane.on('message', renderPrivateThread)
lane.on('proposal', (p) => showApproveDeny(p))
await lane.send('Refund Jane $40 for the late order, and email her an apology')

A Lane is a Space — its private thread arrives on message — with what this section adds. Its cards arrive on proposal: kind: 'message' is a drafted message to the shared room, kind: 'action' one of your actions, and nothing happens until approve() or deny() (approvals).

A lane is the member's to keep. Store its link, and on the next page load reopen it rather than making a new one:

javascript
localStorage.setItem('lane:' + shared.room, lane.shareLink)
// a later page load:
const lane = await wb.openLane(localStorage.getItem('lane:' + shared.room), { space: shared, actions, resume: saved })
await lane.ready   // the private thread is loaded: show it, not an empty list

Actions for one turn

javascript
await lane.send('Plan the offsite', { tools: [proposePlan] })                 // offered beside the standing actions
await lane.send('Just the plan, please', { tools: [proposePlan], replace: true })   // only these, this turn
lane.setActions([issueRefund, sendEmail])                                       // the standing set, from now on
  • tools offers extra actions — the same shape as actions — for this turn only, for actions that depend on what the member is talking about. replace: true makes them the whole set for the turn; an empty list means none.
  • setActions replaces the standing actions for every later turn; handlers for names you keep are kept.
  • What a turn actually proposed. send()'s answer carries proposed: [{ id, action }], the cards this turn created ([] when none). The platform knows this for certain and the reply's words do not, so a page waiting for a card should wait only when proposed names one.
  • A call that leaves out a required argument is never proposed: the model is told what is missing and calls again, so no empty card reaches a member.

The mirror

javascript
const lane = await shared.privateLane({ agent, mirror: true })   // or wb.openLane(link, { space, mirror: true })
await lane.mirrorNow()                                           // copy what is new right away

With mirror: true, this device copies the shared room's messages into the lane as they arrive, so the lane's own history holds the whole shared conversation and its assistant reads it like any other. Without it, a lane's turn sees only the newest 20 shared messages.

  • Telling copies apart. A copy arrives on the lane with source: 'shared' and an sh: id. isLaneCopy(m), exported by the SDK, is the Spaces app's own test: filter on it to show a lane's private messages only. Do not filter on source alone — a recreated room's own history is copied in with source: 'shared' too.
  • Attachments are copied as bytes and stored again under the lane's key.
  • It runs while both handles are open. snapshot() keeps its position as mirrored, so a lane reopened with resume catches up without copying anything twice.
  • Messages only, unless you pass mirror: { data: true }, which also copies the shared room's row writes, as lines. Off by default.

One lane, several rooms

javascript
lane.setLinks([teamA, { space: teamB, label: 'Team B' }])   // every turn
await lane.send('What did both teams decide?', { links: [teamA, teamB] })   // this turn only

The lane's assistant also reads the latest messages of each linked room, read-only and labelled; it can still post only into the lane's own shared room. Up to 16 rooms. Each linked room keeps its own gate: a gated room is read only if the signed-in member is on its list.

Across tiers

A lane of the other tier — a confidential lane beside a standard room, or the reverse, as the Spaces app makes them — opens with openLane, and privateLane({ tier: 'enclave' }) makes one beside a standard Space. Your device copies the room's messages into it (the mirror, whether or not you asked), and its turns carry no link to the shared room on the server: a confidential room's key never goes to the standard tier, and neither tier reads the other's store. So that assistant does not read or post into the room on the server. A lane's linked rooms must be on its own tier (tier_mismatch otherwise), because a linked room's key goes with each turn.

Which lane you get, by the Space it belongs to:

The Space privateLane() privateLane({ tier: 'enclave' }) privateLane({ tier: 'standard' }) openLane(link, { space })
Standard a standard lane, bridged to the room a confidential lane: mirrored by this device, no bridge (since 3.6.1) a standard lane either tier; the other tier is mirrored, no bridge
Confidential a confidential lane (it inherits), bridged the same refused: tier_mismatch either tier; the other tier is mirrored, no bridge
Requires an attested app (owner policy) refused: attested refused: attested refused: attested —
In a vault the vault makes it, on the Space's tier only on a confidential Space, else tier_mismatch only on a standard Space, else tier_mismatch the lane's room id from wb.vault.rooms(); the other tier is mirrored

setLinks takes rooms of the lane's own tier only; lane.link(otherLane) takes a lane of either tier.

A member with two lanes — say one for home and one for work — can let the two assistants ask each other:

javascript
const link = homeLane.link(workLane, { to: 'Work', as: 'Home' })   // what each assistant calls the other
// "Ask my work assistant when the Q3 deadline is": Home's assistant calls tell_lane, Work's assistant answers,
// and the answer comes back into Home, whose assistant tells the member.
localStorage.setItem('link-state', JSON.stringify(link.state))      // pass back as link(other, { resume })
link.unlink()
  • Asynchronous. tell_lane sends the question and returns at once. The answer arrives in the asking lane later, as a new message, and wakes its assistant, which passes it on. Neither assistant waits inside a turn.
  • On this device, while both lanes are open. The device holds both keys and carries the messages; the server never joins the two rooms. With wb.account on, the link is listed on the member's other devices (wb.account.links), and a device that opens both lanes links them again.
  • Bounded. cap (default 3; 0 for none) limits how many times a chain of assistants asking each other may cross. Past it, tell_lane answers "too many lanes" and the other lane is not woken.
  • Any tier. A confidential lane and a standard one may be linked: your device carries the words between them, and neither room's key goes to the other's tier.
  • Refusals: not_a_lane, self_link, already_linked.

Outside agents, over A2A and MCP

Other people's agents — a booking agent, a colleague's assistant, Claude Code — can reach a member's private lane without a Witbitz account and without seeing anything else of the member, over A2A, the open agent-to-agent protocol, or as a custom MCP server. The door is https://agents.witbitz.chat/a2a/<handle>.

javascript
const wb = new Witbitz({ session, a2a: saved })   // wb.a2a.state holds the lanes' inbox private keys
wb.a2a.onState = (s) => save(s)
const { cardUrl } = await lane.a2a.enable({ name: 'Dana’s assistant' })
const { token, mcpUrl } = await lane.a2a.addPeer('Booking agent')   // shown once: give it the token and cardUrl or mcpUrl
await lane.a2a.addPeer('Helper', { reply: 'auto' })                 // this one may be answered without asking
await lane.a2a.addPeer('Grok', { job: true })                       // takes jobs: its reports are told to the member, never answered

lane.on('a2a', ({ peer, text, draft, approve, askBack, decline }) => showForApproval(peer, text, draft))
await lane.a2a.collect()                                            // bring waiting messages into the lane
  • Witbitz cannot read it. TLS ends inside the attested standing Sentinel, where a peer's message is sealed to the lane's inbox key; only the member's devices open it. Witbitz stores a hash of each token, never the token.
  • The member decides. Each message lands in the lane as the outside agent's words, and the lane's assistant drafts an answer. By default (reply: 'ask') nothing goes back until the member calls approve() (with edited text, if they like), askBack(question) or decline(). setReply(peerId, 'auto') lets the assistant answer that agent itself. lane.a2a.reply(taskId, text, { ask?, reject? }) answers a task directly, without the 'a2a' event's card (ask: true asks back, reject: true declines).
  • A reload keeps what waits. A collected message is gone from the Sentinel, so the tasks waiting for a draft or for the member ride the lane's snapshot() (as a2a): resume the lane with it and each waiting draft comes back as an 'a2a' event — held until you listen for one, so an on('a2a') after openLane resolves still gets it — and one still coming is caught when it arrives.
  • An agent that takes jobs (job: true — Grok Bot, Claude Code) runs a job and reports; it reads no answer, so its messages are told to the member and nothing is drafted or sent back, whatever its reply. A follow-up is a new job.
  • One token per agent. removePeer(id) revokes it and drops its waiting messages; lane.a2a.peers lists them. disable() closes the lane's door.
  • The account's side. wb.a2a.status() reads what the Sentinel holds for the account — its lanes, its peers by name, open tasks; wb.a2a.revoke() turns it off everywhere, so every card and token stops working; wb.a2a.restore(state) loads a saved state into a running client.
  • Over MCP. lane.a2a.mcpUrl is the same inbox for agents that take a custom MCP server, with tools message_member and get_reply and the same token. Both doors, and agents that take jobs from a member's assistant: Outside agents.
  • For the agent: a standard A2A v1.0 JSON-RPC endpoint (SendMessage, GetTask, CancelTask; the v0.3 names work too) with bearer auth. Answers can take a while: a task stays SUBMITTED until the member's device collects it, and an unanswered one is cancelled after 7 days. Text only; a file part is refused.

AG-UI: a Space behind any AG-UI front end

AG-UI is a standard stream of events between an agent and a user interface, which CopilotKit's components and other AG-UI clients read. aguiAgent makes a Space an AG-UI 1.0 agent in your page: the room is decrypted on the device as always, and the adapter turns what it does into AG-UI events, so nothing of the room leaves the device to reach the interface.

javascript
import { Witbitz, aguiAgent } from 'https://witbitz-spaces.pages.dev/sdk/3.10.0/witbitz-sdk.js'
import { AbstractAgent } from '@ag-ui/client'
import { Observable } from 'rxjs'

const space = await wb.openSpace(link)
const agent = aguiAgent({ AbstractAgent, Observable }, space)   // its messages start as the room's history
agent.addMessage({ id: crypto.randomUUID(), role: 'user', content: 'Can we do Friday at 8?' })
await agent.runAgent({}, { onTextMessageContentEvent: ({ event }) => render(event.delta) })

The SDK has no AG-UI or rxjs dependency: you pass your own AbstractAgent and Observable. Without them, aguiRun(space, input) is the same run as a plain async iterator of events, and aguiHistory(space) is the room as a MESSAGES_SNAPSHOT.

AG-UI The Space
the last user message of the input space.send(text) — sent once, though AG-UI clients resend the whole history
input.context that turn's instructions, never stored in the room
TEXT_MESSAGE_START / CONTENT / END the reply as it is written (partial), closed by the reply itself
TOOL_CALL_* and RUN_FINISHED with an interrupt outcome a proposal waiting for a person (a data write, a lane's message, a connection call)
input.resume resolved / cancelled space.approve(id) / space.deny(id), then TOOL_CALL_RESULT
RUN_ERROR busy / timeout / no_input the room was answering an earlier message / no reply in 4 minutes / nothing new to send

Options: aguiAgent(deps, space, { from, mode, timeoutMs, threadId, agentConfig }); mode defaults to 'always', since an AG-UI run expects an answer. Not mapped yet: AG-UI front-end tools (input.tools) and STATE_* events — a Space's own tools and approvals cover the room, and its data has its own data events.


Cloud computers — a coding agent on the person's own computer in the cloud

A cloud computer is a computer that belongs to one person: a protected virtual machine in an AWS Nitro enclave, running a coding agent (OpenCode), with its files saved encrypted. Only that person can open it — not your server, not Witbitz. wb.computers lets your page make one, open it, run the agent's sessions, answer its approvals and questions, and read the files it changes.

Part of the SDK · every call: the reference.


The whole thing

javascript
const box = await wb.computers.create({ name: 'Build box' })   // model keys from the person's account, capped
const computer = await wb.computers.open(box.id, { onStep: (s) => show(s) })
const s = await computer.sessions.create({ dir: '/work' })
computer.on('message.part.updated', (e) => render(e))
computer.on('permission.asked', async (e) => {
  if (await askThePerson(e)) await computer.approvals.answer(e.properties.id, 'once', { session: e.properties.sessionID })
})
await computer.sessions.prompt(s.id, 'Add a test for the parser', { model: 'trustedrouter/deepseek/deepseek-v4-flash' })
await computer.close()                                         // a last save; its hours stop counting

The account must be on (wb.backup.enable or restore, Accounts) and the person signed in (People).

Make one

wb.computers.create({ name?, keys?, egress?, browsePrivate? }) adds a computer to the person's account and returns { id, name, cloud: true, version, egress }. Nothing starts until you open it.

  • Model keys come from the person's account, capped, unless keys brings their own ({ trustedRouter, tinfoil?, github? }; keys_refused if a key does not work).
  • What it may reach (egress): { mode: 'none' } (the default — only the models and the relay), 'trusted' (package registries and GitHub, a list that ships in the image and is measured with it), or 'custom' with hosts. There is no unrestricted mode.
  • Private browsing (browsePrivate: true): the agent has its own web browser on the computer; with this on, the browser forgets cookies, logins and history every time the computer opens or closes, so nothing it signed into outlives the session. Off, the browser keeps them between openings, as a person's own browser would.

wb.computers.list() gives the account's computers, newest first — never a secret. wb.computers.remove(id) closes it, cancels its account keys and removes it from the account.

Open it

wb.computers.open(id, { onStep?, images?, onNewImage? }) starts or wakes the computer, checks it, hands it the person's keys sealed to it, and connects. onStep hears requesting, starting, verifying, opening, connecting — show them; a cold start takes a while.

Opening checks the computer before anything is sent. The enclave and its key guard must run images Witbitz has published (/cloud-claim.json), proven by the enclave's attestation. When Witbitz publishes a new image, images: 'auto' (the default) accepts it; 'ask' calls onNewImage(pcr0, role) — return true to accept. An image Witbitz has not published is refused (attestation); the person saying no is declined.

computer.close() makes a last save and stops it; its hours stop counting.

Sessions, approvals and files

An open computer (id, name, status) is the agent's side of the computer:

  • Sessions — computer.sessions.list / create({ dir?, title? }) / messages(id) / prompt(id, text, { model?, agent? }) / abort / rename / remove / todo / status. model is 'provider/model'.
  • Approvals — the agent asks before it runs a command or edits a file, as its mode says. computer.approvals.pending() and .answer(id, 'once' | 'always' | 'reject', { session?, message? }); a reject with a message tells the model what to do instead ("stop and ask me"). An older connector that does not take the message refuses it; when you pass the session, the SDK then sends the refusal without it.
  • Questions — computer.questions.pending() / answer(id, answers) / dismiss(id).
  • Files — computer.files.list(path?) / changes() / diff().
  • The agent's browser — computer.browser.status(): the computer's own report of its browser, null until the agent opens one. The person watches it and takes the controls in the Witbitz app's Code section.
  • Events — computer.on(type, fn) with OpenCode's event types (message.part.updated, permission.asked, session.idle, or '*'); it returns the function that stops listening.

What your page can reach is limited. Every call goes through the computer's connector over a sealed relay, and the connector serves only these routes — sessions, approvals, questions, files and diffs. There is no shell route: your page cannot run a command on the computer except by asking the agent, under the person's approvals.

Who pays

The person does. A computer's hours are charged to the person's own AWS key, minted at its first opening with its private half kept in their sealed account — about 23 credits an hour against a monthly allowance. wb.computers.usage() reads this month (in dollars: show credits, 100 credits = $1), or null before the first opening. Over the allowance, open rejects with aws_limit and resetsAt.

Where the keys live

With the person. The workspace's key half, the relay secret and the model keys sit in their sealed account and, while your page works with the computer, in its memory — as room keys do without a vault. A computer made in your app is listed in your app's account only.

Vault mode (the keys stay in the vault, Where the keys live) is not available for cloud computers yet: every call rejects with vault_mode.

Errors carry code: not_enabled, sign_in_required, not_found, keys_refused, aws_limit, attestation, declined, vault_mode.

The Witbitz Spaces app's Code section is built on cloud computers.


Embed a Space — one line, still sealed

Drop a Space into any page with one script tag. It runs in its own origin, in an iframe: your page and the scripts on it cannot reach into the frame to read its state or the messages it decrypts. Your page and the Space talk over a narrow bridge, and your page's functions can become the assistant's tools.

See it: witbitz.chat/demo — ask the embedded assistant to restyle the page, and it calls the page's own set_site_color tool.

Before you start. Embedding is off by default. The Spaces viewer must allow your site's origin — today, ask us at hello@witbitz.chat to add it (who may embed). Once it does, embedding a room is this markup:

html
<script src="https://witbitz.chat/embed.js"></script>
<witbitz-space link="https://witbitz.chat/space#<your link>"></witbitz-space>

Or from script:

javascript
const space = Witbitz.embed('#slot', { link })
space.on('message', (m) => …)   // someone posted (metadata only)
space.on('turn', (t) => …)      // an assistant turn finished
space.signIn(token)             // a room that needs sign-in: admit the user

Witbitz.embed(target, opts) returns a controller: on(type, cb) / off, signIn(token), navigate(to), setTheme(theme), destroy(), and iframe. <witbitz-space> exposes the same controller as el.space.


What the iframe protects, and what it doesn't

The Space runs in a cross-origin iframe, not in your page's DOM. If it ran inside your page, your page and its trackers would become part of what has to be trusted; in its own origin, the verified client is out of their reach. The browser keeps your page out of the frame's memory, storage and DOM, so nothing on your page can read the messages the frame decrypts.

It does not hide the room from a page that holds its link. A room's link carries its key. A page that passes the full link to the embed already knows that key, so the iframe protects the room from your page's other scripts — and keeps Witbitz unable to read it — but your app itself is not blind to that room. A room your app should not be able to read must never pass through your page as a full link: let the person open it from their own invite, or keep the keys out of your page entirely with vault mode.

The bridge

The only channel across the boundary is postMessage, carrying a small JSON-RPC protocol. It is a narrow set of messages, not shared memory:

Direction Message Meaning
Space → host ready the bridge is open
Space → host message someone posted — metadata only ({id, self, sys}), never the content
Space → host turn an assistant turn finished
Space → host member a member event
Space → host resize the content's height changed (the iframe sizes itself)
host → Space signIn a sign-in token from your identity provider, or a signed grant
host → Space navigate / theme deep-link or theme, within limits

Both sides check the other's origin, nothing is posted to '*', and the room key never crosses the bridge: it rides in the iframe's URL fragment, which never reaches a server. A sign-in token is checked against the room's sealed list.

Who may embed

Embedding is off by default, and two things decide whether a page may embed a room:

  • The viewer's list — for the whole app. The browser checks the viewer's frame-ancestors before any script runs, when the room is not yet known, so it lists the origins that may embed any of the app's rooms; the room page opens its bridge only to an origin on the same list. On the hosted viewer, Witbitz keeps that list — ask us to add your origin. A page on one of your tenant's declared origins can already frame a room whose link names your tenant (?tid=t_…), but the bridge — events, sign-in, theme — opens only for a listed origin.
  • The room's own policy — for an owner-governed room. A room whose owner signed a policy can narrow the list with embedOrigins: the client reads the sealed policy with the room key and closes the bridge if the embedding page is not on it, and the server never sees it. Owner-governed rooms are Implemented, not yet enabled in production; an ordinary room has no per-room step.

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:

javascript
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.
  • 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, 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

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.

javascript
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.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 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.

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:

javascript
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:

javascript
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 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

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).

javascript
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. See Connections.
  • 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.
  • 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

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.

javascript
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-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 <label>__<tool>, and is a card the member approves unless it is in auto. With no tools or auto, grant offers the server's recommended tools and lets the ones the server marks read-only run on their own — readOnly is the server's own claim, which Witbitz does not verify, so name auto yourself where it matters. An auto call 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.attachMcp files yours, for when the SDK uses it.
  • In a vault, the same calls run through the vault and the callKey stays 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' calls sentinel.onNewBuild(pcr0) and accepts only on true; otherwise the call fails with declined and 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's SENTINEL_HOP_PCRS); its own pins are never followed. loginBase points 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.

javascript
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; elsewhere names other rooms holding drops.
  • Keep the state. wb.siri.state holds the Shortcut key and each room's inbox key pair. Pass it back as new Witbitz({ session, siri: st }), or wb.siri.restore(state) on a running client. wb.siri.shortcut is { key, endpoint }, what the Shortcut posts with (key is null until 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) and meta = { id, room, alias, at }, where alias is the word actually said and at is when Siri took it, not when you collected. To put it in the room as well, call space.send() from your deliver.
  • At least once, not exactly once. A deliver that 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 on meta.id, which is stable for the life of the drop.

Where the keys live — the confidential tier, split keys and vault mode

By default a room's key is made in your page and kept there — in its link, and in whatever you save. Three choices change that: the confidential tier seals the key to a verified enclave for the room's turns, a split-key room leaves half of the key with your own server, and vault mode keeps every key out of your page.

Part of the SDK · every call: the reference.


The confidential tier

A confidential room's turns run inside an attested AWS Nitro enclave, and the room key is sealed to that enclave rather than handed to Witbitz's server:

javascript
const space = await wb.createSpace({ agent: { name: 'Vault' }, tier: 'enclave' })
  • What the create does. It checks the live enclave's attestation first, makes the room, waits for the enclave, and gives it the room's sealed settings before any turn may run — so the first message already reaches the assistant you asked for. It takes longer than a plain create: it is waiting on real hardware.
  • The build is checked, not advisory. If the enclave that answers is not a build Witbitz publishes, the SDK refuses to seal the room key to it and throws enclave_not_accepted rather than falling back. Treat that as "send nothing", not as a passing error.
  • The published build is read from the viewer's origin (/enclave-claim.json); enclave_claim_unavailable means it could not be read, and a viewer of your own must serve it (or set enclaveClaim).
  • The tier is chosen when a room is made; an existing room cannot be moved into it. How the tier works: the confidential tier.

What a room can prove

  • await space.certificates() → { verified, failed, unchecked, before, builds, calls, tools }. In a confidential room every reply is signed inside the enclave with the build's measurement, each model call's verified receipt and each tool call. This checks the history loaded on the device: the attestation, the published audit verdict, each signature, and each reply's text. failed lists { id, reason }; unchecked counts replies whose attestation rides an earlier reply this handle has not loaded, which is not a failure. A standard room has none.
  • await space.sealedProof(), in any room: what the server actually stores for it — the opaque, sealed history, its size and hash, and who can open it (never the operator). null before the first message. The Confidential Room example shows it live.

Checking an enclave with a standard token (EAT)

Witbitz's enclaves can prove themselves with an Entity Attestation Token (RFC 9711): a JWT signed by a key the enclave made, carrying the AWS Nitro attestation that binds that key to the enclave's measured build, for a nonce you choose.

javascript
import { fetchAndVerifyEat } from 'https://witbitz-spaces.pages.dev/sdk/3.10.0/witbitz-sdk.js'
const r = await fetchAndVerifyEat('https://agents.witbitz.chat/eat', { expectedPCRs: { 0: [/* the build you trust */] } })
// r.ok, r.claims (eat_profile, swname, …), r.doc.pcrs, r.userData.siri: the door's TLS key pin
  • A token you already hold — one a peer gave you: await verifyEat(token, { expectedPCRs, nonce }) runs the same checks without the fetch. EAT_PROFILE is the profile the token must name.
  • Where tokens appear. Every A2A lane's Agent Card names this endpoint (capabilities.extensions, uri https://witbitz.chat/eat/nitro-v1), so an outside agent can check it is talking to the measured enclave rather than take our word. A confidential reply's turn certificate is itself an EAT (certToEat), and the audit's verdicts are published as attestation results at /audit/<subject>.results.json.
  • Without the SDK: node tools/eat-verify.mjs https://agents.witbitz.chat.
  • The profile: an ES256 JWT; measurements: [[18, <the Nitro document, base64url>]] (COSE_Sign1); the document's nonce is eat_nonce, and its public_key signed the token; user_data carries the door's TLS key pin.

Split-key rooms

javascript
const wb = new Witbitz({ appShare: '/api/appshare' })   // your function, on your origin
const room = await wb.createSpace({ key: 'split', admission: { emails: [...] } })
room.shareLink   // …#us=<half a key>&spc=<commitment>: the link alone does not open the room

A split room's key is two halves. The link carries one; your function holds the other. The function seals its half under a key only you have (K_eco) before Witbitz stores it, and unseals it only for a page on your own origin. openSpace(link) fetches that half, combines the two, and checks them against the commitment in the link before using the key.

  • Witbitz stores a half it cannot open: it never sees K_eco.
  • A leaked link pasted into another website opens nothing, because a browser cannot fake its origin. A script can, so for an open room this is a policy gate; a gated room still needs an allowed sign-in on every operation.
  • Deleting the room ends every link at once: the stored half goes with it.
  • It does not hide the room from your own page, which puts the key together to use the room.
  • The account backup keeps the whole key for split rooms, so a restore never depends on the stored half. A split room's lanes are split too, through your function.
  • Limits: standard tier only for now (tier_mismatch otherwise). It is chosen per room; rooms without key: 'split' are unaffected.

Your function. Copy the template — appshare.js (a plain Request → Response handler), e2ee.js beside it, and the wrapper for your host (cloudflare-pages.js or node-express.js) — and give it two secrets:

Secret What it is
WITBITZ_TENANT_KEY your tenant secret key (the one your server passes as tenantKey): it registers the room and reads back the sealed half
WITBITZ_ECO_KEY K_eco: 32 random bytes, base64 (node -e "console.log(require('crypto').randomBytes(32).toString('base64'))")
  • Back up K_eco. If it is lost, a split room opens only from a member's account backup.
  • The function answers its own origin only, with no CORS header; rate-limit the route in your host. It answers { op: 'create', room, commit, appShare, … }, which seals the half and registers the room, and { op: 'get', room } → { appShare, commit }, which the SDK sends on open.
  • Errors reach your code by name: cross_origin, no_appshare, appshare_unconfigured, unwrap_failed, and split_mismatch when the halves do not match the commitment. On the page, openSpace throws origin_not_declared, split_unopenable or split_mismatch.

If you cannot host a function, leave appShare out and name your tenant (new Witbitz({ tenant })). Witbitz then holds K_eco for your tenant — ask us to turn it on — and serves the half to your declared origins. Everything above holds except the first point: Witbitz could open the half. With neither, the create is refused (tenant_required, split_unavailable or seal_unconfigured) rather than making a room that cannot open.

Vault mode: no key in your page

In vault mode the room keys live in a vault: a small site you host on a second origin of your own (say vault.acme.com), which shows your app (app.acme.com) inside it. Your app uses the SDK as usual and never holds a key.

javascript
// your app page, served on app.acme.com and opened inside the vault
import { Witbitz } from 'https://witbitz-spaces.pages.dev/sdk/3.10.0/witbitz-sdk.js'

const wb = await Witbitz.inVault({ vault: 'https://vault.acme.com' })   // not_in_vault when not framed by it
const space = await wb.createSpace({ agent: { name: 'Assistant' } })    // the key is made in the vault and stays there
space.on('message', (m) => show(m))
await space.send('Hello')

await wb.vault.share(space)            // the vault shows the invite link, which carries the key
const rooms = await wb.vault.rooms()   // [{ room, title, at }]: no links
const again = await wb.vault.openRoom(rooms[0].room)
await wb.vault.signIn()                // the vault's sign-in screen; your page learns the email, never the token

wb.vault is a VaultSession: the vault's origin, what it said when the page connected (info), and these calls — also whoami(), signOut(), forget(space), showSiriKey() and on('rooms' | 'joined' | 'signedIn' | 'signedOut' | 'account', cb).

Building the vault

The vault is a kit you build once and host yourself; Witbitz hosts nothing for it:

bash
curl -O https://witbitz-spaces.pages.dev/sdk/3.10.0/vault-kit.mjs     # Node 18+, no packages
node vault-kit.mjs ./vault --page https://app.acme.com/chat --vault https://vault.acme.com --tenant t_… --brand Acme
  • It is checked. The kit builds from the release it came with and checks every file against that release's manifest.json. To check the manifest itself, pass its published hash, --manifest-sha <sha384-…> (the kit prints the one it read):
Release manifest.json
3.10.0 sha384-yZ7C+jV2zKD4bVPDJuiYJi7CSEqXjCHiuLTp+Mri6WsXl4rrhYDKLqMbBb6r7Sa5
3.9.0 sha384-O54x1kV/gFYQMcY+R1rB4mhzFfc362yuPJl6YO6xlNPDIrWIoOoIF+xBXi0Tubzh
3.8.1 sha384-BOgk3f2wDJetxUQoNcTgb1T+nQzm0TKQ39nbgJch5GSCBUAeGjXhelrQ1tCG8UW0
3.8.0 sha384-3UnAqfkT/reUouZuZ16dTHQs0IP1frHlxW+DfcYmScyrNhxMND7K4PHDYXG5gW0S
3.7.0 sha384-ErXcFYkF4PLKU9pl57Vj+S8GThFSSg93ibg1NaGjmMcihb1vQMpIbf0KCuMV8rdT
3.6.1 sha384-6cvolS5S4MYFbdvvz2Lh5em6wqAWoEO8rs1nnaTY5qF0IzNs+02T6rF5ZKg+9Tye
3.6.0 sha384-TInR2vsZxqAfsnx/sagRDrxbA1UVadEfVnGQ41F4kNuQOjw6oRLMhfNNtN5rvpjf
3.5.0 sha384-IaPuVH2xvt7SR3PaeYWnWvgPK/cPxCF30lqksLNnXElYtACtz4RHOTo+BCLRvjKX
3.4.0 sha384-nkO/egzP6J9E2WK35h0b+WBqB8KdprT8Kb5431tunYmnKcJK/6qk+0gySas9Y1D/
3.3.0 sha384-nFk1GhoYEumCTysMDHAwO5m+wO0uoGykpMSAg0TegXjUFiheqh0BHw6TWTqcr/2+
3.2.0 sha384-czJHGURTz8He5lvVQuPB4ejnl0B29zMyRCFMSZIGWPeEz+ppQMpIrGBQ/22hWdmC
3.1.0 sha384-UxVCdP2RxjJulee/A7DPESm5nLy7jKgedy2/CWTWbOT4Fo1td33THy2e0ndwlqa2
3.0.0 sha384-uvG9uEylZQwDNynITog8HmsiI6kSEMa2zd5xx7p9qIsalUK8Y49riKLDATEagHqR
2.2.4 sha384-kX9P3Te3vMdh0UF6pvM4/0GiYcfR+ot8EBfWzLaLapPPrm1W3vnYbXrPsJqj8SnS
2.2.3 sha384-qC77F7CYYpYb5+nQkeGpruc0myKyPP9GYLe+IyMrjDu2qQQsnch9ef9a+ALTdHxX
2.2.2 sha384-tNPte4rW41HFwG9r/UoP1YKz1HKNrNLE2apSdKtbs8M90q+ZJkiHoT7FVgnnJRe0
2.2.1 sha384-jnKIjgueEQsAfvA8xsEqaXhE2HJnR/lOrrjOUmQmJr4eJQM3gHT+qzX0TfrcpVy4
2.2.0 sha384-DZL9h9OKKg/UKsmpajI7XPuUwF2FOqPq1Ir6othFmMKDLt5qlt++zdebDV+lp0yV
2.1.2 sha384-yJcmJ7MH5JBasfLeu0QXTUjkO7WvBOAfpblhXnWyhPeMQXgnf33VOHtFrdXeeiQO
2.1.1 sha384-J++TLx7Yhl2bxeLklJGsrHBTB74kk3/k3mUfUQYVs1fESrdGDal0wx5Gcry5irld
2.1.0 sha384-rYHqCNRtVUN5lh2DXbvPbuwYYsuUMKyRlpOYm1fuiPhkgOKJ9W69evU9Kl2tD++p
  • What is yours, and what is not. The folder holds vault.js (your settings) and screens.js (sign-in, share and "didn't load" — restyle them or write your own; each is a plain function that resolves when the person is done), plus core/, everything that touches a key. Do not edit core/: a new run of the kit replaces only that folder, so your screens survive an upgrade.
  • Serving it. HEADERS.md lists the headers to serve it with (nginx, Caddy, Apache) — among them Cross-Origin-Opener-Policy: same-origin, so a page that opens your vault in a window gets no handle on it — and your app page must allow the vault to frame it (frame-ancestors https://vault.acme.com). Declare both origins on your tenant (admin.origins): the vault makes the requests, so the server sees the vault's origin, and a room it creates is your tenant's.
  • Invite links point at the vault (https://vault.acme.com/space?room=…#mk=…). The vault keeps the link, takes the key out of the address bar, and tells your page (wb.vault.on('joined', ({ room }) => …)).

What it protects, and what it doesn't

  • Your page cannot read a key or keep one for later. The keys are in a worker on the vault's origin, and the browser keeps one origin out of another's memory and storage. A bug in your page, a compromised package or an injected script gets no key, no invite link and no sign-in token. Once a room is removed from the vault, the page cannot reach it. Signing out ends the page's access to gated rooms; an open room stays usable on that device until it is removed.
  • What only the person can do, in the vault's own screens: remove a room (wb.vault.forget), sign out, join a room from an invite link, change a room's members or rotate its key, remove a room from the account, take or release a username, remove a connection, let a room's assistant use one, and make a new Siri key or turn the Siri inbox off. Your page can ask; the vault asks the person. The page learns the signed-in email (whoami, the signedIn event), and nothing else about the sign-in.
  • Your page sees the conversation it shows. Vault mode keeps keys out, not content.
  • You host the vault too, so it protects keys from your page's code, not from you.
  • The settings the page reads carry no connection key or funding grant, and the page's settings writes cannot grant either: those go through the vault's own calls, after the person says yes.
  • The vault answers only what an app needs: the room operations of a room it holds (read, send, rows, settings, approvals, reactions); creating a room (only the fields createSpace sends, at most 20 in 10 minutes); the room list; sharing; sign-in. A raw key operation, a destructive one, a key or identity passed in, or a room it does not hold is refused (vault_refused, not_in_vault, vault_unsupported, rate_limited). It answers only the page it framed, on its own origin, and the page cannot navigate the vault's window away (the frame is sandboxed without top navigation).

The SDK inside a vault

Everything the SDK offers works in a vault, except cloud computers. What differs:

In a vault
Links openSpace refuses (vault_opens_links) — the vault opens links. wb.vault.openRoom(room) takes a room id from wb.vault.rooms(); a room the vault does not hold is not_in_vault. openLane takes the lane's room id, not a link; wb.vault.rooms() marks a lane with lane: <its room>.
Sign-in wb.signIn refuses (vault_signs_in); use wb.vault.signIn().
Voice wb.dictation(), wb.speak() and wb.speech get their short-lived speech key from the vault (its sign-in, or your wb.useUserToken()).
Live updates Replies stream as they are written, as outside a vault: the socket is the vault's, since it needs the key, and the page gets its events.
Lanes space.privateLane() works as outside: the vault makes the lane, keeps its key and records it as that room's lane, and the lane reads and posts into the room through the vault. setLinks, link and sealedProof() work too.
Confidential rooms createSpace({ tier: 'enclave' }) works: the vault checks the enclave against Witbitz's published build and seals the key to it — only to a build Witbitz publishes. A lane of a confidential room is confidential; wb.vault.rooms() marks one tier: 'enclave'. Serve the vault with connect-src allowing the claim's origin (the kit's headers do).
Split-key rooms createSpace({ key: 'split' }) works when your vault serves your app-half function at /api/appshare on its own origin and is built with --app-share (or startVault({ appShare: true })); otherwise vault_unsupported. The vault keeps only the person's half in its link and asks the function for the other.
Direct chats createSpace({ direct: email }) works, with the vault's signed-in member as owner.
Members changeMembers and rotateKey go through the vault, which asks the person (members).
Backup and account wb.backup, wb.account and wb.erasure are the vault's: it records every room it holds, keeps the account's list in step across devices, and files the erase list. The recovery code is shown and typed only in the vault's screens: wb.backup.enable() resolves { enabled: true } with no code, and restore() opens the vault's restore screen and resolves { rooms }. The getters are the vault's last view, never a link or a key: state is null, keyFor() refuses, add() does nothing. One backup per account — another person signing in on the same vault sees none of it. On wb.account, archive, rename, forget and signOut return promises, and forget and signOut ask the person first; wb.backup.remove(room) returns a promise too (the vault asks the person; a no rejects with cancelled).
Connections Connections, @usernames, the Siri inbox and outside agents work through the vault's sign-in, a lane's too. wb.connections.attach() returns { id, label, host, account }, plus mcp, webhook and job when the connection has them — the callKey stays in the vault — and space.connections.grant(conn) grants by id. MCP servers (startMcp, attachMcp, finishMcp, mcpTools) run through the vault the same way; startMcp's sign-in comes back to your page's /mcp-callback unless you pass redirectUri.
Siri wb.siri.state is null and restore() refuses: the vault keeps the inbox's private keys. wb.siri.shortcut.key is null, because that key posts words into the person's rooms; the vault shows it in its own screen, wb.vault.showSiriKey(), and rotateKey() resolves null. space.siri.collect({ deliver }) hands each drop to your deliver.
Outside agents wb.a2a.state is null (the vault keeps the lanes' inbox keys); lane.a2a.cardUrl, .mcpUrl and .peers are what the vault last said; addPeer returns { id, token, cardUrl, mcpUrl } as outside a vault. The vault asks the person before an outside agent may reach a lane, and before its answers may go without them.
Moves An older client's move is followed by the vault, and the page gets 'moved' with { room, link: null }; a confidential room's move is refused (vault_unsupported), and one whose new room cannot be read yet gives 'moveRefused' with code: 'unreadable' and nothing is followed.
Cloud computers Not in a vault yet: every wb.computers call rejects with vault_mode, because the computer's keys would have to sit in the page.

Your server — WitbitzAdmin

What belongs to your tenant rather than to a room — its keys, the origins your pages run on, your users and what they cost, deleting rooms — is done from your server with WitbitzAdmin, never from a page.

Part of the SDK · every call: the reference.


Set it up

Most of the SDK is a member's work and needs no key: a room proves itself by its key, so the room endpoint asks for nothing else. A few calls are the builder's — your OAuth apps, the hosts your users may attach a key for, the origins your pages run on, deleting rooms, billing — and those belong to your tenant, proved by your tenant secret key (wsk_…). They live in their own module, for your server: a page holding the key has given it away, and a page that imports only witbitz-sdk.js cannot hold it by accident.

Download it once, as one file, beside your server's code (Node cannot import from a URL):

shell
curl -O https://witbitz-spaces.pages.dev/sdk/3.10.0/witbitz-admin.mjs   # a released version; Node 18+, no packages
javascript
import { WitbitzAdmin } from './witbitz-admin.mjs'
const admin = new WitbitzAdmin({ tenant: 't_…', tenantKey: process.env.WITBITZ_TENANT_KEY })
const { ok, tenant, reason } = await admin.verifyTenantKey()   // is this key good, and whose is it?
  • Two endpoints. WitbitzAdmin sends its calls to the authenticated endpoint (/v1/tenant-space) and the page SDK sends member calls to the keyless one (/v1/space). The first is derived from the second, so a staging or self-hosted deployment needs no configuration; new WitbitzAdmin({ endpoint, tenantEndpoint }) overrides both.
  • 403 tenant_required? Read the reason. It tells apart two faults that look the same from outside: a request that reached the keyless endpoint, where your key was never examined — a routing mistake, not a bad secret — and one that authenticated but carried a credential naming no tenant (a session or a publishable key instead of a wsk_). verifyTenantKey() settles which you have.
  • Older names. The page client's builder calls (wb.origins, wb.users, wb.billing, …, with tenantKey on new Witbitz) still reach the same calls; a page that passes tenantKey gets a console warning. admin.customers is what wb.users was: your app's billed users, not Witbitz accounts.

Restricted keys

Your secret key can do everything, including deleting every room and the tenant itself. A server that needs only part of that — an account server that creates customers and mints their tokens — should hold a restricted key (wrk_…) with only the capabilities you choose. Mint one with your secret key:

javascript
const { key, keyId } = await admin.keys.create({
  type: 'restricted', label: 'account server',
  scopes: ['customers:write', 'customers:tokens'],
  maxCustomerCapUsd: 50,     // optional: the highest monthly cap this key may give a customer
  expiresAt: '2027-10-01',   // optional: refused from then on; at most two years ahead
})
const accounts = new WitbitzAdmin({ tenant: 't_…', tenantKey: key })   // the same client, fewer powers

key is shown once: store it as that server's secret, and keep the wsk_ key offline.

Capability What it opens
customers:read admin.customers.list()
customers:write customers.create, customers.update, customers.revoke
customers:tokens customers.token(userId) — a customer's turn token
customers:erase customers.erase({ email }) — deletes the rooms that person created
rooms:read · rooms:write · rooms:delete your room counts · refreshing and restoring rooms · deleting one room
settings:read · settings:write your tenant record · the webhook URL, requireUserToken, visitorLimit, your app's name, provider keys
origins:write admin.origins.declare — which pages act as your app
integrations:write admin.providers and admin.hosts
billing:read · wallets:grant admin.billing · granting credit
apps:release releasing your app through /v1/apps (witbitz app release): reserve, submit and read a release — not claiming a new app id
  • Never grantable: managing keys (minting, listing, revoking), deleting the tenant, deleting every room.
  • A restricted key cannot make itself or another key more powerful. To change what a key may do, mint another and revoke the old one (admin.keys.revoke(keyId)); admin.keys.list() lists them.
  • Any key may call verifyTenantKey(). A call the key cannot make answers 403, naming the capability it lacks. A cap above the key's ceiling answers 403 cap_above_ceiling, and a customer created with no cap gets the default lowered to the ceiling.

Signing your tenant's requests

Your tenant key proves who you are to Witbitz's gateway, and the Sentinel — the enclave that holds your OAuth apps' secrets, your users' connections and their erase lists — would otherwise take the gateway's word for it. Give your server a signing key and the Sentinel checks you itself: every request your server sends it is signed, and once the key is registered, an unsigned or altered request for your tenant is refused, whoever sends it, Witbitz's own servers included.

javascript
const { privateKey, publicKey } = await WitbitzAdmin.generateSigningKey()   // once; keep privateKey with your tenant key
const admin = new WitbitzAdmin({ tenant: 't_…', tenantKey: process.env.WITBITZ_TENANT_KEY, signingKey: privateKey })
await admin.signing.register()   // from now on, every Sentinel request of this tenant must be signed
  • From then on, pass signingKey wherever you make a WitbitzAdmin; nothing else changes.
  • A new key only with the current one. The first key is registered on your tenant key alone; a new one only by a request signed with the current key (new WitbitzAdmin({ …, signingKey: oldKey }).signing.register(newPublicKey)). So a stolen tenant key alone can neither act in the Sentinel nor swap your signing key.
  • Your pages, signed too. With a signing key, admin.origins.declare([...]) also files the same list, signed, with the Sentinel (or call admin.signing.publishOrigins()). From then on a page that names your tenant there — a person connecting through your OAuth apps, a device filing a person's erase list for you — must be on one of those origins. The Sentinel reads the page's origin from the person's own sign-in, so another app's page cannot spend your OAuth apps or file lists for you. If Witbitz's own app should be able to name your tenant, list one of its origins.
  • Watch the record. The Sentinel keeps your registered key in its sealed store on a Witbitz host. No one can forge or change that record, but someone with access to the host could delete it, and a tenant with no key registered is again taken on the gateway's word. await admin.signing.status() → { registered, key, origins }: alert if it ever reads registered: false after you registered.
  • Lost your signing key? Write to us: we remove the record, with your tenant key and proof of one of your origins, and you register a new one.

Your page origins

The origins your pages run on act for your tenant: a room created from one of them is yours, and your rooms can be read and embedded from them. A page on an origin you have not declared gets 403 tenant_required when it creates a room.

javascript
await admin.origins.declare([
  'https://app.example.com',
  'https://test.example.com',
  'https://old-box.example.ts.net',   // declaring replaces the list: keep every origin you still use
])   // → { origins, unproven }
await admin.origins.list()
  • Compared by exact string against the Origin header the browser sends, so the server stores that exact form: https://host or https://host:port. It lower-cases and drops a trailing slash for you, and refuses anything else — http://, a path, a wildcard, localhost, an IP — naming the entry, rather than store something that would admit nobody. At most 20. A change takes effect within about a minute.
  • Over HTTP it is PUT /v1/tenant/webhooks with { "origins": [...] } and your key; GET /v1/tenant reads it back.
  • Nothing else of ours is tied to your origin: sign-in codes, connections, admin.hosts, the Siri inbox and the API's CORS work from any origin. What moves with you is on your side: your own OAuth app's redirect URI and JavaScript origins (and the redirectUri you registered with admin.providers).

Prove the origin is yours

Declaring an origin is enough for your pages to work. For your app's name to appear in sign-in mails sent from it, the origin's host must say it belongs to your tenant, one of two ways:

  • a file at https://<your origin>/.well-known/witbitz-tenant.txt with a line holding your tenant id (t_…), or
  • a DNS TXT record _witbitz.<host> with the value witbitz-tenant=t_….

We check when you declare: declare answers each origin it could not prove in unproven, with the exact file or record to add — add it and declare the same list again. GET /v1/tenant lists the proven ones in origins_proven. Without this, anyone could declare a site that is not theirs and have Witbitz mail its users a code in that site's name.

Your app's name

A sign-in code mailed from one of your declared and proven origins comes from " via Witbitz", with the subject "Your code: 123456", and names the site that asked — so your users find it when they look for your app.

javascript
await admin.app.rename('Keepwarden')   // → { name: 'Keepwarden' }
await admin.app.name()                 // → 'Keepwarden'
  • 1–40 characters: letters, digits, spaces and . & ' + -. A name containing "Witbitz" is refused.
  • A rename reaches the mail within about 15 minutes.
  • A page on an origin you have not declared or proven, or a sign-in that names no tenant, gets the plain Witbitz mail. The page sends your tenant when you make its client with new Witbitz({ tenant }).

Hosts and OAuth apps

javascript
await admin.hosts.declare(['api.example.com'])   // the hosts your users may attach a key for; replaces the list
await admin.hosts.list()
await admin.providers.attach({ kind: 'google', clientId, clientSecret, redirectUri, scopes, label })   // → { id, kind, tenant }
await admin.providers.attachMcp({ issuer, clientId, clientSecret, redirectUris, label })
await admin.providers.list()
await admin.providers.revoke(id)   // → { revoked, connections }

The client secret of an OAuth app you attach is sealed to the Sentinel. Your users then connect through it from the page — connections. attachMcp files an OAuth app for an MCP server that does not let apps register themselves (GitHub, Asana, Box).

Billing, per user

Your rooms' model use is billed to you, in the provider's own numbers per key — never our token arithmetic. To see, cap and pass on what each of your users costs, give each one their own key: their turns then bill to it, and what they cost is what the provider reports for that key.

javascript
// on your server, once per user
const user = await admin.customers.create({ userRef: 'cust-42', capMonthlyUsd: 10 })   // → { userId: 'u_…', … }
// on your server, when that user signs in, and again before it expires
const { token, expiresAt } = await admin.customers.token(user.userId, { ttlSec: 900 })
javascript
// in their page
const wb = new Witbitz({ tenant: 't_…', userToken: token })
wb.useUserToken(async () => (await fetch('/api/witbitz-token')).text())   // or a function that fetches a fresh one
  • Each user key has a hard monthly cap (capMonthlyUsd, default 25). When it is spent, that user's turns are refused by the provider until the month resets; nobody else is affected. userRef is your label, shown to you in billing and never sent to the provider, which sees an opaque key name.
  • The token is signed by Witbitz and lasts ttlSec (60–3600 s, default 900). It goes only with operations that can spend — a turn, a title, approving an action, an import — so a page left open past its expiry can still read its rooms.
  • A token that does not hold is refused, never billed to you: 401 user_token_invalid (bad or expired — mint a new one), 402 user_key_unavailable (revoked). Without a token, a turn bills to your tenant key.
  • The person prompting pays, in any room. A token bills its user's key wherever that user prompts — your rooms, another tenant's, or a room with no tenant.
  • Managing them: admin.customers.list() (nothing secret in it); update(userId, { capMonthlyUsd: 40 }) for a plan change — the provider's limit moves first, and the user, their key and their history stay; revoke(userId) deletes the key, and spend up to that moment is still billed. A revoked user cannot be changed.
  • Requiring the token. Without one, a spending operation bills to your tenant key — so on a public page anyone could skip your per-user caps by sending none. Turn that off once, from your server: await admin.billing.requireUserToken(true). From then on a turn, title, approval or import in your rooms that arrives without a user token is refused (401 user_token_required) instead of billed to you; reads never need one. It takes effect within about a minute, and requireUserToken(false) undoes it. If Witbitz cannot read this setting at that moment and has no recent answer, a spending operation in your rooms is refused (503 billing_policy_unavailable) rather than billed to you on a guess — retry shortly.
  • Confidential rooms bill the same way. The token travels sealed to the attested enclave, which checks it and fetches that user's key, also sealed, so no key is usable outside the enclave.

People who are not signed in

A public page — a help desk, a demo, a support chat — has visitors with no account and so no user token. Their questions bill to your tenant key. To keep that bounded, set how many questions a day they may ask:

javascript
await admin.billing.visitorLimit({ perNetwork: 20, total: 2000 })   // null turns it off
  • What is counted: a question (a turn) in your rooms that carries no user token, when the assistant will answer it. A message the assistant does not answer (an on-demand room, not addressed to it) is not counted, and a question resent while the room is busy counts once. A question with a user token is that user's, under their own cap.
  • Per network, per UTC day: at most perNetwork questions from one network (an IPv4 address, or an IPv6 /64), and at most total from all of them. The address itself is never stored or sent: the Space's front door signs a daily hash of it, salted with your tenant, so it cannot be matched across apps.
  • Over a limit, the question is refused before anything runs or is stored: send() answers { ok: false, status: 429, error: 'visitor_limit', scope: 'network' | 'total', reason } — reason is a sentence to show the visitor. If the count cannot be had, the question is refused (503 visitor_count_unavailable), never answered for free; retry shortly.
  • Both tiers, standard and confidential: in a confidential room the count is asked for by the attested enclave, at the moment it knows the question bills to you.
  • Takes effect within about a minute. Over HTTP: PUT /v1/tenant/webhooks { "visitorLimit": { "perNetwork": 20, "total": 2000 } } with your secret key (or a restricted key with settings:write).

What you are billed, from your server — the same numbers as the tenant portal:

javascript
const b = await admin.billing.get()                    // { month, days, users, userDays, storage, rate, … }, in microdollars
const csv = await admin.billing.csv({ byUser: true })   // one line per user per day; without it, one line per day

month is your own key, live, plus your users' keys through yesterday; users lists each with its cap and this month's spend. Over HTTP: POST|GET /v1/tenant/users, PATCH /v1/tenant/users/{id} { "capMonthlyUsd": … }, DELETE /v1/tenant/users/{id}, POST /v1/tenant/users/{id}/token { "ttlSec": … }, PUT /v1/tenant/webhooks { "requireUserToken": true }, and GET /v1/billing (?format=csv, &by=user), each with your secret key.

Knowing who is signed in, on your server

Before your server mints a user token, it should know the caller really is the Witbitz account it claims. A signed-in page holds wb.session = { email, idToken, exp, sign }, and idToken is a standard JWT your server can verify on its own:

  • alg: RS256. The key is in the issuer's JWKS — for the SDK's default issuer, iss: "https://witbitz-spaces.pages.dev", at https://witbitz-spaces.pages.dev/api/email/jwks (select by kid; cache it, and fetch again on an unknown kid).
  • Check aud === "witbitz-email", exp in the future and email_verified === true; the account is email.
  • The token lasts 30 days, and every Witbitz app shares its audience, so a token alone proves the account but not that this page is presenting it. Make the page prove it holds the session's key: your server sends a fresh random challenge; the page signs it with wb.session.sign.priv (ECDSA P-256, SHA-256) and returns { idToken, spk: wb.session.sign.pub, sig }. Your server checks the signature against spk, and that the token's nonce equals standard base64 (with padding) of SHA-256 over the canonical JSON of spk exactly as the page sent it — keys sorted at every level, no spaces, such as {"crv":"P-256","kty":"EC","x":"…","y":"…"} — so the token is bound to that key. A copied token cannot pass without the key.

Deleting a room

When a person deletes their data, delete their rooms from your server. You choose how much to keep, and for how long:

javascript
await admin.rooms.delete('sp-…')                                     // retain: 'none' — everything, now
await admin.rooms.delete('sp-…', { retain: 'backups' })              // now, except old versions for 35 days
await admin.rooms.delete('sp-…', { retain: 'grace', graceDays: 30 }) // nothing yet; undo until the window ends
await admin.rooms.restore('sp-…')                                    // → { restored: true, room }
retain The room Its data Undo
'none' (default) gone at once every stored version deleted now: sealed messages, files, the confidential tier's copies none
'backups' gone at once current copies deleted now; old versions expire within 35 days only by Witbitz operators, by hand, until then
'grace' gone at once nothing deleted until the window ends (graceDays, 1–90, default 30), then purged as after says ('none' by default, or 'backups') admin.rooms.restore(room) until the window ends

"Gone at once" holds in every mode: from the moment you call, the room answers 410 deleted to everyone, a member's open device included, and no one can make a room with the same id again. A member's space.send returns { status: 410, error: 'deleted' }.

  • Only for a room no one else uses. A shared room belongs to its other members too. Our server cannot tell a room with one person from a shared one, because membership is sealed, so that decision is yours. When a person leaves a shared room, forget it on their side instead.
  • What it answers: { deleted: true, room, retain, versions, bytes, turnWasRunning } (plus backupsUntil with 'backups'), or { deleted: false, held: true, room, purgeAt, after } with 'grace'. restore answers purged or grace_ended once it is too late.
  • turnWasRunning: true means a reply was being written at that moment and may land a few seconds later. Call delete again after a few minutes; it is idempotent and sweeps up whatever arrived.
  • It throws if anything could not be removed (partial). Call it again; it is never reported done when it is not.
  • A held room can be purged early: call delete again with 'none' or 'backups'.
  • What no option deletes on request: our database keeps a 35-day point-in-time recovery of room records, which hold no messages, only encrypted settings, and expire by themselves. The model providers that answered a room's turns keep their own logs under their own terms.
  • Over HTTP: DELETE /v1/tenant/rooms/{room} with your key and { "confirm": "<room>", "retain": …, "graceDays": …, "after": … }; POST /v1/tenant/rooms/{room}/restore to undo. DELETE /v1/tenant/rooms (with { "confirm": "<tenant id>" } and the same options) does it for every room you own.

Your rooms, and the idle rule

admin.rooms.list() returns your rooms a page at a time — to find a room whose link was lost, or to audit what you hold:

javascript
let cursor
do {
  const { rooms, next } = await admin.rooms.list({ cursor })   // rooms: [{ room, createdAt, lastUsedAt, kind, split, gated, held }]
  cursor = next
} while (cursor)

createdAt and lastUsedAt are ISO times or null. lastUsedAt is when a member last opened the room, wrote to it or prompted in it (recorded since 2026-10-07; your own rooms/touch does not count). held is { purgeAt } for a room waiting to be deleted. Rooms made before creation times were recorded show createdAt: null.

The idle rule deletes rooms nobody uses, on a schedule you set:

javascript
await admin.rooms.idleDeletion(180)    // rooms no member used for 180 days: held, then deleted
await admin.rooms.idleDeletion(null)   // off

Once a day, each room past the limit is put on a 30-day hold — it answers 410 at once, and admin.rooms.restore(room) brings it back until the hold ends — and is then deleted (retain: 'grace' above). The limit is 30 to 3,650 days. A room with no recorded use counts from 2026-10-07, so turning the rule on deletes nothing that day. At most 100 of your rooms are held a day. Like delete, it cannot know who else uses a room — but a room past the limit is one nobody has opened.

Erasing a user who has left

When someone deletes their account in your app, you want their rooms gone too. You cannot simply delete "their rooms" from your server: you have no list, membership is sealed, and a room they shared belongs to the others in it. Erasure works from a list the person's own devices filed while they used your app, so neither you nor Witbitz holds it before:

javascript
// on your server, with your tenant key — after the person asked to be erased
const r = await admin.customers.erase({ email: 'dana@example.com' })
// → { filed, rooms, deleted, shared, notTheirs, failed, cleared, incomplete? }
if (r.failed > 0 || r.incomplete) { /* run it again later; what was deleted stays deleted */ }
await admin.customers.revoke(userId)   // their billing key, if they had one
  • What is deleted: the rooms that person created in your app and nobody else used — every message, file and stored version, as admin.rooms.delete with retain: 'none'.
  • What stays: a room they created that someone else used is refused as shared, because what they wrote there is part of the others' conversation; a room they only joined is counted as notTheirs, because its creator decides.

How it holds. Three things in the page make it safe, and wb.erasure does all three for you:

  1. A deletion commitment. Every room the SDK creates records a hash of a delete secret, derived from the account's backup master secret and the room id — so only that account can produce it, on any of its devices. A person who joined holds the room key but not the secret, so cannot delete the room.
  2. A membership mark. Every room the SDK opens is marked with an opaque tag for the account. A room that carries anyone else's mark is refused as shared, even when its creator asks.
  3. The filed list. A few seconds after a room is created or opened, the account's list of rooms and delete secrets is sealed to Witbitz's attested enclave under your tenant and the person's email, each filing replacing the last. It is opened only when your server calls erase, and cleared once nothing failed.

What the page needs: new Witbitz({ tenant }), a signed-in person (wb.signIn) and an enabled wb.backup — the backup's master secret is what every device of the account derives the same delete secrets from, and the enclave keeps one list per account.

javascript
await wb.erasure.file()      // file now, e.g. right after a sign-in or a restore (it also runs by itself)
await wb.erasure.status()    // → { filed, rooms, updated }
wb.erasure.available         // true when this page can file: a tenant, a sign-in and a backup
  • filed: false, rooms: 0 from erase means no device of that person ever filed: nothing in the page met the three conditions above.
  • A room created before October 2026, or while no backup was on, has no commitment. erase cannot delete it; admin.rooms.delete(room) can, if you know the room is theirs alone.
  • To clear out rooms nobody opens, rather than one person's, see the idle rule.

SDK reference — every call, the configuration, the errors, the limits

The lookup page: every public call of the page SDK and the server SDK, each linked to the page that explains it, then the configuration, the error codes and the limits. What changed between versions: Changes.

Part of the SDK · every call: the reference.


The surface

Three calls get you started; everything else hangs off the Space they return. Types for the whole surface: witbitz-sdk.d.ts, and for your server witbitz-admin.d.ts (save them beside your code, or use them for editor hints). A test holds this page to the SDK's code both ways: every name here exists, and every public name the types declare is here.

new Witbitz({ endpoint?, viewer?, app?, tenant?, session?, userToken?, … }) A client bound to the Space endpoint. Make one, reuse it. Defaults to the public production endpoint. Creating rooms from your own page needs tenant — see Do I need a tenant?; every option is under Configuration.
await wb.createSpace({ agent, tools?, immediateTools?, model?, data?, admission?, tier?, key?, platformTools?, region?, framing?, dataWrites?, direct?, temporary?, safeMedia? }) Mint a Space and return a live Space. agent = { name, instructions, tools?, model? } (the name up to 80 characters, the instructions up to 4,000). data declares the store; admission gates it on email; tier: 'enclave' runs it in the confidential tier; key: 'split' makes a split-key room. tools, immediateTools, tier and key are fixed when the room is made; admission changes only through changeMembers(). platformTools, framing ('builder': your instructions and tools only) and dataWrites start the room's in-place settings; region picks where the room lives; safeMedia: true turns on Safe media. Any other key is refused.
await wb.openSpace(link, { resume?, tier? }) Join a Space from a share link — link is required; to open the page's own URL, pass location.href (see Invite links to tell whether it is one). The link's fragment carries the key; it never touches the server.

A Space:

space.shareLink The link to hand someone — the link is the admission capability.
await wb.signIn({ email }) → { ticket, email } then await wb.signIn({ email, code }) → session · wb.signOut() Sign in with an emailed code (any address, any origin). wb.session is JSON you may persist and pass back as new Witbitz({ session }). See Sign in.
wb.connections.attach({ label, host, secret }) · .list() · .detach(id, { spaces? }) The account's connections — a bearer token the standing Sentinel holds and spends; attach returns { id, label, host, callKey, account } (account: the signed-in address it belongs to) and the secret is gone from the page. A whsec_ secret with the webhook's whole https:// address as host makes a signed webhook: the Sentinel signs each approved request (Standard Webhooks) and only that one path can be called.
wb.connections.startConnect({ tenant, providerId }) · .finishConnect({ state, code, label?, host? }) Connect to a builder's OAuth app: startConnect → { authUrl, state } (send the person there), then pass the callback's state and code to finishConnect → a connection like attach's. The tokens stay in the enclave.
space.connections.grant(conn) · .revoke(id) What THIS Space's assistant may spend: it gets connection_call for a granted API-key or OAuth connection, always proposed; an MCP connection's tools are proposed too, except those in auto (connection calls).
space.siri.enable({ alias, makeDefault? }) → { key, endpoint, alias } · .collect({ deliver? }) · .disable() The Siri inbox for this Space. deliver(text, meta) takes each drop instead of posting it, for an app with its own inbox. wb.siri.state (JSON — it holds the inbox private keys) is yours to persist and hand back with wb.siri.restore(state); wb.siri.shortcut is { key, endpoint } for the Siri Shortcut; wb.siri.rotateKey() / .revoke() are account-wide.
space.room The server-visible room id.
space.safeMedia.copy(message, { at?, i? }) · .make(…) · .set(on) · .refresh() · .isOriginal(copy, b64) · .on · space.on('safeMedia' \| 'safeCopy', cb) Safe media: others' photos and PDFs shown only from copies an attested enclave made safe, verified on the device. createSpace({ safeMedia: true }) / configure({ safeMedia }) turn it on; send() makes the copies of what it sends — see Safe media.
space.temporary true for a temporary room (createSpace({ temporary })): wiped when idle, never in the account backup.
await space.run(input, { instructions?, schema?, images? }) → { ok, text, output?, usage?, error? } The assistant as a function: one answer, optionally structured, nothing written to the room — see The assistant as a function.
await space.admission() · await space.admits(email) Read this room's real gate from its sealed config — see Reading the gate.
await space.isAdmin(email) · await space.isOwner(email) Is this address one of the room's admins, or one of its owners — see Admins and Owners.
await wb.createSpace({ direct: email }) · await space.directPeer() A direct chat with one person, as in the Spaces app — see Direct chats.
await wb.createSpace({ key: 'split' }) A split-key room: its link is half a key; your own function holds the other half and serves it only to your origin — see Split-key rooms.
await space.changeMembers(admission) · await space.rotateKey() · space.on('rekeyed' \| 'removed' \| 'pending', cb) · wb.invite Change members in place (3.0.0): the same room and history, a new key only the members on the list receive — see Changing members in place.
space.on('moved' \| 'moveRefused', cb) · space.fork() A move an older client made is followed (a stamped notice sealed to this device); fork() is retired and throws use_change_members — members change in place. See A Space no longer moves.
await space.attachment(att) → { b64, mime, name? } The bytes of message.images[i] / message.files[i]. Attachments are stored out of line, sealed under the room key; this fetches and opens them on the device.
lane.a2a.enable({ name, description? }) · .addPeer(name, { reply?, job? }) · .setReply(id, mode) · .removePeer(id) · .collect() · .disable() · .cardUrl · .mcpUrl · .peers · on('a2a', …) Let outside agents reach this lane over A2A, with the member approving each answer — see Outside agents, over A2A.
aguiAgent({ AbstractAgent, Observable }, space) · aguiRun(space, input) · aguiHistory(space) The Space as an AG-UI 1.0 agent in your page — behind CopilotKit or any AG-UI front end; a proposal is an interrupt — see AG-UI
lane.link(otherLane, { to?, as?, cap?, resume? }) → a link: .a · .b (its two lanes) · .state · .unlink() Two of the member's lanes ask each other: each assistant gets tell_lane; persist state and pass it back as resume after a reload, unlink() ends it. See Assistants that ask each other.
lane.setLinks([space, { space, label }]) · lane.send(text, { links }) Let a lane's assistant read other rooms the member holds — one lane for several teams. See Private lanes.
space.on('message', cb, { replay? }) · space.messages cb(message) for every entry in the room — your messages, other members', the agent's replies, store writes and system lines; messages is the array so far. On an opened Space, the existing history replays here first; pass { replay: false } to hear only what arrives after it (notifications, analytics, side effects). Every on() returns a function that removes the listener (and still chains: .on(…).on(…)). self: true marks the agent's output, never yours — the full shape and how to tell senders apart: Messages.
await space.send(text, { from?, mode?, instructions?, images?, files?, stopKey? }) Post a message. stopKey is the turn's cancel key (8–64 letters and digits): space.turn() sets one so t.abort() can stop the reply — use that rather than passing your own. instructions (up to 8,000 characters) reaches the assistant for this reply only — today's date, "answer in Hebrew" — and is never stored. images (up to 4) and files (up to 2) are { b64 \| bytes, mime, name? }; the room stores them sealed, and anything over the limits is refused at the call. The agent decides whether to answer and does so on its own turn; the reply arrives via on('message'), not as a return value. mode overrides the reply mode for this turn ('auto' / 'always' / 'ondemand').
const t = space.turn(text, { timeoutMs?, …send's options }) · t.on('partial', cb) · t.text · t.result · await t.finished · t.abort() A turn you can hold. The same message as send(), plus a handle on its answer: finished resolves { status, reply, text, proposed, usage?, error? } with status one of 'done', 'silent' (the assistant chose not to answer), 'failed', 'aborted', 'timeout' — it never rejects. partial streams this reply; abort() stops it (what streamed so far is kept as the reply). timeoutMs defaults to 180 s and stops the turn when it passes. See A turn you can hold.
await space.send(text, { replyTo: message }) · await space.react(message, '👍') A reply quotes the message it answers, as in the Spaces app. A reaction is written on the message itself — no new entry, no assistant turn — and reaches every device as an 'update'; { off: true } takes yours off. In a verified room a reaction belongs to the signed-in key that made it.
await space.setReplyMode(mode) · await space.replyMode() The room's standing reply mode ('auto' / 'always' / 'ondemand', default 'auto') — mode on a send overrides it for one turn. It is the same setting as the Spaces app's reply chip, re-read before a turn once it is 20 s old. Every turn (and run) also carries the room's model pick from the app's model chip, as the app's own turns do.
await space.approve(id, { from? }) · await space.deny(id, { from? }) · await space.pending() Decide a pending card by id, the same as a proposal's own approve({ from? }) / deny({ from? }); pending() polls now and returns the waiting cards. approve(id, { recordResult: false }) is for a connection call whose answer you hand the assistant yourself: the room records the call (who approved, what, how it went) but not the answer. A refused decision throws (not_admin, not_authorized, …) and keeps the card. The full round trip: Proposals, end to end.
space.snapshot() / resume snapshot() returns a JSON-safe { count, etag, messages, rows }; hand it back as resume to openSpace(link, { resume }) (or the Space / Lane constructor) and the next open continues from there instead of replaying the whole ledger.
space.toJSON() · wb.toJSON() What logs, JSON.stringify and error reporters see: { room, shareLink: '[REDACTED]', messages, … } and { viewer, tenant?, signedIn, tenantKey?: '[REDACTED]' } — never a key or a link. Read space.shareLink itself when you mean to share it.
await space.sealedProof() What the server actually stores for this room — the opaque, sealed ledger, plus its size, hash, and who can decrypt it (the operator never can). Lets your app prove the operator can't read the store, not just claim it. null before the first message.
await space.privateLane({ agent, actions? }) Attach a private assistant lane for the current member — see Private lanes. → a Lane.
await wb.openLane(laneLink, { space, actions?, resume?, mirror? }) Reopen a member's existing lane on a later page load, from the link privateLane returned (lane.shareLink). space is the shared Space it belongs to (its crossings go there). A lane of a confidential Space opens as confidential. → a Lane.
space.on('partial', cb) The agent's reply as it is written: cb({ text, step }) — text is the reply so far ('' before the first words, with step saying what the agent is doing: { phase: 'thinking' \| 'writing' \| 'deciding' } or { tool }; 'deciding': an Auto-mode assistant is deciding whether to reply at all — it may end with no reply), and text: null once no reply is in progress. The finished reply still arrives as a message. See Live updates.
space.on('error', cb) Poll errors (non-fatal; the loop keeps going). After a refusal no retry cures (not_authorized with reason not_on_list, or gate_unreadable) it reads once a minute — see Errors.
await wb.usernames.claim(name, { publish?, name? }) · .release() · .mine() · .resolve(name) The signed-in member's @username. One name means one person across all of Witbitz, not per app: verified messages and rows carry it (_user), and the Spaces app can admit by it (the SDK's admission takes emails and domains). claim refuses a taken name (taken) or one released in the last 30 days (held); publish: true also lists the person in the public directory (off by default), under the display name when you pass one. resolve needs no sign-in and answers { username, exists }. A released name doesn't change what the member already wrote.
await space.certificates() → { verified, failed, unchecked, before, builds, calls, tools } Turn certificates in a confidential room: every assistant reply is signed inside the enclave with the build's measurement, each model call's verified receipt and each tool call. This verifies the loaded history on the device: the attestation, the published audit verdict, each signature, each reply's text. failed lists { id, reason }. unchecked counts replies whose attestation rides an earlier reply this instance has not loaded — not a failure. A regular room has none.
const mic = wb.dictation() · await mic.start({ onPartial?, onLevel? }) · await mic.stop() → text · mic.cancel() · mic.listening Press-to-talk: the microphone streams straight from the page to a confidential speech model; Witbitz never hears it. Needs a signed-in user or a user token — see Voice input.
await wb.speak(text) · wb.speech.play(text) · wb.speech.stop() · wb.speech.playing Read text aloud with the Spaces app's Play aloud player; the text goes straight to a confidential speech model. Needs a signed-in user or a user token — see Reading aloud.
wb.account.rooms · .devices · .links · .archive(room) · .rename(room, label) · .forget(room) · .signOut(id) · await .sync() · .enabled · .deviceId · .onChange · .onSignedOut · .lastError The account's live index: the rooms, archived and forgotten marks, and devices, kept in step on every device that holds the backup — see One account on every device.
wb.protect.available() · await .enable({ label?, withoutBackup? }) · await .unlock() · .lock() · await .disable() · await .save(name, value) · await .load(name) · .remove(name) · await .seal(v) · await .open(x) · .autoLock(minutes) · .on(event, cb) · .state Device storage protection: what the app keeps on the device, sealed under a key a platform passkey derives — see Protect what the app keeps on the device.
await wb.models({ tier? }) The models this deployment offers on a tier, each with the reasoning efforts it takes — see The model and its reasoning effort.
await space.configure({ agent: { name?, instructions?, model? }, data?, platformTools?, framing?, dataWrites?, safeMedia?, from? }) Change the assistant in place, for every member — see Changing the assistant. data declares or changes the store's collections; platformTools, framing, dataWrites and safeMedia as at createSpace; from is the name the change line shows.
await space.plan(change) → { how, history, call, steps, reason?, code? } What a change would require, before you make it — nothing is changed. change takes configure's, changeMembers' (admission) and createSpace's fields. how: 'in_place' (the assistant's name, instructions, model; the store; platformTools, framing, dataWrites — admins only where the room has them; a new member list in an email-gated room too, call: 'space.changeMembers(admission)'), 'new_room' (the tier, a split key, the tools — the history does not come along), or 'refused' with the reason and the error code that changeMembers() or configure() would give (use_change_members for a history: a Space no longer moves).
await wb.capabilities() Feature detection: { sdk, endpoint, features, limits, tiers: { standard, enclave }, models } — this SDK's version ('dev' on the live build) and what the deployment serves. Mostly for on-prem.
space.data · space.on('data', cb) The Space as a store: rows(collection), get(collection, id), put, delete, batch(ops) — see The room as a store.
await space.ready Resolves once the room's whole history has been read (a long room loads in pages; every page): by then messages and data.rows() hold what the room had, so it is the moment to swap a cached copy for the live room. A refused read (the confidential tier waking up, a gated room before sign-in) does not count; those retry and raise error. Rejects only when the key can never open the room (cannot_open) or the handle was closed first (closed). Every Space and Lane has it.
space.close() Stop streaming and release the handle.
space.title · space.on('title', cb) The room's name, the same on every device and in the Spaces app; null while it has none — see The room's name.
space.on('proposal' \| 'proposals' \| 'resolved', cb) A card waiting for approval, the whole waiting list, and a card decided — see Proposals, end to end.
lane.setActions(actions) · await lane.mirrorNow() The lane's standing actions, offered on every turn until changed; copy the shared room's new messages into a lane opened with mirror now, without waiting — see Private lanes.
wb.a2a.state · .restore(state) · .onState · .endpoint · await .status() · await .revoke() The account's door for outside agents: state holds its private keys — persist it and hand it back like the session — see Outside agents, over A2A.
wb.connections.startMcp({ url, label?, redirectUri? }) · .attachMcp({ url, label?, token? }) · .finishMcp({ state, code, label?, host? }) · .mcpTools(id) Connect an MCP server: startMcp says whether it needs a sign-in (attachMcp when not; else send the person to authUrl and finish with finishMcp); mcpTools lists what the connection offers — see MCP servers.
wb.computers.list() · .create({ name?, keys?, egress?, browsePrivate? }) · .open(id, { onStep?, images?, onNewImage? }) · .remove(id) · .usage() The person's cloud computers, kept sealed in this app's account: create takes model keys from the account unless keys brings the person's own; open checks the computer runs the published Witbitz image (a new published image: images: 'auto', or 'ask' → onNewImage(pcr0, role)) and returns a Computer; its hours charge the person's AWS key (usage). Not in vault mode yet (vault_mode) — see Cloud computers.
computer.sessions.list() · .create({ dir?, title? }) · .messages(id, { limit? }) · .prompt(id, text, { model?, agent? }) · .abort(id) · .rename(id, title) · .remove(id) · .todo(id) · .status() A cloud computer's coding sessions. computer.id, computer.name, computer.status.
computer.approvals.pending() · .answer(id, 'once' \| 'always' \| 'reject', { session?, message? }) · computer.questions.pending() · .answer(id, answers) · .dismiss(id) The agent's approvals and questions; a reject with a message tells the model to stop and ask.
computer.files.list(path?) · .changes() · .diff() · computer.browser.status() · computer.egress.get() · computer.on(type, fn) · computer.close() Its files and changes, the agent's browser and what the computer may reach, its live events ('*' for all), and close — a last save, then its hours stop.
wb.useSession(session) · await wb.refreshSession() · wb.useUserToken(token \| fn) Put a saved sign-in back, renew it before it expires (refresh_failed → sign in again), or let your server's customer token pay for this page's turns — see Sign in.
wb.backup.enable({ email? }) → { code } · .restore({ code, email?, … }) · .save() · .add(space) · .remove(room) · .keyFor(room) · .rooms · .state · .onState · .enabled · .format · .upgrade() · .invite · .setInvite(state) The account's encrypted backup and its recovery code; state holds the master secret — see Account backup and the recovery code.
wb.invite.state · .status · .restore(state) · await .enroll() The account's invite key, so others can add this person to a room by email — see Changing members in place.
wb.erasure.available · await .file({ force? }) · await .status() File this account's room list for your tenant, so admin.customers.erase can delete the person's rooms later — see Erasing a user.
await Witbitz.inVault({ vault, … }) · wb.vault → .rooms() · .openRoom(room) · .share(space) · .signIn() · .signOut() · .whoami() · .forget(space) · .showSiriKey() · .on(event, cb) · .origin · .info Vault mode: your page runs framed by a vault you host on another origin, which keeps every key; sign-in and sharing happen there — see Vault mode.
wb.verifyTenantKey() · wb.origins · wb.hosts · wb.providers · wb.rooms · wb.users · wb.billing Builder calls on the page client — deprecated: use WitbitzAdmin on your server.

What the module exports, beside Witbitz (also the default export), Space, Lane, VaultSession and Protect (the class of wb.protect):

SDK_VERSION This SDK's version — 'dev' on the live build, the release number under /sdk/<version>/.
LIMITS · EFFORTS The runtime's declaration limits, and the reasoning efforts a model may take.
checkActions(actions) · checkDataSchema(data) · checkModelEffort(model, catalog?) Check before you send: they throw limit_exceeded (or invalid_effort) for what the server would cut, drop or refuse. catalog is wb.models().
isLaneCopy(message) Is this a lane's copy of its shared room's line — the ones the Spaces app draws as "from the room".
aguiAgent · aguiRun · aguiHistory The Space as an AG-UI agent (above).
verifyEat(token, opts) · fetchAndVerifyEat(url, opts) · EAT_PROFILE Check an enclave's standard attestation token — see EAT.
WitbitzError (a type) What every SDK error is: an Error with a code from Error codes.

The server SDK — WitbitzAdmin

For your server, never a page: it takes your tenant's secret key (or a restricted key). new WitbitzAdmin({ tenant, tenantKey, signingKey?, endpoint? }), from witbitz-admin.mjs; types in witbitz-admin.d.ts. How each part works: Your server.

await admin.verifyTenantKey() → { ok, tenant } Is this key good, and whose is it?
WitbitzAdmin.generateSigningKey() · admin.signing.register(publicKey?) · .status() · .publishOrigins(origins?) Sign every request to the Sentinel with your own Ed25519 key — see Signing your tenant's requests.
admin.keys.create({ type, label?, scopes?, expiresAt?, maxCustomerCapUsd? }) · .list() · .revoke(keyId) Your API keys, and restricted keys with only the capabilities you choose — see Restricted keys.
admin.app.name() · .rename(name) Your app's name in the sign-in mail — see Your app's name.
admin.origins.declare(origins) · .list() The page origins that act for your tenant; declaring replaces the list — see Your page origins.
admin.hosts.declare(hosts) · .list() The hosts your users may attach a key for — see Hosts and OAuth apps.
admin.providers.attach({ clientId, clientSecret, redirectUri, scopes, … }) · .attachMcp({ issuer, clientId, … }) · .list() · .revoke(id) Your own OAuth apps, the secret sealed in the Sentinel — see Hosts and OAuth apps.
admin.rooms.delete(room, { retain?, graceDays?, after? }) · .restore(room) · .list({ cursor?, limit? }) · .idleDeletion(days) Delete, restore and list your rooms, and the idle rule — see Deleting a room and Your rooms.
admin.customers.create({ userRef, capMonthlyUsd? }) · .list() · .update(userId, { capMonthlyUsd }) · .revoke(userId) · .token(userId, { ttlSec? }) · .erase({ email }) Your app's billed users, each with a monthly cap, the token their page uses, and erasing a person who has left — see Billing, per user and Erasing a user.
admin.billing.get() · .csv({ byUser? }) · .requireUserToken(on?) · .visitorLimit(limit) What you are billed, whether every turn must carry a customer token, and how many questions a day people who are not signed in may ask (visitor limits).
admin.toJSON() What logs see: never the key.

Configuration

  • endpoint — the Space API. Defaults to the public production endpoint (https://api.witbitz.chat/v1/space), a self-authenticating endpoint: a Space proves itself by its key-commitment, not a tenant account.
  • viewer — the origin a shareLink opens in. Defaults to the reference Spaces app, so a link Just Works. Point it at your own origin to open Spaces in a viewer you build (the SDK also exposes space.key so you can construct links yourself).
  • app — an optional tag recorded with the Space.
  • tenant — your tenant's public id (t_…, never a key). Spaces this page creates are attributed to your tenant — its inventory, its take-down, its storage — when the page's origin is one your tenant declared (PUT /v1/tenant/webhooks with origins). A page on an undeclared origin gains nothing by naming a tenant; a declared origin needs no credential on the page.
  • live — false turns off live updates (polling only). On by default in a browser.
  • session, userToken — a persisted sign-in (see Sign in) and a tenant-vouched user's token (see Billing, per user); userToken is a string or a function that returns one.
  • tenantKey, tenantEndpoint — the builder side, server only; prefer new WitbitzAdmin(…) (see Your server).
  • backup, deviceName, vaultApi — the account backup and index; vaultApi defaults to https://api.witbitz.chat/v1/vault.
  • siri, a2a, a2aEndpoint — persisted Siri and A2A state; the A2A door defaults to https://agents.witbitz.chat.
  • appShare — your split-key function's URL (see Split-key rooms).
  • region — the home region new rooms are made in (a code like 'us'); default: the default region. A room is used from wherever its link is opened; a private lane always takes its shared room's region.
  • enclaveClaim — the URL of the enclave's published measurement (/enclave-claim.json), only when your viewer is an origin of your own that does not serve it; https only.
  • loginBase, sentinel — a tenant's own sign-in origin and standing Sentinel pins (see the end of Sign in).
  • fetchImpl, WebSocketImpl — for tests and non-browser runtimes.
  • On-prem / your own model, keys, and IdP: point endpoint at your deployment. See Deployment options and On-prem.

Error codes

Every error the SDK throws, or emits on 'error', carries a code. A refused server call passes the server's own code through (e.g. not_admin, not_authorized, tenant_required).

code Means
limit_exceeded Over a declaration limit (media, actions, schema); refused before anything is sent
invalid_effort A reasoning_effort the model does not take (wb.models())
sign_in_required · no_session · refresh_failed Needs wb.signIn; refreshSession had nothing to renew, or could not
tenant_required Creating a room with no tenant behind it — see Do I need a tenant?
tenant_key_rejected · bad_response · http_<status> A builder call (server side) was refused or answered badly
origin_not_declared · split_unopenable · split_mismatch A split-key room's app half was refused, or the halves do not match
not_in_vault · vault_refused · vault_timeout · vault_unsupported · vault_opens_links · vault_signs_in · keyless · cancelled · rate_limited Vault mode: the page is not framed by that vault; the vault refused the call or did not answer; not available in a vault yet; a link is opened by the vault; sign in with wb.vault.signIn(); a call that needs the key in the page; the person said no in the vault; too many new Spaces
vault_mode Cloud computers are not in a vault yet
no_screen Vault mode: the vault has no sign-in or share screen (your screens lack it). The backup and Siri-key screens fall back to the kit's own
cannot_open · closed space.ready rejected: this key never opens the room, or you closed the handle first
reserved_field · row_changed A row set id or a _ field; an ifVersion write lost the race (e.conflicts)
not_admin · not_owner A decision or fork the room reserves for admins / owners
no_invite_key · changed_meanwhile · stale_key A member change in place: this account has no invite key for your app on this device (restore wb.invite.state; in a vault, the vault's own); someone else changed the members at the same moment (read the room and try again); a 2.2.x page met a re-keyed room (move to 3.0.0)
direct_chat rename() in a direct chat — it is called by the other person's name
gate_required A connection granted to a room that is not email-gated
admins_required A collection written by admins only, in a room with no admins or owners (create, configure, fork)
config_too_large The room's settings (instructions, tools, data schema…) seal to more than 200 KB — on create, configure or fork; nothing is saved
tier_mismatch A lane's linked room (setLinks) is on the other tier, or a tier asked for is not the room's
config_unavailable · attachment_unavailable · enclave_claim_unavailable · enclave_unavailable · unavailable Something the call needed could not be read (enclave_unavailable: the confidential tier's enclave could not be reached or verified — being replaced or relaunched); retry
enclave_seal_failed · partial A confidential fork could not seal the new room; a room was only partly deleted
use_change_members space.fork(), or plan() given a history: a Space no longer moves — change its members in place (space.changeMembers())
not_a_lane · self_link · already_linked lane.link() was given something that is not a Lane, the lane itself, or a lane it is already linked to
removed An option that no longer exists: admission.removeUsers, admission.approve: 'admins'
invalid_argument · invalid_link A call was given something it cannot use (an attachment without mime, an unknown tier, key, framing or reply mode, an empty Siri alias…); openSpace / openLane was given a link that is not a Space link or lacks its key
invalid_model · bad_platform_tools · bad_request model not offered on this tier, a platformTools that is not true, false or a list of platform tools, or a request the server refused as malformed (e.g. a rename() name not 1–80 characters)
governed_room · config_immutable configure() in a room whose owner signed its policy (it changes only by a new room), or a change the server takes only by a move or a new room
enclave_not_accepted A confidential room's enclave runs a build Witbitz has not published; the room key is not sealed to it
no_answer the Sentinel did not answer (a connection's sign-in, a Siri record) — try again
failed · refused a refusal, or no answer at all, that came without a server code of its own — from any call that refuses through the SDK's shared path (row writes, setReplyMode, fork, configure, approve / deny, connections.grant / revoke, the mirror, the Siri record); status and reason, when present, say more
unknown_field · create_time · use_fork configure() refused a field before sending: not a setting it changes; one chosen when a room is made (tools, immediateTools, tier, key, agent.tools); or admission, which changeMembers() changes (the code keeps its old name)
no_client a confidential Space opened without its Witbitz client cannot make a private lane — open it through wb.openSpace
history_incomplete A fork's history could not be read in full (this device carried it), so no notice was posted and no member moved; the new room it made is empty — fork again
keyless The feature is not available when the room key is held by a worker
mic_denied · already_listening · stt_unavailable · voice_unattested Voice input
no_hebrew · empty · tts_rate_limited · tts_unavailable · audio · play Reading aloud
locked · wrong_passkey · backup_required · prf_unsupported · already_enabled · not_enabled Device storage protection

'error' events from the poll loop are transient (the loop keeps going): no_key, bad_mkseal (a confidential room's enclave was replaced: the SDK attests the new one and the call — any keyed call — is sent once more), enclave_warming, not_found, cannot_open (this key opens nothing in the room's history — the wrong link or key), unavailable (no answer just now), http_<status>. Some refusals a retry cannot cure: not_authorized with reason not_on_list (the signed-in email is not on the room's list), and gate_unreadable (the room's settings cannot be read). After one of those the loop slows to one read a minute, so a re-admission is still noticed; a send() or a stream signal reads at once, as always. send() does not throw: its result always carries a boolean ok. It is true when the message was posted (also when the assistant stays silent, and { ok: true, pending: true } when the request was cut after the server accepted it — the reply still arrives on 'message'; do not resend). It is false for every refusal, with status, error and message — e.g. { ok: false, status: 401, error: 'user_token_required' } — and for a busy room, { ok: false, error: 'busy' }. When the request never got an answer (the network failed, the enclave could not be reached) it is { ok: false, status: 0, error, detail } — error is unavailable, enclave_unavailable or the failure's code, detail says what happened. A status-0 send MAY have been posted (the request can be cut after the server took it): read the room (or wait for 'message') before you send it again. space.turn() settles such a send as failed with that error — the same caution applies. Check if (!r.ok). A refused send's error and reason are the ones in the table above: most often not_authorized with reason not_on_list or grant_required (an invite room this device has no invitation to), gate_unreadable, policy_invalid, user_token_required, visitor_limit (429, with scope and reason: your visitor limit), visitor_count_unavailable (503: retry shortly), reply_not_stored (retry: the reply came back but could not be kept) and bad_mkseal / enclave_warming (a confidential room — see above). Row writes (data.put/delete/batch) answer { ok: true, id, ops } and throw on a refusal.

Limits

What an app declares — its actions and its data collections — is bounded by the runtime. Past a limit the runtime does not fail; it cuts or drops the excess, which is why the SDK refuses such a declaration up front with an error whose code is limit_exceeded and whose message names the limit. The numbers are also exported as LIMITS. The same checks are exported, to run on your own (a build step, a test): checkActions(actions) and checkDataSchema(data) throw limit_exceeded; checkModelEffort(model, await wb.models()) throws invalid_effort for an effort the model does not take; EFFORTS lists every effort name.

What Limit Past it, the runtime would…
actions per turn 32 drop the rest
an action's name letters, digits, _; up to 64 drop the action
an action's description 2,000 characters cut it
an action's input schema 8,000 characters of JSON drop the whole action
collections 16 drop the rest
fields per collection 32 drop the rest
a collection or field name letters, digits, _ . -; up to 40 rename it
a collection's description 300 characters cut it
a field's description 200 characters cut it
images on a send 4, each under 1.1 MB refused at the call
files on a send 2, each under 3.7 MB refused at the call
linked rooms on a lane 16 refused at the call

Put what the assistant must not miss at the start of a description. A list at the end of a long description is the first thing a limit takes — and a model that sees two of four calendars does not know there were four.


HTTP API — the endpoints under the SDK

Everything the SDK does goes over one HTTPS API at https://api.witbitz.chat/v1. Most builders never call it directly: members' work goes through the SDK, builder work through WitbitzAdmin on your server. This page is for when you want the wire: what each endpoint is for, how it authenticates, and the rules it keeps. The OpenAPI spec is the source of truth for schemas and examples:

  • https://api.witbitz.chat/v1/openapi.json (.yaml) — the operations callable today
  • https://api.witbitz.chat/v1/ — the discovery index

Two kinds of call

Member calls Builder calls
What everything in a room: read, post, turns, the store, members your tenant: keys, origins, users, billing, rooms, releases
Endpoint POST /v1/space /v1/tenant…, POST /v1/tenant-space, /v1/apps
Proved by the room's key — the request carries proof of it; no account, no tenant key your tenant key, and optionally your signing key
Runs in a page, any device your server only

Member calls need no key on purpose. Whoever holds a room's key is a member, so the endpoint asks for proof of that key and nothing else; the server sees ciphertext and cannot tell what a room says (trust model). Sign-in, where a room requires it, is checked on top (identity and admission).

Keys

Key Where it lives What it can do
Secret wsk_… your server, ideally offline everything your tenant can do
Restricted wrk_… the server that needs it only the capabilities it was minted with (the list)
Publishable wpk_… a page names your tenant; authenticates no one
Signing key (Ed25519) beside your tenant key signs every request that reaches the Sentinel, so a stolen tenant key alone cannot act there (tenant signing)

A key is shown once, when it is made, and stored by Witbitz only as a hash. Lost means mint another and revoke the old. A call a key cannot make answers 403 naming the capability it lacks.

The surface

Your tenant

GET /tenant your tenant record: name, origins (and which are proven), settings
PUT /tenant/webhooks settings: origins, name (your app's name in sign-in mail), requireUserToken, visitorLimit (questions a day without a user token: { perNetwork, total } or null), idleRoomDays, the webhook URL
GET/POST /tenant/keys · DELETE /tenant/keys/{keyId} list, mint and revoke keys (secret key only)
PUT /tenant/provider-keys your own model-provider keys, sealed under KMS

Your users and billing — your app's billed users, not Witbitz accounts

GET/POST /tenant/users · PATCH/DELETE /tenant/users/{userId} create a customer, set a monthly cap, revoke
POST /tenant/users/{userId}/token a customer's turn token, handed to their page
GET /billing (?format=csv, &by=user) usage and charges

Your rooms

GET /tenant/rooms your rooms, with the idle rule
DELETE /tenant/rooms/{room} · POST /tenant/rooms/{room}/restore delete a room, or restore one within its grace period

The Sentinel — POST /tenant-space with { op: 'sentinel', … }: your OAuth apps (admin.providers), the hosts your users may attach a key for (admin.hosts), erasing a user (admin.customers.erase), your signing key and your signed origin list. These go to the Sentinel, the attested enclave that holds credentials (connections).

Releases — your static app, rebuilt and logged (verify)

POST /apps · GET /apps/{app} claim an app id (secret key only) · the app and its releases
POST /apps/{app}/releases reserve the next version, and get a one-time upload URL for your source archive
POST /apps/{app}/releases/{id}/submit · GET /apps/{app}/releases/{id} start the rebuild · follow it: the build enclave rebuilds your archive, and only a reproduced build is released
GET /apps/{app}/log the app's public release log, readable without a key

The CLI does all of it: witbitz app create | source | release | status | log.

Rooms — POST /space, one endpoint for every member operation, dispatched by op. The SDK is its client.

Infrastructure — GET /turn: short-lived TURN credentials for live calls.

Rules every call keeps

  • Responses. Success is { "data": …, "meta"?: … }; an error is { "error": { "code", "message", "request_id"? } }. POST /space answers its raw payload and a flat error — the SDK reads both.
  • Idempotency. Calls that spend money take an Idempotency-Key: the same key with the same body replays the first answer.
  • Secrets. Answers that carry a one-time secret are Cache-Control: no-store; provider keys and the webhook secret are sealed under KMS.
  • Limits. Throttled at the gateway and per key; a tenant can be suspended, and every key of it stops within about 15 s.
  • Versioning. 1.0.0-beta: the contract may still change, with notice to the builders who use it.

Access

The spec is public. Keys are invite-only during the beta: hello@witbitz.chat.


SDK changes — what moved between versions

Every change that needs your attention, newest first. Which version to load: Versions.

Part of the SDK · every call: the reference.


Changes that need your attention

Production apps import a fixed version (/sdk/<version>/): a released version never changes, so an upgrade happens only when you change the number in the address. A page that imports /sdk/latest/ or the live build (/witbitz-sdk.js) gets each change on its next load — use those for development and demos only (Versions). Every release, and every change that can break existing code, is listed here, newest first.

2026-10-27

  • SDK 3.10.0 — Safe media. Nothing breaks.
  • createSpace({ safeMedia: true }), configure({ safeMedia }) and space.safeMedia.set(on): others' photos and PDFs are shown only from copies an attested enclave made safe, as in the Spaces app (Safe media).
  • space.safeMedia.copy(message, { at, i }) returns a copy only once it verifies on the device; .isOriginal(copy, b64) checks the original before you open it; .make(message) makes a copy for everyone. send() makes the copies of what it sends while the setting is on (a signed-in session).
  • Events 'safeMedia' and 'safeCopy'. A copy is never a message: before 3.10.0 a Safe media room's copies were listed in messages as entries with kind: 'safe' — filter them out if you stay on an older version.

2026-10-26

  • SDK 3.9.0 — temporary rooms, page tools on a plain room, a site's visitor limit; and the sync sweep's fixes. Nothing breaks.
  • createSpace({ temporary: true | { days } }): a room the deployment wipes when idle — { days } (1–7) keeps it that many days after its last question; space.temporary. Refused rather than faked: temporary_unavailable, invalid_temporary, vault_unsupported (Temporary rooms).
  • space.send(text, { tools }): your page's own functions for one question on a plain room, in a lane's action shape (Page tools).
  • admin.billing.visitorLimit({ perNetwork, total } | null): a cap on questions from people who are not signed in; a visitor over it gets { ok: false, status: 429, error: 'visitor_limit', scope } from send() (Visitor limits).
  • A room named after this member opened it now gets its name (the name doc is read again on the settings' cadence).
  • Outside agents: a resumed lane's waiting drafts reach your 'a2a' listener even when it is attached after openLane resolves (they wait for one); an empty assistant line is never taken for a draft.
  • In a vault: wb.vault.info follows the vault's sign-in and account (it was the welcome, frozen); "only who manages it changes" is decided with hidden members counted (it said "anyone else can no longer read" for a role change); a lane of an attested Space is refused with attested, as outside a vault.

2026-10-26

  • SDK 3.8.1 — the vault kit's headers add Cross-Origin-Opener-Policy: same-origin. ⚑ If you host a vault, add it to every response of your vault's origin: a page that opens your vault in a window then gets no handle on it (the protection Witbitz's own vault has). Re-run the kit (vault-kit.mjs, 3.8.1) and HEADERS.md lists it for nginx, Caddy and Apache; your screens and vault.js are kept. Nothing else changes for your app.

2026-10-25

  • SDK 3.8.0 — outside agents' waiting drafts survive a reload, and the sync sweep's fixes; one change in a vault.
  • lane.snapshot() carries the outside-agent tasks still waiting for a draft or for the member (a2a); resume the lane with it and each waiting draft comes back as an 'a2a' event (Outside agents). Before, a reload lost them, and the agent's task stayed "working".
  • Outside agents: a job agent's report (job: true) is never drafted as a reply; a shared-room line mirrored into the lane is never taken for its assistant's draft; the agent's name is no longer kept as the member's name.
  • ⚑ In a vault, wb.backup.remove(room) returns a promise (it resolved nothing you could see; a refusal was swallowed) — await it. wb.vault.openRoom(room) refuses a room the vault does not hold (not_in_vault), and JSON.stringify(wb) shows the vault's sign-in.
  • In a vault: changeMembers / rotateKey wake a sleeping confidential Space first (it failed with config_unavailable); webhook and job connections keep webhook / job; a role-only change is said as one and emits no rekeyed; invite.enroll() resolves 'vault'; startMcp's sign-in comes back to your page's /mcp-callback.
  • Cloud computers: a Deny with a message falls back to the plain refusal on a connector from before messages (given the session); the published p2y image is accepted.

2026-10-24

  • SDK 3.7.0 — the person's cloud computers; nothing breaks. wb.computers: list, create, open, remove and usage, and an open Computer with its sessions, approvals, questions, files and live events — the Witbitz app's Code section, from your page (Cloud computers). Kept sealed in your app's account; opening checks the published Witbitz image and follows a new one (images); its hours are charged to the person's AWS key — your app's own key under their account, with its own monthly cap. Not in vault mode yet (vault_mode).
  • SDK 3.6.1 — a confidential lane of a standard Space sees its Space; nothing breaks. privateLane({ tier: 'enclave' }) on a standard Space made a lane whose assistant never saw the room: each turn named the standard room as its bridge, which the confidential tier cannot read, and nothing copied the room in. It now works as a lane of the other tier opened with openLane: your device mirrors the Space into it (without mirror: true), and its turns carry no bridge and not the Space's key (Across tiers). A lane on its Space's own tier is unchanged.
  • SDK 3.6.0 — mark an agent that takes jobs; nothing breaks. lane.a2a.addPeer(name, { job: true }) (and .peers shows job: true): that agent's messages are reports — its run has ended and it reads no reply — so the lane's assistant tells the member what came back and, if more is needed, proposes a new job with connection_call instead of answering the agent. Add your Claude Code routine's peer this way (Outside agents). Also: when a confidential Space's enclave cannot be reached for a moment (it is being replaced or relaunched), changeMembers and rotateKey wake it and read again instead of failing with "only an email-gated Space"; a read that still fails is config_unavailable (retry).
  • SDK 3.5.0 — outside agents over MCP, and agents that take jobs; nothing breaks. lane.a2a.mcpUrl (also returned by enable and addPeer): the lane's door for agents that take a custom MCP server — Claude Code, Grok, Cursor — with the same per-peer token as A2A. wb.connections.attach({ webhook: true, job }): an agent started by a web address (a Claude Code routine's trigger) as a connection; a Space granted it tells the agent, in every job, to report back with message_member. See Outside agents.

2026-10-23

  • SDK 3.4.0 — MCP servers as connections; nothing breaks. wb.connections.startMcp, attachMcp, finishMcp and mcpTools, and space.connections.grant(conn, { tools, auto }) for an MCP connection (MCP servers): the server's sign-in runs inside the Sentinel, and the grant brings its recommended tools (reads on their own, changes ask) or the ones you choose. In a vault they run through it.
  • SDK 3.3.0 — your tenant's origins, signed; nothing breaks if you do not.
  • With a signing key set, admin.origins.declare([...]) also files the same list, signed, with the Sentinel; admin.signing.publishOrigins() files it directly and admin.signing.status() shows the list held (Signing your tenant's requests). From then on only your own pages can name your tenant there — a connection through your OAuth app, a user's erase list, an MCP or GitHub App sign-in. Until you file a list, nothing changes.
  • SDK 3.2.0 — your tenant can sign its own requests; nothing breaks if you do not.
  • Tenant signing keys (Signing your tenant's requests): new WitbitzAdmin({ …, signingKey }), WitbitzAdmin.generateSigningKey(), admin.signing.register() and admin.signing.status(). Once you register a key, the Sentinel refuses any request for your tenant that your server did not sign — Witbitz's own servers included. Until you register one, your requests are taken on your tenant key as before.
  • Your users' sign-in is checked inside the Sentinel, and each of your users' connections, Siri inbox and agent lanes now runs in its own process there, with its own key. No API change.
  • SDK 3.1.0 — nothing to change in your code; move from 3.0.0 when you can.
  • What the account used: wb.account.usage({ month }) returns the month's totals by payer and day, and every SDK turn now carries its receipt address (What the account used). In a vault, the vault holds the key.
  • Link Spaces change in place too: an open Space's members change without a new room, and each member you add gets a link of their own. A page whose link no longer opens the Space meets pending with reason: 'link_changed', not removed.
  • Member changes: removing someone also removes their @username entries; one person added by email and by @username counts once; a role-only change keeps the key; a rotation skips removed members and only owners and admins may rotate; a third member makes a direct chat a group.
  • In a vault: a page cannot change a Space's owners or admins without the person (changeMembers asks them); the vault checks a member change is allowed before it asks; wb.invite says the vault keeps the key; errors name wb.vault.signIn; wb.signOut() refuses with vault_signs_in; connections.detach(id, { spaces }) works; a call that holds a screen for the person waits 15 minutes; an open lane of a verified Space is refused (lane_downgrade).
  • Outside agents: an answer to an outside agent is the lane's own reply, never sent to the shared Space.

2026-10-22

  • SDK 3.0.0 — members change in place, and only in place; pin 3.0.0 if your users are in verified rooms. A verified room's members (and its key) change without a new room (Changing members in place): space.changeMembers(), space.rotateKey(), wb.invite, and the rekeyed / removed / pending events. ⚑ Breaking: space.fork() is retired — it throws use_change_members and makes nothing; plan() refuses a history with the same code and answers in_place for a new member list; capabilities().features no longer lists fork. A move an older client made is still followed (A Space no longer moves). ⚑ A page on 2.2.x cannot follow a re-keyed room: once a room is re-keyed (by your app on 3.0.0, or by a member in the Spaces app), every call a 2.2.x page makes with the old key is refused with stale_key. Move to /sdk/3.0.0/. Also:
  • A new Witbitz Sentinel build is followed everywhere. wb.a2a, wb.erasure and the admin client (WitbitzAdmin, witbitz-admin.mjs) attested with the release's pins alone, so 2.2.4's outside agents, erase list and provider calls failed once the Sentinel moved to a build after its pins. They follow the published claim now, as the rest of the SDK does (sentinel: { follow: 'off' } or your own pins: never).
  • No private lane for an attested Space — a Space whose owner policy requires an attested app, not a confidential Space: privateLane() throws attested (no app signs a lane its owner policy yet), as the Spaces app waits.
  • Lanes linked across tiers: lane.link(otherLane) takes a lane of either tier, as in the Spaces app. The rooms a lane reads (setLinks) stay on its own tier.
  • A Space made in a vault names its creator its owner, as outside a vault.

2026-10-18

  • SDK 2.2.4 — the admin client as one file for your server. witbitz-admin.mjs in each release from 2.2.4: download it and import it from your own code (Builder calls). The import our docs showed before — from 'https://witbitz-spaces.pages.dev/witbitz-admin.js' — does not load in Node. Also: admin.origins.declare answers unproven, the origins whose host does not yet say they are yours (prove an origin); and this release knows the current Sentinel build (st20).
  • SDK 2.2.3 — move to it before the Sentinel your release pins is retired. A new standing-Sentinel build is now followed (sentinel.follow, default 'published' — see Sign in); no earlier release can follow one, so their connections, Siri and outside agents stop when the build they pin is retired. Also (from the daily review): a private lane of a verified Space is gated to its member (it was an open room) — sign_in_required with nobody signed in, lane_downgrade for an admission that would open it; the signed-in creator on a verified Space's list is its owner, as in the app; fork() of a room with no owners but a creator key refuses not_creator (its members follow only a move the creator signed); siri.collect() no longer acks a capture the Space did not take; connections.detach(id, { spaces }) also drops it from those Spaces; approve()/deny() treat a 200 that decided nothing as a refusal; a linked lane's ask waits until the other lane's whole history is read; the room's name is re-asked every 20 of the room's entries (not this handle's sends); witbitz-admin.js errors carry a code; a backup's email step passes the server's reason as the code (update_required). In a vault: a reclaimed worker is restarted (calls waiting on it fail worker_restarted), per-user billing (useUserToken) reaches the vault's turns, lane.link() is recorded on the account, a lane's Siri drops go into the lane, a confidential move is refused before the question with vault_unsupported on moveRefused, errors name wb.vault.signIn, account.lastError is an Error, speech-key limits say how long to wait, and openLane refuses a tier other than the lane's. Rebuild your vault with the 2.2.3 kit to get the vault's half. The examples on this page now load 2.2.3.

2026-10-17

  • SDK 2.2.2 — update if your page restores backups with wb.backup. The email step of a protected restore now sends the key the server needs to check the address it actually mails (gk), and the server REQUIRES it: from today, wb.backup in SDK 2.2.1 or earlier fails at the email step (2.2.1 says email_refused; the server's reason is update_required). Load 2.2.2 (or latest). In a vault, rebuild it with the 2.2.2 kit too: a vault built with an earlier kit keeps the old backup code, whatever SDK the page loads. It also carries the fixes made in the app since 2.2.1.

2026-10-15

  • SDK 2.2.1 (fixes from the daily review; nothing to change in your code): in a vault, a private lane's connections and siri go through the vault as a Space's do; space.plan() judges a change as the vault's signed-in member; turn.abort() stops the turn on the server; a member's vault follows a move of a split-key Space, and both vaults keep it split; a vault whose screens.js came from 2.1.x shows the kit's own recovery-code, restore and Siri-key screens (startVault takes a brand for them). Everywhere: a stored answer's late stream frames are no longer replayed as a partial. Types: VaultRoom.lane/tier, Connection.callKey optional (none in a vault), rotateKey() may resolve null, wb.vault.on('account'). Rebuild a vault with the 2.2.1 kit to get its fixes. The examples on this page now load 2.2.1.
  • SDK 2.2.0 (vault mode): everything the SDK offers now works in a vault — live streams; connections, usernames, the Siri inbox and outside agents through the vault's sign-in; private and linked lanes, sealedProof(); fork() and following a move; confidential, split-key and direct-chat Spaces; the backup, the account index and the erase list; dictation and reading aloud. What changes for a page already in a vault: those calls now work instead of refusing vault_unsupported; wb.siri.shortcut.key is null (wb.vault.showSiriKey() shows it in the vault); wb.backup.enable() resolves { enabled: true } and restore() takes the code in the vault's screen; a Space's settings read carries no connection key; openLane takes the lane's room id. Rebuild your vault with the 2.2.0 kit (it replaces core/ only). A screens.js kept from 2.1.x has no recovery-code, restore or Siri-key screen: the vault shows the kit's own for those (from 2.2.1; in 2.2.0 those calls refused no_screen) until you add yours. Outside a vault: a confidential fork() no longer fails when the enclave already holds the new Space's settings, and a Space with no admins follows only a move its creator key signed (the app's rule). The examples on this page now load 2.2.0.
  • SDK 2.1.2 (vault mode only): seventeen more members that half-worked in a vault now refuse by name — the backup's and the account index's getters and methods, space.connections / space.siri, and wb.dictation() / wb.speak() / wb.speech without a user token. A test now walks every public SDK member in a vault, so a new one cannot ship undecided. The examples on this page now load 2.1.2. Outside a vault nothing changed.
  • SDK 2.1.1 (vault mode only): in a vault, what is not available yet now refuses by name (vault_unsupported, vault_signs_in) instead of half-working — 2.1.0's backup handed out a recovery code that recorded nothing, its connections / usernames / Siri said "sign in first" while signed in, and sealedProof() threw a TypeError. The examples on this page now load 2.1.1. Outside a vault nothing changed.
  • SDK 2.1.0 (Versions): the examples on this page now load /sdk/2.1.0/witbitz-sdk.js. It carries the 2026-10-13 changes below (error codes on every error, plan() answering a refused gate instead of throwing). It adds vault mode — Witbitz.inVault({ vault }), wb.vault, and the kit tool (/sdk/2.1.0/vault-kit.mjs): your app on one origin, the room keys in a vault you host on another — plus what the 2.0.0 entry below calls "in the next release": direct chats and wb.speak().

2026-10-13

  • SDK 2.0.0 (Versions). The examples on this page now load /sdk/2.0.0/witbitz-sdk.js; 1.1.0 and 1.0.0 are unchanged. It is a new major version because it carries the breaking changes of 2026-10-08 to 2026-10-11 below — before you move from 1.1.0, check those entries marked Changed and Removed (the ones most likely to touch your code: configure() refusing fields it used to drop, admission.approve: 'admins' and admission.removeUsers refused, admission() without usernames / approve, no revoked reason, rename() / approve() / deny() rejecting when there was no answer, actions signed by the member's name instead of "You"). New over 1.1.0: space.run(), builder mode, space.title / rename(), react / replyTo, usage on a turn, isLaneCopy, signed webhooks in connections.attach, registered MCP apps (admin.providers.attachMcp), and the confidential-room key healing.
  • New: direct chats — createSpace({ direct: email }) and space.directPeer(), the Spaces app's one-to-one chat (live build; in the next release).
  • New: wb.speak(text) — text read aloud with the Spaces app's Play aloud player (live build; in the next release).
  • Changed: every error the SDK throws now carries a code (Errors); an argument a call cannot use is invalid_argument, a link that is not a Space link invalid_link, and a poll error with no code of its own unavailable. Messages are unchanged.
  • Changed: plan() returns { how: 'refused', code: 'invalid_admission' } (or removed) for a gate fork() would refuse, as documented; it used to throw. fork()'s own error for such a gate now carries code: 'invalid_admission'.

2026-10-11

  • Changed: configure() refuses what it cannot change in place, before sending anything — create_time (tools, immediateTools, tier, key, agent.tools), use_fork (admission; lane on a lane), unknown_field (anything else). It used to drop them without a word.
  • Changed: rename()'s message is the app's (renameRefusal.js), and a gated room's refusal carries e.reason.
  • Changed: connections.revoke and the Siri record throw the server's code (or failed / refused), not no_answer, when the server answered.
  • Changed: tell_lane refuses while the asking lane's history is still loading (the hop it continues is not known yet).
  • Changed: approve() / deny() reject when the decision got NO answer (status 0 — the network, "Hide my IP"); they used to resolve as decided. Such a decision may still have reached the room: pending() says whether the card is still waiting.
  • Changed: an @username is only a redirect. Inviting one stores a hash of that account's email, never the username; admission.removeUsers is refused (code: 'removed') and admission() no longer returns usernames (a legacy room's @username entries count in hidden).
  • Changed: lane.link(other) links lanes of different tiers (it threw tier_mismatch).
  • Removed: a room's revoked list. Removing someone is a move to a room that does not list them (space.fork()); an existing room's list is ignored, and not_authorized no longer carries reason: 'revoked'.
  • Changed: a confidential room no longer carries an acceptance block. Which enclave build is accepted is each person's own choice in the Spaces app (kept on their account); the SDK seals a room key to any build Witbitz publishes.
  • Removed: admission.approve: 'admins' (every card admin-only). Creating or moving a room with it is refused (code: 'removed'), and an existing room's setting is ignored: any member decides an ordinary card. Admins still decide the cards that write an admin-only collection or call a connection. admission() no longer returns approve.
  • Changed: rename() throws unavailable when the room did not answer or failed (status 0, 5xx); it used to write the shared doc and resolve ok: false, as if an older server had taken the name.

2026-10-10

  • Changed (TypeScript): Message.spk is typed as the public key it always was ({ kty, crv, x, y }), not string. Compare x/y; see Messages for "is this mine".
  • New: in a confidential room, a key the enclave no longer holds (bad_mkseal, after the enclave was replaced) or none at all (the room was opened while its enclave slept) is healed when the enclave answers: the SDK attests it and sends the call once more — a send, a reaction, a row, an approval, a rename, a run. If the enclave cannot be reached or verified, the call fails as before: bad_mkseal, or code: 'enclave_unavailable' (try again shortly).
  • Changed: after a refusal no retry cures (not_on_list, revoked, gate_unreadable) the poll and the proposals check read once a minute instead of nonstop; a failed proposals read keeps the cards you have (it used to empty them).
  • New: isLaneCopy(m) — the app's own test for a lane's copy of its shared room's line; filter on it, not source.
  • Changed: a collection declared write: 'admins' in a room with no admins or owners is refused at creation (admins_required) instead of accepted and never enforced.
  • Changed: who an action is signed by. approve, deny, react, rename, configure, row writes and connection grants used to default to "You" — which other members then read ("You renamed…"). With no from/by, they now use the name this member last sent or acted under, else "A member". Pass from to choose. send() still defaults to "You". A lane's crossing is signed the same way.
  • New: every turn (and run) carries the room's model pick from the Spaces app's model chip, and a handle re-reads the room's reply mode and model when they are 20 s old; space.react and send(…, { replyTo }); a room used only through the SDK is named after its first exchange; sealedProof() works in a verified room (read with the key).
  • New: space.run() — the assistant as a function: one answer, optionally structured by a schema, and nothing written to the room. Standard rooms only for now; paid by your user's or your app's key, 4 at once and 60 a minute per room.
  • New: builder mode — framing: 'builder' (your instructions and tools only, no Spaces framing), dataWrites: false (data_query without the write tools) and send(…, { showPrompt: true }) (the exact prompt a turn sent). Nothing changes for a room that sets none of them.
  • New: your app's name in the sign-in mail — "Your Keepwarden code", from "Keepwarden via Witbitz" — for a sign-in on one of your declared origins; admin.app.rename(name) sets it.
  • New: usage on send()'s answer and on space.turn()'s finished — tokens in, out and cached, and the provider's cost (What a turn used).
  • A refused builder call (wb.users.erase, wb.origins…) now carries the server's reason: e.code is the server's error code and e.body its answer. A bare 503 from the gateway is e.code === 'gateway_timeout' — the call ran past 30 s and may have finished; it is safe to call again. users.erase now stops inside that limit and answers incomplete: true when there is more to do.

2026-10-08

  • New: space.title, space.rename() and the title event (The room's name). In the next release; /sdk/1.1.0/ is unchanged. A room's name entries never appear in messages.

2026-10-07

  • SDK 1.1.0 (Versions). The examples on this page now load /sdk/1.1.0/witbitz-sdk.js; 1.0.0 is unchanged. Over 1.0.0, 1.1.0 carries everything below and under 2026-10-05 that 1.0.0 did not: row schemas, sign-ins scoped to your app, two devices syncing an account at once, wb.protect, and types that match what the code returns.
  • send() and Lane.send() results always carry a boolean ok. It is false on a refusal such as user_token_required, which used to come back as { ok: true, error }, and true on a reply, a silent turn, or a turn still running. Code that checks error keeps working. Row writes answer { ok: true, id, ops }.
  • New: wb.erasure — the rooms the SDK creates can be deleted when you erase a user (Erasing a user who has left).
  • New: platformTools on createSpace, privateLane and configure (Choosing the platform tools).
  • New: restricted keys, admin.keys (Restricted keys); admin.rooms.list() and admin.rooms.idleDeletion() (Your rooms, and the idle rule); wb.protect (Protect).
  • A moved room keeps its verified marks: rows carry _carried, messages carried and carriedBy (fork).
  • Email-gated rooms stream live like open rooms (Live updates). No code change.

2026-10-05

  • Sign-ins made on your website are scoped to your app (Sign in). Connections, Siri, A2A and dictation made from your page now live under your app's own account for that user — connections or Siri set up before this change under the plain email are not visible to new sign-ins on your page. wb.usernames.claim() / .release() from your page answer first_party_required (the @username is the user's global name; .mine() and .resolve() still work). Room admission is unchanged.
  • New: row schemas — a collection's schema is enforced on every write, yours and the assistant's (invalid_row); a schema with a rule we cannot check is refused at declaration (invalid_schema).
  • New: space.plan(change) — what a change requires (in place, a move, or a new room) and whether the history comes along, without making it. New: wb.capabilities() and the SDK_VERSION export.
  • This page is reorganised as a learning path: quickstart → build an app → the AI's authority → people → personal AI → production → the security model → builder operations; the full call table is the surface, near the end. Every section and link kept its anchor.
  • SDK 1.0.0, the first versioned release (Versions). The examples on this page now load /sdk/1.0.0/witbitz-sdk.js. /witbitz-sdk.js still serves the live build, as before.
  • New: space.turn(text) — a handle on one turn's answer (A turn you can hold).
  • New: space.on('message', cb, { replay: false }) hears only what arrives after the room's history was read. The default (history replays into the callback) is unchanged.
  • on() now returns a function that removes the listener. It still chains (space.on(…).on(…) works as before), but it is no longer the Space itself: code that kept on()'s return value as the space should keep the space instead.
  • New: WitbitzAdmin in witbitz-admin.js, the server-side client for builder calls (Builder calls). wb.users is admin.customers there. Nothing is removed: the page client's wb.origins, wb.users, wb.billing, wb.rooms, wb.hosts, wb.providers and wb.verifyTenantKey() delegate to it, and a page that passes tenantKey to new Witbitz now logs a console warning.
  • A Space, Lane and the Witbitz client no longer show their key, link or tokens when logged or serialised: console.log(space), JSON.stringify(space) and error reporters print { room, shareLink: '[REDACTED]', … }. The properties that hold them are no longer enumerable, so Object.keys(space) and { ...space } no longer list _mk, shareLink, _client or _wb. Read them by name as before: space.key, space.shareLink, wb.session.
  • lane.a2a.addPeer refuses a 33rd agent on one lane (too_many), as the Spaces app always has.
  • A private lane created without agent.instructions now uses the Spaces app's lane instructions (a fuller version of the old default: it also tells the assistant never to paste a message meant for the room as a normal reply).
  • New: space.on('update', ({ id, message, fields }) => …) — a message you already have changed in place (a reaction, an image caption, a reply's cards), and space.messages holds the new version where the old one stood. Before, these were never seen. A mirrored copy of a message (sh:<room>:<id>) is now the same message as its original.
  • Docs corrected (no code change): wb.openSpace() never read the page's URL on its own, whatever this page said — pass location.href (Invite links). space.deny(id, { from }) takes from, like approve. self: true marks the agent's output, never your own message (Messages). Creating rooms needs a tenant id and a declared origin (Do I need a tenant?). New: the proposal round trip, error codes and types.
  • wb.backup.enable() now writes the account format the Spaces app uses. A backup you already have keeps its format (wb.backup.format === 'legacy') through every restore and save; await wb.backup.upgrade() moves it over (same code, same rooms — a client on an older SDK can then no longer restore it). Each app still has its own vault: a restore never opens a Witbitz account's backup, and the Spaces app never imports yours.
  • New: wb.refreshSession() renews a signed-in session without a new code (Sign in).
  • signIn errors now also carry code, reason and a readable hint; their messages are unchanged.
  • A device action (mode: 'await') that throws now answers the turn with "The tool failed on this device: <message>" instead of the bare message.
  • partial: a status with no words yet ({ text: '', step } — "Deciding whether to reply…", "Working on it…") now ends with { text: null } when the turn ends without a reply. Before, it could stay on screen. Handle text: null as "nothing in progress" (you already do if you clear the bubble after a reply).
  • partial: a status that could not be opened is ignored; before, it could arrive as an unreadable step.
  • New, nothing to change: space.pending() and the proposals event (everything waiting, including proposals decided on another device), deny(id, { from }).

2026-10-02 (second release)

  • data.put / batch refuse a row with an id field or a field starting with _ (reserved_field). Pass the row's key as { id }. Rows are read without such fields.
  • space.connections.grant refuses a Space that isn't email-gated (gate_required).
  • A lane's mirror: true no longer copies the shared room's row writes; pass mirror: { data: true } to keep them.
  • In a gated room, admission(), admits() and isAdmin() throw sign_in_required before the member signs in.
  • Fixed: admins and approve: 'admins' now reach the room; before this release they were dropped at creation.

2026-10-02

  • approve() / deny() throw when the room refuses (not_admin, not_authorized, …) and keep the card; before, the card was dropped and resolved fired.

Trust model

Witbitz makes one trust claim: privacy you can verify — the double blind. A Space is sealed to a key the platform never holds; the app has no backend of its own; the browser egress footprint is locked; and the deployed render can be rebuilt from published source and checked against a signed certificate. Neither the operator nor the app's own owner can read the users' data — and that's a property you check, not a promise.

This page states it in full. It is intentionally explicit about what is live today and what is not — start with Status if you need the shipped / preview / designed map, and Verify it yourself to run the checks in your own terminal.


What is protected today

Claim Production status
A gated Space refuses reads and writes without an allow-listed identity Live and checkable
The stored Space ledger is ciphertext sealed to the room key Live and checkable
There is no operator recovery recipient in the production envelope Live and checkable
The app's browser egress is locked to a published allowlist Live and checkable
The deployed render publishes a signed build certificate Live and checkable
The render source can be rebuilt to the deployed hash Live and checkable

Run the checks: Verify it yourself.

What is not hidden

Witbitz does not hide everything, and the docs should never imply that it does.

  • Metadata remains visible. The platform sees that a room exists, when it changes, and roughly how large writes are. A passive observer on your network sees the same shape in both directions. Hiding it is traffic shaping — a client half (the only half that can hide anything from the platform) and a server half (verifiable, because the server is measured) — and it is Design, not shipped: privacy tiers §4.
  • Endpoints still matter. A compromised member device can read what that member can read.
  • Availability is not privacy. The operator can refuse service. The content-blind claim is about reading, not guaranteeing access forever.

The double blind

Witbitz's core privacy claim: for an app built on it, neither the platform operator nor the app's own owner can read the users' data — and, the part that makes it a property not a promise, that claim is meant to be checkable from the code, not taken on faith. What holds today, exactly where it stops, and how the remaining "readable in use" gap is closed by the attested tier, now in production.

Sealed to a key we never hold → the platform is blind

A Space's data is a snapshot sealed under the room key mk and stored as ciphertext; the platform never holds mk (agent/envelope.mjs seal/open; the key is committed only as commit(mk)). To the operator, a Space is opaque bytes — storage and transport are content-blind, because the platform holds no key. There is no operator recovery key: every record is sealed to the room key alone, so there is nothing on the platform that can read it. The double blind is the only configuration that runs in production.

The confidential room → plaintext only inside the enclave

A sealed store is not enough: a Space has to be opened somewhere to run a turn. In a confidential room, that somewhere is a measured AWS Nitro enclave, and the key that opens the room never reaches anything we operate outside it.

  1. The key is made on your device. The member's app creates the room and its key mk. The server stores only a commitment to it.
  2. Your device checks the enclave before trusting it. The enclave makes its keypair inside itself at boot and never exports it. Its public half comes bound into an attestation document signed by AWS Nitro hardware. Your app verifies that document against the pinned AWS root, and checks the image measurement against the published one — which anyone can rebuild from source — before it sends anything.
  3. The key is sealed to that enclave alone. Your app seals mk to the enclave's public key. Our servers carry the sealed bytes and cannot open them; only the measured image can.
  4. Plaintext exists only inside the enclave. The enclave opens the room, runs the turn, re-seals the result and writes ciphertext back. The model call leaves the enclave only through an attested no-log gateway into confidential-compute inference, and is refused if that gateway does not attest.

So the operator is blind both at rest and in use: storage holds ciphertext, and the one place plaintext opens is a machine whose code you can check and whose key we cannot reach. How each link is measured, and what you can verify yourself: the attested tier.

A standard room has no enclave: its turns and reads open on our server while a member is active, and its model is a frontier vendor's. The difference is the room's tier — see privacy tiers.

The app's owner is blind too

A Witbitz app is a static page with no backend of its own, and its browser egress is locked to the Witbitz API. The users' content is sealed to keys the app's owner never holds, and the page has nowhere else to send it.

The chain: each side checks the other

The guarantee holds only if the code at both ends is the published code. Each side checks the other.

  1. The page is a reproducible build with a public audit trail. Every Official release of the web app is rebuilt from its source commit inside a separate build enclave. The enclave signs its verdict — the release's hash (frontendSha256), commit and version — with a key born inside it, and the verdict goes into an append-only release log and into Rekor, Sigstore's public transparency log. A deploy is refused unless the bytes match the logged hash, and the page's signed /cert.json names the same hash, so you can compare what you were served with what was logged.
  2. The server checks the client. In an attested room every turn and read names the release it came from; the server accepts it only if that version is in the published release log and on the room's accepted list, and refuses an unlogged build. A browser can prove its version but not its device, so the room marks such a message "Witbitz-served vN" — a level below a hardware-attested app.
  3. On a phone, the hardware vouches for the app. In the native app the phone's hardware attests the app itself — Apple App Attest on iOS (the App Store build and its version), Android Keystore key attestation on Android — and the web bundle ships inside the app, pinned to its logged hash. The server verifies the attestation on each request and marks the message "attested app". The server's verifiers are built; the native apps are not released yet (status).
  4. The client checks the server. Before it seals a room key, your app verifies the enclave's hardware attestation and its published measurement — the confidential room, above.

Each link checks the next: a page you can match to a logged build, a server that accepts only logged builds (and, on a phone, only a hardware-attested app), and an enclave your app verifies before it trusts it with a key. The commands to run each check yourself: Verify it yourself.

Status — what holds today

Property Status
Sealed ledger, platform holds no key (nothing on the platform can read it) shipped
No operator recovery key (sealed to the room key alone) shipped & CHECKABLE — POST {"op":"sealed"} → recipients:["room"] (verify Test 5)
Admission gate enforces (reads and writes refused without an allow-listed identity) shipped & CHECKABLE (verify Tests 1–3)
Decrypt-once render, mk from the client only, dropped after shipped
Backendless static app (no backend of its own) shipped
Egress-lock (CSP connect-src/script-src allowlist) shipped & ENFORCED (block-mode) (verify Test 4)
Client E2EE primitive (the sealed-blob envelope) shipped
Signed build certificate (deployed build hash + egress, signed from ground truth) shipped & CHECKABLE — /cert.json (verify Test 6)
Reproducible build of published source (rebuild it → the deployed hash) shipped & CHECKABLE — /source.tar.gz (verify Test 7)
Attested tier (operator provably blind in use) Production — verifiable on real Nitro hardware, always on; the app builder enables the enclave per room (attested tier)
Attested client (a hardware-attested native shell carrying the audited bundle) Design — native shells not shipped; the deployed web app already verifies the enclave's attestation, which is part of the attested tier above (verified client)

Code map

Concern Where
At-rest ledger seal (symmetric, under mk only) + key commitment agent/envelope.mjs (seal, open, commit, newRoomKey); agent/sessionStore.mjs encodeMemory — one recipient, mk. No operator/beta/admin recipient in prod.
Recipient sealing (ECDH sealTo/openBox) — seals to a member/agent public key, never an operator scoped agent reads (agent/spaceScopedView.mjs) and Bridge epoch keys (agent/bridgeMembership.mjs) — a separate mechanism from the at-rest ledger above
Sealed ledger + decrypt-once render agent/sessionStore.mjs, agent/asyncTurn.mjs
Content-blind poll (etag over ciphertext) agent/spacePoll.mjs
Egress-lock CSP (block-mode, enforced on prod Spaces) spaces/tools/gen-space-csp.mjs
The render, published and readable (about 2,700 lines; 1,500 for the core) the published render source

The double blind is not "trust us less." It is "you don't have to" — sealed to your key by default, and provably unobservable in use for any Space that turns on the attested tier.


Identity and admission

A Space starts with key possession: holding the room key proves membership. Gated Spaces add signed identity on top — Google/OIDC identity, signed invites, agent keys, owner policies. The link is the admission capability; the owner decides who may join and at what tier; and members join, rotate devices, and are revoked over a Space's life. The full mechanism — identity, the verified room, admission tiers, the owner rule, member lifecycle, and recovery — is one doc: Identity and admission.

Where to go next


Privacy tiers — what each Space protects, and what you choose

Every Space is private by default. On top of that you make one choice per room — which tier runs its turns — and can switch on a few more protections. This page lists each one: what it hides, from whom, and its status today.

Check the claims yourself: Verify it yourself. The full status map: Status.


Every Space, always

Nothing to configure; these hold for every room.

Protection What it means
Sealed storage The store holds only ciphertext sealed to the room key. There is no operator recovery key, so nothing on the platform can read it.
Keys in a worker Room cryptography runs in a key worker on the user's device; in the hosted app it runs in the vault, so the frame that draws the screen never holds a key. See the app as an operating system.
Locked egress The app in the browser can reach only a published list of hosts; the browser refuses everything else.
Signed, reproducible build Each deployed origin publishes a signed /cert.json binding its exact bytes to published source that rebuilds to the same hash.

The choice per room: Confidential or Standard

Confidential Standard
Where a turn runs A measured AWS Nitro enclave. Your app verifies it before sealing the room key to it. Our server-side render.
Who can read a turn while it runs No one outside the enclave — not us, not AWS. Our render, while a member is active.
The model Open-source models on confidential-compute inference, reached only through an attested no-log gateway; refused if the gateway does not attest. Frontier models. The turn goes to the model's vendor.
Storage at rest Sealed Sealed
Status Production, always on Production

Choose Confidential unless the room needs a frontier model. Choose Standard for a frontier model, and tell the room's members what that trade is. The tier is set per room by the app builder. How the confidential room works, link by link: the trust model and the attested tier.


Protections you can switch on

Protection What it hides or prevents From whom Status
Verified membership Reading or posting with only the link — every read and write needs an allow-listed sign-in (email or @username). Anyone holding the link Production, per room
Hide my IP Which IP address is using which room. Requests go through an attested relay (OHTTP): the relay sees your IP but only ciphertext; our servers see the room but not your IP. Witbitz's servers and the network path Production, a user setting
No notifications That, and when, you received a message. Apple, Google and the push service Production — push is off until the user turns it on
Device protection Keys and cached messages at rest on the device: everything is sealed under a passkey and opened only after an unlock. Someone, or malware, reading the device's storage Production — required on computers, optional on phones
Attested room A modified or unreleased app taking part: every read and turn must come from a release in the public log (the chain). A tampered client In the browser: the Witbitz-served level (proves the version, not the device). Hardware-attested phone apps: Design
Client verification A swapped or leaky app in your own browser: a verifier checks the release and enforces its locks. Whoever serves the app Implemented, not on by default

"Hide my IP" cannot hide your address from the companies that deliver the app itself — Cloudflare when the page loads, Apple or Google for push and sign-in. Only a device-wide VPN or Tor hides it from them, and it works alongside the relay.


Not built yet

  • Traffic shaping (Design). Message sizes, timing and access patterns remain visible to the network path, in both directions. Hiding them takes a client half (fixed-size writes, constant cadence) and a server half (fixed-size replies).
  • Hardware-attested phone apps (Design). The server's verifiers for Apple App Attest and Android key attestation are built; the native apps are not released.

What no setting hides

  • The endpoint. Plaintext has to exist on a device for a person to read it. A compromised device — a keylogger, a screenshot, malware — is outside every software guarantee.
  • The frontier model's vendor, in a Standard room. That is the trade the tier names.
  • Availability. We can refuse service. Sealed storage is a claim about reading, not about access forever.

Running it in your own boundary, or with no outside network at all: deployment options and air-gap. What the Spaces app turns on, and where its settings are: privacy in the Spaces app.


Identity and admission

Identity in Witbitz is not "there is an account in our database." A Space is self-authenticated: its link carries the public material needed to verify who belongs, and members hold the corresponding private credentials. The same definition works with no server at all (true peer-to-peer) and with a server that verifies but cannot read. It is inherited from an open, proven real-time engine — where it secures live P2P calls — and carried, mechanism for mechanism, to asynchronous, end-to-end-encrypted Spaces.

This page is the whole subject: the quick reader model, then the definition and its primitive, the credential families, how membership is proved, the owner rule (who decides admission and at what tier), and — the part usually skipped — removal, rotation and recovery, marked precisely shipped vs designed. For a privacy product, overclaiming the lifecycle is worse than admitting a gap.

See also: the double blind (why the platform can't read the room) and Verify it yourself (reproduce the gate in your terminal).


The quick model

Base membership: hold the room key. Every Space has a room key mk. The client mints it and stores only a commitment on the server — commit(mk) = base64url(sha256(mk)). When a member acts, the render checks the presented key against the stored commitment: a right key proves the caller holds the link; a wrong key opens nothing. This proves a member is present — not which member.

Per-member identity: signed entries. To attribute entries to a specific person, each member holds a signing key. The room's public invite key verifies the member grant; the member's public signing key verifies the entry. Because the author is inside the signed core, a rename or forged attribution fails verification.

Admission families. A room composes any of these verifiers (e.g. a human gate and an agent allow-list):

Family What it proves
Key possession The caller holds the Space key.
Signed invite A creator or room authority granted this member access.
OIDC / Google The member authenticated as an allow-listed identity.
Agent key This agent key is allowed by the room.
Capability grant This participant may perceive or act in specific ways.
Owner policy The app owner mandated admission tiers for the room (the owner rule).

Gated Spaces. On an email-gated Space the link alone is not enough: reads and writes both require an allow-listed sign-in — which is why the verification page starts with token-less poll/turn requests that return 403. The allow-list lives in the sealed config, so the platform never reads the membership list.

Live vs designed, at a glance:

Piece Status
Key-possession membership Shipped
Solo signed identity Live and verified
Email-gated reads and writes Live and checkable
Agent-key admission · scoped agent reads Deployed
2-of-2 per-member signed path · distinct invite links Not yet
Crypto-strong single-Space read revocation by a new key Production (in place: the same room, a new key for the members who stay)

The rest of this page is the mechanism behind that summary.


The definition

A verified room has no account server and no stored access-control list. Instead:

The link carries a verifier, never a secret. A member holds a credential. Anyone can check credential-against-verifier using only the link's public material — so trust is rooted in the link, not in any server.

Three consequences fall out, and together they are the whole model:

  1. The link is a capability. Public verifier material — a room-invite public key, a creator public key, a name list, an OAuth client id — rides the link openly. Secrets — the room key, a member's private signing key — ride the URL fragment, which a browser never transmits. Witbitz adds one thing to the live-call link: the fragment also carries the decryption key for the room's content, so a Witbitz link literally holds everything.
  2. Verification is cryptographic, not a lookup. "Is this a member?" is answered by verifying a signature against a public key from the link — never by asking a database who someone is.
  3. The verifier can be anyone. Because the check needs only public material, the party that runs it is a free choice — a peer, a reader, or a certified function — without changing the credential. This is the hinge the rest turns on.

The hard constraint. Nothing sensitive is persisted server-side. After a Space is created and its member links handed out, the link is the entire authority: it reconstructs the key, proves membership, and (optionally) signs entries. The server keeps only a commitment to the key and the sealed ledger — never the key, never plaintext.

Membership = key possession (mkCommit)

The base Space auth is possession of the room key mk, minted client-side and living in the link fragment — only its commitment is ever stored (commit(mk) = base64url(sha256(mk)), agent/envelope.mjs). On every mutating op the render checks commit(presented mk) === the Space's stored mkCommit:

  • a right key → you hold the link → you are a member → the turn runs;
  • a wrong key → 401 unauthorized, sealed ledger untouched (a wrong key also decrypts nothing);
  • first use (no commitment yet) → the server returns commitOut so the deployment persists it, pinning the key model on first turn.

The key model (k-of-n) governs presence, not per-member identity: mk can be split so presence is required to reconstruct it. 1-of-1 (solo) — mk rides one link fragment (#mk=…). 2-of-2 (couple) — a Shamir split where each partner's link carries one share (#s=…) and mk exists only when both combine; the creator seals shareA under an out-of-band code and registers it as the room's invite, the partner unseals with the code and combines.

The primitive

Every credential in the model is the same ECDSA P-256 token, and it is tiny (the live-call engine's invite token, reimplemented byte-for-byte in agent/spaceEntrySig.mjs):

code
token = base64url(JSON.stringify(payload)) + "." + base64url( ECDSA-P256-SHA256 sig over the first part )
  • Bearer + unforgeable. Minting needs the private key; verifying needs only the public key. Forging one is forging ECDSA, not guessing a short code.
  • Room-bound + expiring. Every payload carries room and exp, so a token cannot be replayed into another room or used forever.
  • The link holds only the public half, so any authority — even one that took over after the creator left — can verify without ever holding a private key.

signPayload / verifyPayload are the shared floor; invite grants, agent assertions and host commands are all this token with a different payload and a wire tag, so they can never be confused for one another.

The credential families

A room can carry any of these verifiers; it composes them.

  • Signed invites (inviteToken.ts). The creator holds an invite keypair; each guest gets a grant {name, room, exp}; the link carries the invite public key (gk). A signed invite link, cryptographically — no server, no account.
  • The signed manifest (roomManifest.ts). The committed roster {room, mode, exp, members?, domains?, agentKeys?}, signed by the creator key and verified against the creator public key (gm) in the link, so every peer checks a joiner against the same committed roster. Can be passphrase-encrypted so a mere link-holder can't read who is allowed.
  • Join-gate modes (joinGate.ts). open · names · code (mailed secret, constant-time compared) · email/google (OIDC) · invite. All but email are serverless.
  • OIDC + cert-binding (oidcVerify.ts · oidcBinding.ts · identityCert.ts). "Sign in with Google," without a backend and without trusting the transport. oidcVerify is hand-rolled RS256-only ID-token verification — alg:"none", HS* (algorithm-confusion) and ES* are rejected outright, the algorithm never taken from the token; the key is chosen by kid from the provider's JWKS, unknown kid fails closed. oidcBinding is the serverless heart: at sign-in the user sets the token's nonce to a hash of their WebRTC DTLS cert fingerprint, and every peer recomputes it from the cert it actually handshook with — so a token is valid only for the peer holding that cert's private key. identityCert pins one cert, private key never leaving the browser → non-transferable. Cert-binding composes with the emoji safety code (both read the same cert), so "this is really Emma" and "there is no man-in-the-middle" collapse into one guarantee.
  • Agent keys (agentKey.ts). An agent's identity is an ECDSA keypair; to enter it signs a cert-bound assertion {k:"kbz-agent-key.v1", room, fp, iat}, admitted iff the key is on the room's committed agentKeys and the assertion is bound to this connection and fresh. The room stores only the public key.
  • Capabilities (capabilities.ts). A per-participant Grant = what it may perceive (read-chat, read-roster, read-media, see-screen, hear-audio, receive-directed) and act (send-chat, speak, act). defaultGrant('agent') is least-privilege — an agent perceives the conversation but acts on nothing until granted.
  • Verified host (hostKey.ts). Admin is bound to a password, not to who holds the room id: the link commits the host public key (gh) and the private key sealed under a host password (ghk). It survives a coordinator migration — every peer already holds the committed key from its own link.

Per-member identity in an async Space

Status — per-participant verified identity is LIVE (deployed + enforcement-proven in prod). Two admission paths sign every turn and the deployed /space render verifies them, attributing each entry to a verified author (forged / unsigned / non-allowed → 400 bad_attribution / 403 not_authorized). Reads are gated too: every content op requires an allow-listed sign-in, so the link alone reveals nothing. Remaining: the couple (2-of-2) path is still unsigned, and distinct per-member invite links aren't built.

A live call attributes each message via the DTLS-bound connection; an async Space has no live connection, so each ledger entry carries its own signature. A member holds an ECDSA signing keypair; their grant is the live-call invite token — byte-identical to inviteToken.signPayload — with the superset payload { name, room, exp, spk } where spk is the member's signing public key, signed by the room invite key and verified against the public gk. Each entry is signed over a canonical core { room, author, ts, kind, id, text } — author is inside the signed core, so a rename is unforgeable. The email/Google path additionally binds a fresh, externally-issued OIDC identity to the signing key (nonce = spkNonce(spk)), so an entry proves "signed by the keyholder who authenticated as this account, now" — a leaked link is then no longer enough to post.

Usernames, discovery, and who is in the room

Status — live in the Spaces app since 2026-09-25. Usernames, invite-by-username, @username on an allow-list, the server-attested @name on verified entries, the email-discovery switch, and the verified room's Members sheet are all in production. Not yet: the on-prem file/Postgres stores answer 501 user_unsupported for usernames.

A username is an address, not an identity. An account has three fields: the email it signs in with, a display name it types, and — optionally — a @username, a globally unique public address it chooses. The username is a label over the account's stable handle (the same OPRF-derived handle the directory already keys on), never a key: nothing in a room binds to the string, so renaming never costs access — every room that admitted @alon keeps admitting the same account under its new name. The server keeps no username → email map; a lookup of @alon yields the handle, the account's invite slot, and the display name only if the owner chose to publish it.

Rules. 3–30 characters, a-z 0-9 _, no leading underscore, no dots — so @x.y is always a domain and @x is always a person, with no guessing. A short reserved list is refused. Claim, rename and release happen in Settings → Choose a username, with the device's sign-in token; a claim is one atomic write, and a released or renamed-away name is held for 30 days before anyone else can take it.

Where a username can be used.

  • Invite by @username. The contact picker and the invite field take @name beside an email. The invite is sealed to the person's invite slot and lands as an in-app drop, the same as an email invite to an enrolled address.
  • On an allow-list. Who can post accepts @alon (a person), @acme.com or acme.com (a domain), and emails. The sealed roster stores the handle, so the admission survives a rename.
  • On every verified entry. In an email-gated room the server derives the poster's handle from the verified token, reads its own registry, and stamps the @username on the stored entry. The client's own claim of a username is stripped. The room shows Alon @alon ✓ with the @alon attested and the display name as decoration. A room can switch this off at creation. Revocation matches the email, the handle, or the @name, so a removal survives a rename too.

Being found by email is a switch, and it is on by default. The directory is a membership oracle: "is this email on Spaces?" That question is what makes a sealed in-app invite possible, and it is also information. Each account can close its email door in the same Your username sheet (People can find me by my email address). Closed reads exactly like unknown — the same 404 as an address that was never enrolled — for everyone except the owner proving their own email. An emailed invite to a closed address still arrives, as an emailed link rather than an in-app drop; an invite by @username still drops in-app, because the username door carries the slot.

A verified room lists its members, not its visitors. In an email-gated room the sheet under More is titled Members and shows every admitted person with their standing: signed in (the gate verified them here — from the server's sealed roster of sightings, or an entry it badged), not yet opened (on the list, never seen), or allowed (a whole domain). The label is the identity the server saw — the @username, else the email — never a typed name; typed, unvouched rows are not shown at all in a gated room. In an open room the same sheet is Who's been here and means exactly that: people who announced themselves. A lurker holding the link is invisible by construction.

Advertised or hidden. When a room is created, each invited person is either advertised — the room can read the address, and the Members sheet names them before they ever open the room — or hidden, in which case only a salted hash of the address is stored and the room cannot read it. Advertised is the default; the eye/lock on each chip flips it. Domains have no toggle. A long-press on a member row offers Save as contact.

One definition, three verifiers

The verifier can be anyone — which is what lets the same credential model span three runtimes. Only who runs the check and what fresh value the credential is bound to change:

Live P2P call Async P2P read Async server verify
Who verifies a peer — no server any reader, at read time the certified-ephemeral function
Membership the join gate over presence hold the key (commit(mk) == mkCommit) same
Freshness / anti-replay the live DTLS cert fingerprint the per-entry signature a server-issued challenge, folded into the signed core
Trust root the link's public key the link's public key the link's public key + a certified deployment
Content secrecy peer-to-peer E2EE sealed ledger, content-blind platform + owner same — the double blind

Async did not need a weaker model. It needed a different anchor for the same model — the member's signing key (identity) plus a server-issued challenge (presence) in place of the DTLS fingerprint.

Where the server sits, precisely. In async there is no peer to run the gate, so the turn function does — but it is a certified, ephemeral, content-blind verifier, not a trusted ACL store: it checks signatures against the link's public keys and a public JWKS (rooting no trust of its own); the allow-list it consults is sealed under mk, decrypted for one turn; an OIDC email it verifies is transient, never persisted; reads it serves an agent are sealed to that agent's key. The server verifies without being trusted with content.


The owner rule

Status: Implemented — built and tested, behavior-neutral, not yet enabled in production. The full path is implemented and tested end-to-end for both trust-root modes: on-prem (the owner pins one OWNER_POLICY_KEY) and hosted multi-tenant (a tenant registry, OWNER_REGISTRY=1, resolves the trusted key by the room's app). Also built: the create-time mandate, the render's integrity gate, tier-driven capabilities, client peer verification, tenant self-registration (op:'register-owner'), and key rotation/revoke (op:'rotate-owner'). It stays inert until an owner opts in — keys unset, rooms behave exactly as before.

A Witbitz room is a door whose key is the link — great between equals, a problem the moment there's an owner. If a company runs an app on Witbitz, it has no way to say "every room in my app is only for my people, and gold customers get more than free ones." The owner rule turns "the link is the authority" into "the owner is the authority," opt-in, without giving up content-blindness.

The rule: a signed App Policy. The owner writes one small document and signs it with a key only they hold:

code
AppPolicy {
  app:      "acme-portal"                       // which app this governs
  ownerKey: <owner's public key>                // the root of authority
  tiers: [
    { name:"open",   admit: anonymous,                                      caps:{ read },                       limits: low  }
    { name:"member", admit: { issuer:"acme.okta.com", any:true },           caps:{ read, chat },                 limits: mid  }
    { name:"gold",   admit: { issuer:"acme.okta.com", claim:{group:"gold"} },caps:{ read, chat, act, premium },  limits: high }
  ]
  roomTiers: { default:"member", admits:["open","member","gold"] }
}
→ signed by ownerKey

The signature is the whole trick: nobody without ownerKey can produce a valid policy, so nobody can forge a weaker one. Admission stops being binary — it's a ladder from anon-public to premium-verified, and the flat "must be my user" rule is just its one-tier case.

Bound to a room at birth. When a room is created through the owner's credential (hosted, the owner's tenant key; on-prem, its own create endpoint), create stamps the signed policy sealed into the room config (the render reads and enforces it) and a cleartext marker { app, ownerKeyId, policyHash } on the public record (so the content-blind platform and keyless read paths know "this room is owner-gated" without reading anything). The owner's create path refuses to mint a room that doesn't carry the policy — the room is born gated.

Only the app can mint its rooms (room-genesis). The signed policy is a reusable public artifact — anyone could copy it and stamp it on a room they create, standing up a look-alike to phish the owner's users. So stamping a policy at creation also requires a room-auth: a signature by the owner key over this specific room (the room id is in the signed core). Only the app owner holds the key, so only the app can mint "a genuine room of X's."

Tiers can't self-promote. A member's tier comes only from a signed source: IdP claims (the owner's directory is the source of truth for who is gold), a signed capability grant (a room admin promoting a member), and the room's own tier (the ceiling). A tampered client can't forge the claim or the grant, and the render — not the client — resolves and enforces the tier.

Enforced in three independent places, no single point to bypass:

When Check Where it runs
Birth create refuses any owner-room without the signed policy + marker the owner's create path (tenant-keyed / on-prem)
Writes (every turn) the render opens the sealed policy, verifies its signature against the pinned ownerKey, resolves the tier, gates each action; policy absent/invalid → refuse the certified render
Blind reads the cleartext marker makes keyless reads refuse; keyed reads pass the same identity gate the content-blind platform
Join the member's app verifies the room policy against its own pinned ownerKey and won't join an unrecognized room every member's app (peer check)

Per-tier limits (rate / token / tool budget) reuse the platform's existing metering — so the ladder doubles as the owner's pricing surface: admission, capability, and monetization collapse into one signed table.

Registration — Witbitz federates; it doesn't run the directory. A "registered user" is a record in the owner's identity provider, not a Witbitz account. Owner registration happens once (the trust root: ownerKey, the IdP issuer + JWKS as a SPACE_OIDC_ISSUERS entry, and the claim → tier map); user registration happens in the owner's own directory (enterprise SSO / SCIM / invite — the tier is a group the owner assigns, so a user can't self-register as gold); device enrollment mints a signing key per device bound at sign-in. So joining a room is authentication, not registration, and revocation is symmetric — remove the user from the gold group and their next sign-in drops a tier, enforced on the very next turn.

Orthogonal to attestation. This is the identity + capability axis; whether the client is trustworthy code is a separate axis (the verified client). They compose — a tier's admit can additionally require an attested client — but identity-tiers work on the plain web with no attestation in sight.

The honest trade. Requiring registration deliberately reintroduces a central authority — the owner's directory — into an otherwise account-free, link-based model. That's not a regression; it's exactly the control an owner asks for, and it's opt-in per app/room. The platform stays content-blind throughout — the policy is enforced in the certified render and by peers, never by a platform that reads the room.


Lifecycle — removal, rotation, recovery

The hard questions are never the initial key exchange. A bearer link is easy to copy, screenshot, sync, log, lose or leak — so these are the ones that matter.

After a member is removed

  • Writes — cut now (shipped). A revoked member is on the Space's revoked list (sealed in the config); the turn op rejects their next turn 403 even with a valid credential.
  • Reads — cut twice. Every read checks the allow-list and revoked first, so a removed member's next read is 403 at once. And the room changes key: what is written after the change is sealed to a new key only the members who stay receive, so the cut holds even if the server were bypassed. Open Spaces have no read gate — the link reads.

"But the link is the auth — how can you cut anyone?" A link is a bearer credential: the removed member already extracted mk the first time they opened it, and no edit reaches into their browser to delete it. You cannot retract a held key — only abandon it. So a removal does both: the server's gate refuses the removed member at once, and the room moves to a new key that only the members who stay receive. The gate stops reads through the platform; the new key makes the cut hold without trusting the platform.

History vs future

Revocation cannot make someone un-see content they already read — true for every end-to-end system. A re-key protects the future, not the past. History a member already saw remains known to them; new content can be blocked by read gates now, and by the new key for what is written after the change. For rooms with hard compartment boundaries, design epochs up front.

Membership changes in place, with a new key

Adding or removing a member gives the room a new key without moving it: the same room id, link, history, store and lanes. The new key is sealed to each remaining member's invite key — one grant each — and the room's sealed settings keep the older keys, so the history stays readable to those who stay. A member's device that still holds the old key is told it is stale, fetches its grant, checks the new key against the room's commitment, and carries on; a removed member has no grant, and their old key opens nothing written after. Standard, confidential and hosted rooms work this way — in the Spaces app (Members) and the SDK (space.changeMembers()). A change of members or an explicit rotation triggers it; absent one, a room keeps its key (no scheduled rotation). The Bridge changes epoch on every membership change in the same spirit. Moving a room to a new one was the earlier model; a move an older client made is still followed.

Rotation and replacing a compromised identity

Rotation = open a new epoch, re-seal forward, hand the remaining members the new key. Per-member signing keys rotate independently: revoke the old grant, admit the new key. Replacing a compromised identity needs no rebuild — a member is a signing key bound by a grant, not the room itself: revoke the old grant, admit a fresh key, and rotate the room's key (space.rotateKey()) where the future must be cut without trusting the platform. The Space, its history and every other member are untouched. Same for an agent — its identity is its key, so a rotated key is a one-line allow-list change.

Metadata that remains

Honestly: timing, sizes and coarse structure — never content or membership. The platform stores the mk-commitment, the sealed config and the sealed ledger, and observes when turns and polls happen and how big the ciphertext is. It does not see the conversation, the members' names (sealed in the config), or — in invite mode — any real-world identity. In Google-sign-in mode it sees the presenter's email transiently, at verify time, and discards it. The irreducible leak is timing correlation, which no content encryption removes.


Recovery

Witbitz deliberately has no operator recovery key. If the platform could recover a Space, the platform could read a Space. Recovery must belong to the user or organization, not to Witbitz as operator.

The current rule. If every copy of the room key is lost, the Space is lost. That is not a product flourish; it is the cost of the privacy claim — the production envelope is sealed to the room key, not to an operator, support, escrow, or beta recipient. Verify it with the sealed-store check: POST {"op":"sealed","room":"<ROOM>"} → the recipients list should name the room, not the operator (Verify it yourself).

Recovery must be user-held. Passkey (shipped) · backup code (shipped) · k-of-n social recovery (the same Shamir split the couple key uses — not built) · organization key policy (the customer controls it — on-prem). None require Witbitz to hold a readable recovery recipient.

The shipped vault. One vault per account: a random 32-byte master secret identifies and encrypts it, and each recovery method stores one wrapper — the master secret sealed to that method's own secret — so a passkey and a recovery code are two doors into the same backup, not two backups to keep in sync. The doors are behind a gate that asks for two things: the recovery code together with an email check on the account's address (a one-time code, or Google), or the backup passkey. The code alone does not open an account created signed in, and the email alone never does. The server stays content-blind throughout: it stores opaque blobs at derived ids, keeps the email only as an HMAC under a key derived from the code, and never receives the code, the passkey secret, the vault key, or any plaintext. The full mechanism: accounts and recovery.

The offline export. Recovery that depends on the platform being reachable is not self-sovereign, so a Space can also be exported to a file you keep, alongside a standalone decryptor page that opens it with no server at all. The export is opaque by construction: an earlier version wrote each Space's room, title and message count in the clear next to its sealed ledger — unreadable ciphertext that nonetheless announced every conversation's name and size to anyone who opened it in a text editor. That metadata is now sealed under the same room key as the ledger it describes; the decryptor shows "Conversation N" until you load your keys file. Two things remain visible: how many conversations the bundle holds and when it was exported (hiding the count would mean padding with decoys — more cost than it buys for a personal backup).

Enterprise / on-prem recovery. In an on-prem deployment the organization controls the runtime boundary — its own backup policy, IdP, secret storage, and recovery procedure. The privacy story changes from "trust Witbitz not to read" to "verify the image you run and control the boundary yourself" (Deployment options).

What not to promise. Witbitz cannot recover a lost Space on hosted production — by design. And don't hide the user burden: user-held recovery is safer than operator-held, but it still requires a recovery artifact, another device, a passkey sync provider, a social-recovery set, or an organizational process.


A room whose link holds the key must never have that key stored by the shortener, or the operator would hold it. So the shortener is blind-safe: POST /shorten refuses any target carrying key material (k/key/mk/sk/secret/seed in query or fragment); GET /r/{code} re-attaches the short link's own #fragment client-side. The shortener may store the room's base, never the key.

Security properties

  • Unforgeable — every credential is an ECDSA P-256 signature; minting needs a private key.
  • No cross-room, no replay — room + exp in every payload, plus a per-connection fingerprint (live) or a single-use, TTL-bounded server challenge (async).
  • No algorithm confusion — OIDC verification is RS256-only; alg:"none"/HS*/ES* rejected; the verifier algorithm is never taken from the token.
  • Fail-closed everywhere — a malformed grant, unknown kid, missing signature or empty allow-list → no access.
  • No server trust root for the credential — a compromised or curious platform cannot mint membership, only (in the async-write locus) decline to run.
  • Sealed policy — the policy is sealed under mk; verified identities are transient.

Status

Piece Live P2P Async
Key-possession membership (mkCommit) n/a — presence gate shipped
k-of-n key model (solo / 2-of-2 Shamir) n/a shipped
Signed-invite grants + per-entry signatures shipped deployed + live-verified (forged → 400)
Capabilities in the grant (perceive/act) shipped deployed
OIDC / Google admission, allow-listed shipped (cert-bound) LIVE — enforcement-proven (unsigned / non-allowed write → 403)
Presence proof / anti-replay shipped (cert-binding) deployed (server-issued challenge)
Agent admission by allow-list (AgentEntry) shipped deployed (same shape, live + async)
Scoped read (least-privilege perceive) shipped (mesh) deployed (view + member-absent fetchview)
Identity-gated reads (the link alone reveals nothing) admission gates presence LIVE — enforcement-proven (token-less poll → 403)
Owner rule (signed App Policy, tiers, room-genesis) n/a Implemented — not yet enabled in prod
Blind-safe /shorten n/a deployed
Remove a member — cut writes shipped shipped (gated Spaces)
Remove a member — cut reads (server read-gate) n/a shipped for email-gated Spaces
Remove a member — cut reads (a new key, forward secrecy) n/a shipped — in place for single Spaces, by epoch in the Bridge
Rotate a compromised member/agent key shipped shipped (identity + revoke, and rotateKey() for the full read-cut)
Device-loss recovery (user-held code / k-of-n social) n/a passkey + code shipped; social recovery designed — never operator-held
Per-member identity — couple (2-of-2); distinct invite links n/a not yet (solo signed path live)

Code map

Concern Live P2P Async
Signing primitive (payloadB64.sigB64) src/core/inviteToken.ts agent/spaceEntrySig.mjs (byte-compatible)
Signed invites / grants src/core/inviteToken.ts agent/spaceEntrySig.mjs + spaces/public/entrySig.js
Signed roster / policy src/core/roomManifest.ts the mk-sealed config (agent/spaceHandler.mjs, spaceService.mjs)
Join-gate modes src/core/joinGate.ts admission open/invite/email in agent/spaceService.mjs
OIDC verify + binding oidcVerify.ts · oidcBinding.ts · identityCert.ts agent/spaceOidc.mjs (RS256/JWKS + spkNonce)
Presence / anti-replay cert fingerprint agent/spaceChallenge.mjs
Agent admission src/core/agentKey.ts agent/spaceAgentKeys.mjs
Capabilities src/core/capabilities.ts agent/spaceCaps.mjs
Scoped read mesh perceive-gating (mesh.ts) agent/spaceScopedView.mjs (view/fetchview)
Verified host src/core/hostKey.ts agent/delegatedAuthority.mjs
Room key mint / commit / at-rest seal — agent/envelope.mjs (newRoomKey, commit, seal/open)
Recipient sealing (ECDH box) — agent/envelope.mjs (sealTo/openBox)
Owner policy (sign / verify / register / rotate) — agent/spaceHandler.mjs, tools/owner-policy.mjs
Link codec + k-of-n — spaces/public/spaceConfig.js, shamir.js, invite.js
Epoch re-key + forward secrecy — agent/bridgeMembership.mjs (the Bridge); agent/roomRekey.mjs, spaces/public/rekeyMembers.js, sdkRekey.js (single Spaces, in place)
Blind-safe short links — control-plane/src/shortlink.mjs

The through-line: a room's authority lives in its link, and the check that enforces it can run wherever the room happens to be — on a peer, in a reader, or inside a function that verifies but cannot read.

Don't take our word for it → Verify it yourself. A token-less read/write to an email-gated Space returns 403; curl -I the app to read its browser-enforced egress allowlist; POST {"op":"sealed"} to read the exact ciphertext the server stores — recipients:["room"] proves it's sealed to your key, not the operator's.


Connections — credentials the agent can spend but never see

A connection lets a Space's assistant call an outside service — GitHub, Google Calendar, an MCP server — with your user's credential, while no one can read that credential: not your page, not the room's members, not the model, not Witbitz. The credential lives in an attested enclave that can use it but never hands it out.

Status: Production. The SDK surface (wb.connections, space.connections) is part of the SDK, in public beta.


How it works

Credentials live in the standing Sentinel: an always-on AWS Nitro enclave whose measurement is published. Each credential is sealed to a key only that measured image can open (AWS KMS, pinned to its PCR0), stored sealed, and spent only inside the enclave.

  1. Attaching checks the enclave first. Before sealing anything, the SDK verifies a fresh Nitro attestation against the pinned measurement. A Sentinel running any other image is refused and nothing is sent.
  2. Your page keeps a handle, not the secret. After attaching, the page holds only the connection's id, label and host, plus a call key. The secret never comes back.
  3. A call names the connection, not the credential. The room says "call host X with connection Y". The enclave attaches the secret, makes the request, strips anything credential-like from the reply and returns the body. Every call carries a proof of spend — an HMAC with a per-connection key the enclave issued — so a captured request cannot be replayed onto another connection.

The same path serves confidential and standard rooms: a confidential room's turn enclave reaches the Sentinel over an attested hop of its own.

Who owns a connection

A connection belongs to an account: the verified email on the user's sign-in token, never a field your page names. A sign-in made on your site is scoped to your app — connections attached there live under an account of your app's own, and your page cannot see or spend the user's connections from other apps. Your app needs no accounts of its own; the SDK signs users in with a one-time email code (sign in).


Three kinds of connection

Kind What it holds How it is attached
API key A bearer token for one host wb.connections.attach({ label, host, secret })
OAuth A refresh token from your OAuth app; refreshed inside the enclave startConnect → consent screen → finishConnect
MCP server A server's own sign-in, run inside the enclave, or a token you hold startMcp → consent → finishMcp, or attachMcp

An API key

javascript
const gh = await wb.connections.attach({ label: 'GitHub', host: 'api.github.com', secret: token })
await wb.connections.list()                              // [{ id, label, host }] — never a secret, never a call key
await wb.connections.detach(gh.id, { spaces: [space] })  // everywhere at once, and out of each Space you pass

OAuth, through your own OAuth app

The OAuth app is yours, not ours. Your users consent to your app, on your consent screen. Witbitz never asks a provider for a scope on anyone's behalf, so Witbitz needs no provider verification — and neither do you, unless your scopes are sensitive (Gmail, for one, needs Google's assessment, for your app).

javascript
// your server, once (the tenant's secret key — never in a page)
await admin.providers.attach({ kind: 'google', clientId, clientSecret, redirectUri, scopes })

// your page
const { authUrl, state } = await wb.connections.startConnect({ providerId })
location.assign(authUrl)                                      // the user consents
// on your callback page:
const conn = await wb.connections.finishConnect({ state, code })
  • The enclave keeps the secrets. It mints the PKCE verifier and holds your client secret, so the code your callback receives is useless to anyone else. Only the account that started a consent can finish it.
  • The callback page is yours to host. There is no Witbitz-hosted one.
  • The consent URL and the hosts a provider may call are measured. They are a table inside the enclave image, so no provider record can send a user to an unmeasured consent screen or open a host. Today it holds Google, whose connections may call www.googleapis.com. A new provider is a new enclave image.
  • Refresh is safe under load. One refresh per connection at a time, a rotated refresh token is always stored, and a refresh that fails to save leaves the previous record intact. Revoking the provider ends every connection under it.

An MCP server

The server's own sign-in runs inside the Sentinel, which registers with the server and keeps the PKCE verifier. Your page gets a URL and a state; the code it receives travels sealed back to the Sentinel. The server's token never reaches your page.

javascript
const st = await wb.connections.startMcp({ url: 'https://mcp.linear.app/mcp', label: 'Linear' })
let conn
if (st.needsSignIn) {
  location.assign(st.authUrl)   // your callback page (default <your origin>/mcp-callback) receives ?code=&state=, then:
  conn = await wb.connections.finishMcp({ state, code, label: 'Linear', host: st.host })
} 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 }

Most MCP servers register a client during sign-in (Linear, Notion, Sentry, DeepWiki) and need nothing from you. Some need an OAuth app registered with the service — GitHub, Asana, Box, and Google's own MCP servers. For those the SDK signs in with Witbitz's registered app today. You can file your own now, with your page's /mcp-callback as its callback; the SDK will use it once it supports it:

javascript
await admin.providers.attachMcp({
  issuer: 'https://github.com/login/oauth',
  clientId: 'Iv1.…', clientSecret: '…',                   // sealed to the attested Sentinel
  redirectUris: ['https://app.example.com/mcp-callback'], // one per origin your app runs on
  label: 'GitHub',
})

Granting a connection to a Space

Nothing is granted by default. A grant writes the connection into the Space's sealed configuration, and only that Space's assistant may spend it.

javascript
await space.connections.grant(gh)                       // an API key or OAuth connection
await space.connections.grant(conn)                     // an MCP server, with its recommended tools
await space.connections.grant(conn, { tools: ['list_issues', 'create_issue'], auto: ['list_issues'] })

Only to a verified Space. In an open Space the link is the whole key: whoever holds it could start a turn and spend the connection. So a grant needs an email-gated Space (otherwise gate_required). A private lane of a verified Space is gated to its member automatically; gate a lane of an open Space yourself before granting: space.privateLane({ admission: { emails: [me] } }).

Detach revokes everywhere. wb.connections.detach(id) revokes the record in the enclave, which stops every room at once.

What the assistant gets

  • An API key or OAuth connection becomes one tool, connection_call: method, path, headers and body against the connection's host. The assistant names the connection by its label or host.
  • An MCP server becomes its tools, each named <label>__<tool>.

A call through an API-key or OAuth connection is always a proposal a member approves. An MCP tool is too, unless it is in the connection's auto set — chosen by whoever grants the connection to the room (the member in the Spaces app, or your page with grant(conn, { auto })); by default it is the tools the server itself marks read-only, a claim the server makes and Witbitz does not verify. An auto call still goes through the Sentinel: the model never sees the credential, and the connection's host and scope still apply. Only after approval (or for an auto tool) does the enclave make the call (proposals, end to end). The model sees the reply's status, content type and body (capped); it never sees the secret or the call key.

Which hosts a connection may call

A connection may call a host on the enclave's measured list — GitHub, the Google APIs, TrustedRouter, Tinfoil and the package registries — or a host your tenant has declared:

javascript
await admin.hosts.declare(['api.todoist.com'])   // server-side; replaces the list, so removing one is declaring without it

A declaration is checked when a key for that host is attached, not later on the first call. Two firewalls enforce it — one inside the measured enclave, reading your declarations from its own sealed store, and one outside it that asks the enclave about any host its fixed list does not name. Both must agree and both deny by default, so a host nobody vouches for is refused.

Not yet

  • One OAuth provider. Only Google is in the enclave's provider table; another provider is a new enclave image.
  • Your own app for MCP sign-in. An app filed with admin.providers.attachMcp is kept but not used by the SDK yet.

Outside agents — other AI agents talking to a member's assistant

A member's private assistant can work with AI agents outside Witbitz — Claude Code, Grok, a booking agent, a colleague's assistant — in both directions:

Direction How The member's control
An agent writes to the assistant The agent sends a message to the assistant's door, over A2A or MCP, with a token the member gave it. The assistant drafts the answer; the member approves it before it goes back (unless that agent is set to 'auto').
The assistant gives an agent a job The assistant calls the agent's trigger — a job agent is a connection — and the agent reports back through the door. Every job is a proposal the member approves.

Both run through the standing Sentinel, the attested enclave that holds connections. Outside agents reach a member's private lane (a private assistant inside a Space), never the shared room.

Status: Production. The A2A door has been live since 2026-10-03; the MCP door and job agents serve Claude Code and Grok today. In the SDK, lane.a2a.mcpUrl and job-agent connections arrive in 3.5.0, and marking a job agent's peer (addPeer(name, { job: true })) in 3.6.0.


Open the door to an agent

javascript
const wb = new Witbitz({ session, a2a: saved })      // persist wb.a2a.state — it holds the lanes' inbox keys
wb.a2a.onState = (s) => save(s)
const lane = await space.privateLane({ admission: { emails: [me] } })

await lane.a2a.enable({ name: 'Dana’s assistant' })
const { token, cardUrl, mcpUrl } = await lane.a2a.addPeer('Claude Code')   // the token is shown once

Give the agent its token and one of the two addresses — the same inbox speaks both protocols:

  • MCP — for agents that take a custom MCP server (Claude Code, Grok, Cursor, Claude): mcpUrl with the header Authorization: Bearer <token>. For Claude Code: claude mcp add --transport http witbitz <mcpUrl> --header "Authorization: Bearer <token>".
  • A2A — for agents that speak the open A2A protocol: the Agent Card at cardUrl, with the token as a bearer.

One token per agent. lane.a2a.removePeer(id) revokes it on both doors at once and drops its waiting messages; lane.a2a.disable() closes the lane's door; wb.a2a.revoke() turns it off everywhere.

What the agent sees

Over MCP, two tools:

Tool What it does
message_member(text, task_id?) Sends a message, a question or a finished result into the member's private lane (up to 8,000 characters). Pass task_id to continue a conversation. Answers with a task_id.
get_reply(task_id) The state — waiting, read, question, answered, declined or expired — and the member's reply once there is one.

Over A2A, a standard v1.0 JSON-RPC endpoint: SendMessage, GetTask, CancelTask (the v0.3 names work too).

Answers can take hours: a message waits until one of the member's devices collects it. An unanswered one expires after 7 days. Text only — a file is refused.

How a message is answered

javascript
lane.on('a2a', ({ peer, text, draft, approve, askBack, decline }) => showForApproval(peer, text, draft))
await lane.a2a.collect()   // bring waiting messages into the lane
  1. The message lands in the lane as the agent's words, marked as coming from that agent. The assistant is told to treat it as information, never as instructions.
  2. The assistant drafts an answer — its ordinary reply in the lane.
  3. The member decides. With reply: 'ask' (the default) nothing goes back until the member calls approve(text?), askBack(question) or decline(). addPeer(name, { reply: 'auto' }), or setReply(id, 'auto') later, lets the assistant answer that agent on its own.
  4. The agent reads it with get_reply (MCP) or GetTask (A2A).

Give an agent a job

An agent that is started by a web address — a Claude Code routine's trigger, a Grok routine's webhook — is a connection whose credential the Sentinel keeps. Attach it as a job agent and grant it to the lane:

javascript
const claude = await wb.connections.attach({
  label: 'Claude Code',
  host: 'https://api.anthropic.com/v1/claude_code/routines/trig_…/fire',   // the whole trigger address
  secret: 'sk-ant-oat01-…',                                               // sent as a Bearer header
  webhook: true,                                                           // one path, POST only
  job: { field: 'text', headers: { 'anthropic-beta': 'experimental-cc-routine-2026-04-01', 'anthropic-version': '2023-06-01' } },
})
await lane.connections.grant(claude)
  • The assistant proposes; the member approves. Asked to hand work to the agent, the assistant proposes a connection_call; once a member approves, the Sentinel POSTs the job to that one path, with the fixed headers and the key — which neither the page nor the model ever sees.
  • Every job tells the agent to report back. The runtime adds a closing line to the job: send the full result, or a question, with the message_member tool. So the agent also needs the lane's door — add it as a peer with job: true (lane.a2a.addPeer('Claude Code', { job: true })) and give its routine the mcpUrl and token.
  • The result is a report for the member, not a message to answer. The agent's run has ended and nothing reads a reply, so the assistant tells the member what came back and never writes back to the agent. If more is needed — a question to answer, a correction, a next step — it proposes a new job with connection_call, which the member approves like the first. A peer added without job: true is answered the way above.

job.field is the field of the request body the job goes in (text for Claude Code); job.headers are fixed headers every call carries — a credential header can never be named there. A webhook whose secret starts with whsec_ is signed instead of sent as a bearer, and needs no webhook: true.


Security

  • TLS ends inside the attested enclave. A message is sealed there to the lane's inbox key; only the member's devices can open it. Witbitz stores a hash of each token, never the token.
  • An outside agent's words are information, not instructions — to the assistant and to the member's approval.
  • Grants only to a gated lane. A connection, a job agent included, is granted only where every turn needs the member's sign-in.
  • The member can cut one agent off without touching the others.

Verifying the client — the code your browser runs

Everything a browser runs for a Space comes from a web origin, and an origin can be compromised: a hijacked hosting account, a bad deploy, a poisoned cache. Sealed storage and an attested server mean little if the page itself can be swapped. This page is how the client is pinned, how anyone can check it, and how the server checks it back.

How the app is put together inside the browser — the vault, the Kernel and Atlas — is the next page: the app as an operating system.


Every release is a reproducible, logged build

A release of the web app is not "whatever we deployed last". Each Official release is rebuilt from its source commit inside a separate build enclave, which signs its verdict with a key born inside it; the verdict — the release's hash (frontendSha256), commit and version — goes into an append-only release log and into Rekor, Sigstore's public transparency log. A deploy is refused unless the bytes rebuild to the logged hash, and never moves backwards to an older release.

Devices choose a channel: Official (the newest logged release) or Beta (the newest build, not rebuilt by the enclave). The service worker downloads a release completely before the app switches to it.

The page pins everything it loads

The shell names every file it will load by its hash, and the browser refuses anything else:

What How it is pinned
Every JavaScript module An import map carries a sha384 integrity for each module; the browser checks each module as it loads it (Chrome 127+, iOS Safari; older engines ignore it).
Script tags Subresource Integrity on each <script src>.
Inline scripts The Content Security Policy lists their hashes, the import map's included.
The key worker Bundled into a pinned module and started from it, so it is covered by the same pins.
Network The enforced CSP's connect-src lists the only hosts the page may reach — the published egress lock.

The vault (the hosted app's outer frame) is pinned the same way, under its own enforced CSP, and cannot be framed.

What this does not cover by itself: the pins live in the shell, and the shell is whatever the origin serves. A compromised origin could serve a different shell with different pins. Closing that needs a check outside the page — the signed certificate below, checked by a verifier you run, or a native app.

The signed certificate

Each deployed origin publishes a platform-signed /cert.json:

code
pinned platform key  →  signed /cert.json (frontendSha256)  →  /assets-manifest.json  →  every file's hash
  • /assets-manifest.json maps every served file to its hash, plus the inline-script hashes.
  • /cert.json commits to the manifest's hash, signed by the platform key (ES256, P-256). The key is published in Verify it yourself and compiled into the checkers — never fetched from the app. A tenant's own app is signed with a tenant key the same way.
  • The cert's frontendSha256 is the same hash the release log and Rekor carry, so what is served can be matched to a logged, reproducible build.

Checking it yourself


The verifier extension

A page cannot vouch for itself: any check it runs, a swapped page can skip. The verifier is a small browser extension that runs outside the page, so the origin cannot opt out of it.

  • It forces verification. Every app navigation loads in verified mode; the loader checks the signed certificate against the pinned key and refuses to start a release that does not match ("couldn't verify this app").
  • It forces the policy. It sets the page's CSP itself, built from the hashes in the signed manifest — with no 'self', so a same-origin script that is not in the signed release cannot run at all (Chromium 127+).
  • It shows the result in a toolbar badge the page cannot draw: green when the release verifies, red when it does not.

It covers the regular app, the hosted app and its vault, and tenants' apps. Status: Implemented — built and measured against production (the app and the vault boot with no violations; a tampered file fails closed), installed unpacked; not yet in an extension store.

Install the verifier

  1. Get the extension from the repository (docs/verified-client-real/extension/) and read its few files. config.json holds the origins it guards and the pinned keys — check them against Verify it yourself.
  2. In Chrome, open chrome://extensions, turn on Developer mode, choose Load unpacked and pick the folder.
  3. Open the app. It reloads in verified mode, boots normally and the badge turns green.
  4. To see it enforce: turn the extension off and reload — the badge is gone and the page's own policy applies again.

The one thing you take on faith is the extension itself, checked once. The strongest choice is a verifier written by someone other than the app's vendor.

The server checks the client

Verification above protects you from a swapped page. The server can also require that every member of a room runs a released client:

  • In the browser — in an attested room, every read and turn names the release it came from; the server accepts only a version in the published release log and on the room's accepted list. A browser can prove its version but not its device, so this level is marked "Witbitz-served vN". Status: Production.
  • On a phone — the native app ships the same web bundle inside the app and loads it locally, never from the network, so the phone's hardware attestation covers the bundle too: Apple App Attest on iOS, Android Keystore key attestation on Android. The server verifies the attestation on each request, bound to the member's signing key, and marks the message "attested app". Status: the server's verifiers are built and tested with synthetic keys (no real device's evidence yet); the Android app builds reproducibly; the native apps are not released (Design).

Requiring an attested client is a room setting, checked by the server — not a claim a client makes about itself.


Not built yet

The module list in the certificate

A signed list of the shell's modules in /cert.json, so a checker can match the whole module set the browser loaded — not only the files served — to the release log, without the extension.

A byte-level verifier

A Firefox extension that hashes the exact bytes the browser received and checks them against independent transparency logs, removing Witbitz from the trust base for one extra tool. Design.

Certified closed-source apps

A capability-confined app audited inside an enclave by several independent checkers against its declared privacy certificate, with a public verdict log — "this app is good" without publishing its source. Design.

Limits

  • Integrity is not correctness. A verified release can still have a bug; a verified page renders others' content safely only because it is written to.
  • A browser without the extension has no outside check that the shell it got is the logged one — the pins hold within the page, the extension or a native app checks the page.
  • Older browsers ignore import-map integrity; the signed certificate and the extension still apply.

Next


The app as an operating system — the vault, the Kernel and Atlas

A Witbitz app is a static page, but inside the browser it is built the way an operating system is: separate realms, each holding one kind of thing, with walls the browser enforces — different origins, separate worker heaps — rather than the app's good behaviour. A bug or a malicious script in the part that draws the screen cannot reach the keys, because they are not in its realm.

The principle is least authority by information class: keys, the room graph, and what is on screen live apart, so a compromise of one exposes only its own class.

How anyone checks that the code in each realm is the published code: verifying the client.


The realms

Realm Like, in an OS Holds Never holds
The vault — its own origin, the top frame the trusted shell and login the account, the room keys, sign-in, recovery, the device lock what the app draws
The Kernel — a Web Worker the kernel every room key in use, and all the cryptography: opening, sealing, signing the screen
The app — a separate origin, framed by the vault a user process what it renders the account or the room keys at rest
The service worker the package manager the app's builds: which release runs, downloaded whole before a switch room content

Why origins, not discipline. One origin can intend to keep its keys away from its drawing code, but nothing enforces it — any script on that origin can read its storage. Putting the vault on its own origin makes the boundary the browser's same-origin policy: the app frame cannot read the vault's storage, and the vault cannot be framed by anyone.

Easier to audit

The structure also shrinks what a reviewer has to read closely. In an ordinary web app any of its code can read the keys, so an audit has to cover all of it, on every release. In the hosted app only the vault and the Kernel touch keys — the trusted core, kept small and changed rarely. The app frame, which is most of the code and changes most often, is checked for what it shows, not for what it could leak: the browser does not let it reach a key.

It is the same reason an operating system's kernel gets audited instead of every program: the guarantees rest on the small part. And because every file in every realm is pinned and logged (verifying the client), the audit applies to exactly the code that runs.

The vault

The vault is the top frame. It holds the account and its room keys, runs every ceremony that touches them — sign-in, recovery, the device lock — and frames the app with no key in the URL and none in the app's storage.

The two talk over a small postMessage protocol, pinned to each other's exact origin in both directions. The app asks for what it needs — open this room, run this turn — and the vault answers; it never hands over the account.

The vault also keeps what the app frame cannot: on iPhone the system drops a framed page's storage on every relaunch, so the app's settings are mirrored up to the vault and handed back at launch.

The Kernel

The Kernel is a Web Worker — a separate JavaScript heap with no access to the page's objects — that holds the keys in use and does all the room cryptography. The app sends it requests, not keys: poll this room, send this turn, read this state, seal this record. The Kernel opens what comes back, signs what goes out, and makes the request itself.

In the hosted app the Kernel runs inside the vault's origin: the app frame has no Kernel and no keys at all, only a channel to ask. (In the regular app, which has no vault, the Kernel runs in the app's own origin.)

The Kernel hands a key out only where the action is about the key: the share link of a room you just created, and a new key after you change a room's members.

Atlas — the room graph

The account's own records — which rooms you are in, their names, which private assistant belongs to which Space — are the room graph. They are stored in sealed account rooms whose entries also carry each room's key.

Atlas keeps those apart. When the account's index is read, the Kernel takes the keys out and the app receives a keyless graph; when the app writes the index, the Kernel puts the keys back. The app can list your rooms — it is the file browser, and may see the directory listing — but it never holds the keys that open them.

The account

The vault is where the account lives: the master secret every account key comes from, the sealed room list, and the recovery code and passkey that bring the account back on a new device. How an account is made, synced across devices, backed up and recovered — and what Witbitz can and cannot see of it: accounts and recovery.

Storage on the device

Each realm's storage can be sealed on the device: with device protection on, every key and every cached message is ciphertext at rest under a key the device's passkey derives, opened into memory only after an unlock and dropped at the next lock. Off by default; see privacy tiers.

Notifications get their own one-way key, derived from the room key, so the service worker can show a short preview without holding the room key itself.

Network

Each realm reaches the network only under its own Content Security Policy: the app frame, the vault and the Kernel can each reach only the hosts their published policy lists. There is no single gateway; the limit is per realm, enforced by the browser.

Build your own

The SDK's vault mode gives your app the same structure with your own vault: you host the vault and the page, Witbitz hosts neither, and secrets stay in the vault's screens — vault mode in the SDK.

Limits

  • A worker is a separate heap, not separate memory. The Kernel's wall is as strong as what crosses it: no shared memory, no key returned except for the actions above.
  • The app sees your room list. Names and structure are visible to the app frame; only the keys are confined. A separate Atlas worker that would hide the graph from the app too is designed, not built.
  • The regular app has no vault. Its Kernel and screen share one origin, and the page itself also holds room keys. The hosted app is the one where the screen never holds a key.
  • The regular app's service worker can still fall back to a room key on the device to show a notification; the hosted app's never holds one.

Next


Accounts and recovery — an account only its owner can open

A Witbitz account is the user's own: everything in it is sealed under keys made on the user's device, the server keeps only ciphertext, and Witbitz cannot open it or recover it on anyone's behalf. This page is how an account is made, how it reaches every device, and how it comes back when every device is lost.

Status: Production in the Spaces app (regular and hosted) and in the SDK.


What an account is

  • An identity: a verified email. The user proves the address with a one-time email code or Google. The same email is a separate account in each app — an app built on Witbitz cannot see or touch the user's account in another.
  • A root: the master secret. 32 random bytes made on the device when the account is created. Every other account key is derived from it or sealed under it. It never leaves the device in the clear.
Key Made Used for
Master secret on the device, at creation the root: derives the keys below and seals the backup
Account store key derived from the master seals the account's own records (the room list) on the device
Account signing key on the device signs this account's device certificates
Invite key on the device lets others invite this account by email: only its public half is published, under a blinded handle
Room keys on the device, one per Space open each Space

Creating an account

  1. On a computer, secure it first. Before anything personal is written, the user sets up one passkey that is both this computer's lock and the account's backup passkey. A computer that cannot hold such a passkey uses the phone instead (by QR).
  2. Prove the email. A one-time code or Google. If the address already has an account, the user is offered to restore it instead of starting a duplicate.
  3. The device makes the account. The master secret and a recovery code — 128 random bits, shown as groups of letters and digits — are made on the device. The encrypted backup is written, then the recovery gate is set up (below).
  4. Keep the code. The app shows the recovery code to copy and keep somewhere safe, and offers to add a backup passkey.

One account on every device

The account's room list — every Space with its key and name — lives in a hidden index room, sealed on the device under the account store key before it is written: the server stores it and cannot read it. Two more hidden rooms hold the private assistants' links and messages between the user's own devices.

A new device needs only the master secret: from it, the backup gives the index room's address and key, and the index room gives every Space. Changes reach the other devices through the index room as they happen.

Add a device without the recovery code: on a device that is already signed in, confirm with its passkey and show a QR code; the new device scans it, and the master, the code and the sign-in are sealed to the new device's own key and passed through a two-minute relay.

The encrypted backup

What it holds the account's keys (signing, invite), the index room's address and key, the Spaces' keys, contacts, connection handles, the recovery code (so the app can show it again)
Sealed with AES-GCM under a key derived from the master secret, on the device
Stored at an id also derived from the master — without the master, nobody can find it or read it
Written a few seconds after a change, merged with the stored copy

The master itself is stored only wrapped — once under the recovery code, and once under the backup passkey (its WebAuthn PRF secret). Either wrapper opens it on the device.

Recovering an account

The backup is behind a gate that asks for two things — so neither a leaked recovery code nor a hijacked mailbox opens the account alone.

What the user has Opens the account?
Recovery code + the account's email (a one-time code, or Google) Yes — the usual way back after losing every device. It also signs the new device in.
The backup passkey Yes — the passkey made with the account, or one added after an email check, is a factor of the gate by itself.
Another signed-in device Yes — Add a device, above.
The recovery code alone No, for any account created signed in.
The email alone Never.

How the gate checks the email without learning it. The gate stores only an HMAC of the email under a key derived from the recovery code. At recovery, the device sends that key with the email check; the server verifies the proven address against the stored value. The code itself never reaches the server, and decryption always happens on the device.

What Witbitz sees and can do

  • Sees: ciphertext at opaque ids, their sizes and write times; the gate's record; during a recovery, the email it sends the code to. Short-lived per-IP counters for rate limits.
  • Cannot: read the backup, the room list or any Space; recover an account without the user's code, passkey or device. There is no operator key.
  • Can: refuse service — withhold a backup, or delete one. Availability is ours to break; reading is not.

Guessing is rate-limited: an emailed code has six digits, expires in ten minutes and allows five tries; every recovery step is limited per IP. A recovery code is 128 random bits.

In your app

  • The Spaces app holds the account in the page (regular app), or in the vault (hosted app) — the frame that draws the screen never holds the master or the room keys (the app as an operating system).
  • The SDK: wb.backup is the same backup, one per app, with the email gate when the user is signed in; wb.account is the live room list. In vault mode your vault holds the account (SDK — account backup, vault mode).

The confidential tier — a room no one outside the enclave can read

In a confidential room every turn runs inside a measured AWS Nitro enclave, and the model that answers runs on confidential-compute inference. Your device checks the enclave before it trusts it with the room key; each link checks the next before it passes anything on; and every answer comes back signed with what produced it. "We cannot read your room" is a property you can check, not a promise.

Status: Production, always on. The app builder chooses the tier per room; the platform can run any room in it. The trade: open-weights models instead of a frontier vendor's.


  1. Your device checks the enclave. It fetches the enclave's attestation — a document signed by AWS Nitro hardware the operator cannot reach — with a fresh nonce, verifies it against the AWS root pinned in the app's source, and compares the measurements with the published ones (/enclave-claim.json). Anything else is refused before a byte is sent.
  2. It seals the room key to the enclave. The enclave makes its keypair inside itself at boot and never exports it; the attestation binds its public half. Your device seals the room key to that key, so our servers carry it but cannot open it.
  3. The turn runs inside. The enclave opens the room, runs the assistant and its tools, re-seals the result and writes ciphertext back to storage. The credentials the enclave itself needs are released to it only by a key policy that checks its measurement — an account administrator is refused.
  4. The model call stays confidential. The enclave sends a prompt only to an attested no-log gateway into confidential-compute inference. Its measured code verifies the gateway's attestation; the gateway's measured code verifies the model's enclave (AMD SEV-SNP, or Intel TDX with NVIDIA GPU evidence) before forwarding. Every call — the turn and every helper call — must come back with the gateway's signed inference receipt, bound to a fresh nonce and to the exact request and response, stating that the upstream was verified. Anything else fails closed and the answer is discarded.
  5. The answer carries its proof. Each reply is signed inside the enclave with the build that ran it, the verified model receipts and the tool calls it made. space.certificates() checks a room's whole history on the device (SDK).

What you can check yourself

Check How
The attestation is genuine AWS hardware The COSE signature and certificate chain, to the AWS Nitro root pinned in source — the root the document carries is discarded. Runs in the browser.
The image is the published source Rebuild it: the measurement (PCR0) reproduces from published source, with every build input pinned. Every release is also rebuilt by a separate build enclave and logged in Rekor, with a published audit verdict.
It was signed by our signing certificate PCR8 derived from the published certificate yourself; a stated value that contradicts its certificate is rejected.
It is live, not replayed Send a nonce; it must come back inside the signed document. Without one the result says fresh: false.
This answer came from that build The reply's turn certificate: its signature, its attestation, the audit verdict for its build.

The commands: Verify it yourself.


Always on, and updated without downtime

One measured enclave serves every confidential room, always on, so a first message never waits for a launch. If it stops answering with a valid attestation, an orchestrator replaces it from a signed template — the new instance pulls its measured image and opens its credentials through the measurement-gated key policy, with no operator step.

A new build runs beside the old one: the published claim names both measurements during the switch, so devices accept either, and the old one is retired after the cutover. Each new instance makes a fresh key, so every device re-verifies before it seals.


The trade: open-weights models

A model can run confidentially only where it can be measured: open-weights models (DeepSeek, Kimi, Qwen, GLM, gpt-oss and others), not a frontier vendor's. A room that needs a frontier model takes the standard tier, where the turn reaches that vendor (privacy tiers). The builder chooses per room.

What it does not hide

  • Some measured environment reads the turn to answer it. Attestation cannot make computation invisible to the thing computing it. What this tier guarantees is that every environment that reads the turn is measured and verified.
  • The chain is transitive. Your device checks the turn enclave; its measured code checks the gateway; the gateway's checks the model's enclave. You do not receive the model chip's own quote directly.
  • The weights are vouched, not measured. The machine, the serving stack and the workload are measured; that the weights are exactly the named model's rests on the attested stack. This is true of every confidential-inference offering today.
  • A measurement proves the code, not its behaviour. It proves the running image is the one you can rebuild. Whether that code does what we say is what reading it is for.
  • Metadata. Which room is active and when, and the sizes and timing of messages.
  • Availability. The host can refuse to forward a request; it cannot read it.

Deployment options

Witbitz can run as a hosted platform, inside a customer's cloud boundary, or in an air-gapped environment. The product claim shifts slightly in each mode — so this page keeps the deployment model explicit, then documents all three in one place: hosted (the private-beta path), your own boundary (enterprise / VPC), and air-gap (zero egress). The runtime is the same reproducible image in every mode; what changes is where its seams point and what a skeptic gets to check.


The options

HostedWitbitz operates the runtime; builders get tenant keys during private beta.
EnterpriseThe same runtime runs inside a customer cloud boundary.
Air-gapNo external egress; model and replacement services stay local.
Option Status Boundary Best fit
Hosted private beta Live, invite-only Witbitz-operated cloud Early builders and platform integrations.
Enterprise VPC / on-prem Runnable today Customer-controlled cloud or network Enterprise buyers who want their own model, storage, IdP, and ops.
Air-gap Runtime switch exists; self-hosted model quality is the remaining work No external egress Regulated, defense, government, and high-control deployments.

Choosing a mode

Need Start with
Build against the API quickly Hosted private beta
Keep data and model calls inside your tenant Enterprise VPC / on-prem
Prove no external egress Air-gap
Verify hosted privacy claims Hosted + the Verify page
Remove Witbitz as an operator On-prem

Hosted

Hosted is the default private-beta path. Witbitz operates the runtime and exposes the tenant-keyed /v1 API to invited integrators. The docs and API contracts are public; tenant keys are gated. This is the operating model for builders building on Witbitz-hosted infrastructure.

What you get

Surface Status
Spaces app Live
/space async runtime Live
Tenant-keyed /v1 API Private beta
OpenAPI production spec Public
Full design OpenAPI Public, status-labelled
Owner rule Implemented (not enabled by default)

Access

Register a coupon-free demo tenant straight from the Get Started walkthrough, or redeem an access code and create a tenant key pair at:

code
https://witbitz.chat/access

Keys are shown once. Secret keys (wsk_…) are server-side only. Publishable keys (wpk_…) are browser-safe identifiers for public reads and funnel-entry writes, but they do not authenticate an end user. If you do not have an access code, email hello@witbitz.chat.

API contract

The production /v1 API is documented openly:

code
https://api.witbitz.chat/v1/openapi.json
https://api.witbitz.chat/v1/openapi.full.json
https://api.witbitz.chat/v1/

During private beta the contract may change in coordination with invited integrators. Treat openapi.json as the production callable surface. See HTTP API.

Hosted trust evidence

Hosted mode is designed to be checked from outside — every claim has a place you can inspect it:

Evidence Where
Admission gate refuses unauthorized reads/writes Verify it yourself
Stored ledger is sealed to a key we never hold op:'sealed'
App egress is browser-enforced Content-Security-Policy
Deployed render build is signed /cert.json
Published source reproduces the deployed hash /source.tar.gz and Test 7

The confidential tier is in production: a hosted Space can run its turns inside a Nitro enclave, with its key sealed to the attested image. The enclave service is always on and serves every confidential room; a Space is inactive between turns and keeps only sealed state, so its first message never waits for a launch. A new enclave build replaces the old one without downtime. The tier is the app builder's choice per room — the platform can run any hosted room in the enclave.

What hosted can and can't see

Hosted Witbitz can still see metadata: room existence, request timing, ciphertext sizes, billing/metering events, and transient identity during gated verification. It cannot read the sealed ledger, because there is no operator recovery recipient — see Trust model.

When that metadata surface isn't enough — you need customer-controlled model calls, customer-owned storage and records, your IdP as the only identity authority, and no Witbitz-operated runtime in the path — move to your own boundary.


Your own boundary — enterprise & VPC

Status: shipped and runnable today (VPC tier). The entire Witbitz runtime — the app, the agent, the sealed ledger, admission — runs inside your own network, off our cloud, on infrastructure you control. One switch (AIRGAP=1) takes it to zero egress. The one piece still in progress is a self-hosted model for the fully air-gapped case (below).

Enterprises increasingly won't send their data — or their AI — to a third party. Witbitz answers that literally: you run the whole thing yourself. And the design keeps this from forking the product — every external dependency is a single environment switch that defaults to the hosted behavior when unset. The build we run for the public and the build you run in your tenant are the same reproducible image; you just point its seams at your own infrastructure. Same code, same verifiable-privacy properties, your boundary.

Two tiers

Tier Boundary Model Who it's for
VPC your cloud tenant; may reach your chosen model API + map services your own cloud key (OpenAI / Azure) most enterprise buyers
Air-gapped no egress at all a self-hosted model, in-boundary defense · government · regulated

The VPC tier is a complete, operable product now. The air-gapped tier is one switch away — except for the self-hosted model, which is the remaining substantial piece (see Air-gap below).

One command

The runtime ships as a single container plus a Compose stack that wires the whole thing — the app, an S3-compatible object store for the sealed ledger, the record store, and a reverse proxy with automatic TLS:

bash
cp space.env.example space.env          # your model key + a domain
docker compose up -d --build            # the whole stack, off our cloud
curl https://space.your-co.com/space-config

There is no per-app server to stand up and no dependency on our infrastructure. The app the browser loads is the same static, egress-locked, reproducibly-built page — now served from your host.

What each concern maps to

Every seam is a switch. Unset, it's the hosted default; set, it points at infrastructure you own.

Concern Runs on Switch
Model any OpenAI-compatible endpoint — your cloud key, Azure, or a self-hosted vLLM / Ollama LLM_BASE_URL
Object store (the sealed ledger) MinIO or any S3-compatible store S3_ENDPOINT
Records (Spaces + Bridges) a local store (single node) or Postgres (multi-node HA) DATABASE_URL
Secrets mounted files — a Kubernetes Secret or a Vault agent — instead of a cloud secrets manager SECRETS_BACKEND=file
Who may join your own identity provider — Okta, Entra, or Keycloak — as a trusted OIDC issuer SPACE_OIDC_ISSUERS
Operations backup / restore, a retention sweep, bounded log rotation included in the kit
Zero egress refuse every outbound path — see below AIRGAP=1

Your identity provider

An email-gated Space admits members who sign in with an issuer you trust. Register your corporate IdP and it's authoritative — no dependency on any consumer sign-in. It's additive and fail-closed: a registered issuer can't weaken the built-in ones, the audience is checked (so a token minted for another app can't be replayed), and the Space's own membership list still decides who's actually in. The IdP only proves identity; the Space decides admission. See Identity and admission.

Operate it

The record store has no silent auto-expiry, so retention is explicit: a sweep reaps Spaces idle past a window you set (records carry no timestamps — they hold no plaintext — so "idle" is measured from the last write). Backup captures the sealed ledger, the records, and the secrets in one archive. Logs rotate with a bound and stream to your stack (Loki / ELK / your SIEM). For high availability, switch the record store to Postgres and run more than one node.

The trust story gets stronger, not weaker

Hosted, our signed /cert.json and reproducible build let a skeptic confirm the operator — us — is running exactly the published, readable source, byte-for-byte (Tests 6–7), with the app's egress locked (Test 4). That we are blind to your data in use — unable to read the plaintext during the transient decrypt-and-render instant — is the attested server tier, now in production: turn on the enclave and verify it against the running measurement. On-prem there is no third-party operator at all, so the cert's meaning inverts into supply-chain proof: the image you are running is byte-for-byte the audited source — nothing was smuggled in. This is where verifiable privacy is at its strongest: both halves are checkable by you, on hardware you control, without trusting us at all.

Verify a running deployment

bash
# the egress allowlist, as a header — in air-gap it names no external host:
curl -sD- https://space.your-co.com/ -o /dev/null | grep -i content-security-policy
# the exact build the box is running:
curl -sXPOST https://space.your-co.com/space -H 'content-type: application/json' -d '{"op":"attest"}'

On-prem and air-gapped deployments are handled as enterprise engagements — hello@witbitz.chat.


Air-gap — zero egress

Air-gap mode is the deployment setting where Witbitz refuses every external network path it can control. It is for environments where "no data leaves" must be a property you can inspect, not a vendor promise. The short version: set AIRGAP=1, point the model at an in-boundary endpoint, and verify the browser and server both name no outside hosts.

Status. The runtime has a zero-egress switch today. The remaining substantial work for a fully useful air-gapped tier is not the Witbitz runtime itself — it is operating a self-hosted model with the quality the deployment requires, plus local replacements for any external data tools you want to keep.

What AIRGAP=1 changes

Layer Behavior
Browser Content-Security-Policy removes external hosts — the browser itself refuses any external fetch.
Server External map, route, photo, URL, search, and flight paths are refused at a single chokepoint.
Tools Internet-touching tools fail closed even if a Space configured them.
Model Must be pointed at an in-boundary endpoint; otherwise the turn still leaves the network (the runtime warns loudly at boot).

What still works

Chat, agent turns against an in-boundary model, document reading, PDF generation, charts and diagrams, shared widget state, IdP admission against an in-boundary identity provider, and backup / restore / retention / logs.

What is disabled or needs a local replacement

Public web search · external URL import · Google Places photos/search · map tiles, geocode, and routes (unless self-hosted) · flights lookup (unless backed by an in-boundary service) · remote model APIs.

Verify the boundary yourself

bash
# Browser boundary — in air-gap the CSP names no external host:
curl -sD- https://space.your-co.com/ -o /dev/null | grep -i content-security-policy

# Running build — compare to the published source and your own deploy process:
curl -sXPOST https://space.your-co.com/space -H 'content-type: application/json' -d '{"op":"attest"}'

The trust story

Hosted Witbitz asks you to verify the operator's deployment. Air-gapped Witbitz removes the third-party operator from the path, so the check becomes supply-chain oriented:

  • Is the image you run the audited source? (op:attest returns the exact build commit the box is running.)
  • Does the browser policy name no external host?
  • Do server-side tools refuse outbound access?
  • Is the model endpoint in-boundary?
  • Are logs, backups, and retention under your control?

Read next: Verify it yourself · Status · Trust model


Status — Production, Beta, Implemented, and Design

This page is the product map, and the source of truth for each mechanism's maturity. Witbitz uses four literal states — nothing else:

  • Production — running in production today. (Whether every Space has it structurally, or an app enables it per room, both are in production.)
  • Beta — usable by invited integrators; the contract may change.
  • Implemented — built and tested, the code exists, but not enabled in production.
  • Design — the architecture is specified, but the mechanism is not built.

Maturity is separate from two other facts, and this page never collapses them into the maturity word:

  • Scope — whose property it is: the platform, the first-party app, the client, or the server.
  • Configuration — whether every Space gets a mechanism (structural) or an app switches it on per room. That is a config fact, documented per app (for the Spaces app: privacy in the Spaces app) — never a maturity qualifier. (The attested tier is Production, full stop; the per-room enable is a builder choice, not an "opt-in" that makes it less-than-production.)

Where a mechanism is Production in one place and Design in another, it is split into separate facets so no single label ever describes two states. For the privacy configuration spine — the canonical maximum, the mechanism axes, the named modes, and the feature × mode matrix — see Privacy tiers.


Summary

Capability Maturity Scope What to say
Async Spaces Production platform Persistent shared rooms with AI turns, sealed ledger, documents, widgets, and artifacts.
Private lanes Production platform Each participant can have their own lane — a separate Space, linked on the device; its agent observes the shared room and cannot post to it.
Progressive replies Production platform The answer is sealed and published as it is written, so a reader starts reading before the turn finishes.
Away notification Production platform Content-free Web Push wakes an installed PWA; the notification carries no message text.
Offline export Production client A Space exports to a file you keep, with a standalone decryptor and sealed per-Space metadata.
Admission gate Production platform Gated Spaces refuse reads and writes without an allow-listed identity.
Content-blind storage Production platform The store holds ciphertext sealed to the room key; no operator recovery recipient.
Approvals Production platform High-stakes actions require human approval before execution.
Egress lock Production platform The browser-visible app can reach only a published allowlist.
Signed build certificate Production platform /cert.json binds the deployed render build and egress footprint.
Reproducible render build Production platform Published source rebuilds to the deployed Lambda hash.
Attested server tier Production platform A Space runs its turns inside a measured Nitro enclave in production; the key is sealed to the attested image. Always on. The app builder enables the enclave per room.
Recovery — vault Production client One vault opened by the recovery code with an email check, or by the backup passkey; stored as opaque blobs at derived ids. No operator recovery key; losing every enrolled method is unrecoverable by design.
On-prem VPC tier Production platform The runtime runs in a customer's boundary with customer model, storage, and IdP seams.
Usernames (@name) Production platform An optional, unique public address over the account's handle: invite by @name, @name on an allow-list, and the server-attested @name on every verified entry. Renaming never costs access.
Email discovery switch Production platform Each account decides whether its email can be looked up; closed reads exactly like unknown. Invites by @name still drop in-app.
Members (verified rooms) Production first-party An email-gated room lists its admitted people with their standing — signed in, not yet opened, allowed — by the identity the server saw, never a typed name.
Connections (standing Sentinel) Production platform A member's credential lives in an always-on measured enclave; a Space's assistant spends it through one proposed-and-approved tool and never sees it. Bearer tokens and OAuth grants, the refresh inside the enclave.
Siri inbox Production first-party "Hey Siri, Witbitz": a Shortcut posts dictated text to an endpoint whose TLS ends inside the enclave; the words are sealed to the Space and collected by your device.
Conditional writes (space.data) Beta client Every row carries the version that last wrote it; a put or delete may be made conditional on it and is refused with 409 row_changed otherwise.
Platform API Beta platform Tenant-keyed /v1 API; invite-only, public OpenAPI, contract may change.
Application SDK Beta client A small ES-module surface so a static page becomes a private, agent-backed, link-shared app; wraps the reference client.
Code section (OpenCode over the sealed relay) Beta first-party The Spaces app drives OpenCode on your own computer through an end-to-end sealed WebSocket relay; pair by QR, connector is a single-file download (Node) or pipx install witbitz-code (Python).
Cloud computers Beta first-party A coding agent's own computer in the cloud: a Nitro-enclave VM only its owner can open, files saved encrypted every turn, keys added outside it by the Credential Sentinel, chosen egress (nothing · packages and GitHub · listed hosts), a browser watched and taken over from any device. Part of the Plus plan; early access, invited accounts.
App owner rule Implemented platform An app owner mandating a signed admission policy and tiered capabilities for every room the app governs: built and tested, not enabled in production (inert unless configured). Not to be confused with a room's own owners and admins, below — those are live.
Room owners and admins Production platform An email-gated room names owners and admins in its sealed config. Admins add and remove members; only an owner changes the owners or lowers or removes an admin. Enforced by the room, in the standard and confidential tiers. SDK: admission.owners / admins, isOwner, isAdmin.
Membership re-key — Bridge Implemented platform Adding or removing a member forks the room to a new epoch key; live in the Bridge, which is itself an early preview.
Verified client (web) Implemented client The verifier extension forces verification and a CSP built from the signed release's hashes (no 'self'); measured on production, installed unpacked, not the default for visitors without it. Every module is already integrity-pinned by default.
Bridge protocol Implemented platform Cross-organization governed collaboration exists as an early protocol surface.
Membership re-key — single-Space Production platform Changing a gated room's members gives it a new key, in place: the same room, link, history, store and lanes, and a key only the members still on the list receive — each sealed to the member's invite key — so a removed member cannot open anything written after. In standard, confidential and hosted rooms; in the app (Members) and the SDK (space.changeMembers(), space.rotateKey()). A membership change or an explicit rotation triggers it — no schedule.
Social recovery (k-of-n) Design client k-of-n social recovery is designed, not built.
Attested client (native shells) Design client Native hardware-attested shells are not shipped. The server's App Attest and Android key-attestation verifiers are built and tested with synthetic keys (no real-device evidence yet), and the Android app builds reproducibly.
Companion Design client A native helper may improve notifications and key storage; the platform does not depend on it.
Confidential model (attested inference) Production platform The confidential tier routes its model call through an attested no-log gateway into confidential-compute inference on an open-weights model (The attested tier).
OHTTP (network-metadata) Production platform Oblivious transport (RFC 9458), behind the "Hide my IP" toggle: a pinned L1.5 client + a client-verified relay (GCP Confidential Space, relay.witbitz.chat) — no party sees both your IP and your rooms. Covers all app→AWS traffic incl. the enclave attest/wake path (fail-closed); relay verification is offline (Google's Confidential-Space keys baked into L1.5, so checking the relay never reveals your IP to Google) (Privacy tiers).
Traffic shaping (client + server halves) Design platform Sizes/timing/patterns in both directions; specified, not built.

Production

Async Spaces

The production Spaces app runs the async model: a member posts a turn, the render opens the sealed ledger, the agent answers, state is re-sealed, and the Space goes cold again. Idle Spaces do not require a running server process. Read: How a turn runs

Identity and admission

Spaces support key-possession membership and deployed signed identity for gated rooms. For an email-gated Space, reads and writes both require an allow-listed identity; a link holder without that identity receives 403. Read: Identity and admission and Verify it yourself

Usernames and discovery

An account may claim a @username: a public address over its stable handle, never a key, so a rename costs no room any access. It works as an invite target, on an allow-list, and as the server-attested author mark on verified entries. Each account also decides whether it can be found by email; closed reads exactly like unknown. Read: Usernames, discovery, and who is in the room

Connections

A member attaches a credential once; it lives sealed in the standing Sentinel, a measured always-on enclave. Each Space is granted the connections its assistant may spend, and every spend is a proposal a member approves. The model sees the reply, never the secret. Read: Connections

Approvals

Effectful agent actions go through propose, approve or deny, then execute. Execution is refused unless the proposal is approved and has not already run. The audit trail is sealed with the room. Read: Approvals

Verifiable privacy checks

The current production checks cover the admission gate, the app's egress footprint, content-blind storage, the signed build certificate, and a reproducible build of the published render source. Read: Verify it yourself

Attested server tier

A Space runs its turns inside an AWS Nitro enclave in production. The room key is sealed to a key generated inside the measured image and bound into a hardware-signed attestation that the deployed web app verifies and pins before it seals anything. Runtime secrets reach the enclave only through a PCR8-gated KMS grant — the measured image is the only thing that can decrypt them. The tier runs always on, so the first turn never waits for a launch. The app builder enables the enclave per room — the platform can run any room in it — and the model call leaves the measured image only through an attested no-log gateway into confidential-compute inference on an open-weights model — the frontier-vendor residual applies only to rooms that choose the frontier trade (the attested tier). Read: The attested tier

The Siri inbox

Dictated text posted to an endpoint whose TLS terminates inside the standing Sentinel, sealed to the Space and collected by your device. Live and attested. Read: The Siri inbox in the SDK

Beta

Cloud computers

The coding agent's own computer in the cloud, from Code → ☰ → Computers → New cloud computer. Each runs in an AWS Nitro Enclave whose image is pinned and attested; its workspace is saved encrypted after every turn and opens only for the account that made it; it sleeps after 30 minutes unused. Part of the Plus plan; during early access, open to invited accounts — the rest are told so when they create one. Read: Code and the Spaces guide's "Cloud computers"

Platform API

The /v1 API is tenant-keyed and documented with a public OpenAPI contract. Access is invite-only during private beta; the contract may change with invited integrators before general availability. Read: HTTP API

Application SDK

A small ES-module surface (Witbitz/Space) so a static page becomes a private, agent-backed, link-shared app; it wraps the exact reference client production runs. Usable today; the surface may still change. Released as fixed, versioned files with integrity hashes; an npm package (with the @witbitz/test harness) is not released yet. Read: The SDK

Implemented (built, not enabled in production)

App owner rule

Not a room's owners and admins (those are live). The app owner rule lets an app owner mandate signed admission policy and tiered capabilities for every governed room. The implementation is built and tested but inert: unset keys leave existing rooms unchanged, so it is not enabled in production. Read: The owner rule

Verified client (web)

The web verifier extension + CSP-loader pins the platform-signed release and forces the egress-lock in the running browser. It is built and proven headless on staging, gated behind ?vc / the extension — not the default production boot. Read: The verified client

Membership re-key (Bridge) · Bridge protocol

In the Bridge, adding or removing a member forks the room to a new epoch key the removed member cannot derive. The Bridge itself is an early cross-organization protocol surface. Read: Identity and admission

Design

Attested client (native shells)

The native attested-client apps are not shipped. On the server, the parts are built: the admission check, and the real verifiers behind it — Apple App Attest (attestation and assertions, against Apple's root) and Android key attestation (the certificate chain, the key description, the app's signing certificate). They are tested with synthetic keys only: no real device's evidence exists until a native app does. Android's app binding is a best-effort check of the signing certificate, and a Windows client stays refused. The rule for the native apps: the reproducible web bundle is shipped inside the attested binary and loaded locally. Read: The attested client

Social recovery · traffic shaping

k-of-n social recovery and traffic-shaping are each specified but not built. (Single-Space membership re-key is in production — in place, with a new key for the members who stay; see the matrix above; the confidential model and OHTTP shipped too — Privacy tiers.)


Deploy it — moved

This page was three things at once, and each already had a better home. It is kept only so existing links land somewhere true rather than on a 404.

  • Running Spaces as your own tenant → Run Spaces as your own tenant. One paste: register, provision, poll. This page used to say a hosted app was "provisioned per tenant — email us"; it is self-serve now.
  • Choosing where it runs — hosted, your own boundary, air-gapped → Deployment options.
  • Putting a Space in a page you already have, and the CSP notes for it → Embed.

Proving what any of them deployed is Verify it.

Machine-readable source: llms-full.txt · Generated 2026-10-27T07:05:42Z · build f301388c · every doc in one fetch: llms-full.txt (HTML)