Async Spaces — the agent is a function, not a server
The first Witbitz Spaces are async: a persistent room that lives over weeks, where the AI is a function invoked per turn, not an always-on process. Between turns the Space is cold ciphertext — so an idle Space costs ≈ $0. This is the model the Spaces app runs on. Live in production.
See also: the Space link is the auth (membership + identity) and the double blind (why the platform can't read a Space).
1. Agent-as-function
A live call runs the agent as a container for the call's whole life. An async Space instead invokes a turn function only when a present member acts:
A member posts → the transport invokes the turn with the member's
mk→ the function opens the sealed ledger, asks the model, appends the reply, re-seals, stores, and dropsmk. Then the Space is cold again.
No held socket, no always-on compute → idle ≈ $0 (the reason to prefer poll/push over held connections; cost
model in the repo's async-rooms-cost.md). It stays on AWS (DynamoDB ledger + S3 blobs + Bedrock/LLM in-boundary)
so the ciphertext and the inference share one trust boundary.
2. The sealed ledger
A Space's conversation is one snapshot sealed under mk and stored as ciphertext
(sessionStore.putLedger/getLedger → agent/envelope.mjs seal/open). It is sealed to the member key
only — there is no operator recovery key. The platform holds no key; at rest it is opaque bytes. This is the
persistent Space memory.
3. The turn — a decrypt-once render
agent/asyncTurn.mjs runAsyncTurn is the render, and it is the one place plaintext exists:
- access control —
mkarrives from a present member's client;agent/spaceTransport.mjscheckscommit(mk) === the Space's stored commitment(proves membership + that this turn seals to the ledger's key). - attribution (for a signed Space) — verify the turn's signature against the member grant, set the verified author (room-link-auth §4).
- decrypt-once — open the sealed ledger, append the incoming entry, run the brain (its tools may produce
widgets/artifacts), append the reply, re-seal,
putLedger. - drop
mk— the key is never held past the turn.
Between turns nothing runs and nothing is readable.
4. Reaching members — poll, content-blind
agent/spacePoll.mjs is the read side, and it stays blind on the common path:
- The client sends its last etag; the server computes
etagOf(sealed)= a hash of the ciphertext — it reveals only "changed", never the turn count, and it is computed without decrypting. - Unchanged → return no entries and never decrypt (
mkunused). - Changed → decrypt-once, return only the plaintext new entries after the client's cursor, drop
mk.
The sealed blob is never sent to the client — the client is thin (sends turns, receives rendered plaintext, never touches ciphertext or crypto). Deferred: reaching an away member whose app is fully closed (polling needs a running app) — real-time push/email is a later cut.
Reads are gated too, for a private Space. On an admission:'email' Space the poll (and every content op —
title, state, pending, decide, import) requires an allow-listed Google sign-in before the server will
decrypt-once and return anything — the room key alone no longer reveals the history. The server is the plaintext
chokepoint (it decrypts in-use), so it enforces the allow-list on reads exactly as on writes (room-link-auth §4).
Live + proven in prod: a token-less poll to an email-gated Space returns 403. An open Space is unchanged —
the link reads.
5. The ops
One HTTP surface (agent/spaceHandler.mjs → agent/spaceService.mjs), at /space:
| op | what it does |
|---|---|
create |
register the public Space record (its key commitment + config + gk); the client mints room+mk, the server never sees mk |
turn |
a member posts; the render replies (or proposes a high-stakes tool call — see delegated authority) |
poll |
the content-blind read (etag → plaintext delta); on an email-gated Space it requires an allow-listed sign-in |
decide |
approve/deny a pending proposal (delegated authority) |
pending |
list pending proposals |
import |
seed the Space from a public ChatGPT share link (SSRF-guarded) |
title / state |
a sealed room title; the shared co-edited widget doc |
6. What runs inside
A Space's capabilities are ordinary tools in its config (e.g. search_places → a shared map widget,
search_flights → a flights widget). The turn runs the normal brain tool-loop over them. The one async-specific
twist: a high-stakes tool call is deferred to a human approval instead of running in-room — that is
delegated authority.
7. Code map
| Concern | Where |
|---|---|
| Turn render (decrypt-once) | agent/asyncTurn.mjs (runAsyncTurn, renderLedger) |
| Access control + mk lifecycle | agent/spaceTransport.mjs, agent/spaceService.mjs (handleSpaceRequest) |
| Content-blind poll | agent/spacePoll.mjs (etagOf, pollSpaceView) |
| Sealed ledger | agent/sessionStore.mjs, agent/envelope.mjs (seal/open/commit/newRoomKey) |
| HTTP handler + record store | agent/spaceHandler.mjs |
| Thin client | spaces/public/spaceClient.js, spaces/public/space.html |
| Delegated authority | agent/delegatedAuthority.mjs (doc) |
8. Status
Live in production: the /space zip Lambda (agent brain as a function) + the Spaces app at
witbitz-spaces.pages.dev. The turn core, transport (mk-commit), content-blind poll, delegated authority, and
per-member signed identity (solo) are all shipped. Deferred: real-time away-notification (closed app);
compaction of long ledgers; the couple (2-of-2) signed path.
Machine-readable source: async-spaces.md · every doc in one fetch: llms-full.txt · ← the-owner-rule · the-double-blind →