lchat · developer guide

Mini-apps: how the architecture works

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.

1 · The core model

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.

2 · The system map

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

Appsweb/static/apps/*.js — one self-contained file each
chat 💬remind ⏰todo ✓ bookmarks 🔖poll 📊 events map · reducers · render · hooks
Frameworkweb/static/apps.js
foldEvent / foldHostEventsnapshots (seed / save) keyedListnotifyLivecommands preview / unread hooksephemeral dispatchctx
Hostweb/static/chat.js — keys, session, shell
thread keys + cryptoWebSocket + reconnect sidebar / roster / invitestransports (emit · schedule · ephemeral) chat host bridgenative shell bridge
end-to-end encryption boundary — no key ever crosses
Servermain.go · hub.go · miniapps.go · push.go
message log (blobs)schedule queue (blobs) state snapshots (blobs)attachment blobs thread ids · timestamps · sizes reaction emoji + counts member-scoped fan-out (WS + push)

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.

3 · Example one: /remind — an app on the primitives

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 || "" });
  1. Client · schedule, then record

    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.

  2. Server · hold, deliver

    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.

  3. Every device · react by situation

    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.

  4. Every device · converge

    remind.fire folds to fired: true idempotently — replays, racing cancels, and dismissed ids are all no-ops.

4 · Example two: chat — the flagship app

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
  …
})

5 · Inviting into a DM: a feature that deliberately isn't an app

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.

  1. Inviter's browser · mint

    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.

  2. Every device of the inviter · render immediately

    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.

  3. The pane · waiting card, not the feed

    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?

The 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.

Why not fold this into 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.

6 · How apps integrate with the shared shell

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:

7 · The trust ledger

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.

CapabilityServer surfaceServer learns
Event logGET/POST /api/messages · WS msgthread, time, size
Ephemeral eventsWS msg + ephemeral flag (relay-only)thread, moment — never stored
SchedulingPOST/GET/DELETE /api/schedule + sweepthread, delivery time
State snapshotsGET/PUT /api/threads/{id}/state (versioned)thread, size, write times
AttachmentsPOST /api/upload · GET /api/blob/{id}size
ReactionsWS react/unreact (server counts)emoji + who reacted (plaintext — pre-app mechanism)
Link previewsGET /api/unfurlthe URL (the one documented exception)
Design rule: don't add an app capability chat couldn't use, and prefer generalising an existing chat mechanism over inventing an app-only parallel — the ephemeral flag generalised the typing frame; hostEvents generalised “server-enforced mutation”. Chat stopped being a special case because the primitives were shaped this way.

8 · Invariants that keep it all true

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.