Witbitz docs HomeTrustAll docs

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:

  1. The page creates or opens a Space.
  2. Members join by link, sign-in, invite, or owner policy.
  3. The Space stores conversation, documents, artifacts, and shared widget state.
  4. An AI agent runs when a member posts a turn.
  5. High-stakes tool calls become proposals; an authorized human approves before anything executes.
  6. 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.

Static appLoads in the browser with a locked egress footprint.
SpaceHolds members, admission policy, sealed config, and shared state.
Agent turnRuns only when a member acts, then re-seals and goes cold.
EvidenceDocs, OpenAPI, CSP, build certificate, and raw sealed bytes stay inspectable.

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:

  1. Humans keep authority. Agents do not own credentials. They propose; people approve; the runtime executes only approved actions.
  2. 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


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:

  1. Interaction — how humans and AI meet. Today: live calls and async Spaces (more modes to come).
  2. The platform — a general-purpose app backend: the whole app flow, plus the AI that runs inside the app. Exposed as the tenant-keyed /v1 API (API reference).
  3. 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:

  1. 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.
  2. Pro + AI with a private back room — both serve a client, and confer privately. Teacher + AI, doctor + scribe, lawyer + paralegal.
  3. Two sovereign sides — the Bridge — each side preps privately, they meet in a shared room. Negotiation, M&A, procurement, mediation. (the Bridge).
  4. Hub & spoke — a person (or case) at the center, several orgs bridge in. Care coordination, eldercare.
  5. Many players — the Spaces app. Group planning, a family council, community rooms.
  6. 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


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:

  1. The browser mints a room key mk.
  2. The browser computes commit(mk).
  3. The browser registers only the room, commitment, and sealed config with op:'create'.
  4. 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 room returns 409 exists.
  • Missing commit returns 400 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

One request-scoped render
Member sends turn with room key and message
->
Render checks admission, opens the ledger, runs tools, re-seals, and drops the key
  1. A member sends { op:'turn', room, mk, message }.
  2. The transport checks commit(mk) against the Space record.
  3. If the Space is gated, identity is verified against the sealed admission policy.
  4. The render opens the sealed ledger.
  5. The member entry is appended.
  6. The agent runs with the Space's persona and tools.
  7. The reply, widgets, files, or pending proposals are appended.
  8. The ledger is re-sealed and stored.
  9. 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 own set_site_color tool 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 via SPACE_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 AppPolicy may name embedOrigins — 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 drops mk. 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/getLedgeragent/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:

  1. access controlmk arrives from a present member's client; agent/spaceTransport.mjs checks commit(mk) === the Space's stored commitment (proves membership + that this turn seals to the ledger's key).
  2. attribution (for a signed Space) — verify the turn's signature against the member grant, set the verified author (room-link-auth §4).
  3. 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.
  4. 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 (mk unused).
  • 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.mjsagent/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.


Delegated Authority — humans keep the final say

The second of Witbitz's two guarantees (the first is the double blind). An agent never owns authority — it receives it, limited and revocable, and a human approves anything high-stakes before it happens. The runtime enforces one invariant: no action runs without an approval.

Built + tested (async Spaces M2 Cut 4); runs inside the deployed /space render. Code: agent/delegatedAuthority.mjs.


1. Propose → approve → execute

An effectful action moves through three recorded steps, asynchronously (the human may approve on a later turn, not in-the-moment):

  1. propose — on its turn, the agent proposes an action; it is recorded pending, not run (propose(room, { action, by })).
  2. approve / deny — a human decides on a later turn (decide(room, { id, approve, by })). A decision on a non-pending proposal is refused; a decided proposal can't be flipped.
  3. execute — only an approved proposal runs, through the injected executor (execute(room, { id, run })), and only once.

2. The invariant

Enforced and unit-tested: an un-approved proposal never runs, and no proposal double-runs. Approval is a precondition to execution, checked in the runtime — not left to the model's discretion. This is the couples-therapy safety-floor idea raised to the action level: the model reasons, the human authorizes, the runtime gates.

3. Capabilities are just tools

A Space's effectful capabilities are ordinary tools in its config (send_summary, schedule_checkin, …) — there is no separate "actions" concept. The turn runs the normal brain tool-loop; the only async-specific twist is that a high-stakes tool call is deferred to a human approval instead of running in-room. makeProposingBrain wires this: it runs the brain with the Space's tools and turns a tool call into a pending proposal (via proposeCallTool) rather than an execution. On approve, execute runs the same proposal through the same tool executor a live call would use, keyed on the tool name — so approval routes to the real implementation.

4. The audit trail

Every step — propose / approve / deny / execute (with result or error) — is appended to a single per-room authority doc: { proposals, audit }, an append-only log. It is mk-sealed like the ledger, so it is content-blind at rest (the platform can't read it) and it persists across weeks. A wrong mk yields nothing — the trail is readable only to a present member, in the decrypt-once render.

5. Code map

Concern Where
propose / decide / execute + the invariant agent/delegatedAuthority.mjs (propose, decide, execute)
pending list agent/delegatedAuthority.mjs (pendingProposals)
tool-call → proposal glue agent/delegatedAuthority.mjs (makeProposingBrain, proposeCallTool)
the ops that drive it agent/spaceService.mjs (turn proposes · decide approves→executes · pending)
the sealed authority doc agent/sessionStore.mjs (putDoc/getDoc), sealed via agent/envelope.mjs

6. Status

Built + tested (the invariant, deny-can't-execute, no-double-run, the ordered audit trail, platform-blindness) and running inside the deployed /space Lambda. What it does today is the async propose → approve → execute + audit loop; richer policy (spend caps, per-capability authority tiers, org-level delegation from the vision) builds on this primitive.


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 owner admin_token or the add-only contributor_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 an Idempotency-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 and POST /tenants are operator-only (X-Admin-Token, not a tenant key) and are deliberately absent from the published spec, so don't go looking for them in openapi.json.
  • Secret handling: one-time-secret responses are Cache-Control: no-store; sensitive fields x-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}/renderkeyless 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/redeemtwo 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 /epochsissuer 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

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

InfraGET /turn (ephemeral TURN credentials).

Not at /v1: ratings, /agents/*, /earnings and /sessions/{id} are x-status: legacy — they exist on the older surface but are not routed here, and calling them returns 404. They appear in openapi.full.json labelled 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.json is the source of truth (schemas, per-op auth, examples).

Trust model

Witbitz has two separate trust claims. Keep them separate:

  1. Humans keep authority. An AI agent can propose work, but the runtime requires a human approval before any high-stakes action executes.
  2. 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:

  1. The agent proposes.
  2. A human approves or denies.
  3. Only an approved proposal executes.
  4. 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


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)

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

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


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

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-src is 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), and https://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-src allows only same-origin files and each inline block by its sha256no 'unsafe-inline'. So even injected script can't run, and can't exfiltrate.
  • default-src 'none' means anything not named above is denied by default; form-action and object-src are shut; frame-ancestors 'self' blocks cross-origin framing.

This is the egress-allowlist half of the signed certificate (the double blind §4–6) — now shipped and enforced. You are reading the app's true data footprint off ground truth. (What it does not 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:

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

This is content-blindness 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 mk for a transient instant — on an AI turn and on every changed read, the one door plaintext opens. Tests 6–7 now prove the deployed code is a faithful, reproducible build of readable source — you can rebuild it and read it. The last thing they can't prove is that the running process can't be watched while it runs: that during that instant the operator (or the cloud) can't read the plaintext out of memory. That needs a TEE — a confidential-compute enclave whose remote attestation binds the running measurement to this reproducible build (and thereby excludes even the operator + AWS from the render). 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:

  1. Revoke the old grant.
  2. Admit a fresh member key.
  3. 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.


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

  1. 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."
  2. 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.
  3. 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.
  4. 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 & CHECKABLEPOST {"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 endpointop:'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 a revoke cuts 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:

  1. The signed policy, sealed in the room config — so the render can read and enforce it with the room key.
  2. A cleartext marker on the public record — { app, ownerKeyId, policyHash }, generalizing today's gated flag — 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:

  1. Owner registration (once, the trust root). The owner registers the app: its ownerKey, its IdP (issuer + JWKS, the SPACE_OIDC_ISSUERS entry), 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."
  2. 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.
  3. 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/revoked config + the render's per-turn gate, the gated cleartext marker, pluggable OIDC issuers, tenant keys, the signed-grant capability gate, and session/tool metering.
  • New: (1) the signed AppPolicy artifact + a pinned ownerKey; (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.json will carry an enclave block 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.json on 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 a requireAttestation room 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:

  1. present a hardware attestation — prove a genuine, unmodified build of the app is running on a genuine device;
  2. hold keys in hardware — the Secure Enclave / StrongBox / TPM, so the room key never sits in JS;
  3. load the bundle locally into a locked web view;
  4. 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


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

HostedWitbitz operates the runtime; builders get tenant keys during private beta.
EnterpriseThe same runtime runs inside a customer cloud boundary.
Air-gapNo external egress; model and replacement services stay local.
BridgeSovereign Spaces collaborate without merging tenants or trust domains.
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:

  1. 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.
  2. 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:attest returns 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?

The Bridge — governed collaboration between sovereign Spaces

The Bridge is Witbitz's forward-looking frontier: a governed relationship between two or more sovereign Spacesnot 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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_token the 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 an Idempotency-Key with 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:

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