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.
<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
- Build — the SDK: the example above, then members, tools, the store and private assistants.
- Understand — the platform · the trust model.
- 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
- A member posts —
{ op: 'turn', room, mk, message }. - 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.
- 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.
- The room opens. The sealed ledger is opened, and the new message appended.
- The assistant runs with the room's persona, model and tools.
- The result lands — the reply, any widgets and files it made, any proposals waiting for approval.
- 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.
pollsealedreturns 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 olderpollop 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).
Read next
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.
One paste
Needs curl and python3. Nothing to clone, no Cloudflare account.
# 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; doneIt ends with your address:
{"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_econobody 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.
# 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\"}"; echoTaking 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:
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:
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:
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 — prove the deployed bytes are the published, readable code.
- Deployment options · Embed · HTTP API · SDK
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.
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 folderThe 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:
- Forces
?vc=1on/space, so verification cannot be skipped. - 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. - Clears unapproved service workers, which would otherwise synthesize a response the forced CSP never reaches.
- 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:
| 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)
- 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. - From its link
…/space?room=sp-XXXX#mk=YYYY&gate=email, copy theroom(sp-XXXX) and themk(YYYY) — the room key that everyone with the link holds. - Discover the API endpoint (it's public; the app fetches it the same way):
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 toYou now hold exactly what a link-holder holds: the room and its key. The tests below show that is not enough.
Test 1 — the read gate: the link alone reveals nothing
A token-less poll — the read op — with a valid room key:
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"}
# 403Holding 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:
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"}
# 403Rejected 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:
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:
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-srcis 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-srcallows only same-origin files and each inline block by itssha256— 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-actionandobject-srcare 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:
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:
recipientsis["room"]. The envelope is sealed to exactly one key — the room key (mk), which lives in your link's#fragmentand 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 — anaws s3 cp, a backup, or a subpoena returns exactly these bytes: noise.sealedis opaque. It's an AES-256-GCM ciphertext; withoutmkit is indistinguishable from random. Confirm it's genuinely your chat, not a decoy, by decrypting it locally with themkfrom 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.
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):
{"kty":"EC","crv":"P-256","x":"TFXrR5DFL3Oo3rBwa6clwJnrfvw0TN2AewDDEcy8hBM","y":"MgLLzEq7g8B86qS7NNxkIW64ytp296d1kkK1wSKBoec"}// 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))) // trueWhat 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).
# 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:
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 aconnect-srcviolation (not merely that the header says so); - cross-origin isolation holds — the embedding site gets a
SecurityErrortrying 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
curlcheck 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
mkfor 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.
<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: |
Invite links
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:
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:
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
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.agentis{ name, instructions, tools?, model? }: the name up to 80 characters, the instructions up to 4,000.tools,immediateTools,tierandkeyare fixed when the room is made, andadmissionchanges only throughchangeMembers(); 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.openSpacedoes not read the page's own address; to open it, passlocation.href(invite links).space.readyresolves once the room's whole history has been read — a long room loads in pages. From then,messagesanddata.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 asresumeand the next open continues from it instead of reading the whole room again.space.shareLinkis the link to give someone: holding it lets them in.space.roomis the room's id.space.close()stops listening and releases the handle.
Temporary rooms
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 quiettemporary: truemakes 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.temporaryistruefor one. - Where a deployment cannot wipe rooms, the call is refused (
temporary_unavailable) rather than making a room that stays. A value other thantrueor{ 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".
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:
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
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, usespace.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.modeoverrides the reply mode for this turn.images(up to 4) andfiles(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
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).
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 })orspace.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.onis what this handle knows (nullbeforeawait 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 callawait 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.atis'images'or'files',ithe attachment's index; the default is the first image, else the first file. Copies are kept beside their message: they never appear inmessages. - 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”".
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
messagesand never firesmessage.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_authorizedwithe.reasonnot_on_list(the signed-in email is not on the list) orunsigned(no one is signed in; a sign-in that did not verify saystoken: …). unavailable: the room did not answer, and nothing was stored — try again.ok: falsemeans 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.chatinconnect-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:
await space.configure({ agent: { instructions: 'Never invent a value. Never create a project unasked.' } })- What changes in place: the assistant's
name,instructionsandmodel; the store's collections (data;data: nullremoves them); which platform tools it gets (platformTools); the builderframing; and whether it may write rows (dataWrites).configureanswers{ 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 throughchangeMembers(). For a different setting, make a new room and copy what you need into it. - Refusals, made by
configurebefore anything is sent:create_time(a field fixed at creation),use_fork(admission— usechangeMembers),lane(on a lane),unknown_field(anything else). A room whose owner signed its policy refuses every change in place withgoverned_room; another client that tries getsconfig_immutablefrom 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:
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 islow. A room that names no model gets the tier's default:gpt-6-solon the standard tier,deepseek-v4.1-flashon the confidential one. createSpace,privateLaneandconfigurerefuse a level the model does not take, before anything is made, withinvalid_effortand a message naming the levels it does take. A stored room whose model no longer takes its level (after switching models) runs atlowrather than failing.- Higher levels cost more tokens and time. On most models
lowis right for a conversational assistant; reach forhighor 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
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():
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)finishedresolves{ status, reply, text, proposed, usage?, error? }and never rejects.statusis'done','silent'(the assistant chose not to answer),'failed'(with the server'serror, such asnot_admitted),'aborted'or'timeout'.replyis the reply as a message — the same objecton('message')receives.partialstreams this reply as it is written;t.textis the text so far, andt.resultissend()'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 afterabort()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:
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' }costMicrosis in millionths of a dollar.source: 'provider-partial'or'none'means some calls reported no cost, and the total counts only those that did. Nousagemeans the turn made no model call.- A reply longer than about 30 s reaches
send()aspending: true, with no usage;space.turn()then reads it from the stored reply. Every assistant message inmessagescarries the server's record asusage(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.
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 seesinput, 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.
schemaasks 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 afinal_answertool, the result is checked, andoutputalways fits.- Text and images (
images, up to 4). Files are read through the room, withread_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.
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.
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:
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_sharedis 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.
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 defaultsframing 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.
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.
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()throwssign_in_requiredbefore 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.shinconnect-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:
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 withsign_in_requiredbefore 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.shinconnect-srcandblob:inmedia-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:
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 cardshowCard 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:
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()anddeny()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
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.
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'sspace.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:
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 abatch, one bad row refuses the whole batch. - The assistant's writes: a
data_putthat 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,
requiredanddescriptionappear in its data tools. A schema's descriptions take the place offields. - Supported:
type,properties,required,additionalProperties(true/false),enum,const,minLength/maxLength,minimum/maximum/exclusiveMinimum/exclusiveMaximum,items,minItems/maxItems, andformat(date,date-time,email,uri);description,title,defaultandexamplesare kept as notes. Anything else —patterntoo, 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:
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_changedand 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.
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 }), orwb.useSession(session)on a running client (nulldrops it).wb.signOut()forgets it. - Renew it before it expires (
session.exp) withrefreshSession(), the renewal the Spaces app runs. It throwsrefresh_failedwhen the person has to sign in again, andno_sessionwhen 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.
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:
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;
hiddencounts them. To show some to members, list them inadvertisewhen you make the room (admission: { emails, advertise: ['lead@keepwarden.com'] }), or passtruefor 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_unavailablewhen the settings cannot be read (the SDK refuses to guess), andsign_in_requiredbefore 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.
idand every name starting with_are the room's:data.putrefuses 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 ownverified, which was not earned here, and name who carried them (carriedByon a message,_carriedByon a row). A lane's mirror copies messages only (rows as lines, withmirror: { 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:
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') // → falseIn 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:
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.
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)directtakes the other person's email in place ofadmission: the room admits exactly the two of you (both addresses visible to members), you are its owner, anddirect: trueis 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 isnullfor 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);directtogether withadmission, 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.
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.statelike the session. It holds a private key: persist it and pass it back asnew 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.statusreadsheld_elsewhere, and its rooms saypendingwithreason: 'no_invite_key', until youwb.invite.restore(state). In vault mode the vault keeps and publishes the key:statusreadsvault, andrestoreis refused (vault_unsupported). unreachablelists 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 toldpending; send themlink.- Who may change it, or rotate its key: a signed-in owner or admin (
not_adminotherwise), 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
pendingwithreason: 'link_changed'. Ask for the link again. plan()answersin_placefor a new list, withcall: 'space.changeMembers(admission)'. A lane is one member's own room:changeMemberson a lane throwslane.- In a vault,
changeMembersandrotateKeygo 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 nolink.
@usernames
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.
claimrefuses a name that is taken (taken) or was released in the last 30 days (held).publish: truealso lists the person in the public directory, under the displaynamewhen 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:
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
carriedon a message and_carriedon a row:{ from, verified, user? / email? }, the server's marks from the room it was first written in, pluscarriedBy. - 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 withwb.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.
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:
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 listActions for one turn
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 ontoolsoffers extra actions — the same shape asactions— for this turn only, for actions that depend on what the member is talking about.replace: truemakes them the whole set for the turn; an empty list means none.setActionsreplaces the standing actions for every later turn; handlers for names you keep are kept.- What a turn actually proposed.
send()'s answer carriesproposed: [{ 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 whenproposednames 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
const lane = await shared.privateLane({ agent, mirror: true }) // or wb.openLane(link, { space, mirror: true })
await lane.mirrorNow() // copy what is new right awayWith 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 ansh: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 onsourcealone — a recreated room's own history is copied in withsource: '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 asmirrored, so a lane reopened withresumecatches 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
lane.setLinks([teamA, { space: teamB, label: 'Team B' }]) // every turn
await lane.send('What did both teams decide?', { links: [teamA, teamB] }) // this turn onlyThe 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.
Assistants that ask each other
A member with two lanes — say one for home and one for work — can let the two assistants ask each other:
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_lanesends 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.accounton, 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;0for none) limits how many times a chain of assistants asking each other may cross. Past it,tell_laneanswers "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>.
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 callsapprove()(with edited text, if they like),askBack(question)ordecline().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: trueasks back,reject: truedeclines). - 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()(asa2a): resume the lane with it and each waiting draft comes back as an'a2a'event — held until you listen for one, so anon('a2a')afteropenLaneresolves 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 itsreply. A follow-up is a new job. - One token per agent.
removePeer(id)revokes it and drops its waiting messages;lane.a2a.peerslists 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 savedstateinto a running client. - Over MCP.
lane.a2a.mcpUrlis the same inbox for agents that take a custom MCP server, with toolsmessage_memberandget_replyand 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 staysSUBMITTEDuntil 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.
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
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 countingThe 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
keysbrings their own ({ trustedRouter, tinfoil?, github? };keys_refusedif 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'withhosts. 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.modelis'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? }); arejectwith amessagetells the model what to do instead ("stop and ask me"). An older connector that does not take the message refuses it; when you pass thesession, 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,nulluntil 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:
<script src="https://witbitz.chat/embed.js"></script>
<witbitz-space link="https://witbitz.chat/space#<your link>"></witbitz-space>Or from script:
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 userWitbitz.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-ancestorsbefore 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.
Read next
- The assistant — your page's functions as the assistant's tools.
- Verifying the client — why the code in the iframe can be trusted.
- Identity and admission — who may enter a room.
Accounts and connections — the backup, the device, connected services
An account is a verified email — the one on a sign-in, never a field a page fills in. It holds what belongs to the person rather than to one room: the list of their rooms and its backup, their devices, the services they connect, and their Siri inbox. Signing in is on People.
Part of the SDK · every call: the reference.
Account backup and the recovery code
A person's rooms are in their links, and a lost device loses the links. wb.backup keeps them in the same encrypted
account backup the Witbitz app uses, opened by a short recovery code:
const wb = new Witbitz({ backup: JSON.parse(localStorage.getItem('wb-backup') || 'null') })
wb.backup.onState = (s) => localStorage.setItem('wb-backup', JSON.stringify(s))
const { code } = await wb.backup.enable() // e.g. 44YW-572I-… — show it, for the person to keep
// on a new device:
const { rooms } = await wb.backup.restore({ code })
// → [{ room, link, kind: 'space' | 'lane', label?, shared? }]: reopen with wb.openSpace(link) / wb.openLane(link, { space })Once it is on, every room this client creates or opens is recorded.
- Witbitz cannot open it. The code never leaves the device. It unlocks a random master secret, which seals the list — each room's link, and so its key. The server stores ciphertext, under ids derived from the secrets.
- One backup per app. Each
enable()makes its own backup and code, so your users' backups never mix with their Witbitz app account or another app's. - The email as a second factor. If the person is signed in when the backup is turned on (or you pass
{ email }), its recovery gate also records that address — as an HMAC under a key derived from the code, so the server cannot read it. From then on the code alone does not restore:restore({ code })emails a six-digit code to that address, and only that one, and throwsemail_code_requiredwith aticket;restore({ code, ticket, emailCode })finishes. A backup made with no email is opened by the code alone. How recovery works end to end: accounts and recovery. - By hand:
wb.backup.add(spaceOrLane, { label? }),.remove(room)and.save();wb.backup.roomslists what is kept (up to 500), andwb.backup.keyFor(room)returns a kept room's key. - The invite key is kept too.
wb.backup.inviteis the account's invite key, so a restored device can be added to rooms again;wb.invitehands it over by itself, andwb.backup.setInvite(state)puts one in by hand. wb.backup.stateholds the master secret. Store it as you store room links, and never send it anywhere. It lets a device keep saving without the code.- Older backups.
formatis'account'for every new backup;upgrade()rewrites an older'legacy'one in the account format, with the same code and rooms — after which an older SDK can no longer restore it.
One account on every device
The backup brings a person's rooms back on a new device. wb.account keeps the devices already running in step: a
room made on the phone appears on the laptop, and archiving, renaming or forgetting a room reaches every device. It
turns on with the backup and needs nothing else.
const wb = new Witbitz({ backup: saved, deviceName: 'Dana’s phone' })
wb.account.onChange = ({ rooms, devices }) => render(rooms)
await wb.account.sync() // any time → { ok, rooms, devices }
wb.account.rooms // [{ room, link, kind, label?, shared?, archived? }], archived ones included
wb.account.archive(room) // archive(room, false) undoes it
wb.account.rename(room, 'Kyoto trip')
wb.account.forget(room) // off the account on every device; the room itself is not deleted
wb.account.devices // [{ id, name, seenAt, signedOut, current }]; wb.account.deviceId is this one
wb.account.signOut(id) // that device drops the account at its next sync
wb.account.onSignedOut = () => { localStorage.removeItem('wb-backup') }- Changes arrive in about a second. In a browser it holds a socket on the account's own
live stream. A sync every minute while the page is visible, and whenever it comes back into
view, covers a socket that cannot open; your CSP needs
wss://code-relay.witbitz.chatinconnect-src.wb.account.stop()ends the background syncs andstart({ every: 60_000 })turns them back on; it starts by itself. - Witbitz cannot read it. The list lives in a Witbitz room whose id and key every device derives from the backup's master secret, and its content is sealed on the device under a third derived key before it is sent.
- Devices agree entry by entry. Each room, removal and device keeps its own newest version, so devices converge in whatever order they sync. A device whose read fails writes nothing, so a new or offline device can never replace the account's list with its own.
- Forget is not delete.
forgettakes the room off the account, and opening its link later brings it back. To erase a room's content, useadmin.rooms.deleteon your server. - Signing a device out is the SDK's part only. The device drops
wb.backup.stateand callsonSignedOut; clearing whatever else your app keeps is yours, in that callback. wb.account.enabledis true once the backup is on;wb.account.lastErroris the last background sync's error (unreadable,unopenable,save_failed, …) ornull;wb.account.linkslists the linked lanes.
What the account used
Every reply costs something, and in a shared room someone else may pay: the room's admin, your app, or Witbitz.
wb.account.usage() answers what this account's turns cost in a month, across all its rooms, whoever paid:
const u = await wb.account.usage() // this month (UTC); or usage({ month: '2026-10' })
u.replies; u.costUsd // the replies this account's turns got, and their cost
u.byPayer // { you, admin, app, witbitz }: micro-dollars each paid
u.byDay // { 23: 1234, … }: micro-dollars by day of the month- Recorded on every turn. Each turn carries a receipt address only the account's devices can derive; the server
seals the reply's cost to it, in the room, and
usage()collects and adds them up. - The same on every device. The usage key travels in the account's list, and the monthly totals are kept with it, sealed the same way.
- In a vault, the vault holds the usage key, adds the receipt to each turn and answers
usage().
Protect what the app keeps on the device
Your app keeps things on the device: the sign-in session, the backup state, room snapshots and links (which carry room
keys), and the Siri and A2A state (private keys). In localStorage, any infostealer that copies the browser profile
can read them. wb.protect seals them under a key a platform passkey derives — Windows Hello, Touch ID, Android —
and that key is nowhere on disk:
import { Witbitz, Protect } from 'https://witbitz-spaces.pages.dev/witbitz-sdk.js'
const protect = new Protect() // first: the session and the backup are kept there
if (protect.state.locked) await protect.unlock() // one Face ID / Touch ID / Windows Hello prompt
const wb = new Witbitz({ protect, session: await protect.load('wb:session'), backup: await protect.load('wb-backup') })
wb.backup.onState = (s) => wb.protect.save('wb-backup', s)
await wb.protect.save('wb:session', wb.session)
if (wb.protect.available() && !wb.protect.state.on) await wb.protect.enable({ label: 'My app' })
wb.protect.autoLock(5) // lock after five minutes away
wb.protect.on('locked', () => showYourLockScreen()) // the SDK draws nothingsave,loadandremovekeep values in aStorage—localStorageunless you passprotect: { storage }. While protection is off they store values as they are, so an app can route its storage through them from day one;enable()seals what was saved before, anddisable()writes it back in the clear.seal(value)andopen(stored)do the same for a store of your own, such as IndexedDB or a file.- A lost passkey loses what it sealed. So
enable()refuses withbackup_requiredwhile the backup is off — the backup is what brings the rooms back on a new device. Pass{ withoutBackup: true }if you keep nothing you cannot get again. - While locked,
loadandsaverefuse withlocked, so nothing is ever written in the clear by mistake.unlock()refuses a passkey that opens nothing here withwrong_passkey. - What it does not cover: malware that reads the page's memory while it is unlocked, keyloggers, screen capture. The away timer bounds that window.
- Browsers: a passkey that can hold a key (PRF or largeBlob) — Chrome, Edge and Safari can.
available()answers for the browser, andenable()throwsprf_unsupportedfor a passkey that cannot. In an iframe, the frame needsallow="publickey-credentials-create *; publickey-credentials-get *". - The passkey is your app's. It is made for your page's site (
protect: { rpId }for a parent domain) under its own user handle, so it never replaces another passkey your app made for the same site.
Connections
A connection is a credential the room's assistant can spend but never see: an API key, an OAuth grant, an MCP
server. It belongs to the account. The Sentinel — an attested, always-on enclave — holds the secret; a room's assistant
gets a tool that calls through it, and each call is a proposal a member
approves — except an MCP tool granted to run on its own (auto).
const gh = await wb.connections.attach({ label: 'GitHub', host: 'api.github.com', secret: token })
await space.connections.grant(gh) // this room's assistant may now propose connection_call
await wb.connections.list() // [{ id, label, host }]: never a secret, never a callKey
await wb.connections.detach(gh.id, { spaces: [space] }) // everywhere at once, and out of each room you pass- The page keeps only what a room needs.
attachchecks a fresh attestation of the Sentinel against the pinned measurement before it seals anything; a Sentinel running any other image is refused, and nothing is sent. It answers{ id, label, host, callKey, account }, and the secret is gone from the page. - Grants go only to gated rooms (otherwise
gate_required). In an open room the link is the whole key: whoever holds it can start a turn, and the assistant would spend the connection. - A lane of a gated room is gated to its member by itself: it takes the room's gate, narrowed to the signed-in
email. With nobody signed in it refuses
sign_in_required, and anadmissionthat would open it refuseslane_downgrade. - A lane of an open room is open unless you gate it, so gate one that holds a connection:
space.privateLane({ admission: { emails: [me] } }). - What the model sees of a granted
connection_callis the reply's status, content type and body; never the secret or the call key. The hosts a key may be used for are the Sentinel's allow-list — GitHub, Google APIs, Tinfoil and the package registries today, plus the hosts you declare. See Connections. - A signed webhook. A
whsec_secret with the webhook's wholehttps://address ashostmakes the Sentinel sign each approved request (Standard Webhooks), and only that one path can be called.attach({ webhook: true, job })makes an agent started by a web address — a Claude Code routine's trigger — a connection: see Outside agents. - Your own OAuth app. File it from your server with
admin.providers.attach({ kind: 'google', clientId, clientSecret, redirectUri, scopes }); then, in the page,wb.connections.startConnect({ tenant, providerId })→{ authUrl, state }sends the person to the consent screen, andwb.connections.finishConnect({ state, code })with the callback's values returns a connection likeattach's. The tokens stay in the enclave.
MCP servers
The Spaces app's ⋯ → Connections → Other MCP server, for your page. The server's own sign-in runs inside the Sentinel, which registers an app with the server and keeps the PKCE verifier; your page gets a URL and a state, and the code it receives goes back to the Sentinel sealed. The server's token never reaches your page.
const st = await wb.connections.startMcp({ url: 'https://mcp.linear.app/mcp', label: 'Linear' })
let conn
if (st.needsSignIn) {
location.assign(st.authUrl) // the member consents; your callback page receives ?code=&state=
conn = await wb.connections.finishMcp({ state, code, label: 'Linear', host: st.host }) // on the callback page
} else {
conn = await wb.connections.attachMcp({ url: 'https://mcp.deepwiki.com/mcp', label: 'DeepWiki' }) // or { token }
}
await wb.connections.mcpTools(conn.id) // { tools: [{ name, title, description, readOnly, … }], server }
await space.connections.grant(conn) // its recommended tools: reads run on their own, changes ask
await space.connections.grant(conn, { tools: ['list_issues', 'create_issue'], auto: ['list_issues'] }) // or your choice- The callback page is
<your origin>/mcp-callbackunless you passredirectUritostartMcp.attachMcptakes atokenfor a server you already hold one for. - Each tool reaches the room's assistant as
<label>__<tool>, and is a card the member approves unless it is inauto. With notoolsorauto,grantoffers the server's recommended tools and lets the ones the server marks read-only run on their own —readOnlyis the server's own claim, which Witbitz does not verify, so nameautoyourself where it matters. Anautocall still goes through the Sentinel: the credential stays there. - A server that registers apps itself — most do: Linear, Notion, Sentry, DeepWiki — works as shown. One that needs a
registered app (GitHub, Asana, Box) signs in with Witbitz's own app for now;
admin.providers.attachMcpfiles yours, for when the SDK uses it. - In a vault, the same calls run through the vault and the
callKeystays there: grant by id.
A new Sentinel build
When Witbitz moves the standing Sentinel to a new build, the SDK follows it: a build Witbitz publishes at
/sentinel-claim.json, on its own site and checked against the attestation like the pinned one, is accepted.
new Witbitz({ sentinel: { follow: 'published' } })is the default.follow: 'ask'callssentinel.onNewBuild(pcr0)and accepts only ontrue; otherwise the call fails withdeclinedand nothing is sent.follow: 'off'keeps this release's pins, so connections, Siri and outside agents stop working once that build is retired.- Releases before 2.2.3 cannot follow a new build; move to a later one before the build they pin is retired.
- A tenant running its own standing Sentinel pins its image with
new Witbitz({ sentinel: { pins } })(and its Lambda'sSENTINEL_HOP_PCRS); its own pins are never followed.loginBasepoints the sign-in endpoints at its own origin.
The Siri inbox
"Hey Siri, Witbitz": a Shortcut posts the dictated text to an endpoint whose TLS ends inside the enclave; the words are sealed there to the room's inbox key, and a device that holds the key collects them.
const { key, endpoint, alias } = await space.siri.enable({ alias: 'groceries', makeDefault: true })
// show the member the Shortcut: Dictate Text → Get Contents of URL, POST `endpoint`, JSON { key, text }
const st = wb.siri.state // persist it: without it, drops can never be opened
const { delivered, elsewhere } = await space.siri.collect() // posts each drop as the member (🎙-marked)
await space.siri.collect({ deliver: async (text, meta) => myInbox.add(text, meta) }) // or take each one yourself- Collect when the room opens and every time the page comes back to the foreground.
collect()opens this room's drops, posts each one as the member and acknowledges them;elsewherenames other rooms holding drops. - Keep the state.
wb.siri.stateholds the Shortcut key and each room's inbox key pair. Pass it back asnew Witbitz({ session, siri: st }), orwb.siri.restore(state)on a running client.wb.siri.shortcutis{ key, endpoint }, what the Shortcut posts with (keyisnulluntil Siri is on). - Account-wide:
wb.siri.rotateKey()invalidates the old Shortcut key as soon as the record is pushed;wb.siri.revoke()turns Siri off for the account;wb.siri.status()reads the account's record. deliver, for an app with its own inbox. Without it each drop is posted as the member, which starts an assistant turn. With it your function takes the drop instead: the words as spoken (no 🎙 — that mark tells a room something was said rather than typed) andmeta = { id, room, alias, at }, wherealiasis the word actually said andatis when Siri took it, not when you collected. To put it in the room as well, callspace.send()from yourdeliver.- At least once, not exactly once. A
deliverthat throws is not acknowledged, so a failed write never loses a drop — the next collect sees it again. The other side of that: a write that succeeded but whose acknowledgement failed is delivered again. Dedupe onmeta.id, which is stable for the life of the drop.
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:
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_acceptedrather 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_unavailablemeans it could not be read, and aviewerof your own must serve it (or setenclaveClaim). - 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.failedlists{ id, reason };uncheckedcounts 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).nullbefore 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.
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_PROFILEis the profile the token must name. - Where tokens appear. Every A2A lane's Agent Card names this endpoint (
capabilities.extensions, urihttps://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'snonceiseat_nonce, and itspublic_keysigned the token;user_datacarries the door's TLS key pin.
Split-key rooms
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 roomA 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_mismatchotherwise). It is chosen per room; rooms withoutkey: '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, andsplit_mismatchwhen the halves do not match the commitment. On the page,openSpacethrowsorigin_not_declared,split_unopenableorsplit_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.
// 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 tokenwb.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:
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) andscreens.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), pluscore/, everything that touches a key. Do not editcore/: a new run of the kit replaces only that folder, so your screens survive an upgrade. - Serving it.
HEADERS.mdlists the headers to serve it with (nginx, Caddy, Apache) — among themCross-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, thesignedInevent), 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
createSpacesends, 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):
curl -O https://witbitz-spaces.pages.dev/sdk/3.10.0/witbitz-admin.mjs # a released version; Node 18+, no packagesimport { 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.
WitbitzAdminsends 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 thereason. 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 awsk_).verifyTenantKey()settles which you have.- Older names. The page client's builder calls (
wb.origins,wb.users,wb.billing, …, withtenantKeyonnew Witbitz) still reach the same calls; a page that passestenantKeygets a console warning.admin.customersis whatwb.userswas: 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:
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 powerskey 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 answers403, naming the capability it lacks. A cap above the key's ceiling answers403 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.
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
signingKeywherever you make aWitbitzAdmin; 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 calladmin.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 readsregistered: falseafter 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.
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
Originheader the browser sends, so the server stores that exact form:https://hostorhttps://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/webhookswith{ "origins": [...] }and your key;GET /v1/tenantreads 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 theredirectUriyou registered withadmin.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.txtwith a line holding your tenant id (t_…), or - a DNS TXT record
_witbitz.<host>with the valuewitbitz-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 "
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
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.
// 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 })// 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.userRefis 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, andrequireUserToken(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:
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
perNetworkquestions from one network (an IPv4 address, or an IPv6 /64), and at mosttotalfrom 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 }—reasonis 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 withsettings:write).
What you are billed, from your server — the same numbers as the tenant portal:
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 daymonth 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", athttps://witbitz-spaces.pages.dev/api/email/jwks(select bykid; cache it, and fetch again on an unknownkid).- Check
aud === "witbitz-email",expin the future andemail_verified === true; the account isemail. - 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 againstspk, and that the token'snonceequals standard base64 (with padding) of SHA-256 over the canonical JSON ofspkexactly 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:
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 }(plusbackupsUntilwith'backups'), or{ deleted: false, held: true, room, purgeAt, after }with'grace'.restoreanswerspurgedorgrace_endedonce it is too late. turnWasRunning: truemeans a reply was being written at that moment and may land a few seconds later. Calldeleteagain 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
deleteagain 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}/restoreto 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:
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:
await admin.rooms.idleDeletion(180) // rooms no member used for 180 days: held, then deleted
await admin.rooms.idleDeletion(null) // offOnce 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:
// 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.deletewithretain: '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 asnotTheirs, because its creator decides.
How it holds. Three things in the page make it safe, and wb.erasure does all three for you:
- 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.
- 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. - 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.
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 backupfiled: false, rooms: 0fromerasemeans 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.
erasecannot 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 ashareLinkopens 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 exposesspace.keyso 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/webhookswithorigins). A page on an undeclared origin gains nothing by naming a tenant; a declared origin needs no credential on the page.live—falseturns 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);userTokenis a string or a function that returns one.tenantKey,tenantEndpoint— the builder side, server only; prefernew WitbitzAdmin(…)(see Your server).backup,deviceName,vaultApi— the account backup and index;vaultApidefaults tohttps://api.witbitz.chat/v1/vault.siri,a2a,a2aEndpoint— persisted Siri and A2A state; the A2A door defaults tohttps://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 yourvieweris 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
endpointat 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 todayhttps://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 /spaceanswers 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 })andspace.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 inmessagesas entries withkind: '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 }fromsend()(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 afteropenLaneresolves (they wait for one); an empty assistant line is never taken for a draft. - In a vault:
wb.vault.infofollows 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 withattested, 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) andHEADERS.mdlists it for nginx, Caddy and Apache; your screens andvault.jsare 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) —awaitit.wb.vault.openRoom(room)refuses a room the vault does not hold (not_in_vault), andJSON.stringify(wb)shows the vault's sign-in. - In a vault:
changeMembers/rotateKeywake a sleeping confidential Space first (it failed withconfig_unavailable); webhook and job connections keepwebhook/job; a role-only change is said as one and emits norekeyed;invite.enroll()resolves'vault';startMcp's sign-in comes back to your page's/mcp-callback. - Cloud computers: a Deny with a
messagefalls back to the plain refusal on a connector from before messages (given thesession); 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,removeandusage, and an openComputerwith 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 withopenLane: your device mirrors the Space into it (withoutmirror: 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.peersshowsjob: 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 withconnection_callinstead 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),changeMembersandrotateKeywake it and read again instead of failing with "only an email-gated Space"; a read that still fails isconfig_unavailable(retry). - SDK 3.5.0 — outside agents over MCP, and agents that take jobs; nothing breaks.
lane.a2a.mcpUrl(also returned byenableandaddPeer): 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 withmessage_member. See Outside agents.
2026-10-23
- SDK 3.4.0 — MCP servers as connections; nothing breaks.
wb.connections.startMcp,attachMcp,finishMcpandmcpTools, andspace.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 andadmin.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()andadmin.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
pendingwithreason: 'link_changed', notremoved. - 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 (
changeMembersasks them); the vault checks a member change is allowed before it asks;wb.invitesays the vault keeps the key; errors namewb.vault.signIn;wb.signOut()refuses withvault_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 therekeyed/removed/pendingevents. ⚑ Breaking:space.fork()is retired — it throwsuse_change_membersand makes nothing;plan()refuses ahistorywith the same code and answersin_placefor a new member list;capabilities().featuresno longer listsfork. 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 withstale_key. Move to/sdk/3.0.0/. Also: - A new Witbitz Sentinel build is followed everywhere.
wb.a2a,wb.erasureand 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 ownpins: never). - No private lane for an attested Space — a Space whose owner policy requires an attested app, not a confidential
Space:
privateLane()throwsattested(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.mjsin 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.declareanswersunproven, 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_requiredwith nobody signed in,lane_downgradefor anadmissionthat 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 refusesnot_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.jserrors carry acode; 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 failworker_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 withvault_unsupportedonmoveRefused, errors namewb.vault.signIn,account.lastErroris anError, speech-key limits say how long to wait, andopenLanerefuses 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.backupin SDK 2.2.1 or earlier fails at the email step (2.2.1 saysemail_refused; the server's reason isupdate_required). Load 2.2.2 (orlatest). 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
connectionsandsirigo 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 whosescreens.jscame from 2.1.x shows the kit's own recovery-code, restore and Siri-key screens (startVaulttakes abrandfor them). Everywhere: a stored answer's late stream frames are no longer replayed as apartial. Types:VaultRoom.lane/tier,Connection.callKeyoptional (none in a vault),rotateKey()may resolvenull,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 refusingvault_unsupported;wb.siri.shortcut.keyisnull(wb.vault.showSiriKey()shows it in the vault);wb.backup.enable()resolves{ enabled: true }andrestore()takes the code in the vault's screen; a Space's settings read carries no connection key;openLanetakes the lane's room id. Rebuild your vault with the 2.2.0 kit (it replacescore/only). Ascreens.jskept 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 refusedno_screen) until you add yours. Outside a vault: a confidentialfork()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, andwb.dictation()/wb.speak()/wb.speechwithout 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, andsealedProof()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 andwb.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'andadmission.removeUsersrefused,admission()withoutusernames/approve, norevokedreason,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,usageon a turn,isLaneCopy, signed webhooks inconnections.attach, registered MCP apps (admin.providers.attachMcp), and the confidential-room key healing. - New: direct chats —
createSpace({ direct: email })andspace.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 isinvalid_argument, a link that is not a Space linkinvalid_link, and a poll error with no code of its ownunavailable. Messages are unchanged. - Changed:
plan()returns{ how: 'refused', code: 'invalid_admission' }(orremoved) for a gatefork()would refuse, as documented; it used to throw.fork()'s own error for such a gate now carriescode: '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;laneon 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 carriese.reason. - Changed:
connections.revokeand the Siri record throw the server's code (orfailed/refused), notno_answer, when the server answered. - Changed:
tell_lanerefuses 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.removeUsersis refused (code: 'removed') andadmission()no longer returnsusernames(a legacy room's @username entries count inhidden). - Changed:
lane.link(other)links lanes of different tiers (it threwtier_mismatch). - Removed: a room's
revokedlist. Removing someone is a move to a room that does not list them (space.fork()); an existing room's list is ignored, andnot_authorizedno longer carriesreason: '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 returnsapprove. - Changed:
rename()throwsunavailablewhen the room did not answer or failed (status 0, 5xx); it used to write the shared doc and resolveok: false, as if an older server had taken the name.
2026-10-10
- Changed (TypeScript):
Message.spkis typed as the public key it always was ({ kty, crv, x, y }), notstring. Comparex/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, orcode: '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, notsource. - 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 nofrom/by, they now use the name this member last sent or acted under, else"A member". Passfromto 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.reactandsend(…, { 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 aschema, 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_querywithout the write tools) andsend(…, { 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:
usageonsend()'s answer and onspace.turn()'sfinished— 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.codeis the server's error code ande.bodyits answer. A bare 503 from the gateway ise.code === 'gateway_timeout'— the call ran past 30 s and may have finished; it is safe to call again.users.erasenow stops inside that limit and answersincomplete: truewhen there is more to do.
2026-10-08
- New:
space.title,space.rename()and thetitleevent (The room's name). In the next release;/sdk/1.1.0/is unchanged. A room's name entries never appear inmessages.
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()andLane.send()results always carry a booleanok. It isfalseon a refusal such asuser_token_required, which used to come back as{ ok: true, error }, andtrueon a reply, a silent turn, or a turn still running. Code that checkserrorkeeps 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:
platformToolsoncreateSpace,privateLaneandconfigure(Choosing the platform tools). - New: restricted keys,
admin.keys(Restricted keys);admin.rooms.list()andadmin.rooms.idleDeletion()(Your rooms, and the idle rule);wb.protect(Protect). - A moved room keeps its verified marks: rows carry
_carried, messagescarriedandcarriedBy(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 answerfirst_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
schemais 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 theSDK_VERSIONexport. - 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.jsstill 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 theSpaceitself: code that kepton()'s return value as the space should keep the space instead.- New:
WitbitzAdmininwitbitz-admin.js, the server-side client for builder calls (Builder calls).wb.usersisadmin.customersthere. Nothing is removed: the page client'swb.origins,wb.users,wb.billing,wb.rooms,wb.hosts,wb.providersandwb.verifyTenantKey()delegate to it, and a page that passestenantKeytonew Witbitznow logs a console warning. - A
Space,Laneand theWitbitzclient 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, soObject.keys(space)and{ ...space }no longer list_mk,shareLink,_clientor_wb. Read them by name as before:space.key,space.shareLink,wb.session. lane.a2a.addPeerrefuses a 33rd agent on one lane (too_many), as the Spaces app always has.- A private lane created without
agent.instructionsnow 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), andspace.messagesholds 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 — passlocation.href(Invite links).space.deny(id, { from })takesfrom, likeapprove.self: truemarks 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). signInerrors now also carrycode,reasonand a readablehint; 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. Handletext: nullas "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 unreadablestep.- New, nothing to change:
space.pending()and theproposalsevent (everything waiting, including proposals decided on another device),deny(id, { from }).
2026-10-02 (second release)
data.put/batchrefuse a row with anidfield or a field starting with_(reserved_field). Pass the row's key as{ id }. Rows are read without such fields.space.connections.grantrefuses a Space that isn't email-gated (gate_required).- A lane's
mirror: trueno longer copies the shared room's row writes; passmirror: { data: true }to keep them. - In a gated room,
admission(),admits()andisAdmin()throwsign_in_requiredbefore the member signs in. - Fixed:
adminsandapprove: '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 andresolvedfired.
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.
- 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. - 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.
- The key is sealed to that enclave alone. Your app seals
mkto the enclave's public key. Our servers carry the sealed bytes and cannot open them; only the measured image can. - 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.
- 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.jsonnames the same hash, so you can compare what you were served with what was logged. - 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.
- 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).
- 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
- Check the claims: Verify it yourself
- Configure privacy: Privacy tiers · privacy in the Spaces app
- Understand admission and lifecycle: Identity and admission
- Go unobservable in use: The attested tier
- Know the code on the user's side: The verified client
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:
- 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.
- 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.
- 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
commitOutso 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):
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
roomandexp, 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 butemailare serverless. - OIDC + cert-binding (
oidcVerify.ts·oidcBinding.ts·identityCert.ts). "Sign in with Google," without a backend and without trusting the transport.oidcVerifyis 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 bykidfrom the provider's JWKS, unknownkidfails closed.oidcBindingis the serverless heart: at sign-in the user sets the token'snonceto 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.identityCertpins 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 committedagentKeysand the assertion is bound to this connection and fresh. The room stores only the public key. - Capabilities (
capabilities.ts). A per-participantGrant= 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
/spacerender 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,
@usernameon an allow-list, the server-attested@nameon 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 answer501 user_unsupportedfor 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@namebeside 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.comoracme.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
@usernameon the stored entry. The client's own claim of a username is stripped. The room shows Alon @alon ✓ with the@alonattested 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:
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 ownerKeyThe 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
revokedlist (sealed in the config); the turn op rejects their next turn403even with a valid credential. - Reads — cut twice. Every read checks the allow-list and
revokedfirst, so a removed member's next read is403at 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.
Short links stay blind (/shorten)
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+expin 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 -Ithe 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.
- 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.
- 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.
- 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
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 passOAuth, 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).
// 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.
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:
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.
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:
await admin.hosts.declare(['api.todoist.com']) // server-side; replaces the list, so removing one is declaring without itA 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.attachMcpis kept but not used by the SDK yet.
Read next
- The SDK — sign in and connections: the full surface, with sessions and vault mode.
- Outside agents: other AI agents talking to a member's assistant, and agents that take jobs — a connection the assistant calls to start an agent's run.
- Proposals, end to end: the approval a call goes through.
- The attested tier: how measurement and PCR pinning work — the same mechanism the Sentinel uses.
- Verify it yourself.
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.mcpUrland 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
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 onceGive 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):
mcpUrlwith the headerAuthorization: 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
lane.on('a2a', ({ peer, text, draft, approve, askBack, decline }) => showForApproval(peer, text, draft))
await lane.a2a.collect() // bring waiting messages into the lane- 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.
- The assistant drafts an answer — its ordinary reply in the lane.
- The member decides. With
reply: 'ask'(the default) nothing goes back until the member callsapprove(text?),askBack(question)ordecline().addPeer(name, { reply: 'auto' }), orsetReply(id, 'auto')later, lets the assistant answer that agent on its own. - The agent reads it with
get_reply(MCP) orGetTask(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:
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_membertool. So the agent also needs the lane's door — add it as a peer withjob: true(lane.a2a.addPeer('Claude Code', { job: true })) and give its routine themcpUrland 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 withoutjob: trueis 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.
Read next
- Connections: how the Sentinel keeps and spends credentials.
- The SDK — outside agents over A2A and private lanes.
- Proposals, end to end: the approval every job goes through.
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:
pinned platform key → signed /cert.json (frontendSha256) → /assets-manifest.json → every file's hash/assets-manifest.jsonmaps every served file to its hash, plus the inline-script hashes./cert.jsoncommits 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
frontendSha256is the same hash the release log and Rekor carry, so what is served can be matched to a logged, reproducible build.
Checking it yourself
- From a terminal — fetch
/cert.json, verify the signature against the published key, compare the hashes with what is served and with the release log: Verify it yourself. - In your browser, continuously — the verifier extension.
- On a phone — the attested client.
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
- Get the extension from the repository (
docs/verified-client-real/extension/) and read its few files.config.jsonholds the origins it guards and the pinned keys — check them against Verify it yourself. - In Chrome, open
chrome://extensions, turn on Developer mode, choose Load unpacked and pick the folder. - Open the app. It reloads in verified mode, boots normally and the badge turns green.
- 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
- Verify it yourself · The attested tier · Trust model
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
- Verifying the client — the code in each realm is the published code
- Vault mode in the SDK · Privacy tiers · Trust model
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
- 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).
- 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.
- 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).
- 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.backupis the same backup, one per app, with the email gate when the user is signed in;wb.accountis the live room list. In vault mode your vault holds the account (SDK — account backup, vault mode).
Read next
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.
One confidential turn, link by link
- 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. - 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.
- 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.
- 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.
- 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.
Read next
- Verify it yourself — run each check
- The trust model · Privacy tiers · Verifying the client
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
| 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:
https://witbitz.chat/accessKeys 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:
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:
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-configThere 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
# 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
# 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:attestreturns 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)