Witbitz docs HomeTrustAll docs

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.

How it is sealed. v2 re-seals the whole ledger on every turn — simple, and O(n) in the history. v3 appends the new entries as per-entry sealed boxes, so appending is O(1) in the delta and a reader can take a content-blind slice without opening everything. v3 is what production runs; the writer is flag-gated (SPACE_LEDGER_V3) and migrates on write, so the two formats coexist and the change is reversible.

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 sealed blob is never sent to the client — the client is thin (sends turns, receives rendered plaintext, never touches ciphertext or crypto).

Away members are reached by push (agent/webPush.mjs, ops subscribe / unsubscribe). The notification is a content-free tickle: it carries no message text, only "something happened in this room". The service worker wakes, polls, and decrypts locally — so the push service learns nothing a poll would not already reveal, and the content-blind property survives the notification path. This works in an installed iOS/Android home-screen PWA, with no native app.

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.

The reply arrives progressively

A turn does not wait for the model to finish. As the reply is generated it is sealed and published as a partial (agent/asyncTurn.mjsputPartial), and that sealed partial rides every poll response — including the etag-unchanged short-circuit, so it is delivered without disturbing the content-blind fast path. The client opens it with mk and reveals it at a readable rate.

The value is not a shorter wait. It is that the reader starts processing the answer sooner. The partial is cleared the moment the finished entry lands in the ledger, so a live bubble can never duplicate a real message — including on the failure path, where a stream that died after emitting text must also disappear.

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, plus any sealed partial); on an email-gated Space it requires an allow-listed sign-in
pollsealed the same history returned still sealed — the server takes no key and decrypts nothing; the client opens the envelope itself
decide approve/deny a pending proposal (delegated authority)
pending list pending proposals
import seed the Space from a public ChatGPT share link (SSRF-guarded) or a WhatsApp export
title / state a sealed room title; the shared co-edited widget doc
blob sealed out-of-line content (images, files). Content-addressed and immutable, so a client may cache a ref forever; the server never opens it
subscribe / unsubscribe Web Push registration for away members (content-free tickle)
view a scoped read for an admitted agent: decrypt with a present member's mk, keep only what the agent's caps.perceive allow, and re-seal that slice to the agent's box key. The agent never receives mk
fetchview the agent collects that sealed slice. Un-gated and content-blind — it is ciphertext only the agent's box private key opens
mirror append a linked room's entries to this ledger (the private-lane bridge). Deduped by id, no agent turn. The server never joins the two rooms — the device does, holding both keys
policy resolve a room's governance + owner key, so a client can verify the room against its own pinned owner before trusting it
attest the running service reports the source it was built from (verifiable trust); cross-check against the signed build certificate
invite public, no mk: a joining partner fetches the commitment + the sealed share, unseals it with the invite code and combines it with their own share to reconstruct mk. The server only ever hands back ciphertext

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.

Shipped: the turn core; transport (mk-commit); content-blind poll; delegated authority; per-member signed identity; progressive (streamed) replies; Web Push for away members; the 2-of-2 code-sealed couple handshake (spaces/public/invite.js — the server only ever holds ciphertext under a code it does not have); private lanes; membership forks; email-gated admission; import from ChatGPT and WhatsApp; the built-in tool catalogue.

Ledger v3 — appending only the new entries as per-entry sealed boxes, so a turn's crypto is O(1) in the delta rather than O(n) in the whole history — is enabled in production (SPACE_LEDGER_V3=1) while the code default stays off, so a rollback is one environment variable. v2 seals the whole blob each turn and remains the fallback.

Deferred: delivering an agent's scoped view while no member is present (a push-on-turn sealed-view store); compaction of very long ledgers beyond the current bound.

Machine-readable source: async-spaces.md · every doc in one fetch: llms-full.txt (HTML) · ← embed · delegated-authority