eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1# ๐Ÿค– Building a mini-app
2
3A **mini-app** is a small client-side program that lives alongside chats โ€” a
4to-do list, a poll, reminders. This guide is written for both humans and LLMs:
5follow it top to bottom and you can add a new app in one self-contained file.
6
7> **This directory holds apps and nothing else.** Every `.js` file in
8> `web/static/apps/` calls `defineApp` โ€” one app per file. Shared machinery an
9> app leans on (the schema engine + renderer, the conversation-flow engine,
10> Eleven's scripted-conversation feel, the Vault's lock crypto) is a sibling
11> module in `web/static/`, imported as `../<name>.js`. So listing this
12> directory lists the apps.
13
14## The one idea
15
16An app has no server. It trades **typed events** over the same end-to-end
17encrypted thread stream that chat messages use, and derives its state by
18**folding** those events. The server only ever sees ciphertext, so it can't run
19or even recognise app logic โ€” everything happens in the browser.
20
21> **The encrypted thread log IS the app's database.** You never store state; you
22> emit events and fold them back into state. Two devices that fold the same
23> events arrive at the same state by construction.
24
25> **The app schema.** To-do and Reminders are SCHEMA-DESCRIBED: a declarative
26> JSON schema (`schemas/<name>.json`, spec: `docs/app-schema.md`) declares
27> their state, folds, actions, and views, and BOTH platforms interpret it โ€”
28> the web via `../schema-engine.js` + `../schema-render.js` (their app files wire
29> `schemaEvents`/`schemaRender` into defineApp and keep only the glue:
30> commands, preview/unread, chat wording, named parsers), the native apps via
31> the Swift twin engine with SwiftUI components. To change those apps' events,
32> folds, or view behaviour, change the schema (and its byte-identical copy in
33> `ios/LChatCore/Sources/LChatCore/Schemas/`) in the same PR; `npm test` holds
34> the twins together.
35>
36> **Views** (`views.js`, design: `docs/views.md`) ride this same engine from
37> the other direction: a bot posts SCHEMA + DATA (never code) as thread
38> events, and the views app expands each into a schema list pinned above the
39> feed โ€” opened from an `open_view` chip on the bot's reply, reopened with
40> `/views`. Apps and bots in the chat can read and replace a view's data
41> through ordinary `view.create`/`view.update` events.
42
43An app is four small things:
44
451. an **events map** โ€” the event types it owns and how each folds into state,
462. an **initialState** โ€” the empty state to start from,
473. **commands** and/or a **message action** โ€” how a user creates events,
484. a **surface** โ€” how state renders (a full pane, or a panel above a chat).
49
50Everything ships in **one file** under `web/static/apps/`, including the app's
51CSS. No build step; these are plain ES modules.
52
53On top of that model the framework gives every app three capabilities for free
54(details in their own sections below):
55
56- **Time** โ€” `ctx.schedule(event, when)` has the *server* deliver an event into
57 the thread later, so things happen when no client is open (reminders that
58 actually fire). The server holds only ciphertext and a timestamp.
59- **Memory** โ€” folded state is snapshotted (encrypted) to the server and
60 re-seeded on open, so an app's state survives beyond the loaded message
61 window. 1,000 todos work as well as 10. Apps do nothing to opt in.
62- **Voice** โ€” an event can carry a `notice` (or the app a `notify` hook) to
63 alert the user the moment it arrives: a toast in the open app, a system
64 notification when hidden, a push notification when closed. All wording stays
65 encrypted end-to-end.
66
67## A complete app
68
69This is a real, working app โ€” copy it, rename it, and change the guts. It's a
70personal "Notes" list: `/note <text>` from any chat files a note; the app's own
71sidebar pane lists them.
72
73```js
74// web/static/apps/note.js
75import { defineApp, namespace, addItem, removeItem } from "../apps.js";
76
77// One declaration of the app's prefix โ€” N.ev("add") is the "note.add" event
78// type, N.cls("item") is the "note-item" CSS class. See "Namespacing" below.
79const N = namespace("note");
80
81export const app = defineApp({
82 name: "note", // unique id, lowercase โ€” also the default namespace
83 title: "Notes", // shown in the sidebar and header
84 glyph: "๐Ÿ“", // avatar glyph
85 places: ["you"], // where copies can live: just for you (its own sidebar thread)
86
87 // The empty state. A factory (not a value) so every thread gets its own.
88 initialState: () => ({ items: [] }),
89
90 // The events map: KEYS are the event types this app owns, VALUES fold each
91 // event into new state. This one declaration is the whole model โ€” the owned
92 // types and the reducer are both derived from it. Handlers must be PURE:
93 // return new state, never mutate the old. The collection helpers make that
94 // easy and keep adds idempotent (see "Invariants" below).
95 events: {
96 [N.ev("add")]: (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text, by: e.name || "" }) } : s),
97 [N.ev("remove")]: (s, e) => ({ items: removeItem(s.items, e.id) }),
98 },
99
100 // A slash command a user can type in any chat. It emits an event; because
101 // this is a personal app, the event is routed into the user's own Notes
102 // thread wherever they typed it, and they get a toast confirming it landed.
103 commands: {
104 "/note": (args, ctx) => {
105 const text = args.trim();
106 if (text) ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text });
107 },
108 },
109
110 // The app's CSS travels with it โ€” injected once by the framework. Read the
111 // theme's CSS variables (var(--ink), var(--accent), โ€ฆ) so it follows the theme.
112 // The stylesheet stays a plain literal; ctx.cls() below keeps the JS in step.
113 styles: `
114.note-item { display: flex; gap: 8px; padding: 6px 2px; border-bottom: 1px solid var(--border); }
115.note-text { flex: 1 1 auto; overflow-wrap: anywhere; }
116`,
117
118 // Personal, full-pane: `el` is the whole thread pane. Build DOM nodes and set
119 // text with textContent โ€” NEVER innerHTML for anything a user typed.
120 render(state, el, ctx) {
121 for (const item of state.items) {
122 const row = document.createElement("div");
123 row.className = ctx.cls("item"); // "note-item"
124 const text = document.createElement("span");
125 text.className = ctx.cls("text"); // "note-text"
126 text.textContent = item.text; // user content โ†’ textContent, never innerHTML
127 const rm = document.createElement("button");
128 rm.textContent = "ร—";
129 rm.onclick = () => ctx.emit({ type: ctx.ev("remove"), id: item.id });
130 row.append(text, rm);
131 el.appendChild(row);
132 }
133 },
134});
135```
136
137Then register it (one line) in `web/static/shell.js`, next to the others:
138
139```js
140import "/static/apps/note.js";
141```
142
143That's a whole app. Everything below is reference detail and the optional hooks.
144
145## Pick your app's shape first
146
147Before the field reference, decide **which archetype you're building**. An
148app's shape is two independent choices โ€” where copies can LIVE, and how each
149copy PRESENTS:
150
1511. **Places** (`places: ["you", "chat", "space"]`, any subset) โ€” where a copy
152 of this app can live: your own sidebar thread, a chat everyone in it
153 shares, one copy for the whole space. A place is a thread; place = privacy
154 (docs/space-apps.md). `[]` = host-mounted/utility, never instantiable.
155 When a place is off on purpose, say why with `placeNote` โ€” the Apps sheet
156 shows it where the Add button would be.
1572. **Does it project into chat?** (a `chatMessage` hook) โ€” with it, the app is a
158 **companion** (a Chat view beside its Interface); without it, a **takeover**
159 (its `render` IS the whole surface). See "Views" in CLAUDE.md.
160
161Those compose into the archetypes you'll actually build:
162
163| You wantโ€ฆ | declare | surface | examples |
164| --------- | ------- | ------- | -------- |
165| A private tool that IS its own thread | `places: ["you"]` (no `chatMessage`) | full-pane takeover | a secrets vault |
166| A private tool you also chat with | `places: ["you"]` + `chatMessage` | full-pane companion (Chat โ‡„ Interface) | `todo`, `remind` |
167| โ€ฆeither of those, shared with a chat or the whole space | add `"chat"` / `"space"` to `places` | the widget bar's Interface pane, beside any conversation | `todo`, `remind`, `bookmarks` |
168| A group tool with no personal copy | `places: ["chat"]` | widget pane; its command auto-adopts it in a group | `poll`, `board` |
169| Not user-instantiable (host mounts it) | `places: []` | host decides | the chat feed, `setup`, `/emoji` |
170
171The old `scope` enum and the `personal`/`groupCapable`/`spaceCapable` flags
172are COMPAT-ONLY inputs now (#405 closed): already-deployed custom and member
173apps that declare them keep working โ€” `defineApp` normalizes either
174generation into `places` and then DELETES the old fields from the app object
175โ€” so new apps declare `places`, and nothing else in the product can even
176read the old vocabulary. (How long "keep working" lasts, who upgrades
177legacy apps, and how the floor may eventually rise:
178`docs/member-apps.md ยง Legacy formats: the compat policy`.) The one legacy archetype `scope` carried, the
179always-on ambient panel above every feed, is retired: Poll and Whiteboard
180live in chats now (`places: ["chat"]`), and a legacy `scope:"shared"` custom
181app degrades to command-only.
182
183### Layout patterns โ€” and the example app that shows each
184
185Once you've picked a shape, copy the closest working app rather than starting cold:
186
187- **Full-pane list** (`todo.js`, `remind.js`) โ€” an add form on top,
188 then the list, optionally a collapsed archive/history section. Rebuilt from state
189 on every change (NOT `incremental`); it's the simplest correct thing, and it's
190 what makes a group instance converge โ€” another member's edit just refolds and
191 redraws. Keep transient UI (a reveal toggle, a grace-period ring) on the row DOM,
192 never in reducer state.
193- **Ambient panel** (`poll.js`) โ€” **render nothing when empty** so the panel
194 collapses (invariant #6), and set `incremental: true` + use `keyedList` so a
195 half-interacted card (an armed "Close?" button) survives the refolds that votes
196 trigger. Handlers read `card._ctx` (refreshed each render), because the one
197 `#app-surface` is reused across threads โ€” a stashed thread id would target the
198 wrong chat.
199- **Long / scroll-back lists** (`chat.js`, `board.js`) โ€” `incremental: true` +
200 `keyedList` with a `rev`, so unchanged rows keep their exact nodes (scroll
201 position, focus, decrypted images survive).
202- **Companion projection** โ€” implement `chatMessage(event, { group })`: return a
203 bot bubble (a string) or a `/me`-style action line (`{ text, action: true }`),
204 or `null` for silence. The `{ group }` hint lets wording differ when many people
205 read it (`remind.js` drops "you asked me" in a group).
206- **Two-step inline confirm** (`poll.js`) โ€” embedded webviews have no
207 `confirm()` panel (it silently answers false), so never call it: a first click
208 arms the button ("Remove?"), a second within a few seconds commits.
209
210A shape decision worth making deliberately: **a takeover secrets/notes tool should
211usually NOT implement `chatMessage`**, so its events don't spill names into the
212chat feed, previews, and push notifications. ๐Ÿค– When a group still needs to *see
213that something happened* (without the app becoming a companion), implement
214**`groupFeedMessage(event)`** instead: the feed-only projection hook.
215`foldGroupAppEventBubble` prefers it when folding a group's own feed โ€” and only
216there, so the personal thread stays a pure takeover (no Chat view ever grows on
217it; `appWantsChatView` reads `chatMessage` alone). `vault.js` uses this for
218add/remove, deliberately without naming the secret in the line. Companion apps
219need no second hook โ€” with no `groupFeedMessage`, the group fold falls back to
220`chatMessage(event, { group: true })` (that's the `{ group }` hint above); a
221companion that wants *different* group wording implements both, and returning
222`null` from `groupFeedMessage` keeps it silent in groups.
223
224## `defineApp(spec)` โ€” the fields
225
226| field | required | what it is |
227| -------------- | -------- | ---------- |
228| `name` | โœ“ | Unique lowercase id. Also the conventional prefix for your event types. |
229| `namespace` | | The prefix for event types + CSS classes. Defaults to `name` (leave it off unless you need them to differ). Drives the `ctx.ev` / `ctx.cls` helpers and `app.ns` โ€” see [Namespacing](#namespacing-one-prefix-declared-once). |
230| `title` | โœ“ | Human name (sidebar, header). |
231| `glyph` | โœ“ | One emoji/character used as the app's avatar. |
232| `places` | โœ“ | WHERE copies of this app can live โ€” the one placement declaration: any subset of `["you", "chat", "space"]`. `"you"` = a personal, self-thread instance (Apps menu, personal-command routing, `defaultCommand`); `"chat"` = addable to a group chat (โ‹ฎ โ†’ Apps), one shared instance for everyone there (see "Group-scoped apps"); `"space"` = installable for the WHOLE space, one shared instance in the hidden space thread (see "Space-wide apps" + `docs/space-apps.md`). `[]` = host-mounted/utility. (Formally optional โ€” omitting it means `["you"]`, and the legacy flags/scope normalize into it โ€” but every new app declares it.) |
233| `groupFeedMessage` | | ๐Ÿค– `(event) => { text, action? } \| string \| null`. The FEED-ONLY projection: narrates the app's events in a GROUP chat's feed without opting the app into a companion Chat view (that's `chatMessage`'s meaning). Implement it *instead of* `chatMessage` for a takeover app whose group activity should still be visible (`vault.js`); alongside it for group-specific wording; absent, the group fold falls back to `chatMessage(event, { group: true })`. See "Companion projection" above. |
234| `listed` | | `false` for a host-mounted app that is never user-instantiable (the chat feed, `setup`, the `groupapps` registry). Defaults to `places.length > 0`. |
235| `quiet` | | ๐Ÿค– `true` if a command answering in place must never itself force this app's PERSONAL thread into view. The thread still exists and still gets every event โ€” that's the only durable record of the interaction โ€” this only governs visibility. See "Quiet apps" below and issue #227. |
236| `requiresService`| | A connected-service name (e.g. `"dnsimple"`). The app is hidden from New chat until that service is linked โ€” how a service "installs" its companion app. See `docs/connected-services.md`. |
237| `initialState` | โœ“ | `() => state`. A **factory**, called to seed each thread's empty state. |
238| `events` | โœ“ | `{ "<type>": (state, event, meta) => state }`. The fold. |
239| `commands` | | `{ "/cmd": (args, ctx) => void }`. Slash commands. The richer form `{ run, desc, args, local }` feeds the `/` typeahead; `local: true` scopes a command to the app's **own thread** (see "Local & default commands"). |
240| `defaultCommand` | | The name of one of the app's commands that plain, non-slash text typed into the app's thread runs โ€” a local verb (To-do's `"/add"`) or, when the global verb IS the in-thread grammar, that global (Reminders' `"/remind"`). Only honoured while no bot member is in the thread. |
241| `composerPlaceholder` | | The composer placeholder shown in the app's thread while `defaultCommand` is active (e.g. `"Add a to-doโ€ฆ"`) โ€” it's how the implicit routing announces itself; set it whenever you set `defaultCommand`. |
242| `render` | | `(state, el, ctx) => void`. Draws the surface. |
243| `messageAction`| | `(msg) => spec \| null`. Adds an item to the chat message menu. |
244| `renderEvent` | | `(event, meta) => Node \| null`. An inline line in the message feed. |
245| `notify` | | `(event, ctx) => {title, body} \| null`. Speak on a live event (see Voice). |
246| `ephemeral` | | `(event, ctx) => void`. Handle relay-only events (see Ephemeral events). |
247| `hostEvents` | | Like `events`, but HOST-ONLY: folded via `foldHostEvent`, never registered as wire types โ€” a member can't trigger them by posting a typed message. For translating server-enforced mechanisms (chat's reactions/edits/deletes) into fold state. |
248| `snapshot` | | Set `false` to opt out of state snapshots (an app whose state IS the log). |
249| `incremental` | | Set `true` to keep the surface between renders and reconcile it yourself (see `keyedList`). |
250| `styles` | | A CSS string, injected once. |
251
252`defineApp` returns the app object (with `.types` and `.reduce` derived from
253`events` โ€” handy in tests).
254
255### Places & discovery (`places`, `listed`)
256
257`scope` used to be a single required enum, and then three capability flags,
258but both quietly conflated the one real question โ€” WHERE can copies of this
259app live? โ€” with how the app is discovered (#405). The model is now exactly
260two fields:
261
262- **`places`** โ€” the placement declaration (see the table above). `"you"`
263 gives the app a **personal instance**: its own single-member thread in the
264 sidebar, rendered **full-pane** (`el` is the whole thread view); a command
265 or message action typed in *any* chat routes into it. Private tools live
266 here: to-do, reminders, bookmarks, vault. Adding `"chat"` lets it *also* be
267 **added to a group chat**, one shared instance for everyone there (next
268 section) โ€” a group tool isn't a separate archetype, just a place. `"space"`
269 is the same idea, one scope up.
270- **`listed: false`** โ€” the app is **not user-instantiable**; the host mounts
271 it explicitly (the chat feed, the `setup`/`welcome` conversations, the
272 `groupapps` registry). Never appears in the Apps menu.
273
274The legacy inputs still normalize in (`scope: "personal"` โ†’ `["you"]`,
275`scope: "system"` โ†’ `[] + listed: false`, and each capability flag โ†’
276its place) because stored member-app manifests carry them โ€” but `defineApp`
277deletes them after normalizing, so no code path reads them.
278
279Not every full-pane thread surface should be an app, though โ€” a pending (not-yet-
280joined) DM shows a waiting card instead of the chat app's pane, but that's a
281host-level concern, not an app archetype. See `docs/mini-apps-architecture.html`
282ยง5 for why, and the design question to revisit once the classic feed is retired.
283
284### Group-scoped apps: the same app, personal AND in a group
285
286A personal app whose `places` include **`"chat"`** (To-do) can also be **added to a
287group chat** (โ‹ฎ โ†’ Add apps), where one shared instance runs for the whole
288group โ€” at the same time as everyone's personal copies. This is not a new
289model; it's the existing one pointed at a different thread:
290
291- **Same events, same reducer, different log.** The group's instance folds the
292 app's ordinary events (`todo.add`, โ€ฆ) from the *group thread's* encrypted
293 log instead of your personal app-thread's. Every member folds the same
294 events, so everyone sees the same list; snapshots, scheduling, and live
295 delivery already work per-thread and need nothing new.
296- **Which apps a group has added is itself fold state** โ€” the framework's
297 `groupapps` registry app owns `groupapps.add` / `groupapps.remove` wire
298 events (`{ app: "todo" }`), folded per thread like any other. It syncs to
299 every member and the server never learns which app was added (or that one
300 was). Any member can add or remove, like adding people; both acts render as
301 action lines ("Ana added To-do to this chat").
302- **The widget bar.** A group with apps added gets the header view toggle:
303 Chat | Split (desktop) | one glyph button per added app. Tapping an app's
304 button shows it beside the chat; a second tap gives it the full pane (on
305 mobile, where there's no split, the first tap does); tapping a different
306 app's button swaps the pane in place. The app renders in the same Interface
307 pane a companion app-thread uses, with `ctx` bound to the group thread โ€”
308 `ctx.emit` lands in the group log. Nothing about your `render` changes.
309- **Chat projection is attributed.** In a group, a `chatMessage` ACTION
310 projection leads with the actor's name ("Ciara added โ€œbuy milkโ€" โ€” a group
311 has many hands); a MESSAGE projection (a reminder firing) stays a bubble in
312 the app's voice. Write `chatMessage` exactly as for the companion view; the
313 host adds the prefix. In a group it's called with `{ group: true }` as a
314 second argument, for wording that must differ when many people read it
315 (Reminders drops "you asked me" there) โ€” apps that don't care ignore it.
316 ๐Ÿค– The host also substitutes **โ€œYouโ€** for the reader's own action
317 (`foldGroupAppEventBubble` in `shell.js`, matching `membershipLine`'s
318 join/leave convention) โ€” this is automatic and free; write your `text` as if
319 it always follows a name (`added โ€œbuy milkโ€`), never bake in โ€œYouโ€ yourself,
320 or a bystander reading someone else's event will see โ€œYou added โ€œbuy milkโ€โ€.
321 This attribution is unique to `chatMessage` โ€” a `preview` hook (below) gets
322 no such help and must do its own โ€œYouโ€ check.
323- **@-mention runs the default command.** "@To-do buy milk" typed in the
324 conversation runs the app's `defaultCommand` with the rest of the text โ€” the
325 event is what lands, shown as an action line, and the app joins the `@`
326 typeahead. This is the *explicit* form of defaultCommand: a group's plain
327 text is real conversation, so a group app must never eat it implicitly
328 (`defaultCommand`'s automatic form stays personal-thread-only).
329- **Slash commands scope to the nearest instance.** In a chat that has the app
330 added, its global command acts on the GROUP instance โ€” "/todo buy milk"
331 there files into the shared list, exactly like "@To-do" (see `runCommand`).
332 Everywhere else it keeps routing to your personal app-thread, so the same
333 verb always addresses the instance you're looking at.
334- **`@here` installs and shares in one gesture.** In a group *without* the
335 app, "/todo @here buy milk" adds the app to the group (the same
336 `groupapps.add` the sheet emits, so everyone gets the widget bar and the
337 action line) and files the item into the new shared instance; a bare
338 "/todo @here" just installs. Anywhere the flag can't apply โ€” not a group,
339 or an app that can't be shared โ€” it answers with a private explanation
340 instead of mis-filing the text. The "/" typeahead makes all of this
341 discoverable: a group-capable command's description says where it will
342 land โ€” "for this group" when the app is added, "just for you" when it
343 isn't โ€” and in the fresh case the flags that would install it ride as
344 extra rows that say so in the same sentence shape ("/todo @here" ยท "Add a
345 to-do to a new To-do app for this group"; likewise "@everyone" for the
346 space, when the policy lets you), so the install is never hidden. (Since
347 the places grammar landed,
348 EVERY app command names its place โ€” personal ones read "just for you"
349 everywhere โ€” and /help closes with the one routing sentence: a command
350 acts on the nearest copy of its app.)
351
352What the `"chat"` place asks of your app: nothing beyond the standard invariants โ€”
353a pure, idempotent, validating reducer (strangers' events are now first-class
354inputs), a `render` that works off `ctx.threadId` (never a stashed thread id),
355and per-user logic keyed on `e.uid`. If your app satisfies those, the place is
356the whole opt-in. Vectors: `test/event-vectors/groupapps.json`.
357
358Currently chat-placed: To-do, Reminders, Bookmarks, the Vault โ€” plus Poll and
359the Whiteboard, which are chat-place-ONLY (`places: ["chat"]`, no personal
360copy): their commands auto-adopt the app into the group on first use (the
361ambient panel they used to ride is retired โ€” #405). Deliberately NOT
362flagged: apps whose state derives from one member's private service
363credentials (Integrations, DNSimple) โ€” sharing those needs its own design
364(whose credentials fetch? who may see the result?), so the per-app gate is
365the point, not a limitation.
366
367### Space-wide apps: install once, use everywhere
368
369The third place up: an app whose `places` include **`"space"`** can be **installed
370for the whole space** (โ‹ฎ โ†’ Apps โ†’ "For everyone in this space", or
371`/app @everyone`) โ€” ONE shared instance, its commands available in every chat,
372nobody else installs anything. Design + trust model: `docs/space-apps.md`.
373**Currently parked for shipped apps** (`docs/space-apps.md ยง The pullback`):
374the built-ins declare `places: ["you", "chat"]` and the multi-place UI hides
375behind the `PLACES_UI` device flag โ€” the machinery below stays live and
376tested, and an app rejoins by adding `"space"` to its `places` line.
377Again not a new model, the same one pointed at a different thread:
378
379- **The space thread.** Every instance gets (lazily, on first install) a
380 hidden, all-members thread; the shared instance folds the app's ordinary
381 events from ITS encrypted log. Its key travels like an open group's โ€”
382 `open_key`, wrapped under the instance key โ€” so every member, including
383 late joiners, can already unwrap it; the server still can't. The thread has
384 no chat view and never pushes.
385- **Which apps the space installed is fold state** โ€” the `spaceapps` registry
386 app owns `spaceapps.add` / `spaceapps.remove` (`{ app: "stickers" }`),
387 folded from the space thread exactly like `groupapps` from a group's. Any
388 member installs or removes; the Apps sheet's "Installed for everyone by โ€ฆ"
389 line is the visible record. Vectors: `test/event-vectors/spaceapps.json`.
390- **Commands route to the nearest instance** (see `runCommand`): local verbs โ†’
391 the app's OWN thread โ†’ this chat's group instance โ†’ the SPACE instance โ†’
392 your personal thread. So a space-installed app's global verb acts on the
393 shared instance from every chat โ€” the "/" typeahead says so ("for this
394 space") โ€” while the same verb typed inside the app's own
395 personal thread, or in a group that added it, keeps addressing the instance
396 you're looking at. `@App text` mentions work in any chat (and the `@`
397 typeahead suggests them) and target the space instance too.
398- **`@everyone` installs and uses in one gesture** โ€” `/stickers @everyone`
399 installs for the space (creating the space thread on first use) and runs any
400 trailing text against the fresh shared instance; a bare `@everyone` is just
401 the install, confirmed privately (`@space` is an accepted alias). An app
402 without the space place gets an explanation, never a mis-filed event.
403- **`@me` completes the flag family, pointing at privacy** โ€” `/todo @me
404 dentist` files into YOUR copy even where a chat or space copy claims the
405 bare verb. No install side effects, ever; an app with no personal place
406 explains (its `placeNote`) instead of mis-filing. The `/` typeahead teaches
407 the flags itself: when several copies are reachable it offers the flagged
408 variants as extra rows (`/todo @me ยท just for you`), ranked after the
409 default route โ€” nobody reads docs, everybody reads the menu.
410- **Each space app gets a sidebar row โ€” its home.** The space band (badged
411 "space", subtitle "For everyone in this space") opens your `render`
412 full-pane with `ctx` bound to the space thread; your `preview`/`unread`
413 hooks drive the row's activity line and dot, exactly as they do for chats.
414 Space apps deliberately do NOT appear in other chats' widget bars โ€” the
415 widget bar always means "this chat's own copies" (place = location). A
416 group's own add of the same app is a separate copy, in that chat.
417- **Message actions stay personal.** "Save link" files into YOUR bookmarks
418 even when Bookmarks is space-installed โ€” saving somebody's message privately
419 is the point of that gesture; the shared instances are reached by command or
420 mention, always explicit.
421
422What the `"space"` place asks of your app: the `"chat"` bar, verbatim โ€” a
423pure, idempotent, validating reducer (the whole space now writes to one log),
424`render` off `ctx.threadId`, per-user logic keyed on `e.uid`. Member apps opt
425in through their manifest โ€” `places` including `"space"`, or the legacy
426`spaceCapable: true` boolean, which normalizes into it (see
427`docs/building-member-apps.md`). No shipped app currently declares the space
428place (ยง The pullback); the built-ins that had it โ€” Bookmarks, To-do,
429Reminders, the Vault โ€” are one places-line edit from returning.
430
431### Local & default commands
432
433A command is **global** by default: it's in the `/` typeahead of every thread,
434and (for a personal app) routes into your app-thread from wherever you typed it โ€”
435that's `/todo`, the *routing* verb. Mark a command `local: true` and it becomes an
436**in-thread** verb instead: it resolves โ€” and appears in the typeahead โ€” only
437inside the app's own thread, never elsewhere, so short names like `/add` can't
438collide across apps. In its own thread the local commands lead the typeahead and
439the app's global aliases drop out as redundant. An app with **no** local commands
440leads with its globals instead โ€” Reminders' `/remind` heads its own thread's list
441(the global verb is the in-thread verb; nothing supersedes it).
442
443`defaultCommand` names one of the app's commands that **plain text runs**: with
444no bot member in the thread, typing `buy milk` into To-do's chat is `/add buy
445milk`, and typing `call mum in 2h` into Reminders is `/remind call mum in 2h` โ€”
446because sending a chat message no one can answer is a dead end, the message
447becomes the action instead. The command can push back: Reminders replies "When
448should I remind you?" for text with no parseable time. (In-thread, replies
449carrying a `short` โ€” transient confirmations โ€” are suppressed, since the event's
450action line is feedback enough; replies without one, content and errors, always
451render.) Three rules keep this honest:
452
453- **Advertise it.** Set `composerPlaceholder` (and write your `chatIntro` to
454 say "just type") โ€” implicit routing a user can't see is magic, not delight.
455- **It steps aside for an LLM.** The gate is "no bot member here"
456 (`defaultCommandFor`): if a bot later joins the thread, plain text goes back
457 to being a real message for it to answer, and your commands become things it
458 can invoke on the user's behalf.
459- **Personal app-threads only.** A shared app in a group chat must never eat
460 plain messages โ€” those are real conversation. Slash text never falls through
461 to the default either: a leading `/` always means "command".
462
463A default-command thread is text-only: the composer hides its attach affordances
464(there's no one to read a file), and they return if a bot joins.
465
466### Quiet apps
467
468๐Ÿค– A personal command answers **in place** by design (CLAUDE.md), but by
469default it still has a side effect on your sidebar: the first time you run
470a personal app's global command, its app-thread is created โ€” via
471`resolveAppThread`/`createAppThread` in `shell.js` โ€” and appears in the
472thread list, whether or not the command actually did anything. That's right
473for To-do or Notes (the thread IS where the item now lives, so surfacing it
474is the point), but wrong for an app whose commands mostly just answer a
475question or offer an action chip โ€” it shouldn't spawn a sidebar entry the
476user never asked for.
477
478Set **`quiet: true`** and the app's personal thread stops forcing its own way
479into view. The thread still exists โ€” it's still the only durable record of
480the interaction, and every event you `ctx.emit` still lands there exactly as
481before โ€” `quiet` only changes *visibility*, never storage:
482
483- **Brand new** (never interacted with before): the thread is created (so
484 your command has somewhere to emit into) but stays hidden โ€” no new sidebar
485 row.
486- **Already visible** (the user has it open or it's in their list): nothing
487 changes. Quiet is a no-op once the thread is already showing.
488- **Was visible, then the user closed it**: your command's activity โ€” a
489 reply, a panel, or an emitted event โ€” must not reopen it. Only the user's
490 own tap (an action chip's `ctx.open`, or finding it via the Apps menu)
491 brings it back, and when it does, every event sent while it was hidden is
492 already there waiting.
493
494You don't have to do anything else to get this โ€” no different `ctx.emit`,
495no different reply shape. The enforcement lives entirely in the host
496(`createAppThread` creates it pre-hidden; the WS message handler reasserts
497the hide instead of letting the server's default "new activity reopens a
498closed chat" rule apply) because the **server never learns which app owns a
499thread** (see CLAUDE.md), so only the client can make this call. See issue
500#227. `quiet` only means something for a **personal** app-thread โ€” it has no
501effect on a chat-placed app's shared group instance, since that thread is
502the chat the user already has open, never one the command creates.
503
504### Command panels โ€” a transient results surface
505
506A command can answer with more than a private reply: `ctx.panel(spec)` opens a
507**panel** โ€” a temporary, interactive surface in the chat you *typed* in, sitting
508just above the composer (or pinned above the feed with `position: "top"`). It's
509still the "answer in place" doctrine: nothing navigates, nothing overlays what
510you're reading (the panel takes layout space), and leaving is always the user's
511tap. Two working references: `apps/emoji.js` (`/emoji cat` opens a picker; a
512tap drops the emoji into your message via `panel.insertText`) and
513`apps/gif.js` (`/gif high five` shows Giphy results โ€” the first member to run it
514supplies a free Giphy key, which then belongs to the space and nobody is asked
515again; a tap posts the GIF
516into the chat as an ordinary encrypted attachment and closes the panel โ€” its
517network + upload go through the host's scoped `ctx.host.gif` doorway, because
518apps never fetch).
519
520```js
521"/emoji": {
522 run(args, ctx) {
523 const results = searchEmoji(args);
524 if (!results.length) return ctx.reply({ text: "No matches." });
525 const opened = ctx.panel({
526 title: `Emoji matching โ€œ${args}โ€`, // header line, next to the app's glyph
527 // position: "top", // pin above the feed instead (default: above the composer)
528 // animate: false, // appear/vanish instantly (default: ease up on open, roll down on dismiss)
529 render(el, panel) { // build DOM into el โ€” textContent for anything user-typed
530 for (const r of results) {
531 const b = document.createElement("button");
532 b.textContent = r.ch;
533 b.onclick = () => { panel.insertText(r.ch); panel.close(); };
534 el.appendChild(b);
535 }
536 },
537 onClose() {}, // optional: told when the panel is replaced or dismissed
538 });
539 if (!opened) ctx.reply({ text: "โ€ฆ" }); // no chat surface here โ†’ answer with a reply
540 },
541},
542```
543
544The `panel` handle a render gets:
545
546| member | what it does |
547| ------ | ------------ |
548| `panel.close()` | Dismiss the panel (the app deciding it's done). |
549| `panel.refresh()` | Re-run `render` (e.g. async results arrived โ€” render a spinner first, refresh when ready). |
550| `panel.insertText(t)` | Insert `t` at the caret of the composer the panel sits above (forwarded to the native composer in the embedded shell). |
551| `panel.attachFiles(files)` | Stage files on that composer โ€” the same encrypt-and-upload path the attach button uses. |
552| `panel.threadId` | The thread the panel is showing in (where you typed). |
553
554**A panel is generic UI, not a picker widget.** `render` gets a bare container
555and full control: build any DOM you like, own its clicks, and โ€” because the
556handler's `ctx` is your app's real context โ€” a button can dispatch a typed
557event straight into the thread's encrypted log, where it folds like any other
558app event:
559
560```js
561"/lunch": (args, ctx) => {
562 ctx.panel({
563 title: "Start the lunch poll?",
564 render(el, panel) {
565 for (const q of ["Pizza or noodles?", "Eat out or order in?"]) {
566 const b = document.createElement("button");
567 b.textContent = q;
568 b.onclick = async () => {
569 // An ordinary app event โ€” encrypted, attributed, folded by every
570 // member. The panel was just the UI that chose it.
571 await ctx.emit({ type: "poll.create", id: ctx.id(), q, options: ["๐Ÿ‘", "๐Ÿ‘Ž"] });
572 panel.close();
573 };
574 el.appendChild(b);
575 }
576 },
577 });
578},
579```
580
581`/gif` composes the same pieces the other way round: its tiles call a
582privileged **host doorway** (`ctx.host.gif` โ€” search/download/upload) and then
583`ctx.emit` a chat message with the uploaded attachment. If your panel needs
584the network or another privileged capability, don't reach for `fetch` (apps
585never fetch โ€” Invariants #7): add a narrow, named doorway to `setHostBridge`
586in `shell.js`, the way `gif` and the connected services do, and route any new
587external host through the server's CSP deliberately (CLAUDE.md documents the
588Giphy exception).
589
590**The declarative form (and member apps).** Instead of a `render` closure you
591may declare a top-level **`panel(data, el, panelCtx)`** hook on the app and
592call `ctx.panel({ title, data })` โ€” the host synthesizes the render, handing
593your hook the JSON `data` plus a panelCtx of `{ close, insertText, emit, id,
594me, getState }`. This is the ONLY form the member-app sandbox supports (a
595closure can't cross its bridge โ€” the hook runs in a fresh sandboxed frame
596mounted inside the panel), and it works identically compiled-in, so one file
597runs in both worlds: see `apps-disabled/mood.js` and
598`docs/building-member-apps.md ยง Command panels`.
599
600**On the native apps.** The Mac app runs panel commands through a hidden
601per-thread *command-host* webview โ€” your panel code runs there unchanged
602(`insertText` reaches the native composer; `ctx.emit` posts for real). One
603extra step: add your command to `WebPanelCommands.catalog`
604(`ios/LChatCore/Sources/LChatCore/SlashCommands.swift`) so the native composer
605forwards it and lists it in the typeahead. Only commands that act in the
606CURRENT thread (or need no thread) belong there โ€” the native page holds a
607single thread's key, so a command that routes elsewhere (/todo) can't resolve
608in it.
609
610Rules that keep panels honest:
611
612- **A panel is client-only chrome.** Never sent, never folded, gone on dismissal
613 or when the pane closes โ€” state that matters goes through events, as ever.
614- **One per thread.** Opening another replaces it (the outgoing one's `onClose`
615 fires). The user can always dismiss it โ€” its โœ•, or Escape.
616- **It's a response, not an ambush.** Only open one from a command the user just
617 ran (`ctx.panel` exists only in command/mention handlers) โ€” never spontaneously.
618- **`ctx.panel` returns `false`** when the thread has no mounted chat surface to
619 draw in โ€” fall back to `ctx.reply`.
620- **It eases open and shut by default** (and honours reduce-motion); pass
621 `animate: false` for an instant panel. On the Mac the native shell drives the
622 one animation, from the same flag.
623- The usual surface rules apply: build DOM nodes, `textContent` for user
624 content, theme variables for styling (ship the CSS in your app's `styles`).
625
626## The events map & the reducer
627
628Each handler is `(state, event, meta) => newState`. Rules:
629
630- **Pure & immutable.** Return new state; never mutate `state`. (Two devices fold
631 the same events and must converge; the UI also re-folds on every reopen.)
632- **You don't write a `default` case.** The framework returns state unchanged for
633 any type your app doesn't handle. Your map only lists what you own.
634- **Event types must be globally unique.** Two apps can't share a type โ€” prefix
635 every type with your app `name` (`note.add`, `poll.vote`).
636- `meta` carries `{ id, ts }` where `id` is the *server message id* (used for
637 dedupe โ€” see Invariants) and `ts` the sender's send time. You rarely need it.
638
639### Namespacing: one prefix, declared once
640
641Every app prefixes the same string onto two families of names: its **event
642types** (`board.stroke`, joined with `.` โ€” the wire string, part of the
643[cross-platform contract](#chat-as-an-app--the-default-chat-experience), so the
644helper only *spells* it, it never changes it) and its **CSS classes**
645(`board-canvas`, joined with `-`). Writing that prefix by hand in a dozen places
646is where it drifts. So declare it once with `namespace(name)` and derive the rest:
647
648```js
649import { defineApp, namespace, addItem } from "../apps.js";
650
651const N = namespace("board"); // one declaration of the prefix
652
653export const app = defineApp({
654 name: "board",
655 events: {
656 [N.ev("stroke")]: (s, e) => โ€ฆ, // key is "board.stroke"
657 [N.ev("clear")]: (s, e) => โ€ฆ, // key is "board.clear"
658 },
659 render(state, el, ctx) {
660 const canvas = document.createElement("canvas");
661 canvas.className = ctx.cls("canvas"); // "board-canvas" (ctx.cls === N.cls)
662 ctx.emit({ type: ctx.ev("stroke"), id: ctx.id(), points }); // "board.stroke"
663 if (e.type === N.ev("stroke")) โ€ฆ // compare against the same string
664 },
665});
666```
667
668- **`N.ev(short)`** โ†’ `"board.short"` โ€” use it for the `events` map keys
669 (computed: `[N.ev("stroke")]`), every `ctx.emit({ type })`, and every
670 `e.type === โ€ฆ` comparison.
671- **`N.cls(short)`** โ†’ `"board-short"` โ€” use it for `className` and selector
672 strings. The `styles` blob stays a plain literal (it's static text); the helper
673 keeps the *JS* references to those classes in lock-step with them.
674- Inside `render`/`commands` the same resolver is on `ctx` as **`ctx.ev`** /
675 **`ctx.cls`**, bound to your app โ€” no module-level `N` needed there.
676- `namespace` **defaults to `name`**, so it's fully opt-in: an app that never
677 calls it behaves exactly as before, and `board.js` is the migrated reference.
678- A name that already contains a `.` passes through untouched
679 (`N.ev("chat.msg")` โ†’ `"chat.msg"`), so referencing a full or foreign type
680 still works โ€” which makes converting an existing app a safe find-and-replace.
681
682`board.js` is the worked example. (`chat.js` deliberately does *not* namespace its
683classes โ€” it reuses the original feed's `.msg`/`.bubble` markup on purpose; the
684helper is opt-in precisely so it can. `todo.js`/`remind.js` are schema-described
685(docs/app-schema.md): their `events` map keys come from the JSON schema, not
686`namespace()` โ€” but the hand-written glue around a schema app (commands,
687`chatMessage`/`preview`/`unread`) still compares against the wire type by hand,
688so it namespaces too: `const N = namespace(schema.name)`, then `N.ev("add")`
689instead of a second hardcoded `"todo.add"`.)
690
691### Collection helpers
692
693Most apps fold into a list of `{ id, โ€ฆ }` items. Import these from `../apps.js`
694and your reducer is correct by construction โ€” every op returns a **new array**,
695and `addItem` **dedupes by id**:
696
697```js
698addItem(list, item) // append item unless its id is already present; always a fresh array
699updateItem(list, id, patch)// replace matching item with { ...item, ...patch }
700removeItem(list, id) // drop the matching item
701```
702
703Because `addItem` always returns a fresh array, you can `.sort()` its result in
704place safely:
705
706```js
707"remind.add": (s, e) => {
708 if (!e.id || !e.text || !e.when) return s;
709 const items = addItem(s.items, { id: e.id, text: e.text, when: e.when, fired: false });
710 items.sort((a, b) => a.when - b.when); // safe: items is a fresh copy
711 return { items };
712},
713```
714
715Not every fold is a flat list. `poll.js` keeps a per-poll `votes` map and writes
716it with a manual immutable update โ€” the helpers are a convenience, not a
717requirement. When you go custom, keep it pure:
718
719```js
720"poll.vote": (s, e) =>
721 ({ polls: s.polls.map((p) => (p.id === e.pollId ? { ...p, votes: { ...p.votes, [e.uid]: e.choice } } : p)) }),
722```
723
724## Emitting events โ€” and the three free fields
725
726You create events with `ctx.emit(event)`. The event needs a `type` and whatever
727data your reducer reads. **Mint `ctx.id()` once for a new entity** and reuse that
728id in follow-up events (done / remove / vote):
729
730```js
731ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text }); // "note.add" โ€” new entity โ†’ new id
732ctx.emit({ type: ctx.ev("remove"), id: item.id }); // "note.remove" โ€” follow-up โ†’ existing id
733```
734
735Three fields are **added for you** by the transport on every emit โ€” don't set
736them yourself, just read them in your reducer:
737
738| field | value |
739| -------- | ----- |
740| `e.name` | the sender's screen name |
741| `e.ts` | the send time (epoch ms) โ€” use this for sorting, not `Date.now()` |
742| `e.uid` | the sender's user id โ€” e.g. `poll.vote` keys votes by `e.uid` |
743
744Encryption is automatic: `emit` encrypts the event to the thread key and sends it
745as a normal message. You never touch keys, and never send plaintext to the
746server.
747
748### Optimistic sends (`optimistic: true`)
749
750By default an emitted event only appears once the server broadcasts it back โ€” a
751round-trip's wait, and for a drawing a visible *flash* (the line vanishes on
752pen-up, then reappears). Set **`optimistic: true`** on your app and the framework
753folds each emitted event into your state **immediately**, locally, then lets the
754authoritative echo quietly replace it:
755
756```js
757export const app = defineApp({
758 name: "board",
759 optimistic: true, // a stroke shows the instant you lift the pen
760 // โ€ฆ
761});
762```
763
764How it stays correct: the transport tucks a client id (`_cid`) inside every
765event; the server relays the same ciphertext back, so the echo carries it
766verbatim. The optimistic copy folds with **no server message id**, and the echo
767re-folds the *same event* with the real id. That means your reducer runs on the
768event **twice** โ€” so `optimistic: true` is only safe when your reducer is
769**idempotent by the event's own `id`** (mint it with `ctx.id()`, land it with a
770collection helper like `addItem`). This is the same property every reducer
771already needs โ€” the message window re-folds on each thread re-open โ€” so most apps
772qualify already; opt in explicitly rather than assume it. An app whose reducer
773*counts* or otherwise applies an event more than once per id must **not** set the
774flag. (Chat messages get this treatment built in โ€” an outgoing bubble shows at
775once and wears a subtle "Sendingโ€ฆ" state if it's still unconfirmed after 5s.)
776
777## `ctx` โ€” what a handler and a render get
778
779| member | in | what it does |
780| ---------------------------- | ------------- | ------------ |
781| `ctx.emit(event)` | anywhere | Emit a typed event (encrypted + sent). Returns `Promise<boolean>`. |
782| `ctx.schedule(event, when)` | anywhere | Have the **server** deliver `event` into the thread at `when` (epoch ms). Returns `Promise<sid \| null>`. |
783| `ctx.unschedule(sid)` | anywhere | Cancel a pending scheduled event. |
784| `ctx.id()` | anywhere | A fresh unique id for a new entity. |
785| `ctx.ev(name)` | anywhere | Your event type from a short name โ€” `ctx.ev("add")` โ†’ `"todo.add"` (see [Namespacing](#namespacing-one-prefix-declared-once)). |
786| `ctx.cls(name)` | anywhere | Your CSS class from a short name โ€” `ctx.cls("item")` โ†’ `"todo-item"`. |
787| `ctx.me` | anywhere | The current user's id. |
788| `ctx.getState()` | anywhere | The app's latest folded state for this thread. |
789| `ctx.threadId` | anywhere | The thread the app is acting in. |
790| `ctx.place` | anywhere | Where this ctx's copy lives: `"you"` (the app's own thread), `"chat"` (a shared copy in a conversation), or `"space"` (the one space-wide instance). |
791| `ctx.open()` | anywhere | Show this app **instance** โ€” for an action chip, so leaving the current context is always the user's tap. The host picks the surface per place (the app's own thread, a chat copy's Interface pane, the space home). |
792| `ctx.interval(fn,ms)` | render only | A ticker the framework stops for you on redraw / reset. |
793| `ctx.onCleanup(fn)` | render only | Run `fn` before the next redraw or on thread reset. |
794| `ctx.panel(spec)` | commands only | Open a transient results panel where the command was typed (see "Command panels"). Returns `false` when the thread can't draw one. |
795| `ctx.closePanel()` | commands only | Dismiss that thread's open command panel. |
796
797`ctx.interval` is how `remind.js` keeps its relative labels live without leaking
798a timer โ€” never call `setInterval` directly in a render, or you'll leak one per
799redraw.
800
801**Replies disclose their place for you.** When a command routes away from the
802thread it was typed in (a personal copy reached from a chat, or the space-wide
803instance), the framework stamps the reply with the routed place and the notice
804renders it as a muted footer in the stand-alone place words ("just for you" /
805"for everyone in this space" โ€” `placePhrase`; the `/` typeahead's row labels
806use the flowing sibling `placeRowPhrase`). You get this without doing anything.
807If you want to *word* the destination yourself, read `ctx.place` โ€” `todo.js`
808("Added โ€ฆ to the space to-do list.") and `remind.js` ("I'll remind everyone in
809this space: โ€ฆ") are the reference examples โ€” but never rely on apps doing so:
810the framework stamp is the disclosure of record.
811
812## Time โ€” scheduled events
813
814`ctx.schedule(event, whenMs)` is `ctx.emit` shifted into the future, executed by
815the server: the event is encrypted **now**, the server holds the ciphertext, and
816at the due moment it lands in the thread's message log and fans out exactly like
817a posted message โ€” every device folds it, closed apps get a push, open apps can
818toast it. The server learns only *(thread, time)*, never what was scheduled.
819
820```js
821// Schedule first โ€” the sid is part of the reminder's record.
822const id = ctx.id();
823const sid = await ctx.schedule(
824 { type: ctx.ev("fire"), id, notice: { title: "โฐ Reminder", body: text } }, // "remind.fire"
825 whenMs,
826);
827ctx.emit({ type: ctx.ev("add"), id, text, when: whenMs, sid: sid || "" }); // "remind.add"
828```
829
830Rules of thumb (all visible in `remind.js`, the reference implementation):
831
832- **Store the returned `sid` inside an emitted event.** It's how *any* device โ€”
833 not just the one that scheduled โ€” can later `ctx.unschedule(sid)`.
834- **Give a scheduled event a `notice`** (see Voice) โ€” it's usually the whole
835 point of scheduling one.
836- **Handle `sid === null`** (offline / error): degrade gracefully (remind falls
837 back to a best-effort client-side ticker for sid-less items).
838- **The scheduled event is still just an event.** Its fold handler must be
839 idempotent like any other โ€” a cancel can race a delivery, so e.g. a
840 `remind.fire` folding onto an already-removed id must be a no-op (it is, with
841 `updateItem`).
842- Cancelling an id that already fired succeeds quietly; that race is expected.
843
844## Memory โ€” snapshots (automatic)
845
846The client only holds a window of a thread's messages (~100), but folded app
847state is **snapshotted automatically**: after new events fold, the framework
848saves `{ states, upTo }` โ€” encrypted under the thread key โ€” to the server
849(`thread_state`, versioned against concurrent writers), and re-seeds it when the
850thread opens, folding only events newer than `upTo` on top.
851
852You get this for free. It works *because* of the reducer rules: folds are pure
853and deterministic, so `snapshot + newer events โ‰ก folding all of history`. What
854it means for you:
855
856- **Don't bound your state by the window.** A 1,000-item todo list is fine.
857- **State is durable, so treat it with respect**: never put anything in reducer
858 state you wouldn't put in an event (it's all encrypted, but it's persisted).
859 Credentials for a third-party service an app talks to can live here โ€” the
860 server sees only ciphertext โ€” but prefer an event (`service.connect`) so all
861 devices learn them through the normal fold.
862- **Schema-evolve defensively.** A snapshot written by an old version of your
863 app will be seeded into a new version. Reducers and render must tolerate
864 missing fields (`e.note || ""` โ€” the same rule as for old events).
865
866## Voice โ€” talking to the user
867
868Apps can speak the moment something happens, wherever the user is:
869
8701. **`notice` on an event** โ€” the simple, usual way. Any event (typically a
871 scheduled one) may carry `notice: { title, body }`. When it arrives live, the
872 framework toasts it in-app and raises a system notification if the page is
873 hidden; when the app is **closed**, the service worker decrypts the push and
874 shows the same notice. One field, every situation, end-to-end encrypted.
875
8762. **A `notify(event, ctx)` hook** โ€” for wording that depends on state. Called
877 only for **live** events (never during history refolds โ€” replaying the past
878 must not re-alert it). Return `{ title, body }` or `null` for silence:
879
880 ```js
881 notify(e, ctx) {
882 if (e.type !== "poll.create" || e.uid === ctx.me) return null;
883 return { title: "๐Ÿ“Š New poll", body: e.q };
884 },
885 ```
886
887App events with neither are silent โ€” a `todo.add` from your other device never
888buzzes anyone. (Chat messages have their own notification path; this section is
889only about app events.)
890
891## Ephemeral events โ€” presence without a trace
892
893`ctx.emitEphemeral(event)` relays an event to the thread's connected members and
894**forgets it**: never stored, never pushed, never folded. It's for presence
895traffic โ€” typing indicators, live cursors โ€” where persistence would be wrong,
896not just wasteful. Receivers handle it in the app's `ephemeral(event, ctx)`
897hook; senders get their own echo (check `e.uid !== ctx.me` or filter by name).
898
899Two rules:
900
901- **Own the type.** Routing is by event type, so an ephemeral type must appear
902 in your `events` map โ€” claim it with a no-op fold (`"chat.typing": (s) => s`).
903 That also makes it harmless if a hostile client stores one as a real message.
904- **Never touch reducer state from the hook.** Ephemeral events are allowed to
905 differ between devices (some were offline when it fired); folding one would
906 break convergence. Keep transient UI in module-level state and redraw it
907 yourself (see `shell.js`'s typing line).
908
909## Incremental surfaces โ€” `keyedList`
910
911By default the framework wipes and rebuilds an app's surface on every state
912change โ€” the right default for small surfaces. For long lists (a chat feed, a
913big checklist) set `incremental: true` (the framework stops wiping) and
914reconcile with `keyedList`:
915
916```js
917keyedList(listEl, state.msgs, {
918 key: (m) => m.id, // stable identity
919 rev: (m) => m.editedAt, // re-render an item only when this changes
920 render: (m) => buildRow(m) // build one item's DOM
921});
922```
923
924Unchanged items keep their exact DOM nodes โ€” scroll position, focus, and loaded
925images survive; appends build only the new item. The container must be fully
926managed by `keyedList` (no other children). Build any once-only skeleton
927(composer, headers) on first render by checking whether it already exists.
928
929## Rendering a surface
930
931`render(state, el, ctx)` is called whenever state changes. The framework clears
932`el` first, so **always rebuild from `state`** โ€” don't diff.
933
934- **Personal** apps render full-pane: `el` is the thread view. Usually an add
935 form on top, then the list.
936- **Shared** apps render a panel above the chat feed. **Render nothing when state
937 is empty** (just `return`) so the panel collapses until there's something to
938 show. See `poll.js`.
939- **Never `innerHTML` user content.** Build nodes with `createElement` and set
940 text with `textContent`. `innerHTML` is only OK for static in-code strings.
941- Style with the theme's CSS variables (`--ink`, `--muted`, `--accent`,
942 `--border`, `--panel`, `--bubble-them`, โ€ฆ) so the app follows light/dark.
943
944### Optional: `messageAction(msg)`
945
946Contribute an item to a chat message's long-press / hover menu. Given the
947decrypted message `{ text, from }`, return `{ label, glyph, run(ctx) }` to offer
948the action, or `null` to sit this message out. The `run` gets a `ctx` already
949bound to your app-thread (for a personal app), so the action files into *your*
950app. This is how `bookmarks.js` offers "Save link" on any message with a link:
951
952```js
953// import { firstLink } from "../format.js";
954messageAction(msg) {
955 const url = firstLink(msg.text || "");
956 if (!url) return null;
957 return {
958 label: "Save link",
959 glyph: "๐Ÿ”–",
960 run(ctx) {
961 ctx.emit({ type: ctx.ev("add"), id: ctx.id(), url, from: msg.from || "" });
962 },
963 };
964},
965```
966
967### Optional: `renderEvent(event, meta)`
968
969Return a DOM node to show an inline line in the message feed for one of your
970events (e.g. "Poll created"), or `null` to keep it silent. Most apps return
971nothing and render only in their surface.
972
973## What the server provides (and what it can see)
974
975Everything an app touches goes through `ctx` โ€” you never call these endpoints
976yourself โ€” but knowing the surface keeps the trust model clear. In every row the
977blob is encrypted client-side under the thread key; the server can never open it.
978
979| capability | endpoint(s) | server learns |
980| -------------- | ---------------------------------------- | ------------- |
981| Event log | `POST /api/messages`, `GET /api/messages`, WS `msg` | thread, time, size |
982| Scheduling | `POST/GET /api/schedule`, `DELETE /api/schedule/{id}` | thread, delivery time |
983| State snapshots| `GET/PUT /api/threads/{id}/state` | thread, size, write times |
984| Attachments | `POST /api/upload`, `GET /api/blob/{id}` | size |
985| Push fan-out | (server-initiated) | who is subscribed |
986
987All endpoints are token-gated and thread-membership-gated. Server code lives in
988`miniapps.go` (scheduling + state), `hub.go` (fan-out), `push.go` (Web Push).
989
990The app UI itself is client-side ES modules under `web/static/` (this folder),
991embedded in the binary via `go:embed` โ€” as are the HTML pages in `web/pages/`
992(served with the group name substituted). There is currently no per-app
993server-rendered HTML; an app IS its module. (If an app ever needs a public,
994unauthenticated page, that's a new, deliberate surface โ€” design it with the
995same rule: the server serves only what it's allowed to see.)
996
997## Mixing deterministic and LLM behaviour
998
999An app's deterministic core is the reducer + commands: same events in, same
1000state out, unit-testable in Node. When you want judgment, language, or agency,
1001you don't bolt an API call into the app โ€” you **invite a bot into the thread**.
1002
1003Bots (see `docs/bots.md`) are ordinary members with keys: a bot in a thread
1004decrypts its events like any member, and its sidecar can emit app events back
1005through the same `POST /api/messages` doorway (`lchat-post` encrypts in the
1006sidecar's trust domain). That composes with everything above, with no new
1007machinery:
1008
1009- the bot **reads** your app's events as they happen (they're just messages),
1010- the bot **writes** events your reducer already understands โ€” to your fold,
1011 an LLM's contribution is indistinguishable from a human's,
1012- your reducer stays the deterministic arbiter: it validates every event the
1013 same way regardless of who emitted it (it already must โ€” see Invariants).
1014
1015So "add AI to an app" = define the events, let the bot emit them. Example: a
1016`/plan dinner party` app where a bot member watches `plan.create` and responds
1017with `plan.suggest` events the reducer folds into the list โ€” every suggestion
1018encrypted, attributed (`e.uid` is the bot), and replayable.
1019
1020## Chat as an app โ€” the default chat experience
1021
1022`shell.js` (scope `system`) is the chat feed: group/DM threads render it
1023full-pane by default. It is the original chat retooled onto the framework, not
1024a parallel one. There is no new wire protocol: its composer emits the same
1025untyped `{ text }` payload every client has always understood, and the host
1026feeds the app exactly what the original feed consumed:
1027
1028- each stored message's decrypted payload (text, attachment metadata, replyTo)
1029 folds as a host event keyed by its server message id โ€” history, live, and
1030 scroll-back pages alike,
1031- the server's reaction / edit / delete frames fold as host translations
1032 (`hostEvents`, so no wire payload can spoof them โ€” the server-enforced
1033 authorship and dedupe rules keep applying),
1034- typing rides the one wire type the app owns, as a relay-only ephemeral event,
1035- everything outbound (send, react, edit, unsend, upload, unfurl, older pages)
1036 goes back through the host machinery via a narrow bridge (`setHost`) โ€” the
1037 app never touches crypto or tokens.
1038
1039The app renders the ORIGINAL MARKUP: every element uses the same class names
1040and structure the original `appendMessage` builds (`.msg`/`.content`/`.bubble`,
1041`.time-sep`, `.msg-actions` hover strip with the exact time, `.reply-quote`,
1042`.reactions`, `.emoji-picker`, the original composer classes), so style.css
1043styles both feeds identically and there is no duplicated CSS โ€” hover reveals,
1044spacing, gestures all match by construction. The app's own stylesheet is a few
1045lines of layout glue (a `display: contents` item wrapper; a mirror of the one
1046id-based `#feed` rule). Feature surface: rich text (same tokenizers), @mentions
1047with the member menu, jumbo emoji, time separators and sender runs, images
1048(box reserved pre-decrypt, lightbox) and file chips, YouTube cards and
1049unfurls, replies (quote + banner + tallies + swipe-right on touch), editing
1050(15-min window, โ†‘-to-edit, โ†“-to-cancel, full payload rebuild), reactions
1051(hover icon, press-hold / double-tap picker, chips), unsend, attachment
1052sending, paste/drop, typing rows in-feed, slash commands, helper action chips,
1053the :emoji/@mention composer typeahead (one shared ranking function), and
1054scroll-back pagination.
1055
1056What stays deliberately OUTSIDE the event model โ€” the boundaries of the fold,
1057worth knowing when you design any app:
1058
1059- **Unsend / edit** โ€” real deletion removes ciphertext from the server's log
1060 (author-verified); an append-only fold could only tombstone. The app renders
1061 these but the *mechanism* is the server's, reached via the host bridge.
1062- **Finding and inviting people** โ€” roster, invites, key wrapping: identity
1063 machinery, not thread events. (A pending DM also stays on the original path
1064 until the invitee joins โ€” its waiting-card gating lives there.)
1065- **Reactions' emoji live in a plaintext server column** (pre-app mechanism);
1066 migrating them to encrypted fold events is a candidate follow-up.
1067
1068One deliberate restyle: the original touch action sheet was a full-screen
1069long-press menu; the app's long-press opens the compact emoji picker (same
1070actions, Copy included) instead.
1071
1072### Sidebar presence hooks
1073
1074An app decides how its events appear in the chat list: `preview(event)`
1075returns the sidebar preview line, `unread(event)` says whether a live event
1076lights the thread's unread dot. Both default to silent (the old behaviour);
1077poll opts in โ€” a new poll previews as `๐Ÿ“Š <question>` and marks unread, while
1078votes stay quiet. Push wording for app events is the `notice` field (Voice).
1079
1080๐Ÿค– `preview` gets no `{ group }`-style hint and no ctx โ€” it's a pure
1081`event => string`, called for BOTH your personal thread's row and a group's.
1082Two things worth knowing before you write one (learned adding vault.js's):
1083
1084- **It runs your event through, not a generic formatter.** Before `preview`
1085 existed for a given type, the host fell back to treating the raw typed event
1086 as if it were a plain chat message (reading `.text`) โ€” which a typed event
1087 doesn't have, so a group row read as the sender's name, a colon, and nothing
1088 (`"Ciara: "`). This is fixed at the host level (both the live path and the
1089 cold-load path in `shell.js`'s `loadThreads` now call `preview()` for any
1090 typed event, updating the row only when it returns non-null) โ€” but it means
1091 **any event type your reducer owns should either have a `preview` line or
1092 stay silent on purpose**; there's no longer a "raw" fallback to fall into by
1093 accident, so don't leave a type unhandled by assuming something reasonable
1094 will show.
1095- **"You" is on you.** Unlike `chatMessage` (attributed by the host, see
1096 above), `preview` has to do its own "your own event" check โ€” import
1097 `currentUserId` from `../apps.js` and compare it to `event.uid`:
1098 ```js
1099 import { defineApp, currentUserId } from "../apps.js";
1100 preview(e) {
1101 const who = e.uid && e.uid === currentUserId() ? "You" : e.name || "Someone";
1102 return `${who} did the thing`;
1103 }
1104 ```
1105 `currentUserId()` exists for exactly this โ€” a narrow door to "who's reading"
1106 for the pure hooks (`preview`, and any future one) that don't get a `ctx`.
1107
1108### Embedded (native shell) mode
1109
1110The iOS/macOS shell renders one thread per webview and draws its own composer,
1111speaking a small contract: `window.lchatNative` inbound (sendText / typing /
1112stageAttachment / cancelReply / typeahead / didAttach) and `nativePost`
1113outbound ({replying}, {staged}, {error}). The chat app implements the same
1114contract and is the default in the webview too; the host routes
1115`window.lchatNative` to the app's composer handlers, the app hides its own
1116composer chrome, and reply banners / staged state / errors post back to the
1117shell. A shell that needs the original feed sets `originalChat: true` on the
1118injected `__lchatEmbed` config.
1119
1120### Switching engines
1121
1122The chat app is the default. A device switches to the original feed with the
1123โ‹ฎ menu โ†’ โ€œUse original chatโ€ (or
1124`localStorage.setItem("lchat-original-chat", "1")`) โ€” the same menu item
1125switches back, and on the original feed the composer placeholder reads
1126โ€œMessage (original chat)โ€ so you always know which engine you're on. Once the
1127original path has soaked out of rotation, it (~2k lines of shell.js plus its
1128CSS) can be deleted.
1129
1130The design rule stands: **don't add app capabilities chat couldn't use**, and
1131prefer generalising an existing chat mechanism (as `ephemeral` generalised the
1132typing frame) over inventing an app-only parallel.
1133
1134## Invariants (the things that fail silently if you skip them)
1135
11361. **Idempotent fold.** The message window is re-folded from scratch on every
1137 thread reopen, so folding the same add twice must produce one item. `addItem`
1138 handles this by deduping on `id`; a custom fold must dedupe too. (The
1139 framework also skips re-folding the same *server message* within a session,
1140 but that doesn't cover a genuinely duplicated emit โ€” dedupe on your entity id.)
11412. **Pure, immutable reducers.** Never mutate prior state. The collection helpers
1142 return fresh arrays; if you go custom, spread (`{ ...obj }`, `[...arr]`).
1143 This is also what makes snapshots (Memory, above) correct.
11443. **Don't set `name` / `ts` / `uid` on emit** โ€” the transport injects them.
1145 Setting `ts: Date.now()` is dead code (the transport overwrites it).
11464. **Namespace event types** with your app `name`; they're globally unique.
1147 `namespace(name)` (see [Namespacing](#namespacing-one-prefix-declared-once))
1148 derives them for you so the prefix can't drift.
11495. **Never `innerHTML` user content** โ€” `textContent` / DOM nodes only.
11506. **Widget-pane apps render an honest empty state** (a one-line hint beats a
1151 blank pane โ€” see poll.js).
11527. **You never touch the server or keys.** State persistence, scheduling, and
1153 notifications all flow through `ctx` โ€” if you find yourself reaching for
1154 `fetch` or a key, you've taken a wrong turn.
11558. **Validate events in the reducer, not at the emitter.** Anything a thread
1156 member (person, other device, bot) can send is a legal input to your fold โ€”
1157 malformed or hostile events must fold to a no-op, never a crash or a lie.
11589. **A scheduled event will arrive later, into different state.** Fold handlers
1159 for scheduled types must tolerate their target being gone (dismissed while
1160 the delivery was in flight).
1161
1162## Testing
1163
1164Pure logic (reducers, parsers) is unit-tested in Node โ€” see `test/apps.test.mjs`.
1165Import the app object and test its synthesised reducer directly:
1166
1167```js
1168import { app as noteApp } from "../web/static/apps/note.js";
1169const reduce = noteApp.reduce;
1170// reduce(undefined, { type: "note.add", id: "a", text: "hi" }) โ†’ { items: [ โ€ฆ ] }
1171```
1172
1173Keep any non-trivial parsing pure and exported (like `parseReminder` in
1174`remind.js`) so it's testable without the DOM.
1175
1176## Shipping checklist
1177
1178- [ ] `web/static/apps/<name>.js` โ€” one file, self-contained (logic + styles).
1179- [ ] `import "/static/apps/<name>.js";` added in `web/static/shell.js`.
1180- [ ] `node --check web/static/apps/<name>.js` passes.
1181- [ ] Reducer / parser tests added to `test/apps.test.mjs`; `npm test` green.
1182- [ ] A panel-opening command? Add it to `WebPanelCommands.catalog`
1183 (`ios/LChatCore/.../SlashCommands.swift`) so the native apps forward it.
1184- [ ] A user-facing changelog entry in `changelog/<date>-<slug>.md`.
1185- [ ] No trailing whitespace; user content rendered with `textContent`.
1186
1187## ๐Ÿค– Voice: how one event speaks on every surface
1188
1189An app's events can surface in three places, each with its own hook โ€” and a
1190defaulting chain so declaring less still reads well everywhere:
1191
11921. **The chat feed** โ€” `chatMessage(e, opts)` / `groupFeedMessage(e)`.
1193 Action lines are actor-neutral (`added โ€œXโ€ to the to-dos`); the HOST
1194 prefixes the actor ("You" for the reader's own act, their name
1195 otherwise) โ€” never the app. Group lines should say where the data went
1196 ("to the to-dos"); the personal thread IS the list, so it stays terse.
11972. **The thread-list preview** โ€” `preview(e, opts)`, with `opts.you` when
1198 the host resolved the actor as the reader. No hook โ†’ the chat
1199 projection speaks for the sidebar too, actor-prefixed.
12003. **Notifications** โ€” the event's `notice: {title, body}` when set;
1201 otherwise the same projection chain as the preview. State-only events
1202 (a whiteboard stroke) project null and stay silent everywhere.
1203
1204Native clients honour the same chain: schema apps project natively
1205(AppChatProjection mirrors the wording โ€” change both sides + the vectors
1206together), everything else answers through the app host
1207(`projectEvent`/`previewEvent`, docs/native-apps.md).
1208