lchat · developer guide
Every app — including chat itself — is a client-side program whose database is the encrypted thread log. The server stores, schedules, and fans out ciphertext it can never open. This page maps the moving parts, then follows two real apps through them: /remind and chat.
An app action is a typed event, encrypted under the thread key and sent as an ordinary message. Each client independently folds the event stream through the app's pure reducer. Folds are deterministic, so every device converges on the same state without any state-sync protocol — replaying the log is the sync protocol.
One declaration — the app's events map — is the whole model: its keys are the owned wire types, its values the fold. defineApp derives everything else from it.
Four layers. The dashed line is the security model: keys exist only above it. Below it, teal cells handle ciphertext; amber cells are the metadata the server genuinely learns (routing, timing, sizes).
The server can't run app logic — it can't even tell which app a blob belongs to. A personal app-thread is just another self thread whose name is encrypted.
Reminders exercise all three framework capabilities. Time: ctx.schedule(event, when) encrypts the future remind.fire now; the server holds the blob and, at the due moment, moves it into the message log transactionally and fans it out like any post. Voice: the event carries an encrypted notice, so every situation alerts. Memory: folded state is snapshotted per thread and re-seeded on open, so a reminder list outlives the message window.
const id = ctx.id();
const sid = await ctx.schedule(
{ type: "remind.fire", id, notice: { title: "⏰ Reminder", body: text } },
whenMs,
);
ctx.emit({ type: "remind.add", id, text, when: whenMs, sid: sid || "" });
The fire event is encrypted and queued (POST /api/schedule → an opaque blob + a timestamp). The remind.add that follows carries the schedule id, so any device can cancel it later.
A sweep goroutine moves due blobs into the thread's log in one transaction (a crash can't deliver twice) and broadcasts the standard message frame — a delivered schedule is indistinguishable from a posted message.
Open on the pane: the list updates, a toast speaks. Open elsewhere: toast + system notification. Closed: Web Push carries the same ciphertext; the service worker decrypts the notice. Offline: it waits in the log and folds silently at next open — no stale re-alert.
remind.fire folds to fired: true idempotently — replays, racing cancels, and dismissed ids are all no-ops.
Chat proves the model can carry the core product. It is the existing chat retooled, twice over: it speaks the wire protocol we already had (the composer emits a classic untyped { text } payload every client understands), and it renders the classic feed's exact markup, so style.css styles both feeds identically.
Chat needed two ideas beyond the basic model. First, some chat mechanisms are server-enforced — deletes really remove ciphertext (author-verified), reactions are deduped per user — and an append-only fold can neither do that nor be trusted to claim it. So chat's mutations fold through hostEvents: handlers the host synthesizes from real server frames, never registered as wire types. A member cannot spoof a delete by posting a typed message.
The bridge is the whole coupling surface between chat-the-app and the host — small enough to list. The app never touches crypto, tokens, or the socket.
Second, chat's state is the log, so it opts out of snapshots (snapshot: false) and renders incrementally: incremental: true stops the framework wiping its surface, and keyedList reconciles bubbles by key + rev — an append reuses every existing DOM node (scroll, focus, loaded images survive), and a reaction re-renders exactly one bubble.
defineApp({
name: "chat", scope: "system", // mounted by the host, not the Apps menu
snapshot: false, // state IS the log
incremental: true, // keyedList reconciliation
events: { "chat.typing": (s) => s }, // wire: relay-only presence
hostEvents: { "chat.msg": …, "chat.react": …,
"chat.edit": …, "chat.delete": … }, // host-only translations
…
})
One more flow is worth tracing end to end, precisely because it stays outside the app model even though it lives entirely inside a chat thread. A member (or the admin) can invite a brand-new person straight into a DM — before that person has ever joined the instance. Following it shows where the line between "host" and "app" actually falls.
Picks "Invite someone" from New Chat, generates an invite secret T the same way instance invites do (docs/invite-redesign.md), and wraps a fresh thread key under it. POST /api/invites creates, in one transaction: a placeholder users row (no public_key yet), a real dm thread, both members, and a thread_keys row — but only for the inviter. The invitee's copy doesn't exist until they claim the invite.
The DM shows up right away, like any other thread — the server never "waits" for the second party. pendingInvitee(thread) is a plain roster lookup: does this DM's other member still have no public_key? Nothing about that fact is stored, replicated, or folded — it's derived fresh from the roster on every render.
While pending, the thread view shows a centered card (name, share link, copy/re-send/cancel) instead of a feed and composer nobody but the inviter could use. Once the invitee opens the link and claims their own thread_keys row, the next message (or the next reconnect) clears the pending flag and the pane becomes a normal DM.
Steps 2 and 3 are where this stays a host concern rather than an app. chat (§4) is the flagship example of a full-pane thread surface built as an app — so why doesn't this full-pane surface work the same way?
#pending-banner, either wayThe same fact has to suppress whichever surface happens to be mounted underneath — the classic feed for a device on useOriginalChat, or the chat app's .app-pane for everyone else. That's genuinely a shell-level concern: it sits above the choice of engine, not inside one of them.
/* style.css — one flag, two possible surfaces underneath it */
.thread-view.waiting .feed,
.thread-view.waiting .app-pane, /* added when chatAppFor() stopped excluding pending DMs */
.thread-view.waiting .composer,
.thread-view.waiting .typing { display: none !important; }
That .app-pane line is recent. Originally chatAppFor() special-cased a pending DM back to the classic feed outright — the CSS only knew how to hide .feed/.composer, so the chat app's pane would have shown a live composer nobody could use. Rather than teach the chat app about pending state, the fix taught the hide rule about the chat app's mount point, and dropped the special case — pending DMs now render through whichever engine is default, exactly like every other thread.
chat's own render, the way §4 folds reactions and edits? Because it isn't event-sourced state. Nothing was posted — there's no wire event, no fold, no two-devices-converge story to write a reducer for. It's a live-computed fact about the roster, exactly as available to a thread using the classic feed as one using the chat app. Modeling it as an app would mean an events: {} app that owns no wire type and exists purely to read ambient host state — the framework's model (replicated log → pure fold → surface) buys nothing there. The one thing worth revisiting: once the classic feed is retired and there's only one engine left to keep in sync with, this could move from a shell-level flag into a branch at the top of the chat app's own render() — reading a new host.pendingInvitee(threadId) fact the same way it already reads host.isGroup/host.agentKind — which would delete the .waiting CSS axis entirely rather than maintain it. Deferred, not done, because it only pays for itself once the classic feed has nothing left to share it with.Apps aren't islands — the framework gives them defined sockets into the surrounding product. Every pin below is a hook an app can (but needn't) implement:
personal app gets its own thread row — a self thread whose encrypted name is the app id, so the server can't tell a Reminders thread from Notes. Title + glyph come from defineApp; the row opens the app full-pane.preview(event) and unread(event) hooks let an app title the sidebar line and light the dot — poll shows 📊 <question> on create; apps without the hooks stay silent. No host special-casing per message type.commands) run from any chat — a personal app's command routes to your app-thread with a confirming toast. The :emoji/@mention typeahead ranks candidates with one shared host function, so the app feed, the original feed, and the native strip agree.messageAction(msg) contributes to every message's hover strip / picker — bookmarks adds “Save link” to any message containing one, filing it encrypted into your own thread.notice field speaks everywhere: toast in the open app, system notification when hidden, service-worker push when closed. A notify(event, ctx) hook covers state-dependent wording. Live events only — history refolds never re-alert.window.lchatNative; an app that owns a thread's pane (chat) registers handlers via the bridge and posts {replying}/{staged}/{error} back. Same contract, two composers, one code path.Everything an app can reach goes through ctx or the host bridge; this is what each capability costs in metadata. Blobs are always encrypted client-side under the thread key.
| Capability | Server surface | Server learns |
|---|---|---|
| Event log | GET/POST /api/messages · WS msg | thread, time, size |
| Ephemeral events | WS msg + ephemeral flag (relay-only) | thread, moment — never stored |
| Scheduling | POST/GET/DELETE /api/schedule + sweep | thread, delivery time |
| State snapshots | GET/PUT /api/threads/{id}/state (versioned) | thread, size, write times |
| Attachments | POST /api/upload · GET /api/blob/{id} | size |
| Reactions | WS react/unreact (server counts) | emoji + who reacted (plaintext — pre-app mechanism) |
| Link previews | GET /api/unfurl | the URL (the one documented exception) |
ephemeral flag generalised the typing frame; hostEvents generalised “server-enforced mutation”. Chat stopped being a special case because the primitives were shaped this way.Short version (the full list lives in web/static/apps/README.md): reducers are pure and idempotent — the window re-folds on every open, so the same add twice must be one item; never set name/ts/uid on emit (the transport injects them); validate events in the reducer, not the emitter — anything a member can send is a legal input and must fold to a no-op at worst; user content renders via textContent, never innerHTML; and an app never touches keys, tokens, or the server — if you're reaching for fetch, the design took a wrong turn.