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.
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:
- 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).
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.mjs → putPartial), 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.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, 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 →