All documentation
Overview — build static AI apps around Spaces
Witbitz is a trusted runtime for collaborative AI applications. You build the app as a static website; Witbitz provides the shared room, identity, permissions, agents, storage, payments, and execution underneath it.
The important primitive is a Space: a persistent room where people, an application, and one or more AI agents work together under explicit permissions. A Space can be a product by itself, or it can sit inside another app as the place where users ask, approve, and inspect what the app does.
Start here if you are new. For the complete architecture argument, see the platform. For the current shipped/preview/designed map, see status.
The short version
Your app does not need its own private backend for the collaborative surface. It can be a static page that talks only to Witbitz:
- The page creates or opens a Space.
- Members join by link, sign-in, invite, or owner policy.
- The Space stores conversation, documents, artifacts, and shared widget state.
- An AI agent runs when a member posts a turn.
- High-stakes tool calls become proposals; an authorized human approves before anything executes.
- The data footprint is declared and checkable: sealed storage, locked egress, signed build metadata, and a reproducible render.
That is the trade: replace many app-specific backends with one constrained runtime whose behavior can be inspected.
What a Space gives you
| Primitive | What it means |
|---|---|
| Room identity | A Space carries its own identity, members, keys, and admission policy. |
| Shared state | Everyone sees the same thread and the same live widget state. |
| Agent turns | The AI is invoked as part of the room, not as an external chatbot bolted onto the side. |
| Tools and widgets | App functions become tools; tool results can render as maps, charts, files, or custom widgets. |
| Delegated authority | The agent can propose an effectful action, but the runtime requires human approval before execution. |
| Content-blind storage | The store holds ciphertext sealed to the room, not to the platform operator. |
| Verifiable footprint | The app is static and egress-locked, so the hosts it can reach are visible and enforced. |
The first app: Spaces
The first product built on the runtime is Witbitz Spaces: a multiplayer ChatGPT-style room where several people and an AI collaborate over time. It can read shared documents, search, build maps, render charts, generate artifacts, and keep the room alive between visits.
Spaces matters because it is not a special internal product. It is the reference app for the platform surface: collections, artifacts, agent tools, widgets, identity, and the content-blind render.
The two guarantees
Witbitz docs are organized around two guarantees:
- Humans keep authority. Agents do not own credentials. They propose; people approve; the runtime executes only approved actions.
- Privacy is verifiable. The app has no private backend of its own, storage is sealed at rest, egress is locked, and the render's deployed build can be checked against published source.
The second guarantee has important status boundaries. The production tier is content-blind at rest, with plaintext opening briefly in the server-side render while a member is active. The attested server tier is built and independently verifiable, but production Spaces do not run inside it yet. That distinction is deliberate and should stay visible in every security conversation.
The three layers
| Layer | Role |
|---|---|
| Interaction | How people and AI meet: async Spaces today, live calls through the Kibitz engine, and future modes. |
| Platform | The runtime services: identity, permissions, agents, sealed storage, payments, secure execution, API. |
| App | A static page with no backend of its own, connecting only to Witbitz. |
The app is not "backendless" in the absolute sense. Witbitz is the backend. The point is that the backend is shared, constrained, and inspectable instead of being an opaque server unique to every app.
Where to go next
- Build something: Platform API
- Understand Spaces: Async Spaces
- Check the claims: Verify it yourself
- Review the privacy boundary: The double blind
- Understand admission: The Verified Room
- Run it yourself: Enterprise and on-prem
Status — what is live, preview, built, and designed
This page is the product map. Witbitz deliberately separates four states:
- Live: serving production users today.
- Private beta: available to invited integrators, contract may change.
- Built, not production traffic: implemented and demonstrated, but not a property of ordinary production Spaces.
- Designed: architecture is specified, but the production mechanism is not complete.
Use this page before making a security, product, or enterprise claim.
Summary
| Area | Status | What to say |
|---|---|---|
| Async Spaces | Live | Persistent shared rooms with AI turns, sealed ledger, documents, widgets, and artifacts. |
| Admission gate | Live and checkable | Gated Spaces refuse reads and writes without an allow-listed identity. |
| Content-blind storage | Live and checkable | The store holds ciphertext sealed to the room key; no operator recovery recipient. |
| Delegated authority | Live and tested | High-stakes actions require human approval before execution. |
| Egress lock | Live and enforced | The browser-visible app can reach only a published allowlist. |
| Signed build certificate | Live and checkable | /cert.json binds the deployed render build and egress footprint. |
| Reproducible render build | Live and checkable | Published source rebuilds to the deployed Lambda hash. |
| Platform API | Private beta | Tenant-keyed /v1 API; invite-only, public OpenAPI. |
| Owner rule | Built, opt-in, not enabled by default | Owner-governed rooms and tiered admission are implemented, but inert unless configured. |
| On-prem VPC tier | Runnable today | The runtime can run in a customer's boundary with customer model, storage, and IdP seams. |
| Bridge protocol | Early preview | Cross-organization governed collaboration exists as an early protocol surface. |
| Attested server tier | Built and independently verifiable, not production traffic | Nitro enclave verification is demonstrated, but ordinary production Spaces do not run inside it. |
| Attested client | Server seam built; native clients not shipped | Policy can require attestation, but production native clients are still future work. |
| Sealed Spaces | Designed | The platform-never-receives-mk tier is specified, not yet the production Space path. |
| Companion | Optional roadmap | A native helper may improve notifications and key storage, but the platform does not depend on it. |
Live today
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: Async Spaces
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: The Verified Room and Verify it yourself
Delegated authority
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: Delegated authority
Verifiable privacy checks
The current production checks cover the admission gate, the app's egress footprint, content-blind storage at rest, the signed build certificate, and a reproducible build of the published render source.
Read: Verify it yourself
Private beta
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: Platform API
Built but not default production behavior
Owner rule
The owner rule lets an app owner mandate signed admission policy and tiered capabilities for every governed room. The implementation is opt-in and behavior-neutral: unset keys leave existing rooms unchanged.
Read: The owner rule
Attested server tier
The Nitro enclave tier is built and independently verifiable: the room key is generated inside AWS Nitro hardware, the image measurement is reproducible from published source, and browser-side verification can check the hardware attestation. No ordinary production Space runs inside an enclave today.
Read: The attested tier
Preview and designed work
Bridge
The Bridge is the cross-organization frontier: sovereign Spaces collaborate without merging trust domains. It is an early protocol preview, not the primary production app path.
Read: The Bridge
Attested client
The server-side admission seam exists, but the native attested client shells are not shipped. The rule for the future client is that the reproducible web bundle must be bundled inside the attested binary and loaded locally.
Read: The attested client
Sealed Spaces
Today the server-side render receives mk briefly while a member is active. Sealed Spaces is the designed stronger
tier where the platform never receives mk and can append without reading.
Read: The double blind
Companion
The Companion is optional roadmap. Installed web app behavior already covers much of the original need: reachability, passkey-based key durability, and local client decrypt. A native Companion may still improve notification reliability and device-keystore ergonomics.
Read: The Companion
What Witbitz Is — the platform
Witbitz is a trusted runtime for building collaborative applications where humans, applications, and autonomous AI agents work together safely, under explicit permissions. You build your app as a static website with a verifiable privacy policy, and Witbitz provides the runtime underneath. Read this first; the other docs go deep on each piece.
One line: a runtime for people and their AI agents to collaborate, where privacy is verifiable and humans keep the final authority.
1. The idea
The internet was built for users and applications. The next generation needs a runtime where people, apps, and autonomous AI agents collaborate — but safely, with permissions that are explicit and revocable, and with a human always able to say no. Today's AI ecosystem is siloing: every vendor wants your users, agents, memory, and work inside its own platform, on a backend it can read. Witbitz is the sovereign alternative — your Space holds your people, agents, and data, and nothing leaves it unless you allow it.
2. What the runtime provides
A collaborative app needs the same primitives over and over; Witbitz provides them so the app can be a thin static page:
- identity — who a participant is (see the link is the auth)
- permissions — who may do what (per-member capabilities; delegated, revocable authority)
- agents — hosting + running the AI that lives inside the app
- storage — the sealed, content-blind ledger + documents
- payments — metering + wallets for agent compute
- secure execution — the decrypt-once render where the AI acts, then drops the key
3. The three layers
Witbitz is three layers — this is its architecture, not a product stack:
- Interaction — how humans and AI meet. Today: live calls and async Spaces (more modes to come).
- The platform — a general-purpose app backend: the whole app flow, plus the AI that runs inside the
app. Exposed as the tenant-keyed
/v1API (API reference). - The app — a static page with no backend of its own, connecting only to Witbitz. To be precise: this is not "no backend" — Witbitz is the backend (layer 2: identity, storage, agents, execution, permissions, billing). The proposition is narrower and more honest: you trade an arbitrary, app-specific backend for a single, constrained, inspectable platform one. That's what makes an app's privacy verifiable — there is exactly one trust surface and it is auditable, not that centralized infrastructure or trust dependency went away.
Everything happens in a Space: a self-authenticated room whose identity, members, and keys live in the link, not a central account server (room-link-auth).
4. The two guarantees (why it's safe)
- Verifiable privacy — the double blind. A Space's data is sealed ciphertext at rest under a key the platform never holds. As long as the compute runs the declared program, the operator is blind to the data and activity — and so is the app's own owner. Neither can read the users' content. Because the app is a backendless static page talking only to the audited Witbitz API, its whole data footprint is declared and its privacy is checkable, not promised. (the double blind).
- Human authority. Agents never own authority — they receive it. An agent proposes an action, the runtime evaluates policy, an authorized human approves or edits, then it executes — explicit, limited, revocable, audited. (delegated authority).
5. Built on Kibitz (what's reused, precisely)
Witbitz runs on the open Kibitz engine, and the relationship is exact — worth stating so nothing is overclaimed:
- The live call is Kibitz (a pinned dependency, rebranded). That path gets Kibitz's full peer-to-peer gate — account-free rooms, the join gate, cert-bound OIDC, end-to-end-encrypted media — by running the engine.
- The async Space is witbitz's own. It is a turn-based, content-blind HTTP service (no WebRTC, no OIDC, no cert-binding). The only thing it borrows from Kibitz is the invite-token wire format, reimplemented byte-compatibly for per-member identity (room-link-auth §4).
So: for the engine (calls, mesh, cert-binding, the agent-in-a-call protocol), the canonical docs are Kibitz's
— see the "engine" links in /llms.txt. For witbitz's own layer (async Spaces, the platform
API, the double blind, the Bridge), these docs are canonical.
6. The first app: Spaces
The first app built on Witbitz is Spaces — a multiplayer ChatGPT: a shared room where people and an AI collaborate together, with tools that render as live widgets and a room that persists. Try it: witbitz-spaces.pages.dev.
7. What you can build — the topologies
Spaces connect in different shapes; the valuable apps are the ones where the shape is the product:
- One governed room — an org's AI serves a person (tools + governance; the org can't read individual sessions). Customer service, a bank advisor, telehealth intake.
- Pro + AI with a private back room — both serve a client, and confer privately. Teacher + AI, doctor + scribe, lawyer + paralegal.
- Two sovereign sides — the Bridge — each side preps privately, they meet in a shared room. Negotiation, M&A, procurement, mediation. (the Bridge).
- Hub & spoke — a person (or case) at the center, several orgs bridge in. Care coordination, eldercare.
- Many players — the Spaces app. Group planning, a family council, community rooms.
- A traveling personal agent — your Space + agent joins others with scoped permissions. Personal assistant, shopping concierge.
8. Access
The /v1 platform API is invite-only during private beta (tenant keys). The Bridge is an early protocol preview.
Email hello@witbitz.chat.
9. Where to go next
- The Space link is the auth — membership, keys, per-member identity, the double blind (deep).
- The Verified Room — one definition for live P2P, async reads, and server-verified writes (crown-jewel).
- async Spaces · the double blind · identity lifecycle · delegated authority · the Bridge · API reference
- The engine (Kibitz) deep-dives — linked from
/llms.txt.
Quickstart — open a Space and call the runtime
This quickstart gets you through the live path without pretending there is a finished public SDK. Today, the working
browser client is the thin client used by the Spaces app (spaces/public/spaceClient.js), and the lower-level HTTP
surface is a single /space op-dispatched endpoint.
Use this page to understand the flow. For production integration access to the tenant-keyed /v1 API, see
Platform API.
1. Open the reference app
The fastest way to see the runtime is the hosted Spaces app:
https://witbitz-spaces.pages.dev
Create a Space, post a message, and watch the AI answer. That exercises the same primitives your own app would use:
- a Space record with a room key commitment
- a sealed config
- a sealed conversation ledger
- an agent turn
- shared widget/artifact state when tools are used
2. Discover the Space endpoint
The app discovers its runtime endpoint from a public config file:
curl -s https://witbitz-spaces.pages.dev/space-config | python3 -m json.tool
# {
# "spaceEndpoint": "https://api.witbitz.chat/v1/space"
# }
The response includes the spaceEndpoint the client posts to. In hosted production that endpoint fronts the /space
Lambda. In an on-prem deployment it is usually same-origin, commonly /v1/space or /space.
3. Check the running service
The attest op reports the source/build identity the running render was built from:
SPACE=$(curl -s https://witbitz-spaces.pages.dev/space-config \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["spaceEndpoint"])')
curl -sX POST "$SPACE" \
-H 'content-type: application/json' \
-d '{"op":"attest"}' \
| python3 -m json.tool
# {
# "service": "witbitz-space",
# "gitCommit": "...",
# "runtime": "aws-lambda",
# "note": "..."
# }
That does not create a room and does not require a key. It is the first sanity check that you are talking to the runtime described in these docs. The stronger build checks are in Verify it yourself.
4. Create a Space from a static app
The working client flow is:
- The browser mints a room key
mk. - The browser computes
commit(mk). - The browser registers only the room, commitment, and sealed config with
op:'create'. - The share link carries the room in the URL query and the key material in the fragment.
The server never receives mk at create time.
In the current codebase, this is wrapped by makeSpaceClient().create():
import { makeSpaceClient } from './spaceClient.js'
const client = makeSpaceClient({ endpoint: '/space' })
const space = await client.create({
keyModel: '1-of-1',
agentName: 'Witbitz',
persona: 'A concise collaborative assistant.',
tools: []
}, {
base: location.origin
})
console.log(space.room)
console.log(space.links[0])
For a hosted third-party app, treat this as the reference client pattern rather than a package contract. The formal developer SDK should wrap this shape.
5. Post a turn
Once a client has opened a member link and reconstructed mk, a turn posts the member message and the room key for
the decrypt-once render:
const { room, mk } = await client.open(space.links[0])
const reply = await client.turn(room, mk, {
from: 'Ada',
text: 'Plan a simple two-day trip to Paris.'
})
The render opens the sealed ledger, appends the member entry, runs the agent, appends the reply, re-seals the ledger, and drops the key.
6. Poll for changes
The preferred read path is pollSealed: the server returns the sealed ledger and the client opens it locally. For
gated rooms, the request also carries identity so the server can enforce the read gate before returning anything.
let etag = ''
let count = 0
const r = await client.pollSealed(room, mk, etag, count)
if (r.changed) {
etag = r.etag
count = r.version
console.log(r.entries)
}
The legacy poll path still exists and returns plaintext from the render. Use pollSealed for the hot read loop.
Next
Create a Space
A Space is created by the client. The server stores a public record, but the secret that opens the Space is minted and distributed by the browser.
This page describes the production shape used by the Spaces app.
The create-time contract
Creating a Space registers:
| Field | Visibility | Purpose |
|---|---|---|
room |
public | The stable Space id, usually sp-.... |
commit |
public | A commitment to the room key mk; proves future requests carry the right key. |
configSealed |
ciphertext | Agent persona, tools, admission policy, allow-list, model config, owner policy. |
gated |
public marker | Present only when the room is read-gated, so keyless reads can fail closed. |
gk |
public | Optional room invite public key for per-member signed attribution. |
invite |
ciphertext or pad | Used by multi-share invite flows; solo rooms carry a random pad to avoid leaking key model. |
The server does not receive mk on create.
Key models
The current client supports:
| Model | Meaning |
|---|---|
1-of-1 |
One link carries the full room key in the fragment. |
2-of-2 |
Two shares are required; used for paired/couple-style rooms. |
{ k, n } |
Shamir threshold sharing for teams. |
The link shape keeps the room id visible and the key material in the fragment:
https://your-app.example/space?room=sp-example#mk=...
https://your-app.example/space?room=sp-example#s=...
Fragments are not sent to the server by the browser. The app reads them locally and reconstructs mk.
Basic create flow
The reference client wraps the details:
const client = makeSpaceClient({ endpoint: '/space' })
const { room, mk, commit, links } = await client.create({
keyModel: '1-of-1',
agentName: 'Guide',
persona: 'Help the group make decisions clearly.',
tools: ['show_chart', 'write_pdf']
})
Under the hood, this calls:
{
"op": "create",
"room": "sp-example",
"commit": "<commit(mk)>",
"configSealed": "<sealed config envelope>",
"invite": "<pad or sealed invite>"
}
Gated Spaces
For an email-gated Space, the admission rule and allow-list live inside the sealed config. The public record also gets
a gated:true marker. That marker leaks only that the room is gated, not who is allowed.
await client.create({
admission: 'email',
allow: ['ada@example.com', 'grace@example.com'],
requireChallenge: true
})
Reads and writes are then refused unless the member presents an allow-listed identity. Holding the link and mk is
not enough.
Owner-governed Spaces
The owner rule adds a signed App Policy at create time. This lets an app owner mandate admission tiers for every room created under that app. It is built, opt-in, and inert unless configured.
Read: Owner-governed rooms
What can go wrong
- A duplicate
roomreturns409 exists. - Missing
commitreturns400 commit_required. - A governed room with an invalid owner policy is refused.
- A private/gated room without the right identity may create successfully but later refuse reads or writes.
Code map
| Concern | File |
|---|---|
| Client create wrapper | spaces/public/spaceClient.js |
| Key creation and link parsing | spaces/public/spaceConfig.js |
| Create handler | agent/spaceHandler.mjs |
| Sealed config | spaces/public/e2ee.js, agent/envelope.mjs |
Run an agent turn
An async Space does not keep an agent process alive. A member posts a turn; the render opens the sealed room state, runs the agent, writes the reply, and goes cold again.
That is the core runtime loop.
The turn flow
turn with room key and message- A member sends
{ op:'turn', room, mk, message }. - The transport checks
commit(mk)against the Space record. - If the Space is gated, identity is verified against the sealed admission policy.
- The render opens the sealed ledger.
- The member entry is appended.
- The agent runs with the Space's persona and tools.
- The reply, widgets, files, or pending proposals are appended.
- The ledger is re-sealed and stored.
- The room key is dropped.
Read the deeper version in Async Spaces.
Minimal turn
const r = await client.turn(room, mk, {
from: 'Ada',
text: 'Summarize our plan and list the unresolved decisions.'
})
For an open Space, that is enough.
Signed member turns
A signed turn proves which admitted member wrote the entry. The client signs the entry core and sends the signature with the message. For an email-gated Space, the message also carries an ID token and the member signing public key.
Conceptually:
await client.turn(room, mk, {
text: 'I approve the itinerary.',
}, {
email: 'ada@example.com',
googleIdToken,
sign: { priv: memberPrivateKey, pub: memberPublicKey }
})
The render verifies the identity and stores only the verified attribution, not the raw ID token.
Tools during a turn
A Space's tools are declared in its sealed config. During a turn, the agent can call those tools. Tool outputs may become:
- plain text in the reply
- a shared widget such as a map, chart, diagram, itinerary, or flights card
- a file attached to the reply
- a pending proposal requiring human approval
High-stakes calls are not run directly. They become proposals under Delegated authority.
Reads after a turn
Clients normally watch a Space with pollSealed. When the sealed ledger changes, the client opens it locally and
renders the new entries.
const r = await client.pollSealed(room, mk, previousEtag, previousCount)
For gated Spaces, read operations also include the caller's identity so the server can refuse unauthorized reads before returning sealed content.
What plaintext exposure means
In the current production tier, the server-side render receives mk during turns. That is why the model can reason
over the Space. The key is request-scoped and dropped afterward, and storage remains ciphertext at rest.
This is not the same as "the operator can never see plaintext in use." The attested server tier is the path to making that stronger property checkable for production traffic; it is built and verifiable, but not yet serving ordinary Spaces.
Read: Status, The double blind, The attested tier
Code map
| Concern | File |
|---|---|
| HTTP turn op | agent/spaceHandler.mjs |
| Runtime service | agent/spaceService.mjs |
| Decrypt-once render | agent/asyncTurn.mjs |
| Agent tool loop | agent/brain.mjs, agent/brainTools.mjs |
| Delegated authority | agent/delegatedAuthority.mjs |
| Client turn wrapper | spaces/public/spaceClient.js |
Tools and widgets
In Witbitz, tools are how an agent reaches capability, and widgets are how tool results become shared application state. The important distinction is that a widget is not one user's private chat decoration. It is part of the Space that every member can see.
Tools are declared by the Space
A Space's sealed config names the tools available to the agent:
await client.create({
agentName: 'Planner',
persona: 'Plan trips with the group and keep decisions clear.',
tools: ['search_places', 'show_places', 'show_chart', 'write_pdf']
})
The render opens this config during a turn and exposes the declared tools to the agent. If the config is sealed, the platform cannot read the persona, tool list, admission policy, or model config at rest.
Tool results become room artifacts
Tools can return different kinds of results:
| Result | Example |
|---|---|
| Text | A searched page summary or calculation result. |
| Widget | A map, chart, diagram, itinerary, route, or flights card. |
| File | A generated PDF attached to the agent reply. |
| Image/blob | A sealed out-of-line photo or generated image. |
| Proposal | A high-stakes action waiting for human approval. |
The agent reply can carry pointers to widgets or files created during the same turn. The client renders those pointers as live shared UI.
Shared widget state
The state op stores a named shared widget document. A member or tool can set a patch and summary; later reads return
the latest state. The state is sealed with the room key.
await client.state(room, mk, 'trip-plan', {
days: [
{ name: 'Day 1', items: [{ text: 'Arrive and check in' }] }
]
}, 'One-day starter itinerary', 'Ada')
Because the document belongs to the Space, every member sees the same widget state.
High-stakes tools become proposals
An agent should not directly run actions that spend money, change external systems, or cross a policy boundary. Instead, the turn records a proposal. A human later approves or denies it:
const pending = await client.pending(room, mk)
await client.decide(room, mk, pending.proposals[0].id, true, 'Ada')
Only an approved proposal executes, and only once.
Read: Delegated authority
Air-gap behavior
When AIRGAP=1 is enabled in an on-prem deployment, internet-touching tools are refused server-side. Local tools such
as chat, document reading, PDF generation, charts, and diagrams continue to work. Map/photo/search/flight tools need
either local replacements or must stay disabled.
Read: Enterprise and on-prem
Code map
| Concern | File |
|---|---|
| Tool declaration and validation | agent/affordance.mjs, agent/agentRecord.mjs |
| Space tool execution | agent/spaceHandler.mjs, agent/spaceService.mjs |
| Shared widget docs | agent/sessionStore.mjs, spaces/public/spaceClient.js |
| Widget renderers | spaces/public/*Widget.* |
| Delegated actions | agent/delegatedAuthority.mjs |
See also — the inverse direction: a widget puts your app's UI inside a room; Embed a Space puts a whole room inside your app's page (a cross-origin iframe + an origin-checked bridge, content-blind either way).
Embed a Space — one line, and the drop-in doesn't cost you the encryption
Status: live. The host-side SDK is served at
https://witbitz.chat/embed.js; the iframe seam ships in the Spaces app. Live demo → witbitz.chat/demo: talk to an embedded AI and it calls the page's ownset_site_colortool to restyle the site — your app's functions become the agent's tools. A simpler read-only event-log demo (the bridge events, no host tools) is at/site/embed-demo.html. Cross-origin, browser-verified.
The one-liner
<script src="https://witbitz.chat/embed.js"></script>
<witbitz-space link="https://witbitz.chat/space#<your link>"></witbitz-space>
or drive it from script:
const space = Witbitz.embed('#slot', { link })
space.on('message', m => …) // someone posted (metadata only)
space.on('turn', t => …) // an agent turn finished
space.signIn(token) // owner-gated rooms: admit the user at their tier
Witbitz.embed(target, opts) returns a controller: on(type, cb) / off, signIn(token), navigate(to),
setTheme(theme), destroy(), and iframe. <witbitz-space> re-exposes it as el.space.
Why it's an iframe — and why that's the point
That one line mounts a cross-origin iframe on the Spaces origin, not inline DOM in your page. That isn't a limitation to apologize for — it's what keeps the guarantees true when a Space is embedded:
- The verified Space client runs in its own origin. Your page — and every tracker and third-party script on it — cannot reach into it, cannot read the room key, cannot read the plaintext.
- If a Space were inlined into your page, your page would become part of its trusted computing base, and content-blind would quietly stop being true the moment anyone embedded it. The iframe is the isolation boundary.
So a drop-in embed does not cost you the encryption — the part that touches your users' content still runs only in verified code, in a context your page can't see.
The bridge (host ⇄ Space)
The only channel across an origin boundary is window.postMessage. The SDK layers a small, origin-checked protocol
(JSON-RPC 2.0) on it. It is a narrow capability seam, not shared memory — the host can do exactly these things:
| Direction | Message | Meaning |
|---|---|---|
| Space → host | ready |
the Space handshaked; the bridge is open |
| Space → host | message |
someone posted — metadata only ({id, self, sys}), never content |
| Space → host | turn |
an agent turn finished |
| Space → host | member |
a member event |
| Space → host | resize |
content height changed (auto-sizes the iframe) |
| host → Space | signIn |
hand in an owner IdP token / signed grant |
| host → Space | navigate / theme |
deep-link / theme within limits |
Discipline that makes the seam safe: origin-checked both directions (the Space only accepts control from the one
validated embedder; the host trusts only the Spaces origin), nothing is posted to '*', and the room key never
crosses the bridge — it rides the iframe's URL fragment, client-side, and is never sent to a server. Sign-in is a
verified token, not a trust-me flag: it's checked against the room's sealed allow-list and resolved to a tier (see
the owner rule).
One honesty note: the app that embeds a room necessarily holds that room's link, so it holds that room's key — the isolation doesn't hide the embedded room from the embedder. What it protects is (a) the platform-wide guarantee that Witbitz can't read content, and (b) the room from your page's other scripts and trackers.
Who may embed — two gates
Embedding is off by default (frame-ancestors 'self'). Turning it on is two layers, deliberately:
frame-ancestors— the app-wide COARSE gate (browser-enforced). It has to be coarse: the browser evaluates the header before any JavaScript runs, when the room isn't even known (especially for#mk=fragment links). Set the allowed embed origins for a deployment viaSPACE_EMBED_ORIGINS(it feeds both this header and the client check from one source, so they can't drift).- The owner policy — the per-room PRECISE gate (client-enforced). The owner's signed
AppPolicymay nameembedOrigins— the origins allowed to embed that app's rooms. Because the client holds the room key, it reads the sealed, signed policy itself and closes the bridge if this embedder isn't on the list. The server never sees it — content-blindness is preserved. On the hosted multi-tenant platform the owner key is resolved from the tenant registry (op:'policy'); on a pinned single-owner deployment it's the baked key.
So the browser stops any non-registered origin from framing at all; the owner policy narrows it to this owner's origins per room. Fail-closed.
Orthogonal to what the Space is
The embed is transport + isolation. Whether the client is trustworthy code is a separate axis — the attested client; admission and tiers are the owner rule. They compose: an embedded Space is still content-blind, still owner-governed, still verifiable.
One line: witbitz.chat/embed.js drops a real, isolated, content-blind Space into any page, talks to it over a narrow
origin-checked bridge that never carries your plaintext, and lets the room's owner decide who may embed it.
Async Spaces — the agent is a function, not a server
The first Witbitz Spaces are async: a persistent room that lives over weeks, where the AI is a function invoked per turn, not an always-on process. Between turns the Space is cold ciphertext — so an idle Space costs ≈ $0. This is the model the Spaces app runs on. Live in production.
See also: the Space link is the auth (membership + identity) and the double blind (why the platform can't read a Space).
1. Agent-as-function
A live call runs the agent as a container for the call's whole life. An async Space instead invokes a turn function only when a present member acts:
A member posts → the transport invokes the turn with the member's
mk→ the function opens the sealed ledger, asks the model, appends the reply, re-seals, stores, and dropsmk. Then the Space is cold again.
No held socket, no always-on compute → idle ≈ $0 (the reason to prefer poll/push over held connections; cost
model in the repo's async-rooms-cost.md). It stays on AWS (DynamoDB ledger + S3 blobs + Bedrock/LLM in-boundary)
so the ciphertext and the inference share one trust boundary.
2. The sealed ledger
A Space's conversation is one snapshot sealed under mk and stored as ciphertext
(sessionStore.putLedger/getLedger → agent/envelope.mjs seal/open). It is sealed to the member key
only — there is no operator recovery key. The platform holds no key; at rest it is opaque bytes. This is the
persistent Space memory.
3. The turn — a decrypt-once render
agent/asyncTurn.mjs runAsyncTurn is the render, and it is the one place plaintext exists:
- access control —
mkarrives from a present member's client;agent/spaceTransport.mjscheckscommit(mk) === the Space's stored commitment(proves membership + that this turn seals to the ledger's key). - attribution (for a signed Space) — verify the turn's signature against the member grant, set the verified author (room-link-auth §4).
- decrypt-once — open the sealed ledger, append the incoming entry, run the brain (its tools may produce
widgets/artifacts), append the reply, re-seal,
putLedger. - drop
mk— the key is never held past the turn.
Between turns nothing runs and nothing is readable.
4. Reaching members — poll, content-blind
agent/spacePoll.mjs is the read side, and it stays blind on the common path:
- The client sends its last etag; the server computes
etagOf(sealed)= a hash of the ciphertext — it reveals only "changed", never the turn count, and it is computed without decrypting. - Unchanged → return no entries and never decrypt (
mkunused). - Changed → decrypt-once, return only the plaintext new entries after the client's cursor, drop
mk.
The sealed blob is never sent to the client — the client is thin (sends turns, receives rendered plaintext, never touches ciphertext or crypto). Deferred: reaching an away member whose app is fully closed (polling needs a running app) — real-time push/email is a later cut.
Reads are gated too, for a private Space. On an admission:'email' Space the poll (and every content op —
title, state, pending, decide, import) requires an allow-listed Google sign-in before the server will
decrypt-once and return anything — the room key alone no longer reveals the history. The server is the plaintext
chokepoint (it decrypts in-use), so it enforces the allow-list on reads exactly as on writes (room-link-auth §4).
Live + proven in prod: a token-less poll to an email-gated Space returns 403. An open Space is unchanged —
the link reads.
5. The ops
One HTTP surface (agent/spaceHandler.mjs → agent/spaceService.mjs), at /space:
| op | what it does |
|---|---|
create |
register the public Space record (its key commitment + config + gk); the client mints room+mk, the server never sees mk |
turn |
a member posts; the render replies (or proposes a high-stakes tool call — see delegated authority) |
poll |
the content-blind read (etag → plaintext delta); on an email-gated Space it requires an allow-listed sign-in |
decide |
approve/deny a pending proposal (delegated authority) |
pending |
list pending proposals |
import |
seed the Space from a public ChatGPT share link (SSRF-guarded) |
title / state |
a sealed room title; the shared co-edited widget doc |
6. What runs inside
A Space's capabilities are ordinary tools in its config (e.g. search_places → a shared map widget,
search_flights → a flights widget). The turn runs the normal brain tool-loop over them. The one async-specific
twist: a high-stakes tool call is deferred to a human approval instead of running in-room — that is
delegated authority.
7. Code map
| Concern | Where |
|---|---|
| Turn render (decrypt-once) | agent/asyncTurn.mjs (runAsyncTurn, renderLedger) |
| Access control + mk lifecycle | agent/spaceTransport.mjs, agent/spaceService.mjs (handleSpaceRequest) |
| Content-blind poll | agent/spacePoll.mjs (etagOf, pollSpaceView) |
| Sealed ledger | agent/sessionStore.mjs, agent/envelope.mjs (seal/open/commit/newRoomKey) |
| HTTP handler + record store | agent/spaceHandler.mjs |
| Thin client | spaces/public/spaceClient.js, spaces/public/space.html |
| Delegated authority | agent/delegatedAuthority.mjs (doc) |
8. Status
Live in production: the /space zip Lambda (agent brain as a function) + the Spaces app at
witbitz-spaces.pages.dev. The turn core, transport (mk-commit), content-blind poll, delegated authority, and
per-member signed identity (solo) are all shipped. Deferred: real-time away-notification (closed app);
compaction of long ledgers; the couple (2-of-2) signed path.
Platform API — the /v1 surface
The tenant-keyed HTTP API a third-party app builds on. JSON in, JSON out, over HTTPS. This page is the map; the machine-readable spec is canonical — point tooling at it:
- Production spec (only ops callable today):
https://api.witbitz.chat/v1/openapi.json(.yaml) - Full design spec (every op,
x-status-labelled):https://api.witbitz.chat/v1/openapi.full.json - Discovery index:
https://api.witbitz.chat/v1/
The Bridge is a separate contract (party signatures, not tenant keys) — see the Bridge.
Base + envelope
- Base URL:
https://api.witbitz.chat/v1 - Success is enveloped
{ "data": <payload>, "meta"?: {...} }; errors are{ "error": { "code", "message", "request_id"? } }. One exception:POST /space(x-envelope: false) returns a raw payload and a flat error shape — unifying it is a tracked follow-up. - Version
1.0.0-beta. Private beta — the contract may change (coordinated with invited integrators) until GA.
Authentication
Two tenant API keys, resolved by the /v1 authorizer to a tenantId:
- Secret key
wsk_— full scope, server-side only. - Publishable key
wpk_— browser-safe, public reads + funnel-entry writes. It identifies a tenant; it does not authenticate an end user. - Per-collection capability tokens (
x-collection-token): the owneradmin_tokenor the add-onlycontributor_token, for owner/contributor actions on a collection. Wallet/license credentials travel in the request body, never a URL.
Capability tokens + raw keys are shown once at creation and stored only as a hash (or KMS-sealed). Store them server-side; lost = re-create.
Operational contract
- Idempotency: money/billable ops (
POST /sessions,/wallets/grant,/collections/{id}/paint) require anIdempotency-Key; same key + same body replays the first 2xx byte-for-byte. - Rate limiting + kill switch: throttled at the gateway; per-tenant/per-key-type limits at the authorizer; an
operator can suspend a tenant (
POST /tenants/{tenant_id}/status) — every key is denied within ~15s. That op andPOST /tenantsare operator-only (X-Admin-Token, not a tenant key) and are deliberately absent from the published spec, so don't go looking for them inopenapi.json. - Secret handling: one-time-secret responses are
Cache-Control: no-store; sensitive fieldsx-sensitive; BYOK provider keys + the webhook secret are KMS-sealed at rest.
The surface (by area)
Agents
- GET /catalog — the public agent catalog (publishable).
Collections — the generic content primitive (opaque metadata + items + curation + a keyless render page)
- POST /collections · GET /collections/{id} · DELETE /collections/{id}
- GET /collections/{id}/render — keyless share page for published content (public)
- items: GET/POST /collections/{id}/items · PATCH/DELETE /collections/{id}/items/{itemId}
- POST /collections/{id}/state — merge opaque app state onto the collection
Artifacts — produced media on a collection
- GET/POST /collections/{id}/artifacts · GET/PATCH/DELETE .../artifacts/{artifactId}
- POST /collections/{id}/paint · POST /collections/{id}/produce
- POST /collections/{id}/artifacts/upload → presigned S3 PUT, then
POST /collections/{id}/artifacts/{artifactId}/ready to stamp it complete — both halves are required
Sessions
- POST /sessions — launch (or re-summon) an agent; metered, idempotent. Accepts either key: the spend
credential is the coupon in the body, so a publishable key launches nothing without a funded wallet.
Credits
- POST /wallets/lookup · POST /wallets/grant · POST /checkout · GET /usage
- POST /wallets/redeem — two presentations: a Lemon Squeezy license_key, or a blinded issuer
{token, sig, epoch}. Both mint a wallet_code shown once. For a token the token IS the idempotency
key — a retry gets 409 already_spent, never a second wallet, so don't send an Idempotency-Key.
- POST /epochs — issuer role required. Publish a batch's PUBLIC key and per-token value, after which
tokens signed by the matching private key are redeemable. The platform holds only public keys, so it can
verify what an issuer signed and can never mint on its behalf. Set once: rotation is a new epoch.
Roles. A tenant is an app (builds a product, spends credit) or an issuer (holds the customer and
the payment relationship, funds wallets from its own prefunded balance) — or both. grant and /epochs
are issuer-only. A tenant with no roles recorded predates the split and is treated as holding both.
Roles are set by an operator, not self-service.
Tenants (onboarding + key management)
- POST /tenants/signup · POST /tenants · POST /tenants/{tenant_id}/status
- GET /tenant · GET/POST /tenant/keys · DELETE /tenant/keys/{keyId}
- PUT /tenant/provider-keys (BYOK, KMS-sealed) · PUT /tenant/webhooks
Spaces — POST /space, the async Space's single op-dispatched endpoint. It is in /v1 but keyless: it
authenticates by key possession, not a tenant key (see the note below).
Infra — GET /turn (ephemeral TURN credentials).
Not at
/v1: ratings,/agents/*,/earningsand/sessions/{id}arex-status: legacy— they exist on the older surface but are not routed here, and calling them returns 404. They appear inopenapi.full.jsonlabelled as such, never in the production spec.
Access
Invite-only during private beta. Email hello@witbitz.chat for keys. Everything above is served openly as a contract; the keys are what's gated.
Notes
- The async Spaces runtime (
/space) is a separate, content-blind surface (key possession, not tenant keys) — see async Spaces and the Space link is the auth. - This page is a summary; the OpenAPI at
/v1/openapi.jsonis the source of truth (schemas, per-op auth, examples).
Trust model
Witbitz has two separate trust claims. Keep them separate:
- Humans keep authority. An AI agent can propose work, but the runtime requires a human approval before any high-stakes action executes.
- Privacy is verifiable. A Space is sealed at rest, the app has no backend of its own, the browser egress footprint is locked, and the deployed render can be checked against published source.
The docs are intentionally explicit about what is live today and what is not. Start with Status if you need the shipped/preview/designed map.
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 |
| High-stakes agent actions require approval | Live and tested |
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.
- The model provider sees turn content. A remote model must receive the text it is asked to reason over.
- The render sees plaintext in use today. Production Spaces open plaintext inside the server-side render during a member's active read or turn.
- 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 one plaintext door
The current production architecture is content-blind at rest. Plaintext opens in one code path: the render. That path runs when a member posts a turn and, for changed reads on the legacy render path, when entries are returned to a client.
The reason this is still a useful privacy boundary is that the render is small, published, reproducible, egress-locked, and moving toward attestation. The reason the docs keep calling out the limit is that an ordinary production Space is not yet "unobservable in use."
Read: The double blind
Human authority
Tools are capabilities. Some are harmless, like rendering a chart. Others can spend money, send information, modify an external system, or cross an organizational boundary. The runtime treats high-stakes tool calls as proposals:
- The agent proposes.
- A human approves or denies.
- Only an approved proposal executes.
- The audit trail is sealed with the room.
Read: Delegated authority
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, or other credential families depending on the room.
Read: Identity and admission
Stronger tiers
| Tier | Status | What it changes |
|---|---|---|
| Owner-governed rooms | Built, opt-in | The app owner can mandate admission tiers and capabilities. |
| Attested server tier | Built and verifiable, not production traffic | The render runs inside measured Nitro hardware. |
| Sealed Spaces | Designed | The platform never receives mk; it can append but not read. |
| Attested client | Server seam built; native clients not shipped | A room can require the audited client code, not just an ordinary browser. |
Where to go next
- Check claims: Verify it yourself
- Understand storage privacy: Privacy model
- Understand admission: Identity and admission
- Understand lifecycle: Member lifecycle
- Understand recovery: Recovery
- Review action safety: Delegated authority
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 platform is content-blind at rest — it stores only ciphertext, sealed to your key, not the operator's | ✅ 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) | 🔶 not yet on your traffic — the attested tier is built and verifiable, but no production Space runs inside an enclave yet |
So today you can verify that the gate enforces — the load-bearing "who may read/write" layer — and that the front-end's data footprint is locked to a published allowlist (Test 4). You can also verify the store is content-blind at rest (Test 5) and that the deployed render is a faithful, reproducible build of published source (Tests 6–7). The one thing you cannot yet verify about your own traffic is that the render is unobservable in use — that during the transient decrypt-and-render instant the operator can't read the plaintext out of memory; that needs the attested/TEE tier (the double blind §4–6). That tier is now built and independently verifiable — the room key is generated inside AWS Nitro hardware, the image measurement is reproducible from published source, and the whole check runs in your browser (the attested tier) — but no production Space runs inside an enclave today, so it is a demonstrated capability rather than a property of the room you are using. When that changes, /cert.json will carry an enclave block and a Test 8 will appear on this page. Honest position: the code is exactly this — readable, reproducible, and what AWS runs — and "it can't be watched while it runs" is now buildable and checkable, just not yet switched on.
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 server-side render — and that surface is small: ~600 lines for the seal / turn / read core. It's published, readable, Apache-2.0:
→ github.com/kibitz-chat/witbitz-render
| File | Lines | What it does |
|---|---|---|
envelope.mjs |
287 | seal/open — a per-write key wrapped per recipient (room under HKDF(mk)); no operator recipient |
asyncTurn.mjs |
228 | the decrypt-once turn: open ledger → append → model → append → re-seal → drop mk |
spacePoll.mjs |
79 | the read: the sealed blob never leaves the server; unchanged = no decrypt; changed = new entries as plaintext |
sessionStore.mjs |
243 | session memory, sealed with mk, fail-safe |
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 to
You 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"}
# 403
Holding mk returns no history. Reads require an allow-listed Google sign-in — the server won't decrypt-and-return without one.
Test 2 — the write gate: you can't post without an allowed identity
A token-less turn — the write op:
curl -sX POST "$SPACE" -H 'content-type: application/json' -w '\n%{http_code}\n' \
-d '{"op":"turn","room":"<ROOM>","mk":"<MK>","message":{"text":"hi"}}'
# → {"error":"not_authorized","reason":"unsigned"}
# 403
Rejected before anything is written — nothing lands in the Space.
Test 3 — positive control: the key is valid; the 403 is the gate, not a bad key
Create a second Space with "Who can post?" → Anyone with the link (an open Space), and poll it the same way with its room/key:
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; … frame-ancestors 'self'
Read it directly:
connect-srcis the data path — where the app may send anything. It is exactly'self'(the same-origin app + its map/tile proxy),https://api.witbitz.chat(the Space backend), andhttps://accounts.google.com(Sign-in). No analytics host, no CDN, no third party. Your data has nowhere else to go — the browser refuses it.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 yet prove: that the server-side render binary is the published one — that's the build-hash + attested tier, still pending. This test bounds the front-end; the gate tests bound access; the render remains trusted in use.)
Test 5 — content-blind at rest: the store holds only ciphertext, sealed to YOUR key
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 at rest, 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\",\"gitCommit\":\"…\",\"lambdaCodeSha256\":\"…\",\"egress\":\"…\"}",
# "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" }
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))) // true
What it proves: the platform has publicly, cryptographically committed to a specific deployed build (a named build id + the Lambda's exact code hash) with a specific egress. The commitment is tamper-evident (a changed cert fails the signature), and the running service reports the same build id — so the operator can't quietly point the certificate at one build while running another without it showing.
On its own, Test 6 is a tamper-evident commitment to a build. Test 7 turns it into a check of the actual code — the cert also carries sourceSha256, the hash of the published, readable source that reproduces lambdaCodeSha256.
Test 7 — the faithful build: rebuild the published source, get the deployed hash
The cert commits to a code hash. Test 7 lets you confirm that hash is a faithful build of source you can read — not a black box. The exact render source is published at /source.tar.gz; the cert's sourceSha256 pins it, and it rebuilds byte-for-byte to lambdaCodeSha256.
Needs only Node + Python 3 — no Docker. The zip is STORED (uncompressed), so the bytes don't depend on a zlib version; npm ci is deterministic for this pure-JS graph (no native binaries).
# 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). Built and independently verifiable — but not yet serving traffic: 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 checkable on a demonstration enclave, and remains switched off for production Spaces.
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 Kibitz 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). Built and demonstrated on real AWS Nitro hardware — not yet operated on production traffic (the attested tier); the strongest rung, and the north-star.
This page grew "verify the gate" → "footprint" → "store" → "the build." The gate is live (Tests 1–3), the footprint is live (Test 4), content-blindness at rest is live (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. When it ships, its check lands here.
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.
This page is the short reader path. The canonical deep dive remains The Verified Room.
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 that the presented key matches the stored commitment. A right key proves the caller holds the Space link. A wrong key opens nothing.
This proves "a member is present." It does not by itself prove which member.
Per-member identity: signed entries
To attribute entries to a specific person, each member can hold a signing key. The room's public invite key verifies the member grant, and the member's public signing key verifies the entry.
The signed core is conceptually:
{
"room": "sp-example",
"author": "ada@example.com",
"ts": 1785350400000,
"kind": "text",
"id": "c:...",
"text": "I approve this."
}
Because the author is inside the signed core, a rename or forged attribution fails verification.
Admission families
Witbitz inherits the credential model from Kibitz and carries it to async Spaces.
| 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. |
Gated Spaces
On an email-gated Space, the link alone is not enough. Reads and writes both require an allow-listed sign-in. That is
why the verification page starts with token-less poll and turn requests that return 403.
The allow-list itself lives in the sealed config, so the platform does not read the membership list at rest.
Run the check: Verify it yourself
Who sets admission
There are two layers:
| Layer | Decides |
|---|---|
| The Space creator | Ordinary open, invite, or email-gated rooms. |
| The app owner | Owner-governed rooms where every room must obey a signed App Policy. |
The owner rule is the enterprise path: an organization can require its own identity provider and tiered capabilities without giving itself access to room content at rest.
Read: Owner-governed rooms
Live vs designed
| Piece | Status |
|---|---|
| Key-possession membership | Shipped |
| Solo signed identity | Live and verified |
| Email-gated reads and writes | Live and checkable |
| Agent-key admission | Deployed |
| Scoped agent reads | Deployed |
| 2-of-2 per-member signed path | Not yet |
| Crypto-strong single-Space read revocation by epoch re-key | Designed; Bridge has epoch re-key |
For the complete status table, see The Verified Room.
Code map
| Concern | File |
|---|---|
| Room key commit and envelope | agent/envelope.mjs |
| Client key/link creation | spaces/public/spaceConfig.js |
| Signed entry/grant | agent/spaceEntrySig.mjs, spaces/public/entrySig.js |
| OIDC verification | agent/spaceOidc.mjs |
| Admission enforcement | agent/spaceService.mjs, agent/spaceHandler.mjs |
Member lifecycle
The hard part of a bearer-link system is not initial entry. It is what happens when people leave, lose devices, rotate keys, or need access cut.
This page summarizes the lifecycle model. The full technical version is in The Verified Room.
Removal
For gated Spaces, removal has two layers:
| Layer | Status | Meaning |
|---|---|---|
| Cut writes | Shipped | A revoked member's next turn is refused. |
| Cut reads by server gate | Shipped for email-gated Spaces | The server refuses to decrypt and return entries to the removed member. |
| Cut reads cryptographically | Designed for single Spaces; live in the Bridge | Future content is sealed to a new epoch key the removed member never receives. |
The shipped private-Space behavior is useful because the client is thin: a removed member can hold the old key, but the server refuses to act as their decrypting read path.
Open Spaces are different. If the link reads, then anyone holding the link can read.
History vs future
Revocation cannot make someone un-see content they already read. That is true for every end-to-end or sealed system. The practical distinction is:
- history: content a member already saw remains known to them
- future: new content can be blocked by read gates now and by epoch re-key in the stronger model
For rooms with hard compartment boundaries, design epochs up front.
Key rotation
Rotation means opening a new epoch and sealing future entries to a fresh key. The Bridge already uses epoch re-key for membership changes. Single-Space epoch re-key is designed but not the ordinary production ledger path today.
Per-member signing keys can rotate independently: revoke the old grant and admit the new key.
Replacing a compromised identity
A member identity is a signing key bound by a grant. If that key is compromised:
- Revoke the old grant.
- Admit a fresh member key.
- Re-key the epoch where the crypto-strong future read cut is required.
The Space and other members do not need to be rebuilt.
Metadata that remains
Lifecycle operations do not hide traffic shape. The platform may still observe that a room exists, when it changes, and roughly how large writes are. In Google/OIDC-gated rooms, identity appears transiently during verification and is discarded; the membership policy itself is sealed in the config.
Related pages
Recovery
Witbitz deliberately has no operator recovery key. If the platform could recover a Space, the platform could read a Space. Recovery must therefore belong to the user or organization, not to Witbitz as operator.
This page explains the current tradeoff and the roadmap. The full lifecycle discussion is in The Verified Room.
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. There is no backdoor recipient to call later.
You can verify that with the sealed-store check:
curl -sX POST "$SPACE" \
-H 'content-type: application/json' \
-d '{"op":"sealed","room":"<ROOM>"}'
The recipients list should name the room, not the operator.
Read: Verify it yourself
What recovery should be
Recovery can exist, but it must be user-held:
| Mechanism | Owner |
|---|---|
| Recovery code | The user stores it. |
| k-of-n social recovery | Several trusted parties hold shares. |
| Device keystore/passkey | The user's platform syncs or protects the key. |
| Organization key policy | The customer controls it in its own boundary. |
None of these require Witbitz to hold a readable recovery recipient.
What the Companion would add
The optional Companion was originally imagined as a native runtime for notifications, durable keys, and local decrypt. Much of that is now possible with the installed web app and passkeys, so the Companion is no longer a platform pillar.
It may still improve recovery ergonomics:
- keep room keys in a device keystore
- make rotation less dependent on pasted links
- deliver new epoch keys to devices instead of URLs
- provide more reliable background notifications
Read: The Companion
Enterprise/on-prem recovery
In an on-prem deployment, the organization controls the runtime boundary. That can include its own backup policy, identity provider, 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."
Read: Enterprise and on-prem
What not to promise
Do not promise that Witbitz can recover a lost Space on hosted production. It cannot, by design.
Do not hide the user burden. User-held recovery is safer than operator-held recovery, but it still requires a recovery artifact, another device, a passkey sync provider, a social recovery set, or an organizational process.
The Double Blind — verifiable privacy
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.
This doc is the whole argument in one place: what holds today, exactly where it stops, and Sealed Spaces (§5) — the opt-in tier that turns the remaining gap from a behavioural promise into a property of the build.
See also: async Spaces (the sealed ledger + decrypt-once render), the Space link is the auth (the keys live in the link), and Verify it yourself (run the checks).
1. Ciphertext at rest → 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), room-link-auth).
At rest, to the operator, a Space is opaque bytes — storage and transport are content-blind.
There is no operator recovery key. Every record is sealed to the room key alone — the platform writes no support or recovery recipient of any kind, so there is nothing readable-by-the-operator to read at rest. The double blind is the only configuration that runs in production.
2. The decrypt-once render → the one plaintext door
Plaintext opens in exactly one code path — the certified render — but that path runs on two triggers: an
AI turn (open the ledger with a present member's mk, run the declared program, re-seal, drop mk) and
every changed read (the poll decrypts the new entries to return them, because the client is thin and decrypts
nothing itself — async Spaces §3–4). Reads outnumber turns, so the honest exposure surface is
the render, not "only when the AI acts." The key reaches that render only from a present member's client, per
active window — never from storage — so the operator can act on the data only while a member is present, and learns
nothing at rest. Keeping it one auditable path is exactly what makes the attested tier tractable.
The honest boundary, stated plainly. Because the client is thin, mk travels to the render on every
read and write, and the server opens the ledger in place — a decryption oracle for the moments a member
is active. So content-blind means at rest and against a passive (honest-but-curious) operator, not one
that captures mk in flight; and because the whole ledger is sealed under one long-lived room key, an
operator that logged mk once could read that Space's history past and future — there is no per-Space
forward secrecy yet.
That boundary is the reason §5 exists.
3. Backendless → the app owner is blind too
The second blind is the one nobody else offers. A Witbitz app is a static page with no backend of its own that connects only to the audited Witbitz API. So the app's developer/owner has no backend through which to read its users either: the users' content is sealed to a key the owner doesn't hold, and the app has nowhere else to send it. Data is encrypted from the owner, not merely to them.
To be exact — "backendless" is a claim about the app, not the platform. Witbitz is still a backend (one shared, constrained, inspectable platform). What's removed is each developer's own, opaque, app-specific server. So the trust surface isn't smaller in a hand-wavy "no infrastructure" sense — it's consolidated: one auditable platform to check, instead of N unknown app backends. That consolidation is exactly what makes the footprint declarable (§4).
4. Verifiable → check the code, not the operator
Because the app is a static page with a declared egress (it can only talk to the Witbitz API), its entire data footprint is inspectable and declarable. You verify the code and a signature, not a "trust us" policy.
Four of those checks run in your own terminal today — the admission gate enforcing, the egress lock, the content-blind store, and the deployed render being a reproducible build of published source. Verify it yourself is the commands. What is not yet checkable about your own traffic is the render being unobservable in use; see §6.
5. Sealed Spaces — the tier that removes the key from our reach
Opt-in, and a tier — not a replacement.
Today's Spaces are double-blind, but §2's blindness is behavioural: the operator is trusted not to look at a key
it does, briefly, receive. A single capture defeats it — a logging toggle, a compelled order, a compromised render
reads the entire history, past and future, because the key arrives on a schedule and nothing bounds one room's
exposure. A Sealed Space takes the key off that schedule. The server never receives mk. There is no capture to
toggle. The platform stores ciphertext and holds exactly one capability — the ability to append, never to
read.
5.1 The design — four changes, each assembling a primitive already in the platform
- The ledger becomes an append-only log of individually sealed entries — not one snapshot re-sealed on every write. This is the load-bearing move: appending no longer requires reading, which is exactly what today fuses "write" to "hold the key."
- The client decrypts locally. It syncs sealed history once, caches it on the device, then pulls sealed deltas by cursor — the same poll shape as today, just opaque to the server.
- A session-scoped, ephemeral actor does the reasoning. On the first turn the client seals the assembled
context to the actor's session public key; after that the actor receives only new messages. It never sees
mk, holds nothing across sessions, and is evicted on idle. - Write-back is a write-only capability. The actor appends its reply by sealing to the Space's public write
key (
sealTo/openBox). It can add to the ledger without being able to open it.
The net: the platform routes ciphertext, stores ciphertext, and can append ciphertext. It cannot read the room.
5.2 What it protects — and what it doesn't
A security claim is only worth the threat model it names. Three columns, not two.
Structurally protected: at-rest capture (storage dumps, a compelled disk or database order, a logging toggle, a compromised render — the platform holds only ciphertext and a write-only key); and blast radius, because per-Space epoch keys mean one Space's compromise doesn't cascade and rotation bounds it going forward.
Not protected, stated plainly:
- The live session is plaintext to the ephemeral actor while it reasons. Sealed Spaces closes the at-rest hole; it does not make the AI compute over data it cannot read. That is the attested tier.
- The endpoints. A compromised member device sees plaintext; true of every end-to-end system.
- Metadata — who is in a Space, when they post, how large the messages are. Sealing content doesn't seal traffic shape.
Naming the third column is not a weakness. It is the honest meaning of content-blind: blind at rest, bounded in use.
5.3 What it costs
- No member-absent turns. With no client present, nothing decrypts the context to feed the actor — so "the agent acts while everyone's away" (delegated authority) doesn't exist in a Sealed Space. This is why Sealed is a choice, not the default.
- Prompt caching, likely lost. An ephemeral per-session actor re-receives the full context each session, so server-side prefix caching probably goes away. Likely the largest recurring cost; worth measuring before committing. A warm-actor variant — residency billed, context held longer — is the dial that trades a little blindness back for cache.
- Cold-device sync. A new device pulls sealed history before the first render — a first-open cost, not per-turn.
- Actor residency is billed while a session is open; not scale-to-zero for the length of a burst.
- Forward-only revocation. Removing a member is an epoch re-key — the mechanism the Bridge already runs. It cannot un-share what they already read; inherent to end-to-end, and worth saying out loud.
5.4 Status
In hand: per-entry sealed append and content-blind polling (scaffolded behind a flag, inert until enabled);
sealTo/openBox for write-only append; epoch re-key in the Bridge; the reproducible-build + egress-lock +
certificate stack. To build: the sealed client → actor session channel; ephemeral-actor lifecycle and idle
eviction; cursor-based sealed-delta sync as the default read path; epoch-key distribution to members without the
platform in the loop. To measure first: the real prompt-caching cost on a busy Space.
6. Status — what holds today
Being precise, because this is exactly where a claim can overreach:
| Property | Status |
|---|---|
| Ciphertext at rest (sealed ledger, platform holds no key) | 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) |
Sealed Spaces (the platform never receives mk) — §5 |
designed — primitives in hand, the session channel is not built |
| Attested tier (operator provably blind in use) | built & independently verifiable — NOT on production traffic (the attested tier) |
| Attested client (knowing the code running on the user's side) | design (the attested client) |
7. The moat — honestly
The moat is not "incumbents can't do this." They can: Apple's Private Cloud Compute ships attested confidential inference today, and a competent team could match everything Witbitz has shipped in a quarter. Two things are actually defensible:
- Neutral + backendless. A vendor cloud reads to itself by default; a Witbitz app has no backend of its own and isn't bound to one provider's stack. The trust surface is consolidated to one auditable platform, not removed — the honest claim, not "no backend" (Witbitz is the backend, singular and inspectable). Awkward for an incumbent whose business is the readable backend to adopt — but not something they're barred from.
- The verifiability layer. The signed certificate and the reproducible build have shipped, and the attested tier is built and independently verifiable — though not yet serving production traffic. So the honest position is no longer "a direction we're building toward": four checks run in a stranger's terminal today, and the fifth is demonstrated on hardware and waiting on operations rather than on invention. What remains genuinely unbuilt is Sealed Spaces (§5) and the attested client.
8. 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), Bridge epoch keys (agent/bridgeMembership.mjs), the collection-blob — 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 (~600 lines) | github.com/kibitz-chat/witbitz-render |
Sealed Spaces is not "trust us less." It is "you don't have to." The current tier remains for the workflows that need an always-on agent; Sealed is for the rooms where the key should never leave your hands — and where you can prove it didn't.
The owner rule — the owner decides who may join, and at what tier
Status: built · opt-in · behavior-neutral — not yet enabled in production. The full path below is implemented and tested end-to-end for both trust-root modes: on-prem, where the owner pins a single
OWNER_POLICY_KEY, and hosted multi-tenant, where a tenant registry (OWNER_REGISTRY=1) resolves the trusted key by the room's app so each governed room is rooted in whichever registered tenant owns it (a policy for an unregistered app, or one signed by the wrong key, is refused). Also built: the create-time mandate, the render's integrity gate, tier-driven capabilities, client peer verification, and an operator CLI (tools/owner-policy.mjs) to mint keys, sign policies, and onboard tenants. It stays inert until an owner opts in — keys unset, rooms behave exactly as before, the AWS build is unchanged. Also built (hosted): a tenant-key onboarding endpoint —op:'register-owner', gated by a platform-signed grant scoped to the app + owner-key id, create-once — so a tenant self-registers its owner key instead of the operator running the CLI. And key rotation (op:'rotate-owner', authorized by the current key) — the render verifies each room against the specific key its marker names, so rotating doesn't break existing rooms, and arevokecuts a compromised key. The owner rule is now complete end-to-end. This is how an app owner gets authority over admission without breaking the content-blind model.
The gap
A Witbitz room is a door whose key is the link. Whoever creates the room picks the lock, and the choice is sealed — even the platform can't read it. Great for a private room 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." A creator can leave the door open, and the owner can neither see nor override it. Admission is the creator's private choice; there is no owner authority above it.
This is the design that adds one — turning "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
version: 3 // for rotation
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"] } // what a room may be created as
notBefore, notAfter
}
→ signed by ownerKey
The signature is the whole trick: nobody without ownerKey can produce a valid policy, so nobody can forge a weaker
one. Admission stops being binary — it's a ladder. open is anonymous with read-only caps (a freemium/preview
tier); gold is a verified user carrying a specific entitlement claim, with full capabilities. One signed table spans
anon-public to premium-verified, and the flat "must be my user" rule is just its one-tier case.
Where the owner's authority actually lives
Two anchors, because two different parties must verify the policy:
- The room-creation credential — hosted, the owner's tenant key; on-prem, the owner's own create endpoint. This is what makes a room belong to the owner: an owner-room can only be minted through the owner's credential.
- A pinned copy of
ownerKey— baked into the owner's app and published at a well-known URL — so the render and other members can independently check that a room's policy was really signed by this owner.
Bound to a room at birth
When a room is created through the owner's credential, create stamps two things onto it:
- The signed policy, sealed in the room config — so the render can read and enforce it with the room key.
- A cleartext marker on the public record —
{ app, ownerKeyId, policyHash }, generalizing today'sgatedflag — so the content-blind platform and the keyless read paths know "this room is owner-gated" without reading anything.
And the owner's create path refuses to mint a room that doesn't carry the policy. The room is born gated; there is no "open" escape hatch under the owner's app.
Only the app can mint its rooms (room-genesis)
There is a subtlety the policy alone doesn't cover: the signed policy is a reusable, public artifact — it's the same document on every one of the app's rooms, so anyone could copy it off one room and stamp it on a room they create. Verifying the policy proves "governed by app X's rule," not "created under X's authority." Left there, an attacker could stand up a look-alike room with X's exact governance and sign-in and phish X's users.
So stamping a policy at creation also requires a room-auth — a signature by X's owner key over this specific room. Only the app owner (its backend) holds the key, so only the app can mint a room stamped with its app; the room id is in the signed core, so a room-auth for one room can't authorize another, and it's bound to the resolved owner key (the single key on-prem, or the tenant's registered key hosted), so it's app-specific. Create refuses a stamped policy with no valid room-auth. This is what turns "governed by X's rule" into "a genuine room of X's."
How a member's tier is decided — and why they can't self-promote
Tier comes only from a signed source, never the client's word:
- IdP claims — the owner's login token already carries groups/roles. The policy maps claim → tier, so the owner's directory is the source of truth for who is gold. Sign in, and the token's claim picks the tier.
- A signed grant — a room admin can promote a specific member for a specific room (the existing signed-capability grant, see Delegated authority), overriding upward.
- The room's tier — a room is created as a tier; a member's effective tier is their entitlement, bounded by what the room admits (the room is the ceiling).
A tampered client can't upgrade itself, because it can't forge the IdP claim or the grant — and the render, not the client, resolves the tier and enforces it.
How it's enforced — four layers, 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 member's tier, and gates each action with the capability gate. Policy absent or signature invalid → refuse. |
the certified render (already decrypts per turn to check admission) |
| 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 reads the room policy and verifies it against its own pinned ownerKey — and won't join a room whose policy is missing or unrecognized |
every member's app (peer check) |
The render already does the per-turn identity gate and the capability gate; the new move is that the policy now comes from the owner-signed artifact and the render refuses to run if it doesn't verify — instead of trusting the creator's choice. The boolean tier caps drop straight into the existing gate; the one genuinely new enforcement is per-tier limits (rate / token / tool budget), and the platform already meters sessions and tools — so a gold tier is just a higher metered ceiling. Which means the ladder doubles as the owner's pricing surface: admission, capability, and monetization collapse into one signed table.
Why nobody can strip or weaken it
- Forge a weaker rule? No — it must be signed by
ownerKey. - Omit it at creation? No — the owner's create path won't mint the room, and the render refuses a room with no valid policy.
- Delete the policy or marker later? The render refuses a room whose policy doesn't verify; the marker guards the blind paths; peers reject unrecognized rooms.
- Impersonate the app by stamping its public policy on your own room? No — creation requires a room-auth signed by the app's owner key over that specific room (room-genesis, above), so only the app can mint a room stamped with its app.
- The only escape is creating a room outside the owner's app — which isn't "a room in the owner's app," and the owner's users' apps won't join it. So "every room in my app requires my login" holds exactly.
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. Three distinct steps, and only one is a fresh signup:
- Owner registration (once, the trust root). The owner registers the app: its
ownerKey, its IdP (issuer + JWKS, theSPACE_OIDC_ISSUERSentry), and its claim → tier map. Hosted, this is tenant onboarding proven with the tenant key; on-prem, it's config. After it, the render and peers know "tenant Acme's authoritative ownerKey is X." - User registration (per user, in the owner's directory). However the owner's IdP already does it — enterprise SSO / SCIM provisioning, self-signup in the owner's flow, or admin invite. The tier is a group the owner assigns, so a user can't self-register as gold. If the owner has no directory, they can use a managed auth provider or Witbitz's email-code issuer as the directory — but the owner still owns the "who is gold" data.
- Device enrollment (per device). On first use, the app mints a signing key and binds it to the identity at sign-in. One identity, many devices, each enrolled independently; losing a device just re-enrolls a new key against the same registration.
So joining a room is authentication, not registration — the room federates a sign-in against a registration that already exists and reads the tier from the signed token:
register once (owner's directory) → owner assigns a tier (a group) → open a room link
→ app forces sign-in via the owner's IdP → token {identity + tier claim} bound to the device key
→ render admits at that tier
Revocation is symmetric: remove the user from the directory (or the gold group) and their next sign-in fails or
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 attested client. They compose: a tier's admit can
additionally require an attested client (e.g. "gold requires the attested app"), but identity-tiers work on the plain
web with no attestation in sight. Keep them separate — one is about the person, the other about the code.
The honest trade
Requiring registration deliberately reintroduces a central authority — the owner's directory — into a model that is otherwise account-free and link-based. That's not a regression; it's exactly the control an owner asks for, and it is opt-in per app/room: governed, tiered rooms federate to a directory; open link-rooms don't. The owner chooses where on the spectrum each room sits. 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.
What's reused, and what's new
- Reused: the sealed
admission/allow/revokedconfig + the render's per-turn gate, thegatedcleartext marker, pluggable OIDC issuers, tenant keys, the signed-grant capability gate, and session/tool metering. - New: (1) the signed
AppPolicyartifact + a pinnedownerKey; (2) the create-time mandate through the owner credential; (3) the render's signature check + refuse-if-invalid; (4) peer verification in the app; (5) the tier table + claim → tier resolution + per-tier limits wired to metering.
One line: the owner's signed policy replaces the creator's free choice — stamped on every room at birth through a credential only the owner holds, resolving each federated sign-in to a tier, and enforced in three independent places (render, blind paths, peers) — so a room's authority moves from "whoever holds the link" to "whoever holds the owner key."
The attested tier — "unobservable in use"
Status: built and independently verifiable. NOT what serves your traffic today. Every measurement below was taken on real AWS Nitro hardware and every check is one you can run yourself. But no production Space runs inside an enclave right now. When one does,
/cert.jsonwill carry anenclaveblock and this line will say so. Until then, treat this as a demonstrated capability, not a deployed property — and see Verify It Yourself for what is live.
Everything else on this site proves things about code we publish: the app's egress is locked, the store holds only ciphertext, the deployed render is a reproducible build of source you can read. All of it stops at one wall.
None of it proves what the machine actually loaded. Between the ciphertext arriving and the answer going back, plaintext exists in memory on a server we operate. Read Verify It Yourself closely and it says so: the one thing you cannot check is that the render is unobservable in use.
This is the tier that closes that.
What changes
Your room key already travels sealed to a service public key rather than in the clear
(spaces/public/mkseal.js). That module was written for this moment, and said so in its own header: today the
private half lives in the service; tomorrow it can live only inside an attested enclave, and nothing on this side
changes.
In the attested tier, that recipient keypair is generated inside an AWS Nitro enclave, at boot, and never written or exported. Its public half is bound into a hardware-signed attestation document. Verify the document, and you are sealing your room key to a key that provably exists only inside a specific, measured image.
"We cannot read your room" stops being a policy and becomes a property.
The four things you can check
Nothing here asks you to trust us, and none of it requires our cooperation beyond us publishing what we already do.
1. The document is genuine AWS hardware. A Nitro attestation document is signed by a key in hardware the operator
cannot reach. Our verifier (spaces/public/nitroVerify.js) checks the COSE signature and walks the certificate chain
to the AWS Nitro root G1, pinned in the source — the document carries its own copy of that root and we discard it,
because a verifier that trusts the anchor its input supplied is not verifying anything.
It runs in your browser. WebCrypto cannot walk an X.509 chain, so the chain walker is written out in
spaces/public/x509.js — about 180 lines, ECDSA only.
2. The image is the one you can rebuild. PCR0 is a SHA-384 measurement of the running image.
infra/enclave/build-eif.sh reproduces it from published source. Four things are pinned because each is an input:
the base image by digest, SOURCE_DATE_EPOCH, buildkit by digest, and the nitro-cli version.
This is the part most projects skip. A measurement you cannot reproduce only tells you the image hasn't changed since we built it — which is the "trust us" the whole ladder exists to escape.
3. PCR8, from the certificate rather than our word for it. A signed image also reports PCR8, a measurement of the
signing certificate. We publish the certificate, not just the number, so you derive PCR8 yourself:
SHA-384(48 zero bytes ‖ SHA-384(certificate DER)). Our verifier prefers the value it computed and rejects a
cert.json whose stated PCR8 contradicts its own certificate. A dishonest number is caught by arithmetic.
(AWS's own documentation describes PCR8 differently — as a hash of the certificate's "fingerprint" — which does not match their code and yields a different value. We follow the code, and confirmed it against real hardware.)
4. Liveness, if you ask for it. An attestation document with no challenge proves an enclave existed, not that it
is the one answering you. Send a nonce; it comes back inside the signed document or the check fails. Our verifier
reports fresh: false when no nonce was supplied, so a stale proof can never be presented as a live one.
What it still does not buy
This section is the point of the page. A security claim that only lists what it covers is marketing.
- Model egress is untouched. The enclave establishes TLS itself, so neither we nor the parent instance can read a model call — but the model vendor receives your plaintext, because that is what the room is for. Attestation protects the key and the code. It cannot make a remote API stop seeing its input.
- PCR0 proves the image, not the behaviour. It proves the running code is the code you can rebuild. Whether that code does what we say is what reading it is for; reproducibility makes reading it worthwhile, not unnecessary.
- Metadata remains. Which room is active and when, message sizes and timing — an enclave changes none of it.
- The image is large. Running the existing pipeline means an interpreter, a libc and ~86 MB of dependencies, all measured. The measurement is exactly as sound; the audit is a much bigger job than reading two files. A minimal variant exists for cases where the smaller claim matters more than the existing code.
- Availability is still ours to break. The parent instance can refuse to forward your request. It cannot read it.
Measured, not asserted
On real hardware, 2026-07-29 — and the last row is the one that matters, because it is the product rather than a component of it:
| check | result |
|---|---|
| attestation verified against the pinned AWS root, with our own nonce | ok — PCRs 0/1/2/8, fresh: true |
| PCR0 vs an independent rebuild from published source | match |
| PCR8 vs the published signing certificate | match |
| room key sealed by the unmodified client, opened inside the enclave | opened |
| the enclave asked to hand the key back | it returns HMAC(key, challenge), never the key |
| a sealed blob replayed as a different operation | refused |
| one ciphertext bit flipped | refused |
| verification in a browser | 1.8–22 ms |
| the full agent — model, tools, Places, map widget — answering in a real room | works |
That last line is not a reduced demo. It is the deployed handler, unmodified, with its tools, reached from a phone: the attestation verified in the browser, the room key sealed to a key born inside measured hardware, the reply generated in the enclave, sealed, stored on a parent that cannot open it, and rendered.
The verifier is also exercised against genuine attestation documents from unrelated production enclaves, so it is known to handle real AWS output and not merely our own.
What is left
Everything above is demonstrated, and none of it is switched on. What remains is operational rather than unknown:
- Routing and lifecycle. One instance, started by hand. Production means a fleet behind the API, health checks,
and measurements rolled into
/cert.jsonon every deploy. - Audit surface. Running the existing pipeline means an image of ~250 MB rather than the 5.6 MB minimal variant. The measurement is exactly as sound; reading it is a much bigger job.
Two things that were on this list are now done, and both are the kind of claim worth stating precisely:
Secrets are released only to a measured enclave. Credentials are sealed under a KMS key that refuses to decrypt unless the request carries an attestation matching our signing identity. An account administrator is denied. The host holding the IAM role, using the very credentials the enclave uses, is denied. The enclave is allowed. The parent hands over ciphertext and gets back a list of names — never a value.
Storage is durable and still unreadable. Sealed blobs live in S3. Read back with account-admin credentials, a conversation is an IV, a ciphertext, and one recipient: the room. Not the operator.
When a Space really runs in an enclave, /cert.json will carry an enclave block naming the PCRs, and a Test 8 will
appear on the verify page. Until then this page describes a capability, not a property of your room.
Read next: Verify It Yourself for what is live today, and The double blind for the model this completes.
The attested client — knowing the code that runs on the user's side
Status: the server-side seam is BUILT (opt-in, behavior-neutral); the native clients are not. A room's owner-signed policy can carry
requireAttestation, and the render verifies a freshness-bound platform attestation against it — that half is implemented and tested (with a mock verifier), bound to the member's signing key so it can't be replayed. What still needs the native side: the reproducible native shells and the real App Attest / Play Integrity / Keystore / TPM verifiers (today they're stubbed and fail-closed, so arequireAttestationroom refuses every turn — the seam is ready for the verifiers to drop in). This is the missing symmetric half of the server attestation the rest of this site already does (reproducible build, egress-lock, signed/cert.json).
Every proof on this site is about code we run on our side. The client — the page in the user's browser — has been trusted on faith. And that faith is misplaced by construction: you cannot verify the code a browser is running. The user owns the runtime; any integrity check the page performs on itself can be hooked, patched, or spoofed, and nothing stops the user from ignoring the app entirely and calling the API with their own client. So a modified client can interact with a room in ways the audited app never would.
The response is not to try to attest the browser — that's unwinnable. It's to offer, opt-in, a client whose code can be attested, without giving up the web app.
The idea: a thin native shell around the same reproducible bundle
The app you already ship is a static, egress-locked, reproducibly-built web bundle. The attested client is that exact bundle, wrapped in a thin native shell whose only jobs are narrow and auditable:
- present a hardware attestation — prove a genuine, unmodified build of the app is running on a genuine device;
- hold keys in hardware — the Secure Enclave / StrongBox / TPM, so the room key never sits in JS;
- load the bundle locally into a locked web view;
- freshness-bind the attestation to each action.
You don't rewrite the app natively. There is one source of truth across web and attested-native, the privacy model is preserved — in fact strengthened, since keys move into hardware — and the audit surface is a thin generic shell plus the web code reviewers already read. It is the Companion promoted from a convenience into a trust primitive.
The one rule that makes it real, not theater
The web bundle must ship inside the attested binary and load locally — never off the network.
- Bundled, loaded from a local scheme: the app's signature covers its embedded assets, so an attestation of "genuine, unmodified app" includes the bundle. Tamper with the bundle and the signature no longer matches — the attestation fails. The attested thing and the executed logic are the same thing. ✅
- A web view pointed at a live URL (this includes Android Trusted Web Activities): you attest the shell, which then runs whatever the network hands it — outside the attestation, and hookable at runtime. That is worse than a plain browser, because it launders trust it never earned. ❌
The Trusted Web Activity is the tempting trap here precisely because it is the easiest to build. Two locks keep the bundled version honest: the native↔JS bridge must be minimal and audited (no general script-injection surface), and the existing egress-lock CSP already forbids the bundle from pulling in remote script — so a bundled page can't quietly turn itself back into a network loader.
And the attestation must bind to every action, not once at admission — otherwise a member attests with the genuine app, then switches to a patched client for the writes that follow. The room's existing freshness challenge is the hook: each signed entry carries a fresh, attestation-bound value.
Platform support — and an honest split
The attested client is a web-view shell on each platform. The strength of the guarantee differs, and it's worth being exact about two distinct properties: integrity ("the running bundle is unmodified") and reproducibility ("the shipped binary rebuilds from published source").
| Platform | Shell | Attestation | Keys | Notes |
|---|---|---|---|---|
| Android | Android WebView | Play Integrity, or hardware Keystore attestation (X.509 rooted in Google, carries app-signing digest + Verified Boot) | StrongBox / TEE | Cleanest full chain. Self-distributed + reproducible APK + Keystore attestation gives source → binary → attested. (Play Integrity is easier but ties you to Play distribution, which fights reproducibility — a real tradeoff.) |
| iOS / iPadOS / macOS | WKWebView | App Attest (hardware-backed, iOS 14+ / macOS 11+) | Secure Enclave | Integrity strong; reproducibility partial. Apple re-signs and encrypts the shipped binary, so you can't rebuild the App-Store artifact from source — you lean on a transparency-logged build pipeline for provenance. |
| Windows | WebView2 | Assembled — TPM 2.0 measured boot + Azure Attestation (device) · TPM key attestation (key) · Authenticode/MSIX + optional WDAC (app) | TPM 2.0 | No single "App Attest for Win32"; you compose device + key + binary-measurement attestation. Strongest on a managed enterprise fleet (MDM + WDAC), which is exactly the enterprise buyer. |
So integrity of the running bundle is attestable on every platform; end-to-end reproducibility of the shipped binary is Android-clean, iOS-partial, and Windows-DIY. State that split plainly rather than implying one uniform guarantee.
The egress lock, generalized
Today the egress lock is the CSP header. With attested native clients it splits into two things a verifier keeps separate:
- The policy — one canonical, platform-neutral egress allowlist, signed into
/cert.json: the room-data path goes only to the declared origin, and the shell additionally reaches only a small, fixed set of platform-service endpoints (the attestation provider, push). A verifier reasons about one declaration. - The enforcement — the per-platform binding of that policy. The web CSP is just its web binding:
| Platform | Enforces the allowlist via | How a skeptic checks it |
|---|---|---|
| Web | the CSP header (connect-src …) |
read the live header — self-verifiable, trust nothing |
| iOS / macOS | CSP inside WKWebView + App Transport Security; a Network Extension / per-app VPN under MDM | reproduce + attest the binary and its Info.plist (ATS is TLS policy, not a host allowlist) |
| Android | CSP inside WebView + network_security_config.xml; always-on VPN under MDM |
reproduce + attest the APK and its network config |
| Windows | CSP inside WebView2 + AppContainer network capabilities (MSIX) + Firewall / WDAC | attest the binary + its manifest (capabilities are coarse — internet vs private, not per-host) |
The honest inversion. Every native OS control here is coarser than connect-src — none is a clean per-host
allowlist you can read off a live response. So on native, the fine-grained guarantee leans more on the reproducible,
attested binary ("this app provably contains no network code beyond the declared endpoints") than on a readable OS
policy, and the verification method flips: on the web you read a header; on native you reproduce and attest the
binary and inspect its (coarser) declared config. The web is not the weak link in the egress story — it is the
platform with the cleanest, self-verifiable lock. Native buys code attestation and partly pays it back in
egress-verifiability, making up the difference with the reproducible build.
Purpose-scoping is mandatory. The shell needs egress the web bundle doesn't — the attestation call to Apple / Google / Microsoft, and push. So the canonical allowlist scopes by purpose: the room-data path stays same-origin and locked exactly as today; the shell adds only a small, fixed, declared set of platform endpoints. That is a mild new trust surface (the platform attestor sees metadata), and it is stated, not hidden — the same honesty discipline as the rest of the cert.
How a room requires it
"Only attested clients may join" is not a client-asserted flag — it is an admission condition set by the room/app
owner and verified on the server, in the same slot that carries "only my registered users" (see
The Space link is the auth). At join and on each write, the server
checks the platform attestation, bound to the member's signing key. Other members can be shown that a participant is on
an attested client — the assurance is visible peer-to-peer, not just to the server. Admission and client-integrity
become one policy: { authenticated identity } AND { attested client }.
What it buys — and what it doesn't
It shrinks the hardest residual: content injection between members. If only the audited, unmodified app can write, and that app is audited to seal only well-formed payloads, arbitrary injection is off the table for compliant members.
It does not make the client omnipotent-proof. A jailbroken or rooted device (attestation fights this, but it's an arms race), a vulnerability in the genuine app, and the fact that even an attested client still processes untrusted content from others all remain — so receiver hardening (XSS-safe rendering, sandboxed widgets, validated tool inputs) still matters; it just matters less. And because requiring an install trades away the open-a-link reach that makes the web app spread, this is opt-in and additive: web rooms keep the untrusted-client architecture (accountable identity + server-side enforcement); attested-native rooms get this stronger tier on top.
Why it fits
This is the natural apex of the attestation story the attested tier started on the server side. Today the render is attested and reproducible while the client is trusted on faith — an asymmetry. Closing it with a bundled, attested, hardware-keyed client makes the system attestable end to end for the rooms that ask for it, without abandoning the static-web-page model that makes any of it verifiable in the first place.
The live call — what protects it, and what you can check
The other pages here describe the async Space: a sealed ledger, a content-blind platform, a decrypt-once render. A live call is a different guarantee with a different shape, and conflating the two is the easiest mistake to make about Witbitz. This page is the call's half.
One line: a call's audio, video and chat are end-to-end encrypted between browsers and no server in the path can decode them — but the way you'd verify that is currently not exposed in Witbitz Call's UI (§4), and unlike upstream Kibitz, media here goes through a relay by default (§2).
Witbitz Call is the Kibitz engine, rebranded — a pinned dependency, not a fork. The mechanisms below are Kibitz's; the configuration is ours, and it differs in two ways that matter. Engine-level detail: kibitz.chat/security.
1. Media — end-to-end encrypted
Calls use WebRTC. Audio and video are encrypted with DTLS-SRTP, and the keys are negotiated directly between the participating browsers — they never leave the devices. There is no SFU or media server that decodes, mixes or records anything. Chat, shared links and directed messages travel the same way, over direct DTLS-encrypted data channels between each pair; no participant relays another's content.
The single assumption is that those keys are exchanged honestly. That happens in signalling (§3), and §4 is how you'd check it.
2. ⚑ The relay is the default here, not the exception
Upstream Kibitz uses a TURN relay only when a direct connection is impossible, and says most calls never need
one. Witbitz Call sets FORCE_RELAY_DEFAULT = true (kibitz/src/core/callMedia.ts) — direct-first was
tried and regressed three separate times on real networks, so relay-on is the deliberate default. A ?relay
URL parameter overrides it.
What that means, stated plainly:
- The relay cannot read the call. It forwards packets that are already DTLS-SRTP encrypted; it sees ciphertext, IP addresses and traffic volume. The end-to-end property in §1 is unaffected.
- Participants no longer see each other's IP addresses — the relay's address stands in. On a direct WebRTC connection each peer learns the other's IP; routing through the relay removes that. This is a privacy gain over the direct path, and it is the upstream "hide my IP" behaviour turned on for everyone.
- The relay is ours.
/api/turn, same-origin, on Witbitz infrastructure — not Kibitz's. So Witbitz sees the metadata a relay sees: who connected, from what address, for how long, and how much traffic. Not content. - It costs a little latency, which is the trade accepted for calls that connect reliably.
3. Signalling — introductions only, and the one party that could attack
Two browsers cannot find each other unaided. A signalling server briefly exchanges setup data: a temporary peer id, the room name from the link, and ICE candidates (which contain IP addresses). It sees connection metadata, never call content, and stores none of it.
Witbitz Call uses Kibitz's signalling broker (signal.kibitz.chat) — Witbitz has no equivalent service. So
for the live-call path there is a third party in the introduction step, and it is worth naming rather than
glossing.
It matters because signalling is also where the browsers publish their certificate fingerprints. A compromised or compelled signalling server is the one party that could attempt to sit in the middle of a future call by substituting a key during setup. A relay cannot — it only ever sees ciphertext. That is exactly the attack §4 exists to catch.
4. ⚑ Verifying it — the safety code, and why you cannot use it here today
Every WebRTC "end-to-end" claim ultimately trusts the signalling channel to deliver the right keys. The mechanism that removes that trust is a safety code: a short emoji code derived from the two browsers' actual DTLS certificates — the ones really used on the wire, not the ones named in signalling. Same idea as Signal's safety numbers. Matching codes mean nobody is in the middle; differing codes mean somebody is.
The engine implements this fully, including re-warning if a peer reconnects with different key material, and an SSH-style alarm when a previously verified name reappears with a different key.
It is not reachable in Witbitz Call. The brand sets VITE_BRAND_HIDE_PRIVACY_CHROME=1, which suppresses the
shield button that opens Verify (kibitz/src/widget/SecondaryControls.tsx). The call is still end-to-end
encrypted — the flag hides UI, it does not change cryptography — but the affordance that would let you check
that claim is currently switched off.
That is the honest position: on this path you are trusting the signalling server, where upstream Kibitz would let you verify it. If the call matters to you at that level, use kibitz.chat, where the shield is present. Exposing it here is a UI decision, not a protocol one, and it is the single change that would make this page's §1 claim checkable rather than asserted.
5. Offline — a room on one Wi-Fi, with no internet
For a plane, a cabin, a venue with dead cell: one Android device runs a LAN hub, everyone else opens the app on the same Wi-Fi, and the room works with no internet at all. The download and setup are at witbitz.chat/call/host.
Like the internet broker, the hub is a coordination point — it sees who is in the room and carries the handshakes. Content stays end-to-end encrypted: browser-to-browser when they can connect directly, or relayed through the hub's own LAN TURN as ciphertext where they cannot (on phone Wi-Fi, iOS hides each device behind an mDNS name the other cannot resolve). The hub never reads it, and it is a box you run on your own network.
6. What this protects — and what it does not
Protected: the content of the call, from the network and from Witbitz. It cannot be recorded server-side because the media never reaches a server in readable form.
Not protected, and not hidden:
- That a call happened, its timing, duration and traffic volume — visible to the relay, which is now every call (§2).
- Your IP address from Witbitz. Hidden from other participants by the relay; visible to the relay itself.
- The room is open by link unless gated. By default anyone with the link can join. A knock-to-admit lobby, and verified/gated rooms (Google identity, mailed code, signed invites, a name list) are set at creation — see the verified room, which is the same credential model the async Space uses.
- The signalling channel (§3), until the safety code is exposed (§4).
- An agent's model backend. When an AI agent joins, what it perceives leaves the encrypted room to reach a model — that is what an agent is for. It is disclosed to everyone before they join.
7. The agent in the call
An agent joins as a participant, and the capability layer governs it: every peer carries a grant of what it
may perceive (media, chat, roster, directed data) and act. Humans default to full, agents to read-only.
It is engine-enforced per peer — a withheld peer never receives the bytes — so a read-only agent gets chat but
no audio or screen share and can post nothing until granted. The same Grant vocabulary crosses to the async
Space unchanged (the verified room §7).
8. How this differs from a Space — do not conflate them
| Live call | Async Space | |
|---|---|---|
| Guarantee | E2EE between browsers; no server decodes | content-blind at rest; one decrypt-once render |
| Who could read it | nobody in the path — but see §3, §4 | the render, transiently, while a member is present |
| Verified by | a safety code — not currently exposed (§4) | four checks you run in a terminal (verify) |
| Persistence | ephemeral; the room vanishes when everyone leaves | a sealed ledger that persists |
| The moat claim | standard WebRTC, done carefully | the double blind |
The platform's verifiable-privacy argument is about the Space. The call is a well-built E2EE call — a different and more conventional guarantee. Saying so plainly is the point of this page.
9. Where the code is
| Concern | Where |
|---|---|
Media, relay policy (FORCE_RELAY_DEFAULT) |
kibitz/src/core/callMedia.ts |
| Safety code / verify UI (hidden by the brand flag) | kibitz/src/widget/SecondaryControls.tsx |
| Capability grants | kibitz/src/core/capabilities.ts |
| TURN credentials (ours, same-origin) | witbitz infra/turn.tf → /api/turn |
| The engine, open source (Apache-2.0) | github.com/kibitz-chat/kibitz |
The Verified Room — identity, admission and lifecycle
One definition of who is in a room and who said what — and the reason it is interesting: 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 the open Kibitz engine, where it secures live peer-to-peer calls, and Witbitz has carried it — mechanism for mechanism — to asynchronous, end-to-end-encrypted Spaces, where a certified, content-blind function does the verifying when no peer is present.
This page is the whole subject: the definition and its primitive, the credential families, how membership is proved, and — the part that is usually skipped — removal, rotation and recovery, marked precisely shipped vs designed, because for a privacy product overclaiming the lifecycle is worse than admitting a gap.
A Witbitz Space is a self-authenticated entity: its identity, membership and keys live in the link, not in a central account server. The live call is Kibitz (a pinned dependency, rebranded), so that path gets Kibitz's full gate by running the engine. The async Space carried that gate over mechanism for mechanism — the credential formats are Kibitz's; only the verifier changes.
See also: the double blind (why the platform can't read the room), the owner rule (who decides admission, and at what tier), and Verify it yourself (reproduce the gate in your terminal).
1. 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 Kibitz's 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 on which the rest of the page turns.
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.
2. Membership = key possession (mkCommit)
The base Space auth is possession of the room key mk. mk is minted client-side and lives in the link
fragment; the client reconstructs it locally and sends it with each turn/poll — 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, and the sealed ledger is 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.
So the check is cryptographic, not an identity lookup. Key possession proves a member is speaking; which member is established by the per-member signature (§5).
The key model (k-of-n). mk can be split so presence is required to reconstruct it:
- 1-of-1 (solo) —
mkrides one link fragment (#mk=…), or a random pad shape so a stored record can't tell solo from couple. - 2-of-2 (couple) — a Shamir split: each partner's link carries one share (
#s=…);mkexists only when both combine. The creator sealsshareAunder an out-of-band code and registers it as the room'sinvite(the server stores only that ciphertext); the partner unseals it with the code and combines with their own share.
k-of-n is about presence — who must be there to open the Space — not per-member identity.
3. The primitive
Every credential in the model is the same ECDSA P-256 token, and it is tiny
(kibitz/src/core/inviteToken.ts, 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.
4. The credential families
The link can carry any of these verifiers; a room composes them (e.g. a human gate and an agent allow-list).
4.1 Signed invites (inviteToken.ts). The creator holds an invite keypair; each guest gets a grant token
{name, room, exp}; the link carries the invite public key (gk). A signed invite link, cryptographically — no
server, no account.
4.2 The signed manifest (roomManifest.ts). The room's 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, and the creator's signing key is discarded after minting. A
manifest can be passphrase-encrypted, so a mere link-holder can't even read who is allowed.
4.3 Join-gate modes (joinGate.ts). open · names · code (mailed secret, constant-time compared) ·
email/google (OIDC — §4.4) · invite. All but email are serverless.
4.4 OIDC + cert-binding (oidcVerify.ts · oidcBinding.ts · identityCert.ts). "Sign in with Google," without
a backend and without trusting the transport:
oidcVerify— hand-rolled RS256-only ID-token verification over WebCrypto.alg:"none", HS* (the classic algorithm-confusion attack) and ES* are rejected outright — the algorithm is never taken from the token to pick the verifier. The key is chosen bykidfrom the provider's published JWKS; an unknownkidfails closed.oidcBinding— the serverless heart. At sign-in the user sets the token'snonceto a hash of their WebRTC DTLS certificate fingerprint. Every peer recomputes that hash from the cert it actually handshook with and requires a match, so a token is valid only for the peer holding that cert's private key.identityCert— one pinned cert, reused for every connection, private key never leaving the browser → the binding is non-transferable.
The payoff: 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.
4.5 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}; the authority admits it iff the key is on the room's committed
agentKeys and the assertion is bound to this connection and fresh. The room stores only the public key.
4.6 Capabilities (capabilities.ts). A per-participant Grant = what it may perceive (read-chat,
read-roster, read-media, read-files, see-screen, hear-audio, receive-directed) and act (send-chat,
speak, act), plus agent disclosures (backend, egress). defaultGrant('agent') is least-privilege — an
agent perceives the conversation but acts on nothing until explicitly granted.
4.7 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.
5. 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 (poll,title,state,pending,decide,import) 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.
Key possession (§2) proves you're a member; it does not say which. A live Kibitz call attributes each message via the DTLS-bound connection; an async Space has no live connection to bind to, so each ledger entry carries its own signature.
The grant (gt). A member holds an ECDSA signing keypair. Their grant is a Kibitz invite token —
byte-identical to inviteToken.signPayload — with the payload superset { 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 in the
link. Kibitz's own verifier reads name/room/exp and ignores spk. Strict superset — no new scheme.
The entry signature. 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 reader or the render then checks, purely over the link's
public material: verifyGrant(gt, gk, {room, now}) yields (name, spk); then the entry's claimed name must
match and its signature verify against spk. → a verified author, never rooted in the platform.
In the link. The fragment carries gk (room-invite pubkey), gt (the grant) and sk (the member's signing
private key), using Kibitz g* param names — all fragment-only, never sent.
What the pure invite-grant path cannot prove, and what closes it: a signing key is a bearer credential, so
possession is enough — it can't show the holder is present now. The email/Google path binds a fresh,
externally-issued OIDC identity to the member's signing key (nonce = spkNonce(spk)), so an entry proves not
merely "signed by the keyholder" but "signed by the keyholder who authenticated as this Google account, now".
A leaked link is then no longer enough to post.
6. One definition, three verifiers
§1.3 said the verifier can be anyone. That is what lets the same credential model span three runtimes. The only things that change are who runs the check and what fresh value the credential is bound to so it can't be replayed.
| Live P2P call (Kibitz) | Async P2P read (Witbitz) | Async server verify (Witbitz) | |
|---|---|---|---|
| Who verifies | a peer — no server | any reader, at read time — no server | the certified-ephemeral function — the only party present |
| Membership | admitted by the join gate over presence | hold the key (commit(mk) == mkCommit) |
same |
| Freshness / anti-replay | the live DTLS cert fingerprint | the per-entry signature over a canonical core | a server-issued challenge, folded into the signed core |
| Presence | co-present over the connection | none — verify what's written | none — a fresh challenge stands in |
| 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 |
The unifying trick is a nonce/challenge binding: bind the credential to a fresh value only the right holder could have used. Live, that value is the DTLS fingerprint. Async, there is no connection, so it becomes the member's signing key (identity) plus a server-issued challenge (presence).
Async did not need a weaker model. It needed a different anchor for the same model.
7. Mechanism for mechanism
| Kibitz (live P2P) | Witbitz (async) | Same / changed |
|---|---|---|
inviteToken signed invite |
spaceEntrySig grant {name, room, exp, spk, caps?} |
Byte-identical wire format (no import). Adds spk + optional caps. |
| DTLS-bound attribution | per-entry signature over {room, author, ts, kind, id, text, chal?} |
Attribution moves from the connection to a signature verified at read time. |
oidcVerify + oidcBinding |
spaceOidc — RS256-only vs Google JWKS, bound to the signing key |
Same RS256/JWKS/email_verified discipline; nonce binds to the signing key, not a DTLS cert. Verified inside the render and discarded. |
| cert-binding = presence proof | spaceChallenge — server-issued HMAC challenge, folded into the signed entry core |
The member's existing signature also covers the challenge. Single-use via ledger de-dup. |
agentKey assertion + agentKeys |
spaceAgentKeys — the same AgentEntry |
One authored AgentEntry admits an agent live and async. |
capabilities.Grant |
spaceCaps — identical vocabulary |
A Grant crosses live ↔ async unchanged. |
| mesh perceive-gating | spaceScopedView — seal the entitled slice to the agent's box key, never mk |
An external agent reads only what its caps allow. |
signed roomManifest roster |
the mk-sealed config (admission, allow-list, agentKeys, revoked) |
The policy is ciphertext at rest — the platform can't read who is allowed. |
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 the length of one turn; an OIDC email it verifies is transient, never persisted; and
reads it serves an agent are sealed to that agent's key. The server verifies without being trusted with
content.
8. Admission — where it comes from
Getting into a Space differs by path:
- Live call — this is the Kibitz engine. Admission uses Kibitz's join gate unchanged: open, signed invites, name list, cert-bound OIDC/Google, email + mailed code. Witbitz has these because the live call runs Kibitz, not because code was copied.
- Async Space — the whole gate, carried over. Async has a counterpart for every Kibitz method (§7). Admission is key possession (§2) plus, per the Space's policy, one of those credentials.
Who sets that policy — the owner, and at which tier — is the owner rule.
9. 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.
9.1 What happens 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 — server cut shipped for email-gated Spaces; the crypto cut is designed. Every read op checks the
allow-list +
revokedbefore the server decrypts, so a removed member's next read is403— immediately, no re-key. Open Spaces have no read gate — the link reads. The crypto-strong version, so the guarantee holds even if the server were bypassed, is an epoch re-key sealing new content to a fresh key for the remaining set — live in the Bridge, not yet ported to the single-Space ledger.
"But the link is the auth — so how can you cut anyone?" A link is a bearer credential: the removed member already extracted
mkthe first time they opened it, and no edit to the link reaches into their browser to delete it. You cannot retract a held key — only abandon it. Revocation still works because of a subtlety in the thin-client model: the client never decrypts — the server does. So the server is a decryption oracle, and cutting someone means denying the oracle, not un-giving the key. (There is also no "their slice" to strip — everyone holds the samemk; you remove them from the policy, not the key.)
New secret? Cuts them because… Trust assumption Keep mk, drop from the allow-list — shippedNo the server refuses to decrypt-and-return ( 403)you trust the platform — which already holds plaintext in-use Epoch re-key — Bridge; single-Space designed Yes the new ciphertext isn't sealed to a key they hold none — holds even if the server is bypassed So dropping the member from the allow-list is a complete, shipped revocation for an ordinary private Space. A new secret buys exactly one thing: the cut holding without trusting the platform.
9.2 Can a removed member decrypt history? Can they receive future entries?
History they could already read, they retain — a re-key protects the future, not the past. There is no
cryptographic un-see; where it matters, compartmentalize by epoch from the start. Future entries: server-enforced,
no (their poll is 403); crypto-strong, also no (sealed to an epoch key they were never given).
9.3 How are links and keys rotated?
Rotation = open a new epoch, re-seal forward, hand the remaining members the new key. The Bridge does exactly this on every membership change. On the pure-link path today, rotation means issuing new member links; per-member grants are already independent and expiring, so one member rotates without disturbing the others. The friction a link adds — rotating means re-sharing a URL — is what the Companion removes: the new epoch key is delivered to a device, not pasted into a new link.
9.4 Recovery after losing every device
Today: they don't — by design. The room key lives in the link/device and there is deliberately no operator recovery key (an operator key would be a backdoor — the whole point of the double blind). "Lose every copy, lose the data" is the honest current tradeoff. The roadmap answer is user-held recovery, never operator-held: a recovery code the user stores, and/or k-of-n social recovery (the same Shamir split the couple key already uses), with the Companion safeguarding and syncing those shares. Recovery moves to the user, not to us.
9.5 Replacing a compromised identity
Yes, without rebuilding the Space — this is what per-member identity buys. Each member is a signing key bound
by a grant, not the room itself: revoke their grant (shipped), admit a new key (a fresh grant / AgentEntry),
and re-key the epoch (designed). 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, not a migration.
9.6 What metadata remains visible
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.
10. 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.
11. 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.
- Blind at rest — the policy is sealed under
mk; verified identities are transient.
12. Status
| Piece | Live P2P (Kibitz) | Async (Witbitz) |
|---|---|---|
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) |
| Verified host / moderation | shipped | delegated authority (propose → approve → execute) |
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 (epoch re-key, forward secrecy) | n/a | shipped in the Bridge; designed for the single-Space ledger |
| Rotate a compromised member/agent key | shipped | shipped (identity + revoke) + designed (re-key for full read-cut) |
| Device-loss recovery (user-held code / k-of-n social) | n/a | designed — roadmap, never operator-held |
| Per-member identity — couple (2-of-2); distinct invite links | n/a | not yet (solo signed path live) |
13. Code map
| Concern | Kibitz (live P2P) | Witbitz (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 | src/core/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) |
| Link codec + k-of-n | — | spaces/public/spaceConfig.js, shamir.js, invite.js |
| Epoch re-key + forward secrecy | — | agent/bridgeMembership.mjs |
| 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.
Deployment options
Witbitz can run as a hosted platform, in a customer's cloud boundary, or in an air-gapped environment. The product claim changes slightly in each mode, so the docs keep the deployment model explicit.
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 remains the main work | No external egress | Regulated, defense, government, and high-control deployments. |
| Bridge | Early protocol preview | Multiple sovereign Spaces or external parties | Cross-organization collaboration without merging tenants or trust domains. |
Hosted
Hosted is the default private-beta path. Witbitz operates the runtime and exposes the tenant-keyed /v1 API to invited
integrators. The privacy claim rests on the checkable hosted evidence:
- admission gate checks
- sealed storage transparency
- browser egress lock
- signed build certificate
- reproducible render build
Read: Hosted private beta, Verify it yourself
Enterprise VPC / on-prem
In a customer boundary, the same runtime points its seams at customer-controlled services:
- model endpoint
- object store
- record store
- secrets backend
- identity provider
- logs, backup, retention
The trust story shifts from "verify Witbitz's hosted deployment" to "verify the image you run and control the boundary yourself."
Read: Enterprise and on-prem
Air-gap
AIRGAP=1 refuses outbound paths in the browser and server. External tools are disabled unless replaced with local
services. The model must also be in-boundary for a true air-gap.
Read: Air-gap mode
Bridge
The Bridge is not a deployment target for one app. It is the cross-boundary protocol for governed collaboration between sovereign Spaces, including external parties that do not run Witbitz internally.
Read: The Bridge
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 |
| Collaborate with another organization without sharing a tenant | Bridge |
| Verify hosted privacy claims | Hosted + verify page |
| Remove Witbitz as an operator | On-prem |
Hosted private beta
Hosted Witbitz is invite-only while the platform contract settles. The docs and API contracts are public; tenant keys are gated.
This page is the operating model for builders using 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 | Built, opt-in, not default |
| Bridge API | Early preview |
Access
The access page can redeem an access code and create a tenant key pair:
https://witbitz.chat/access
Keys are shown once. Secret keys are server-side only. Publishable keys 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 and openapi.full.json as the broader design map.
Read: Platform API
Hosted trust evidence
The hosted mode is designed to be checked from outside:
| Evidence | Where |
|---|---|
| Admission gate refuses unauthorized reads/writes | Verify it yourself |
| Stored ledger is ciphertext | 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 attested server tier is built and independently verifiable, but ordinary hosted production Spaces do not run inside an enclave today.
Read: Status, Trust model
Operational boundaries
Hosted Witbitz can still see metadata:
- room existence
- request timing
- ciphertext sizes
- billing/metering events
- transient identity during gated verification
Hosted Witbitz cannot read the sealed ledger at rest because there is no operator recovery recipient.
When hosted is not enough
Use on-prem when you need:
- customer-controlled model calls
- customer-owned object storage and records
- customer IdP as the only identity authority
- local backup, retention, and logs
- no Witbitz-operated runtime in the path
Read: Enterprise and on-prem
Run it in your own boundary — enterprise & on-prem
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.
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-config
There is no per-app server to stand up and no dependency on our infrastructure. The app the browser loads is the same static, egress-locked, reproducibly-built page — now served from your host.
What each concern maps to
Every seam is a switch. Unset, it's the hosted default; set, it points at infrastructure you own.
| Concern | Runs on | Switch |
|---|---|---|
| Model | any OpenAI-compatible endpoint — your cloud key, Azure, or a self-hosted vLLM / Ollama | LLM_BASE_URL |
| Object store (the sealed ledger) | MinIO or any S3-compatible store | S3_ENDPOINT |
| Records (Spaces + Bridges) | a local store (single node) or Postgres (multi-node HA) | DATABASE_URL |
| Secrets | mounted files — a Kubernetes Secret or a Vault agent — instead of a cloud secrets manager | SECRETS_BACKEND=file |
| Who may join | your own identity provider — Okta, Entra, or Keycloak — as a trusted OIDC issuer | SPACE_OIDC_ISSUERS |
| Operations | backup / restore, a retention sweep, bounded log rotation | included in the kit |
| Zero egress | refuse every outbound path — see below | AIRGAP=1 |
Your identity provider
An email-gated Space admits members who sign in with an issuer you trust. Register your corporate IdP and it's authoritative — no dependency on any consumer sign-in. It's additive and fail-closed: a registered issuer can't weaken the built-in ones, the audience is checked (so a token minted for another app can't be replayed), and the Space's own membership list still decides who's actually in. The IdP only proves identity; the Space decides admission.
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.
Air-gap — zero egress
AIRGAP=1 makes the runtime refuse every outbound path, and it does so in ways you can check independently:
- The browser. The served Content-Security-Policy drops every external host. Read the header and it names zero off-box destinations — the browser itself refuses any external fetch.
- The server. The map / route proxies stop calling out, and the agent's internet-touching tools (web search, place lookup, flights, external photos, URL fetch) are refused at a single chokepoint — so even a Space configured with them can't reach out.
Chat, the agent, document reading, PDF generation, charts and diagrams, and IdP admission all keep working. The one
egress this switch can't close is the model call itself — point LLM_BASE_URL at a self-hosted, in-boundary model
or the turn still leaves the network (the runtime warns loudly at boot if you don't). Standing up an open-weights model
and assessing its quality against a frontier model is the remaining air-gapped-tier work.
The trust story gets stronger, not weaker
Hosted, our signed /cert.json and reproducible build let a skeptic
confirm the operator — us — is blind to your data in use. 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. And air-gapped, "no data leaves" stops being a promise and becomes something you verify on
your own terms:
- No data leaves — read the CSP off the served page: zero external hosts. Confirm the proxies refuse.
- The code is the audited code — reproduce the build from published source and match the running image's hash;
op:attestreturns the exact build commit the box is running.
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"}'
Talk to us
On-prem and air-gapped deployments are handled as enterprise engagements — hello@witbitz.chat.
Air-gap mode
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 to an in-boundary endpoint, and verify the browser and server both
name no outside hosts.
Status
The runtime has a zero-egress switch. 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.
Read: Enterprise and on-prem
What AIRGAP=1 changes
| Layer | Behavior |
|---|---|
| Browser | Content-Security-Policy removes external hosts. |
| Server | External map, route, photo, URL, search, and flight paths are refused. |
| 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. |
What still works
- chat
- agent turns against an in-boundary model
- document reading
- PDF generation
- charts
- diagrams
- shared widget state
- IdP admission against an in-boundary identity provider
- 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 browser boundary
Read the CSP header from the served app. In air-gap mode, it should name no external hosts:
curl -sD- https://space.your-co.com/ -o /dev/null | grep -i content-security-policy
Verify the running build
Ask the local runtime what it is:
curl -sX POST https://space.your-co.com/space \
-H 'content-type: application/json' \
-d '{"op":"attest"}'
Then compare that build identity to the published source and your own deployment process.
The trust story
Hosted Witbitz asks you to verify the operator's deployment. Air-gapped Witbitz removes the third-party operator from the path. The check becomes supply-chain oriented:
- Is the image you run the audited source?
- 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?
Related pages
The Bridge — governed collaboration between sovereign Spaces
The Bridge is Witbitz's forward-looking frontier: a governed relationship between two or more sovereign Spaces — not another shared room. Each side keeps its own identity, data, policies, and authority; the Bridge defines what may flow, who participates, and how it's audited. The other side need not even be Witbitz — an external agent and its humans can join over signed HTTPS. The protocol is the product.
Status: an early protocol preview, live at
https://api.witbitz.chat/v1/bridge. Normative spec: bridge-protocol.md; wire contract: bridge.openapi.yaml.
1. The shape — three spaces
Two parties each keep a private prep room (their people + their agent, sealed under their own key — raw work never leaves), and they meet in one shared space — the Bridge — where approved content is placed and humans can talk directly. There is no neutral host; the parties co-host. A party is native (a Witbitz Space) or external (someone else's agent + humans, on their own system) — the two are just implementations of the same wire contract. It generalises to N parties (the shared space is a first-class Space with its own id).
2. The five invariants
The semantics are stated invariants-first; the ops are just mechanism.
- Confinement is structural. The shared space's epoch key opens only the shared space. Private rooms are under different keys — an external or hostile party can't reach them because it holds no key, not because a policy says no.
- Attribution by signature. Every entry is signed by the submitting party's key and verified against its public key in the membership record — on the ciphertext.
- The platform is content-blind. Bodies are sealed under a key the platform never holds. It checks membership, capability, epoch, and the hash chain — never the content.
- Source-side egress. Each party approves what it contributes before it is sealed into the shared space. Nothing crosses un-reviewed; the platform never reads content to police it.
- Governed, revocable membership + trustless audit. Every join or revoke mints a fresh epoch key; a joiner gets no back-history by default; a revoke gives real forward-secrecy. Server-signed checkpoints that parties pin + gossip make equivocation detectable.
3. Key epochs
The shared space is a sequence of epochs, each with its own key sealed per-member (to each party's box key). Every join OR revoke is an epoch boundary: the new key is sealed only to the new member set. So a removed party can't open the next epoch (forward secrecy), and a joiner gets no transcript unless policy explicitly seals past epochs to it (invited into a conversation, not handed its history).
4. Capabilities
Each member carries caps ⊆ {read, submit, admit, revoke} → observer (read-only auditor) / party (read+submit) /
admitter (+admit/revoke). SUBMIT/ADMIT/REVOKE are capability-gated.
5. The ops (live)
At https://api.witbitz.chat/v1/bridge:
| op | what it does | auth |
|---|---|---|
POST /spaces |
found a shared Space (JOIN, first party) | entry signature |
GET /spaces/{s}/members |
the public membership record + sealed key grants | public |
PUT /spaces/{s}/members |
admit / revoke — a re-key, an epoch boundary | capability |
POST /spaces/{s}/entries |
submit a signed, sealed entry (a load or a post) | entry signature |
GET /spaces/{s}/entries |
read the shared thread since a cursor (ciphertext) | public |
GET /spaces/{s}/checkpoint |
the latest server-signed head, to pin + gossip | public |
Deployed auth is thin-but-sound: SUBMIT is authenticated by the entry's own signature (verified against the
membership pubkey); reads/members/checkpoint are content-blind or public. Per-request partySignature
(RFC-9421-style) is the documented hardening (x-status: proposed) — the guarantees already hold from the key
boundary + entry signatures.
6. Native or external — the interop boundary
The whole point is that the other side isn't necessarily Witbitz. A native party and an external party (identity + the protocol crypto only, zero Witbitz-Space code) are two implementations of one contract; the interop was proven end-to-end (a native + an external party co-inhabit one shared space, content-blind). That interop boundary — own the governance; let the wire be MCP/A2A/signed-HTTPS — is why the protocol, not any one app, is the product.
7. Code map
| Concern | Where |
|---|---|
| Membership + capabilities + key epochs | agent/bridgeMembership.mjs |
| Signed, hash-chained log + checkpoints | agent/bridgeLog.mjs |
| Service + party client (native = external) | agent/bridgeSpace.mjs |
| HTTP binding | agent/bridgeHandler.mjs; infra infra/prod-bridge.tf |
| Normative spec / wire contract | docs/bridge-protocol.md · docs/bridge.openapi.yaml |
8. Status
Live over HTTP + verified end-to-end (found → admit an external party → signed+sealed submit → content-blind read → checkpoint; governance rejects a stranger/tamper). It is an early preview — the frontier of the platform, not a beta-stable API. The wider vision (org-to-org negotiation, delegated authority across the boundary, a public transparency log) builds on this.
The Companion — an optional device runtime
Status: roadmap, and now optional. The three things this was meant to add — notifications, durable keys, local decryption — the verifiable web app already does. The Companion remains a worthwhile convenience (more reliable background notifications, a Secure-Enclave keystore), but it is no longer a pillar of the platform, and nothing on the roadmap depends on it shipping.
A Witbitz app is a pure static page — which is exactly what makes its privacy checkable. A static page famously can't, on its own, hold a key safely, notify you while you're away, or decrypt locally. The Companion was conceived as the one native app that would carry those "un-static" capabilities for every Witbitz app.
Since then, the web caught up. Here is how each capability is handled today, with no app — and what a Companion would still add on top.
How it's handled without the Companion
- Reach-me → installed-PWA push. Add a Space to the home screen and the platform can reach you with a content-free push (a "something happened" tickle); the device fetches and decrypts locally. Works on iOS (16.4+, installed), Android, and desktop.
- Keys → a passkey, not a link. A passkey (WebAuthn, hardware-backed, synced by your provider — 1Password, iCloud Keychain) holds the room secret, so it stops riding the URL. See the verified room § lifecycle.
- Recovery → user-held. An encrypted recovery — a code, or a passkey-derived secret — that you control and the server can't read. Lose a link, still get back in. Never an operator key.
- Local decrypt → in the verifiable client. The reproducibly-built, egress-locked page decrypts in your own tab today; Sealed Spaces is the design that makes the key never reach the server at all — structural at-rest blindness, no native app required.
That covers the essentials — and the web version is, if anything, more verifiable than an app would be: a CSP-locked, reproducible static page is something a third party can check; an app-store binary is not.
What a Companion would still add
A thin native app is worth building as a convenience, never a foundation:
- More reliable notifications than web push, which is limited on iOS and only fires once installed.
- A Secure-Enclave keystore — hardware-isolated keys, a step beyond browser-held ones.
- One presence across every Space, and plain app-store legitimacy.
The honest boundaries
- It does not close the in-use gap. The agent turn runs on server compute, so an agent still decrypts server-side until the attested / on-device inference tier (see the double blind and the "not protected" column of Sealed Spaces). A client-side Companion doesn't change that.
- It's a convenience, not a requirement. The platform stands on the verifiable web and Sealed Spaces; the Companion is a nicety for people who want it, and can arrive whenever — or never — without moving the trust story.
Where it sits
| Capability | Today, no app | A Companion would add |
|---|---|---|
| Key storage | passkey (hardware-backed) + link fragment | Secure-Enclave keystore |
| Recovery | user-held code / passkey-derived secret | same, device-native |
| Notifications | content-free push on an installed PWA | more reliable background delivery |
| Local decrypt | in the verifiable tab; Sealed Spaces removes the key from the server | one audited runtime |
| Agent turn (in use) | server-side until the attested tier | unchanged — not what a client app fixes |
See also: the double blind (incl. Sealed Spaces) and the verified room § lifecycle.
The issuer — credit without accounts
There is a shorter overview of this at witbitz.chat/issuer — the same model with less API detail, including the honest-status table of what is live and what is not. This page is the one to integrate against.
Witbitz has no user accounts. Access is a link you hold; credit is a credential you hold. So who takes the payment, and how does the platform know what you paid for?
Someone else does. An issuer holds the customer, the payment relationship and whatever identity that requires. Witbitz validates and meters and knows neither. If that shape sounds familiar it is the one card networks use: your bank knows who you are, the merchant sees only that the payment cleared, and the scheme in the middle settles between them without holding either side's relationship.
The two sides of a tenant
A tenant sits on one side of the platform, or both:
| role | what it does |
|---|---|
app |
builds a product on rooms, agents and collections, and spends credit |
issuer |
holds the customer and the payment relationship, and funds wallets from its own prefunded balance |
GET /v1/tenant reports your roles. They are set by an operator — a tenant cannot promote itself to
issuer, because issuing is what turns a balance into spendable value.
A tenant created before roles existed reports roles: null and is treated as holding both, so nothing
that worked before the split stopped working.
Issuers are prefunded. POST /v1/wallets/grant debits the issuer's own balance and returns
402 insufficient_credit when it is short. The platform never extends credit, which bounds what an
issuer's failure can cost: users keep credentials already paid for, and nothing outstanding is at risk.
Epochs — a batch, not a purchase
Every credential names an epoch: the batch it was minted in, as an opaque id such as
ep_47d5a543e80148deb604.
Credentials used to carry the issuing tenant id instead. Combined with a timestamp that identifies a specific purchase, and the metering ledger used to record which wallet paid for each call — so the two together reconstructed a path from who paid to what happened in which room. Both are gone: the ledger no longer records the wallet at all, and the tenant id became this batch id.
Epoch ids are random, never derived from the tenant. A derived id would be recomputable by anyone holding the tenant list, which would rebuild the link.
Publishing a batch
POST /v1/epochs Authorization: Bearer wsk_… (issuer role required)
{ "pub_key": "-----BEGIN PUBLIC KEY-----…", "cents_per_token": 25 }
→ 201 { "epoch": "ep_0158043c8123…", "cents_per_token": 25 }
Three rules worth knowing before you integrate:
- The platform receives only a PUBLIC key. Private material is refused at write time. Witbitz can verify what you signed and can never mint on your behalf.
- A token's value is fixed at publication. Without
cents_per_tokenthe redeemer would choose what a credential is worth. - An epoch's key is set once. Rotation means publishing a new epoch. Republishing returns
409— otherwise you could restate what already-minted tokens are worth after issuing them.
You may name the epoch yourself if you need to mint tokens before publishing the key.
Redeeming
POST /v1/wallets/redeem
{ "token": "tok_…", "sig": "<base64url>", "epoch": "ep_…" }
→ 201 { "wallet_code": "WYZK-VG2M", "balance_cents": 25 }
The platform verifies your signature against that epoch's published key, records the token spent, and mints a wallet worth the epoch's denomination.
409 already_spent— the token was redeemed. This is deliberately the same answer for a retry and for a double-spend: the token is the idempotency key, so the two are indistinguishable by design. Don't send anIdempotency-Keywith this presentation; a second wallet is never minted.403 unknown_epoch/bad_signature— the batch is unpublished, or the presentation does not verify. An epoch with no published key redeems nothing.
The wallet_code is shown once. It is a bearer credential: store it as the user's capability.
Blinding, and what is honestly true today
The design point of a token is that the platform can verify a credential without being able to link it to an issuance. That is what blind signatures give you: your client blinds a token, you sign the blinded value without seeing it, and the unblinded result is an ordinary signature. A verifier cannot tell it was produced blindly — which is exactly why Witbitz needs no special cryptography to accept one.
Two things are not true yet, and it would be dishonest to imply otherwise:
- Nothing here blinds anything. Blinding is your client's job and blind-signing is yours; neither lives in the platform. Until you do both, these are single-use bearer credentials with the right shape — not unlinkable ones.
- The epoch→issuer mapping is still held by the platform. It sits in its own row precisely so it can be moved out, but until it moves, Witbitz can still resolve which issuer minted a batch.
There is also a third link on the other path: credentials bought through Lemon Squeezy record the order id, so a purchased credential remains linkable to a buyer where a granted one no longer is. That is unavoidable while Lemon Squeezy is merchant of record and legally holds the buyer's identity anyway.
So the claim today is the smaller, real one: a granted credential can no longer be linked to a purchase. The larger claim — that the separation holds mathematically rather than organisationally — waits on blinding and on that mapping moving.
Settlement
Counts, not links. An issuer sells N tokens in an epoch; the platform redeems M of them; the issuer settles on M. Nobody needs to know which, which is what lets the books close while unlinkability is preserved. Double-spend is prevented by a permanent record of spent tokens — permanent deliberately, as an expiring record would make every token replayable once it lapsed.
Where this fits
Access is a link, credit is a credential, and identity belongs to whoever is willing to hold it. Witbitz holds neither your users' content nor their identity — which is the same argument as the double blind, applied to money instead of messages.
Machine-readable source: llms-full.txt · every doc in one fetch: llms-full.txt (HTML)