| 1 | # ๐ค Building a mini-app |
| 2 | |
| 3 | A **mini-app** is a small client-side program that lives alongside chats โ a |
| 4 | to-do list, a poll, reminders. This guide is written for both humans and LLMs: |
| 5 | follow 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 | |
| 16 | An app has no server. It trades **typed events** over the same end-to-end |
| 17 | encrypted 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 |
| 19 | or 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 | |
| 43 | An app is four small things: |
| 44 | |
| 45 | 1. an **events map** โ the event types it owns and how each folds into state, |
| 46 | 2. an **initialState** โ the empty state to start from, |
| 47 | 3. **commands** and/or a **message action** โ how a user creates events, |
| 48 | 4. a **surface** โ how state renders (a full pane, or a panel above a chat). |
| 49 | |
| 50 | Everything ships in **one file** under `web/static/apps/`, including the app's |
| 51 | CSS. No build step; these are plain ES modules. |
| 52 | |
| 53 | On 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 | |
| 69 | This is a real, working app โ copy it, rename it, and change the guts. It's a |
| 70 | personal "Notes" list: `/note <text>` from any chat files a note; the app's own |
| 71 | sidebar pane lists them. |
| 72 | |
| 73 | ```js |
| 74 | // web/static/apps/note.js |
| 75 | import { 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. |
| 79 | const N = namespace("note"); |
| 80 | |
| 81 | export 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 | |
| 137 | Then register it (one line) in `web/static/shell.js`, next to the others: |
| 138 | |
| 139 | ```js |
| 140 | import "/static/apps/note.js"; |
| 141 | ``` |
| 142 | |
| 143 | That's a whole app. Everything below is reference detail and the optional hooks. |
| 144 | |
| 145 | ## Pick your app's shape first |
| 146 | |
| 147 | Before the field reference, decide **which archetype you're building**. An |
| 148 | app's shape is two independent choices โ where copies can LIVE, and how each |
| 149 | copy PRESENTS: |
| 150 | |
| 151 | 1. **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. |
| 157 | 2. **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 | |
| 161 | Those 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 | |
| 171 | The old `scope` enum and the `personal`/`groupCapable`/`spaceCapable` flags |
| 172 | are COMPAT-ONLY inputs now (#405 closed): already-deployed custom and member |
| 173 | apps that declare them keep working โ `defineApp` normalizes either |
| 174 | generation 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 |
| 176 | read the old vocabulary. (How long "keep working" lasts, who upgrades |
| 177 | legacy 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 |
| 179 | always-on ambient panel above every feed, is retired: Poll and Whiteboard |
| 180 | live in chats now (`places: ["chat"]`), and a legacy `scope:"shared"` custom |
| 181 | app degrades to command-only. |
| 182 | |
| 183 | ### Layout patterns โ and the example app that shows each |
| 184 | |
| 185 | Once 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 | |
| 210 | A shape decision worth making deliberately: **a takeover secrets/notes tool should |
| 211 | usually NOT implement `chatMessage`**, so its events don't spill names into the |
| 212 | chat feed, previews, and push notifications. ๐ค When a group still needs to *see |
| 213 | that 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 |
| 216 | there, so the personal thread stays a pure takeover (no Chat view ever grows on |
| 217 | it; `appWantsChatView` reads `chatMessage` alone). `vault.js` uses this for |
| 218 | add/remove, deliberately without naming the secret in the line. Companion apps |
| 219 | need no second hook โ with no `groupFeedMessage`, the group fold falls back to |
| 220 | `chatMessage(event, { group: true })` (that's the `{ group }` hint above); a |
| 221 | companion 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, |
| 258 | but both quietly conflated the one real question โ WHERE can copies of this |
| 259 | app live? โ with how the app is discovered (#405). The model is now exactly |
| 260 | two 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 | |
| 274 | The legacy inputs still normalize in (`scope: "personal"` โ `["you"]`, |
| 275 | `scope: "system"` โ `[] + listed: false`, and each capability flag โ |
| 276 | its place) because stored member-app manifests carry them โ but `defineApp` |
| 277 | deletes them after normalizing, so no code path reads them. |
| 278 | |
| 279 | Not every full-pane thread surface should be an app, though โ a pending (not-yet- |
| 280 | joined) DM shows a waiting card instead of the chat app's pane, but that's a |
| 281 | host-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 | |
| 286 | A personal app whose `places` include **`"chat"`** (To-do) can also be **added to a |
| 287 | group chat** (โฎ โ Add apps), where one shared instance runs for the whole |
| 288 | group โ at the same time as everyone's personal copies. This is not a new |
| 289 | model; 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 | |
| 352 | What the `"chat"` place asks of your app: nothing beyond the standard invariants โ |
| 353 | a pure, idempotent, validating reducer (strangers' events are now first-class |
| 354 | inputs), a `render` that works off `ctx.threadId` (never a stashed thread id), |
| 355 | and per-user logic keyed on `e.uid`. If your app satisfies those, the place is |
| 356 | the whole opt-in. Vectors: `test/event-vectors/groupapps.json`. |
| 357 | |
| 358 | Currently chat-placed: To-do, Reminders, Bookmarks, the Vault โ plus Poll and |
| 359 | the Whiteboard, which are chat-place-ONLY (`places: ["chat"]`, no personal |
| 360 | copy): their commands auto-adopt the app into the group on first use (the |
| 361 | ambient panel they used to ride is retired โ #405). Deliberately NOT |
| 362 | flagged: apps whose state derives from one member's private service |
| 363 | credentials (Integrations, DNSimple) โ sharing those needs its own design |
| 364 | (whose credentials fetch? who may see the result?), so the per-app gate is |
| 365 | the point, not a limitation. |
| 366 | |
| 367 | ### Space-wide apps: install once, use everywhere |
| 368 | |
| 369 | The third place up: an app whose `places` include **`"space"`** can be **installed |
| 370 | for the whole space** (โฎ โ Apps โ "For everyone in this space", or |
| 371 | `/app @everyone`) โ ONE shared instance, its commands available in every chat, |
| 372 | nobody else installs anything. Design + trust model: `docs/space-apps.md`. |
| 373 | **Currently parked for shipped apps** (`docs/space-apps.md ยง The pullback`): |
| 374 | the built-ins declare `places: ["you", "chat"]` and the multi-place UI hides |
| 375 | behind the `PLACES_UI` device flag โ the machinery below stays live and |
| 376 | tested, and an app rejoins by adding `"space"` to its `places` line. |
| 377 | Again 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 | |
| 422 | What the `"space"` place asks of your app: the `"chat"` bar, verbatim โ a |
| 423 | pure, 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 |
| 425 | in 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 |
| 428 | place (ยง The pullback); the built-ins that had it โ Bookmarks, To-do, |
| 429 | Reminders, the Vault โ are one places-line edit from returning. |
| 430 | |
| 431 | ### Local & default commands |
| 432 | |
| 433 | A command is **global** by default: it's in the `/` typeahead of every thread, |
| 434 | and (for a personal app) routes into your app-thread from wherever you typed it โ |
| 435 | that'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 |
| 437 | inside the app's own thread, never elsewhere, so short names like `/add` can't |
| 438 | collide across apps. In its own thread the local commands lead the typeahead and |
| 439 | the app's global aliases drop out as redundant. An app with **no** local commands |
| 440 | leads 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 |
| 444 | no bot member in the thread, typing `buy milk` into To-do's chat is `/add buy |
| 445 | milk`, and typing `call mum in 2h` into Reminders is `/remind call mum in 2h` โ |
| 446 | because sending a chat message no one can answer is a dead end, the message |
| 447 | becomes the action instead. The command can push back: Reminders replies "When |
| 448 | should I remind you?" for text with no parseable time. (In-thread, replies |
| 449 | carrying a `short` โ transient confirmations โ are suppressed, since the event's |
| 450 | action line is feedback enough; replies without one, content and errors, always |
| 451 | render.) 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 | |
| 463 | A 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 |
| 469 | default it still has a side effect on your sidebar: the first time you run |
| 470 | a personal app's global command, its app-thread is created โ via |
| 471 | `resolveAppThread`/`createAppThread` in `shell.js` โ and appears in the |
| 472 | thread list, whether or not the command actually did anything. That's right |
| 473 | for To-do or Notes (the thread IS where the item now lives, so surfacing it |
| 474 | is the point), but wrong for an app whose commands mostly just answer a |
| 475 | question or offer an action chip โ it shouldn't spawn a sidebar entry the |
| 476 | user never asked for. |
| 477 | |
| 478 | Set **`quiet: true`** and the app's personal thread stops forcing its own way |
| 479 | into view. The thread still exists โ it's still the only durable record of |
| 480 | the interaction, and every event you `ctx.emit` still lands there exactly as |
| 481 | before โ `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 | |
| 494 | You don't have to do anything else to get this โ no different `ctx.emit`, |
| 495 | no different reply shape. The enforcement lives entirely in the host |
| 496 | (`createAppThread` creates it pre-hidden; the WS message handler reasserts |
| 497 | the hide instead of letting the server's default "new activity reopens a |
| 498 | closed chat" rule apply) because the **server never learns which app owns a |
| 499 | thread** (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 |
| 501 | effect on a chat-placed app's shared group instance, since that thread is |
| 502 | the chat the user already has open, never one the command creates. |
| 503 | |
| 504 | ### Command panels โ a transient results surface |
| 505 | |
| 506 | A 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 |
| 508 | just above the composer (or pinned above the feed with `position: "top"`). It's |
| 509 | still the "answer in place" doctrine: nothing navigates, nothing overlays what |
| 510 | you're reading (the panel takes layout space), and leaving is always the user's |
| 511 | tap. Two working references: `apps/emoji.js` (`/emoji cat` opens a picker; a |
| 512 | tap 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 |
| 514 | supplies a free Giphy key, which then belongs to the space and nobody is asked |
| 515 | again; a tap posts the GIF |
| 516 | into the chat as an ordinary encrypted attachment and closes the panel โ its |
| 517 | network + upload go through the host's scoped `ctx.host.gif` doorway, because |
| 518 | apps 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 | |
| 544 | The `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 |
| 555 | and full control: build any DOM you like, own its clicks, and โ because the |
| 556 | handler's `ctx` is your app's real context โ a button can dispatch a typed |
| 557 | event straight into the thread's encrypted log, where it folds like any other |
| 558 | app 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 |
| 582 | privileged **host doorway** (`ctx.host.gif` โ search/download/upload) and then |
| 583 | `ctx.emit` a chat message with the uploaded attachment. If your panel needs |
| 584 | the network or another privileged capability, don't reach for `fetch` (apps |
| 585 | never fetch โ Invariants #7): add a narrow, named doorway to `setHostBridge` |
| 586 | in `shell.js`, the way `gif` and the connected services do, and route any new |
| 587 | external host through the server's CSP deliberately (CLAUDE.md documents the |
| 588 | Giphy exception). |
| 589 | |
| 590 | **The declarative form (and member apps).** Instead of a `render` closure you |
| 591 | may declare a top-level **`panel(data, el, panelCtx)`** hook on the app and |
| 592 | call `ctx.panel({ title, data })` โ the host synthesizes the render, handing |
| 593 | your hook the JSON `data` plus a panelCtx of `{ close, insertText, emit, id, |
| 594 | me, getState }`. This is the ONLY form the member-app sandbox supports (a |
| 595 | closure can't cross its bridge โ the hook runs in a fresh sandboxed frame |
| 596 | mounted inside the panel), and it works identically compiled-in, so one file |
| 597 | runs 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 |
| 601 | per-thread *command-host* webview โ your panel code runs there unchanged |
| 602 | (`insertText` reaches the native composer; `ctx.emit` posts for real). One |
| 603 | extra step: add your command to `WebPanelCommands.catalog` |
| 604 | (`ios/LChatCore/Sources/LChatCore/SlashCommands.swift`) so the native composer |
| 605 | forwards it and lists it in the typeahead. Only commands that act in the |
| 606 | CURRENT thread (or need no thread) belong there โ the native page holds a |
| 607 | single thread's key, so a command that routes elsewhere (/todo) can't resolve |
| 608 | in it. |
| 609 | |
| 610 | Rules 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 | |
| 628 | Each 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 | |
| 641 | Every app prefixes the same string onto two families of names: its **event |
| 642 | types** (`board.stroke`, joined with `.` โ the wire string, part of the |
| 643 | [cross-platform contract](#chat-as-an-app--the-default-chat-experience), so the |
| 644 | helper 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 |
| 646 | is where it drifts. So declare it once with `namespace(name)` and derive the rest: |
| 647 | |
| 648 | ```js |
| 649 | import { defineApp, namespace, addItem } from "../apps.js"; |
| 650 | |
| 651 | const N = namespace("board"); // one declaration of the prefix |
| 652 | |
| 653 | export 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 |
| 683 | classes โ it reuses the original feed's `.msg`/`.bubble` markup on purpose; the |
| 684 | helper 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, |
| 688 | so it namespaces too: `const N = namespace(schema.name)`, then `N.ev("add")` |
| 689 | instead of a second hardcoded `"todo.add"`.) |
| 690 | |
| 691 | ### Collection helpers |
| 692 | |
| 693 | Most apps fold into a list of `{ id, โฆ }` items. Import these from `../apps.js` |
| 694 | and your reducer is correct by construction โ every op returns a **new array**, |
| 695 | and `addItem` **dedupes by id**: |
| 696 | |
| 697 | ```js |
| 698 | addItem(list, item) // append item unless its id is already present; always a fresh array |
| 699 | updateItem(list, id, patch)// replace matching item with { ...item, ...patch } |
| 700 | removeItem(list, id) // drop the matching item |
| 701 | ``` |
| 702 | |
| 703 | Because `addItem` always returns a fresh array, you can `.sort()` its result in |
| 704 | place 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 | |
| 715 | Not every fold is a flat list. `poll.js` keeps a per-poll `votes` map and writes |
| 716 | it with a manual immutable update โ the helpers are a convenience, not a |
| 717 | requirement. 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 | |
| 726 | You create events with `ctx.emit(event)`. The event needs a `type` and whatever |
| 727 | data your reducer reads. **Mint `ctx.id()` once for a new entity** and reuse that |
| 728 | id in follow-up events (done / remove / vote): |
| 729 | |
| 730 | ```js |
| 731 | ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text }); // "note.add" โ new entity โ new id |
| 732 | ctx.emit({ type: ctx.ev("remove"), id: item.id }); // "note.remove" โ follow-up โ existing id |
| 733 | ``` |
| 734 | |
| 735 | Three fields are **added for you** by the transport on every emit โ don't set |
| 736 | them 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 | |
| 744 | Encryption is automatic: `emit` encrypts the event to the thread key and sends it |
| 745 | as a normal message. You never touch keys, and never send plaintext to the |
| 746 | server. |
| 747 | |
| 748 | ### Optimistic sends (`optimistic: true`) |
| 749 | |
| 750 | By default an emitted event only appears once the server broadcasts it back โ a |
| 751 | round-trip's wait, and for a drawing a visible *flash* (the line vanishes on |
| 752 | pen-up, then reappears). Set **`optimistic: true`** on your app and the framework |
| 753 | folds each emitted event into your state **immediately**, locally, then lets the |
| 754 | authoritative echo quietly replace it: |
| 755 | |
| 756 | ```js |
| 757 | export const app = defineApp({ |
| 758 | name: "board", |
| 759 | optimistic: true, // a stroke shows the instant you lift the pen |
| 760 | // โฆ |
| 761 | }); |
| 762 | ``` |
| 763 | |
| 764 | How it stays correct: the transport tucks a client id (`_cid`) inside every |
| 765 | event; the server relays the same ciphertext back, so the echo carries it |
| 766 | verbatim. The optimistic copy folds with **no server message id**, and the echo |
| 767 | re-folds the *same event* with the real id. That means your reducer runs on the |
| 768 | event **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 |
| 770 | collection helper like `addItem`). This is the same property every reducer |
| 771 | already needs โ the message window re-folds on each thread re-open โ so most apps |
| 772 | qualify 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 |
| 774 | flag. (Chat messages get this treatment built in โ an outgoing bubble shows at |
| 775 | once 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 |
| 798 | a timer โ never call `setInterval` directly in a render, or you'll leak one per |
| 799 | redraw. |
| 800 | |
| 801 | **Replies disclose their place for you.** When a command routes away from the |
| 802 | thread it was typed in (a personal copy reached from a chat, or the space-wide |
| 803 | instance), the framework stamps the reply with the routed place and the notice |
| 804 | renders 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 |
| 806 | use the flowing sibling `placeRowPhrase`). You get this without doing anything. |
| 807 | If 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 |
| 809 | this space: โฆ") are the reference examples โ but never rely on apps doing so: |
| 810 | the 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 |
| 815 | the server: the event is encrypted **now**, the server holds the ciphertext, and |
| 816 | at the due moment it lands in the thread's message log and fans out exactly like |
| 817 | a posted message โ every device folds it, closed apps get a push, open apps can |
| 818 | toast 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. |
| 822 | const id = ctx.id(); |
| 823 | const sid = await ctx.schedule( |
| 824 | { type: ctx.ev("fire"), id, notice: { title: "โฐ Reminder", body: text } }, // "remind.fire" |
| 825 | whenMs, |
| 826 | ); |
| 827 | ctx.emit({ type: ctx.ev("add"), id, text, when: whenMs, sid: sid || "" }); // "remind.add" |
| 828 | ``` |
| 829 | |
| 830 | Rules 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 | |
| 846 | The client only holds a window of a thread's messages (~100), but folded app |
| 847 | state is **snapshotted automatically**: after new events fold, the framework |
| 848 | saves `{ states, upTo }` โ encrypted under the thread key โ to the server |
| 849 | (`thread_state`, versioned against concurrent writers), and re-seeds it when the |
| 850 | thread opens, folding only events newer than `upTo` on top. |
| 851 | |
| 852 | You get this for free. It works *because* of the reducer rules: folds are pure |
| 853 | and deterministic, so `snapshot + newer events โก folding all of history`. What |
| 854 | it 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 | |
| 868 | Apps can speak the moment something happens, wherever the user is: |
| 869 | |
| 870 | 1. **`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 | |
| 876 | 2. **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 | |
| 887 | App events with neither are silent โ a `todo.add` from your other device never |
| 888 | buzzes anyone. (Chat messages have their own notification path; this section is |
| 889 | only 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 |
| 895 | traffic โ typing indicators, live cursors โ where persistence would be wrong, |
| 896 | not just wasteful. Receivers handle it in the app's `ephemeral(event, ctx)` |
| 897 | hook; senders get their own echo (check `e.uid !== ctx.me` or filter by name). |
| 898 | |
| 899 | Two 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 | |
| 911 | By default the framework wipes and rebuilds an app's surface on every state |
| 912 | change โ the right default for small surfaces. For long lists (a chat feed, a |
| 913 | big checklist) set `incremental: true` (the framework stops wiping) and |
| 914 | reconcile with `keyedList`: |
| 915 | |
| 916 | ```js |
| 917 | keyedList(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 | |
| 924 | Unchanged items keep their exact DOM nodes โ scroll position, focus, and loaded |
| 925 | images survive; appends build only the new item. The container must be fully |
| 926 | managed 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 | |
| 946 | Contribute an item to a chat message's long-press / hover menu. Given the |
| 947 | decrypted message `{ text, from }`, return `{ label, glyph, run(ctx) }` to offer |
| 948 | the action, or `null` to sit this message out. The `run` gets a `ctx` already |
| 949 | bound to your app-thread (for a personal app), so the action files into *your* |
| 950 | app. This is how `bookmarks.js` offers "Save link" on any message with a link: |
| 951 | |
| 952 | ```js |
| 953 | // import { firstLink } from "../format.js"; |
| 954 | messageAction(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 | |
| 969 | Return a DOM node to show an inline line in the message feed for one of your |
| 970 | events (e.g. "Poll created"), or `null` to keep it silent. Most apps return |
| 971 | nothing and render only in their surface. |
| 972 | |
| 973 | ## What the server provides (and what it can see) |
| 974 | |
| 975 | Everything an app touches goes through `ctx` โ you never call these endpoints |
| 976 | yourself โ but knowing the surface keeps the trust model clear. In every row the |
| 977 | blob 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 | |
| 987 | All 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 | |
| 990 | The app UI itself is client-side ES modules under `web/static/` (this folder), |
| 991 | embedded 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 |
| 993 | server-rendered HTML; an app IS its module. (If an app ever needs a public, |
| 994 | unauthenticated page, that's a new, deliberate surface โ design it with the |
| 995 | same rule: the server serves only what it's allowed to see.) |
| 996 | |
| 997 | ## Mixing deterministic and LLM behaviour |
| 998 | |
| 999 | An app's deterministic core is the reducer + commands: same events in, same |
| 1000 | state out, unit-testable in Node. When you want judgment, language, or agency, |
| 1001 | you don't bolt an API call into the app โ you **invite a bot into the thread**. |
| 1002 | |
| 1003 | Bots (see `docs/bots.md`) are ordinary members with keys: a bot in a thread |
| 1004 | decrypts its events like any member, and its sidecar can emit app events back |
| 1005 | through the same `POST /api/messages` doorway (`lchat-post` encrypts in the |
| 1006 | sidecar's trust domain). That composes with everything above, with no new |
| 1007 | machinery: |
| 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 | |
| 1015 | So "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 |
| 1017 | with `plan.suggest` events the reducer folds into the list โ every suggestion |
| 1018 | encrypted, 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 |
| 1023 | full-pane by default. It is the original chat retooled onto the framework, not |
| 1024 | a parallel one. There is no new wire protocol: its composer emits the same |
| 1025 | untyped `{ text }` payload every client has always understood, and the host |
| 1026 | feeds 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 | |
| 1039 | The app renders the ORIGINAL MARKUP: every element uses the same class names |
| 1040 | and 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 |
| 1043 | styles both feeds identically and there is no duplicated CSS โ hover reveals, |
| 1044 | spacing, gestures all match by construction. The app's own stylesheet is a few |
| 1045 | lines of layout glue (a `display: contents` item wrapper; a mirror of the one |
| 1046 | id-based `#feed` rule). Feature surface: rich text (same tokenizers), @mentions |
| 1047 | with the member menu, jumbo emoji, time separators and sender runs, images |
| 1048 | (box reserved pre-decrypt, lightbox) and file chips, YouTube cards and |
| 1049 | unfurls, 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 |
| 1052 | sending, paste/drop, typing rows in-feed, slash commands, helper action chips, |
| 1053 | the :emoji/@mention composer typeahead (one shared ranking function), and |
| 1054 | scroll-back pagination. |
| 1055 | |
| 1056 | What stays deliberately OUTSIDE the event model โ the boundaries of the fold, |
| 1057 | worth 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 | |
| 1068 | One deliberate restyle: the original touch action sheet was a full-screen |
| 1069 | long-press menu; the app's long-press opens the compact emoji picker (same |
| 1070 | actions, Copy included) instead. |
| 1071 | |
| 1072 | ### Sidebar presence hooks |
| 1073 | |
| 1074 | An app decides how its events appear in the chat list: `preview(event)` |
| 1075 | returns the sidebar preview line, `unread(event)` says whether a live event |
| 1076 | lights the thread's unread dot. Both default to silent (the old behaviour); |
| 1077 | poll opts in โ a new poll previews as `๐ <question>` and marks unread, while |
| 1078 | votes 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. |
| 1082 | Two 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 | |
| 1110 | The iOS/macOS shell renders one thread per webview and draws its own composer, |
| 1111 | speaking a small contract: `window.lchatNative` inbound (sendText / typing / |
| 1112 | stageAttachment / cancelReply / typeahead / didAttach) and `nativePost` |
| 1113 | outbound ({replying}, {staged}, {error}). The chat app implements the same |
| 1114 | contract 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 |
| 1116 | composer chrome, and reply banners / staged state / errors post back to the |
| 1117 | shell. A shell that needs the original feed sets `originalChat: true` on the |
| 1118 | injected `__lchatEmbed` config. |
| 1119 | |
| 1120 | ### Switching engines |
| 1121 | |
| 1122 | The 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 |
| 1125 | switches 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 |
| 1127 | original path has soaked out of rotation, it (~2k lines of shell.js plus its |
| 1128 | CSS) can be deleted. |
| 1129 | |
| 1130 | The design rule stands: **don't add app capabilities chat couldn't use**, and |
| 1131 | prefer generalising an existing chat mechanism (as `ephemeral` generalised the |
| 1132 | typing frame) over inventing an app-only parallel. |
| 1133 | |
| 1134 | ## Invariants (the things that fail silently if you skip them) |
| 1135 | |
| 1136 | 1. **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.) |
| 1141 | 2. **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. |
| 1144 | 3. **Don't set `name` / `ts` / `uid` on emit** โ the transport injects them. |
| 1145 | Setting `ts: Date.now()` is dead code (the transport overwrites it). |
| 1146 | 4. **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. |
| 1149 | 5. **Never `innerHTML` user content** โ `textContent` / DOM nodes only. |
| 1150 | 6. **Widget-pane apps render an honest empty state** (a one-line hint beats a |
| 1151 | blank pane โ see poll.js). |
| 1152 | 7. **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. |
| 1155 | 8. **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. |
| 1158 | 9. **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 | |
| 1164 | Pure logic (reducers, parsers) is unit-tested in Node โ see `test/apps.test.mjs`. |
| 1165 | Import the app object and test its synthesised reducer directly: |
| 1166 | |
| 1167 | ```js |
| 1168 | import { app as noteApp } from "../web/static/apps/note.js"; |
| 1169 | const reduce = noteApp.reduce; |
| 1170 | // reduce(undefined, { type: "note.add", id: "a", text: "hi" }) โ { items: [ โฆ ] } |
| 1171 | ``` |
| 1172 | |
| 1173 | Keep 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 | |
| 1189 | An app's events can surface in three places, each with its own hook โ and a |
| 1190 | defaulting chain so declaring less still reads well everywhere: |
| 1191 | |
| 1192 | 1. **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. |
| 1197 | 2. **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. |
| 1200 | 3. **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 | |
| 1204 | Native clients honour the same chain: schema apps project natively |
| 1205 | (AppChatProjection mirrors the wording โ change both sides + the vectors |
| 1206 | together), everything else answers through the app host |
| 1207 | (`projectEvent`/`previewEvent`, docs/native-apps.md). |
| 1208 | |