| 1 | # ๐ค Building a mini-app โ a self-contained authoring guide |
| 2 | |
| 3 | This guide teaches you (a person, or an LLM asked to help) how to write a |
| 4 | **mini-app** for an Eleven Messenger / lchat instance: a small client-side |
| 5 | program that lives alongside chats โ a to-do list, a poll, a tracker, a game. |
| 6 | It is fully self-contained: you do not need the server's source code, a |
| 7 | repository checkout, or any build tooling. Every instance serves this document |
| 8 | at `GET /api/app-guide`. |
| 9 | |
| 10 | You will produce **exactly one JavaScript file** โ a plain ES module โ that the |
| 11 | instance admin installs. Everything the app is (logic, rendering, CSS) ships in |
| 12 | that one file. |
| 13 | |
| 14 | ## The one idea |
| 15 | |
| 16 | An app has no server. It trades **typed events** over the same end-to-end |
| 17 | encrypted message stream that chat uses, and derives its state by **folding** |
| 18 | those events. The server only ever sees ciphertext, so it can't run or even |
| 19 | 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 | An app is four small things: |
| 26 | |
| 27 | 1. an **events map** โ the event types it owns and how each folds into state, |
| 28 | 2. an **initialState** โ the empty state to start from, |
| 29 | 3. **commands** and/or a **message action** โ how a user creates events, |
| 30 | 4. a **surface** โ how state renders (a full pane, or a panel above a chat). |
| 31 | |
| 32 | On top of that model the framework gives every app three capabilities for free |
| 33 | (each has its own section below): |
| 34 | |
| 35 | - **Time** โ `ctx.schedule(event, when)` has the *server* deliver an event into |
| 36 | the thread later, so things happen when no client is open (reminders that |
| 37 | actually fire). The server holds only ciphertext and a timestamp. |
| 38 | - **Memory** โ folded state is snapshotted (encrypted) to the server and |
| 39 | re-seeded on open, so an app's state survives beyond the loaded message |
| 40 | window. 1,000 items work as well as 10. Apps do nothing to opt in. |
| 41 | - **Voice** โ an event can carry a `notice` (or the app a `notify` hook) to |
| 42 | alert the user the moment it arrives: an in-app announcement, a system |
| 43 | notification when hidden, a push notification when closed. All wording stays |
| 44 | encrypted end-to-end. |
| 45 | |
| 46 | ## Hard constraints โ self-check every one before delivering |
| 47 | |
| 48 | An app that breaks any of these will be rejected or will misbehave. Check your |
| 49 | finished file against each rule: |
| 50 | |
| 51 | 1. **Exactly one file.** All logic, rendering, and CSS live in a single |
| 52 | `<name>.js` โ no companion files, no assets, no build step. |
| 53 | 2. **A plain ES module.** No bundler output, no TypeScript, no JSX, no |
| 54 | `require()` โ just standard `import`/`export` syntax a browser runs as-is. |
| 55 | 3. **Imports are allowed ONLY from `"../apps.js"` and `"../format.js"`.** |
| 56 | Nothing else โ no other relative paths, no URLs, no npm packages, no dynamic |
| 57 | `import()`. The exact exports you may use are enumerated in |
| 58 | "Allowed imports" below. |
| 59 | 4. **No network calls.** Never `fetch`, `XMLHttpRequest`, or `WebSocket`. All |
| 60 | I/O flows through `ctx` (emit, schedule, snapshots) โ if you're reaching for |
| 61 | the network, you've taken a wrong turn. |
| 62 | 5. **No browser storage.** Never `localStorage`, `indexedDB`, or |
| 63 | `document.cookie`. Durable state lives in the fold (it's encrypted and |
| 64 | synced for free); transient UI state (an expanded/collapsed toggle) lives in |
| 65 | module-level variables. |
| 66 | 6. **Never `innerHTML` user content.** Build DOM with `createElement` and set |
| 67 | text with `textContent`. `innerHTML` is acceptable only for static, |
| 68 | in-code string literals โ never anything that includes user input. |
| 69 | 7. **Use `ctx.interval(fn, ms)`, never bare `setInterval`, inside `render`.** |
| 70 | The framework stops `ctx.interval` tickers on redraw and thread reset; a |
| 71 | bare `setInterval` leaks one timer per redraw. |
| 72 | 8. **Every event type is prefixed with the app's `name`.** App `name: "note"` |
| 73 | โ types `note.add`, `note.remove`. Event types are globally unique across |
| 74 | all apps. |
| 75 | 9. **Reducers are pure, immutable, and idempotent by entity id.** Return new |
| 76 | state, never mutate the old; folding the same add twice must produce one |
| 77 | item (the collection helpers guarantee this). |
| 78 | 10. **Never set `name`, `ts`, or `uid` on an emitted event** โ the transport |
| 79 | injects them. |
| 80 | 11. **Validate events in the reducer, not at the emitter.** Malformed or |
| 81 | hostile events must fold to a no-op, never a crash. |
| 82 | 12. **You never touch the server, keys, or tokens.** Persistence, scheduling, |
| 83 | and notifications all flow through `ctx`. |
| 84 | |
| 85 | ## Interview โ ask the user these first |
| 86 | |
| 87 | Before writing any code, ask the person who wants the app: |
| 88 | |
| 89 | 1. **Where should it live?** โ the `places` declaration, any non-empty subset |
| 90 | of `["you", "chat", "space"]`: |
| 91 | - *Personal* (`places: ["you"]`) โ their own private tool (a to-do list, |
| 92 | bookmarks), its own thread in the sidebar, full-pane. |
| 93 | - *Group-shareable* (`places: ["you", "chat"]`) โ the same private tool, |
| 94 | ALSO addable to a specific group chat, where one shared instance runs |
| 95 | for that group (see "Group-scoped apps" below). ๐ค Someone asking for |
| 96 | "a group tool everyone in the chat uses together" usually means THIS. |
| 97 | A group tool with no personal copy at all is `places: ["chat"]` (Poll, |
| 98 | the Whiteboard): its command auto-adopts it into the group on first use. |
| 99 | - *Space-wide* (add `"space"`) โ one shared copy for the WHOLE space, |
| 100 | installed once (`/cmd @everyone`), commands usable from every chat: the |
| 101 | shared-library shape (stickers, links, a glossary). `places: ["space"]` |
| 102 | alone is the space-only shape โ one copy for everyone, no personal |
| 103 | thread (`/gif`). |
| 104 | (The legacy `scope` enum is retired: `scope: "personal"` still normalizes |
| 105 | into `places: ["you"]` for old sources, and the `"shared"` ambient panel |
| 106 | is gone โ a legacy `scope: "shared"` app degrades to command-only.) |
| 107 | 2. **Takeover or companion?** For a personal app: should its surface *be* the |
| 108 | whole thread (takeover โ the default; right for boards, games, dashboards), |
| 109 | or should the thread also offer a Chat view where the app's actions read as |
| 110 | a conversation (companion โ implement `chatMessage(event)`; right for |
| 111 | list-like tools where "added X" reads naturally as a chat line)? |
| 112 | 3. **Which slash commands, and what syntax?** e.g. `/note buy milk` โ what's |
| 113 | the command name, what arguments does it take, and what should happen on |
| 114 | empty/bad input? (Reply with a usage line, never fail silently.) |
| 115 | 4. **Anything time-based?** Should something happen *later* โ a reminder, an |
| 116 | expiry, a recurring nudge? That's `ctx.schedule`. |
| 117 | 5. **Should anything alert people?** Which events deserve a notification, and |
| 118 | what should it say? That's a `notice` on the event (or a `notify` hook for |
| 119 | state-dependent wording). Everything else stays silent. |
| 120 | 6. **What is the core entity, and what is its lifecycle?** A note: created, |
| 121 | removed. A task: created, completed, reopened, removed. A vote: cast, |
| 122 | changed. **Derive the events map directly from the answer** โ one event |
| 123 | type per lifecycle transition, e.g. `task.add`, `task.done`, `task.reopen`, |
| 124 | `task.remove`. |
| 125 | 7. **How big can the list get?** If a surface can grow long (hundreds of |
| 126 | rows), plan for `incremental: true` + `keyedList` so redraws stay cheap. |
| 127 | |
| 128 | ## The shape of an app: `defineApp(spec)` |
| 129 | |
| 130 | Your file calls `defineApp` once, at module load, and exports the result: |
| 131 | |
| 132 | ```js |
| 133 | import { defineApp } from "../apps.js"; |
| 134 | export const app = defineApp({ /* fields below */ }); |
| 135 | ``` |
| 136 | |
| 137 | | field | required | what it is | |
| 138 | | -------------- | -------- | ---------- | |
| 139 | | `name` | โ | Unique lowercase id. Also the prefix for your event types + CSS classes. | |
| 140 | | `namespace` | | The event-type / CSS-class prefix. Defaults to `name`; drives `namespace()`, `ctx.ev`, `ctx.cls` โ see Namespacing. Leave off unless it must differ from `name`. | |
| 141 | | `title` | โ | Human name (sidebar, header). | |
| 142 | | `glyph` | โ | One emoji/character used as the app's avatar. | |
| 143 | | `places` | โ | WHERE copies can live โ any non-empty subset of `["you", "chat", "space"]`; see the interview above. (Legacy `scope: "personal"` still normalizes in for old sources.) | |
| 144 | | `groupFeedMessage` | | `(event) => { text, action? } \| string \| null` โ the FEED-ONLY projection: narrates events in a GROUP's chat feed WITHOUT giving your app 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; alongside it for group-specific wording. See "Group-scoped apps" below. | |
| 145 | | `initialState` | โ | `() => state`. A **factory**, called to seed each thread's empty state. | |
| 146 | | `events` | โ | `{ "<type>": (state, event, meta) => state }`. The fold. | |
| 147 | | `commands` | | `{ "/cmd": (args, ctx) => void }` or `{ "/cmd": { run, desc, args } }` โ `desc`/`args` feed the `/` typeahead. | |
| 148 | | `render` | | `(state, el, ctx) => void`. Draws the surface. | |
| 149 | | `chatMessage` | | `(event, opts?) => { text, action? } \| null`. Opting in makes the app a **companion**: projects each event into a Chat view (a bot bubble, or a `/me`-style action line via `{ text, action: true }`). No `chatMessage` โ the app **takes over** the thread's full surface. `opts.group` is `true` when called for a GROUP's own chat feed โ see "Group-scoped apps" below. | |
| 150 | | `chatIntro` | | The empty-state intro line shown the first time the companion Chat view opens. | |
| 151 | | `messageAction`| | `(msg) => { label, glyph, run(ctx) } \| null`. Adds an item to the chat message menu. | |
| 152 | | `renderEvent` | | `(event, meta) => Node \| null`. An inline line in the message feed. | |
| 153 | | `notify` | | `(event, ctx) => { title, body } \| null`. Speak on a live event (see Voice). | |
| 154 | | `ephemeral` | | `(event, ctx) => void`. Handle relay-only events (see Ephemeral events). | |
| 155 | | `preview` | | `(event) => string \| null`. Sidebar preview line for an event (default: silent). | |
| 156 | | `unread` | | `(event) => boolean`. Whether a live event lights the thread's unread dot (default: no). | |
| 157 | | `snapshot` | | Set `false` to opt out of state snapshots (only for an app whose state IS the log). | |
| 158 | | `incremental` | | Set `true` to keep the surface between renders and reconcile it yourself (see `keyedList`). | |
| 159 | | `styles` | | A CSS string, injected once. Style with the theme variables (below). | |
| 160 | |
| 161 | `defineApp` returns the app object with `.types` (the owned event types) and |
| 162 | `.reduce` (the synthesised fold function) derived from `events` โ handy for |
| 163 | testing the reducer in Node. |
| 164 | |
| 165 | ## The events map & the reducer |
| 166 | |
| 167 | Each handler is `(state, event, meta) => newState`. Rules: |
| 168 | |
| 169 | - **Pure & immutable.** Return new state; never mutate `state`. Two devices |
| 170 | fold the same events and must converge; the UI also re-folds on reopen. |
| 171 | - **No `default` case.** The framework returns state unchanged for any type |
| 172 | your app doesn't handle. Your map lists only what you own. |
| 173 | - **Event types are globally unique.** Prefix every type with your app `name` |
| 174 | (`note.add`, `poll.vote`) โ or let `namespace()` do it (below). |
| 175 | - `meta` carries `{ id, ts }` where `id` is the server message id (used for |
| 176 | dedupe) and `ts` the sender's send time. You rarely need it. |
| 177 | |
| 178 | ### Namespacing โ declare the prefix once |
| 179 | |
| 180 | Your app prepends the same string onto its **event types** (`note.add`, joined |
| 181 | with `.`) and its **CSS classes** (`note-item`, joined with `-`). Write that |
| 182 | prefix by hand everywhere and it drifts. `namespace(name)` derives both from one |
| 183 | declaration: |
| 184 | |
| 185 | ```js |
| 186 | import { defineApp, namespace, addItem } from "../apps.js"; |
| 187 | |
| 188 | const N = namespace("note"); // one declaration |
| 189 | |
| 190 | export const app = defineApp({ |
| 191 | name: "note", |
| 192 | events: { |
| 193 | [N.ev("add")]: (s, e) => โฆ, // key is "note.add" |
| 194 | [N.ev("remove")]: (s, e) => โฆ, // key is "note.remove" |
| 195 | }, |
| 196 | render(state, el, ctx) { |
| 197 | const row = document.createElement("div"); |
| 198 | row.className = ctx.cls("item"); // "note-item" (ctx.cls === N.cls) |
| 199 | ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text }); // "note.add" |
| 200 | }, |
| 201 | }); |
| 202 | ``` |
| 203 | |
| 204 | - **`N.ev(short)`** โ `"note.short"` for the `events` keys (`[N.ev("add")]`), |
| 205 | `ctx.emit` types, and `e.type === โฆ` comparisons. |
| 206 | - **`N.cls(short)`** โ `"note-short"` for `className` / selectors. The `styles` |
| 207 | string stays a plain literal โ the helper keeps the JS references in step. |
| 208 | - Inside `render`/`commands` it's on `ctx` as **`ctx.ev`** / **`ctx.cls`**. |
| 209 | - It's **optional** (`namespace` defaults to `name`), and a name already |
| 210 | containing a `.` passes through untouched, so adopting it is a safe rename. |
| 211 | |
| 212 | ### Collection helpers |
| 213 | |
| 214 | Most apps fold into a list of `{ id, โฆ }` items. Import these from |
| 215 | `"../apps.js"` and your reducer is correct by construction โ every op returns |
| 216 | a **new array**, and `addItem` **dedupes by id**: |
| 217 | |
| 218 | ```js |
| 219 | addItem(list, item) // append unless the id is already present; always a fresh array |
| 220 | updateItem(list, id, patch) // replace the matching item with { ...item, ...patch } |
| 221 | removeItem(list, id) // drop the matching item |
| 222 | ``` |
| 223 | |
| 224 | Because `addItem` always returns a fresh array, you can `.sort()` its result |
| 225 | in place safely. Not every fold is a flat list โ a per-entity map is fine too, |
| 226 | as long as you update it immutably: |
| 227 | |
| 228 | ```js |
| 229 | "poll.vote": (s, e) => |
| 230 | ({ polls: s.polls.map((p) => (p.id === e.pollId ? { ...p, votes: { ...p.votes, [e.uid]: e.choice } } : p)) }), |
| 231 | ``` |
| 232 | |
| 233 | ## Emitting events โ and the three free fields |
| 234 | |
| 235 | You create events with `ctx.emit(event)`. The event needs a `type` and |
| 236 | whatever data your reducer reads. **Mint `ctx.id()` once for a new entity** |
| 237 | and reuse that id in follow-up events (done / remove / vote): |
| 238 | |
| 239 | ```js |
| 240 | ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text }); // "note.add" โ new entity โ new id |
| 241 | ctx.emit({ type: ctx.ev("remove"), id: item.id }); // "note.remove" โ follow-up โ existing id |
| 242 | ``` |
| 243 | |
| 244 | Three fields are **added for you** by the transport on every emit โ never set |
| 245 | them yourself, just read them in your reducer: |
| 246 | |
| 247 | | field | value | |
| 248 | | -------- | ----- | |
| 249 | | `e.name` | the sender's screen name | |
| 250 | | `e.ts` | the send time (epoch ms) โ use this for sorting, not `Date.now()` | |
| 251 | | `e.uid` | the sender's user id โ e.g. a poll keys votes by `e.uid` | |
| 252 | |
| 253 | Encryption is automatic: `emit` encrypts the event to the thread key and sends |
| 254 | it as a normal message. You never touch keys, and never send plaintext to the |
| 255 | server. |
| 256 | |
| 257 | ## `ctx` โ what a handler and a render get |
| 258 | |
| 259 | | member | in | what it does | |
| 260 | | ---------------------------- | ------------- | ------------ | |
| 261 | | `ctx.emit(event)` | anywhere | Emit a typed event (encrypted + sent). Returns `Promise<boolean>`. | |
| 262 | | `ctx.schedule(event, when)` | anywhere | Have the **server** deliver `event` into the thread at `when` (epoch ms). Returns `Promise<sid \| null>`. | |
| 263 | | `ctx.unschedule(sid)` | anywhere | Cancel a pending scheduled event. | |
| 264 | | `ctx.emitEphemeral(event)` | anywhere | Relay-only delivery โ never stored, never folded (see Ephemeral events). | |
| 265 | | `ctx.id()` | anywhere | A fresh unique id for a new entity. | |
| 266 | | `ctx.me` | anywhere | The current user's id. | |
| 267 | | `ctx.meName` | anywhere | The current user's screen name. | |
| 268 | | `ctx.getState()` | anywhere | The app's latest folded state for this thread. | |
| 269 | | `ctx.threadId` | anywhere | The thread the app is acting in. | |
| 270 | | `ctx.reply(spec)` | commands only | A private "Only visible to you" confirmation rendered where the user typed. `spec` is `{ text, short?, actions? }` โ `short` is the terse one-liner it calms down to; `actions` is `[{ label, run }]` chips. | |
| 271 | | `ctx.open()` | action chips | Navigate to the app's thread โ offer it as an action chip; **never** call it as a command's side effect. | |
| 272 | | `ctx.interval(fn, ms)` | render only | A ticker the framework stops for you on redraw / reset. | |
| 273 | | `ctx.onCleanup(fn)` | render only | Run `fn` before the next redraw or on thread reset. | |
| 274 | |
| 275 | (`ctx.host` also exists โ a narrow privileged bridge the host injects for |
| 276 | built-in connected-service apps. Leave it alone.) |
| 277 | |
| 278 | ### Commands answer in place |
| 279 | |
| 280 | Running a command produces a **reply in the current context** โ not a UI |
| 281 | change, not a navigation. On bad input, reply with a usage line: |
| 282 | |
| 283 | ```js |
| 284 | commands: { |
| 285 | "/note": { |
| 286 | desc: "File a note", |
| 287 | args: "<text>", |
| 288 | run: (args, ctx) => { |
| 289 | const text = args.trim(); |
| 290 | if (!text) return ctx.reply({ text: "Usage: /note <text> โ e.g. /note call the plumber" }); |
| 291 | ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text }); // "note.add" |
| 292 | ctx.reply({ text: `Filed โ${text}โ.`, short: `filed โ${text}โ`, actions: [{ label: "View notes", run: ctx.open }] }); |
| 293 | }, |
| 294 | }, |
| 295 | }, |
| 296 | ``` |
| 297 | |
| 298 | For a personal app, the event is routed into the user's own app-thread no |
| 299 | matter which chat they typed in; for a shared app it targets the current chat. |
| 300 | If a command should move the user somewhere, offer that as an **action chip** |
| 301 | on the reply โ leaving the current context is always the user's tap. |
| 302 | |
| 303 | ## Time โ scheduled events |
| 304 | |
| 305 | `ctx.schedule(event, whenMs)` is `ctx.emit` shifted into the future, executed |
| 306 | by the server: the event is encrypted **now**, the server holds the |
| 307 | ciphertext, and at the due moment it lands in the thread's message log and |
| 308 | fans out exactly like a posted message โ every device folds it, closed apps |
| 309 | get a push, open apps see it live. The server learns only *(thread, time)*, |
| 310 | never what was scheduled. |
| 311 | |
| 312 | ```js |
| 313 | // Schedule first โ the sid is part of the reminder's record. |
| 314 | const id = ctx.id(); |
| 315 | const sid = await ctx.schedule( |
| 316 | { type: ctx.ev("fire"), id, notice: { title: "โฐ Reminder", body: text } }, // "remind.fire" |
| 317 | whenMs, |
| 318 | ); |
| 319 | ctx.emit({ type: ctx.ev("add"), id, text, when: whenMs, sid: sid || "" }); // "remind.add" |
| 320 | ``` |
| 321 | |
| 322 | Rules of thumb: |
| 323 | |
| 324 | - **Store the returned `sid` inside an emitted event.** It's how *any* device |
| 325 | โ not just the one that scheduled โ can later `ctx.unschedule(sid)`. |
| 326 | - **Give a scheduled event a `notice`** (see Voice) โ it's usually the whole |
| 327 | point of scheduling one. |
| 328 | - **Handle `sid === null`** (offline / error): degrade gracefully. |
| 329 | - **The scheduled event is still just an event.** Its fold handler must be |
| 330 | idempotent like any other โ a cancel can race a delivery, so a fire folding |
| 331 | onto an already-removed id must be a no-op. |
| 332 | - Cancelling an id that already fired succeeds quietly; that race is expected. |
| 333 | |
| 334 | ## Memory โ snapshots (automatic) |
| 335 | |
| 336 | The client only holds a window of a thread's messages (~100), but folded app |
| 337 | state is **snapshotted automatically**: after new events fold, the framework |
| 338 | saves the state โ encrypted under the thread key โ to the server, and re-seeds |
| 339 | it when the thread opens, folding only newer events on top. |
| 340 | |
| 341 | You get this for free. It works *because* of the reducer rules: folds are pure |
| 342 | and deterministic, so `snapshot + newer events โก folding all of history`. What |
| 343 | it means for you: |
| 344 | |
| 345 | - **Don't bound your state by the window.** A 1,000-item list is fine. |
| 346 | - **State is durable, so treat it with respect**: never put anything in |
| 347 | reducer state you wouldn't put in an event (it's all encrypted, but it's |
| 348 | persisted). |
| 349 | - **Schema-evolve defensively.** A snapshot written by an old version of your |
| 350 | app will be seeded into a new version. Reducers and render must tolerate |
| 351 | missing fields (`e.note || ""` โ the same rule as for old events). |
| 352 | |
| 353 | ## Voice โ talking to the user |
| 354 | |
| 355 | Apps can speak the moment something happens, wherever the user is: |
| 356 | |
| 357 | 1. **`notice` on an event** โ the simple, usual way. Any event (typically a |
| 358 | scheduled one) may carry `notice: { title, body }`. When it arrives live, |
| 359 | the framework announces it in-app and raises a system notification if the |
| 360 | page is hidden; when the app is **closed**, the service worker decrypts the |
| 361 | push and shows the same notice. One field, every situation, end-to-end |
| 362 | encrypted. |
| 363 | |
| 364 | 2. **A `notify(event, ctx)` hook** โ for wording that depends on state. Called |
| 365 | only for **live** events (never during history refolds โ replaying the past |
| 366 | must not re-alert it). Return `{ title, body }` or `null` for silence: |
| 367 | |
| 368 | ```js |
| 369 | notify(e, ctx) { |
| 370 | if (e.type !== "poll.create" || e.uid === ctx.me) return null; |
| 371 | return { title: "๐ New poll", body: e.q }; |
| 372 | }, |
| 373 | ``` |
| 374 | |
| 375 | App events with neither are silent โ an add from your other device never |
| 376 | buzzes anyone. |
| 377 | |
| 378 | ## Ephemeral events โ presence without a trace |
| 379 | |
| 380 | `ctx.emitEphemeral(event)` relays an event to the thread's connected members |
| 381 | and **forgets it**: never stored, never pushed, never folded. It's for |
| 382 | presence traffic โ typing indicators, live cursors โ where persistence would |
| 383 | be wrong, not just wasteful. Receivers handle it in the app's |
| 384 | `ephemeral(event, ctx)` hook; senders get their own echo (check |
| 385 | `e.uid !== ctx.me`). |
| 386 | |
| 387 | Two rules: |
| 388 | |
| 389 | - **Own the type.** Routing is by event type, so an ephemeral type must appear |
| 390 | in your `events` map โ claim it with a no-op fold |
| 391 | (`"myapp.cursor": (s) => s`). That also makes it harmless if a hostile |
| 392 | client stores one as a real message. |
| 393 | - **Never touch reducer state from the hook.** Ephemeral events are allowed to |
| 394 | differ between devices; folding one would break convergence. Keep transient |
| 395 | UI in module-level state and redraw it yourself. |
| 396 | |
| 397 | ## Rendering a surface |
| 398 | |
| 399 | `render(state, el, ctx)` is called whenever state changes. The framework |
| 400 | clears `el` first, so **always rebuild from `state`** โ don't diff. |
| 401 | |
| 402 | - **Personal** apps render full-pane: `el` is the thread view. Usually an add |
| 403 | form on top, then the list. |
| 404 | - **Shared** apps render a panel above the chat feed. **Render nothing when |
| 405 | state is empty** (just `return`) so the panel collapses until there's |
| 406 | something to show. |
| 407 | - **Never `innerHTML` user content.** Build nodes with `createElement` and set |
| 408 | text with `textContent`. |
| 409 | - Ship your CSS in the `styles` field and read the theme's variables so the |
| 410 | app follows light/dark and the user's chosen theme. |
| 411 | |
| 412 | ### Theme CSS variables |
| 413 | |
| 414 | These custom properties are defined by every theme โ style with them instead |
| 415 | of hard-coded colours: |
| 416 | |
| 417 | | variable | what it is | |
| 418 | | -------- | ---------- | |
| 419 | | `--bg` | page background | |
| 420 | | `--panel` | panel / card background | |
| 421 | | `--sidebar` | sidebar background | |
| 422 | | `--ink` | primary text colour | |
| 423 | | `--muted` | secondary / de-emphasised text | |
| 424 | | `--border` | hairline borders and dividers | |
| 425 | | `--accent` | the theme's accent colour (buttons, highlights) | |
| 426 | | `--accent-ink` | text colour that sits on `--accent` | |
| 427 | | `--link` | link colour | |
| 428 | | `--bubble-me` | own-message bubble background | |
| 429 | | `--bubble-me-ink` | text colour on `--bubble-me` | |
| 430 | | `--bubble-them` | others' bubble background โ also the standard hover/selected tint | |
| 431 | | `--danger` | destructive-action colour | |
| 432 | | `--warn` | warning colour | |
| 433 | | `--radius` | the standard corner radius | |
| 434 | | `--shadow` | the standard shadow colour | |
| 435 | | `--font-display` | the theme's display font stack | |
| 436 | |
| 437 | For anything clickable, use `cursor: var(--ui-cursor, pointer)` so the app |
| 438 | respects the platform's cursor convention. |
| 439 | |
| 440 | ## Incremental surfaces โ `keyedList` |
| 441 | |
| 442 | By default the framework wipes and rebuilds an app's surface on every state |
| 443 | change โ the right default for small surfaces. For long lists set |
| 444 | `incremental: true` (the framework stops wiping) and reconcile with |
| 445 | `keyedList` from `"../apps.js"`: |
| 446 | |
| 447 | ```js |
| 448 | keyedList(listEl, state.items, { |
| 449 | key: (m) => m.id, // stable identity |
| 450 | rev: (m) => m.editedAt, // re-render an item only when this changes |
| 451 | render: (m) => buildRow(m) // build one item's DOM |
| 452 | }); |
| 453 | ``` |
| 454 | |
| 455 | Unchanged items keep their exact DOM nodes โ scroll position, focus, and |
| 456 | loaded images survive; appends build only the new item. The container must be |
| 457 | fully managed by `keyedList` (no other children). Build any once-only skeleton |
| 458 | (a form, headers) on first render by checking whether it already exists. |
| 459 | |
| 460 | ## Optional hooks |
| 461 | |
| 462 | ### `messageAction(msg)` |
| 463 | |
| 464 | Contribute an item to a chat message's long-press / hover menu. Given the |
| 465 | decrypted message `{ text, from }`, return `{ label, glyph, run(ctx) }` to |
| 466 | offer the action, or `null` to sit this message out. The `run` gets a `ctx` |
| 467 | already bound to your app-thread (for a personal app), so the action files |
| 468 | into *your* app: |
| 469 | |
| 470 | ```js |
| 471 | import { firstLink } from "../format.js"; |
| 472 | // โฆ |
| 473 | messageAction(msg) { |
| 474 | const url = firstLink(msg.text || ""); |
| 475 | if (!url) return null; |
| 476 | return { |
| 477 | label: "Save link", |
| 478 | glyph: "๐", |
| 479 | run(ctx) { |
| 480 | ctx.emit({ type: ctx.ev("add"), id: ctx.id(), url, from: msg.from || "" }); // "bookmark.add" |
| 481 | }, |
| 482 | }; |
| 483 | }, |
| 484 | ``` |
| 485 | |
| 486 | ### `renderEvent(event, meta)` |
| 487 | |
| 488 | Return a DOM node to show an inline line in the message feed for one of your |
| 489 | events (e.g. "Poll created"), or `null` to keep it silent. Most apps return |
| 490 | nothing and render only in their surface. |
| 491 | |
| 492 | ### `chatMessage(event)` โ companion presentation |
| 493 | |
| 494 | A personal app that implements `chatMessage` gets a **Chat view** alongside |
| 495 | its **Interface view**, toggled in the thread header. Project each event into |
| 496 | a chat line โ return `{ text }` for a bot bubble, `{ text, action: true }` for |
| 497 | a `/me`-style action line, or `null` to keep the event out of the chat: |
| 498 | |
| 499 | ```js |
| 500 | chatMessage(e) { |
| 501 | if (e.type === "todo.add") return { text: `added โ${e.text}โ`, action: true }; |
| 502 | return null; |
| 503 | }, |
| 504 | ``` |
| 505 | |
| 506 | Both views fold from the same event log โ one source of truth โ so an action |
| 507 | in one shows in the other. No `chatMessage` โ the app takes over the whole |
| 508 | thread surface (the default). |
| 509 | |
| 510 | ๐ค If your app's `places` include `"chat"` and it gets added to a GROUP, |
| 511 | its events can show up in THAT group's own chat feed too โ through |
| 512 | `chatMessage(event, { group: true })` by default, or the feed-only |
| 513 | `groupFeedMessage` hook โ see "Group-scoped apps" below for what changes |
| 514 | (attribution, the `opts.group` argument, and the takeover case). |
| 515 | |
| 516 | ### `preview(event)` / `unread(event)` |
| 517 | |
| 518 | How your events appear in the chat list: `preview` returns the sidebar |
| 519 | preview line (or `null` to leave the existing preview), `unread` says whether |
| 520 | a live event lights the thread's unread dot. Both default to silent โ opt in |
| 521 | only for events a user would want surfaced (a new poll: yes; every vote: no). |
| 522 | |
| 523 | ๐ค `preview` gets no ctx โ it's a pure `event => string`. If you want it to say |
| 524 | โYouโ for the reader's own event (rather than always naming the actor), import |
| 525 | `currentUserId` and compare it to `event.uid`: |
| 526 | |
| 527 | ```js |
| 528 | import { defineApp, currentUserId } from "../apps.js"; |
| 529 | preview(e) { |
| 530 | const who = e.uid && e.uid === currentUserId() ? "You" : e.name || "Someone"; |
| 531 | return `${who} added โ${e.text}โ`; |
| 532 | } |
| 533 | ``` |
| 534 | |
| 535 | ## Group-scoped apps: personal AND shared with a group |
| 536 | |
| 537 | ๐ค An app whose `places` include both `"you"` and `"chat"` can, IN ADDITION |
| 538 | to everyone's own private instance, be **added to a specific group chat** |
| 539 | (members do this from that chat's โฎ menu โ Apps โ no code of yours makes |
| 540 | that happen, it's host UI). Once added, **one shared instance runs for that |
| 541 | whole group**: every member's `ctx.emit` posts into the GROUP's own encrypted |
| 542 | event log instead of their personal thread, and every member folds the same |
| 543 | state. Nothing about your `render`, `events`, or `commands` changes โ `ctx` is |
| 544 | just bound to whichever thread it's acting in, so the exact same code renders |
| 545 | a to-do list whether it's yours alone or the group's shared one. |
| 546 | |
| 547 | What changes once your app can be group-instanced: |
| 548 | |
| 549 | - **Any group member's event is now a first-class input**, not just yours. |
| 550 | Hard constraint #11 ("validate in the reducer, never trust the emitter") is |
| 551 | no longer a defensive nicety โ a malformed or adversarial event from ANY |
| 552 | member of the group must fold to a no-op, because it can now come from |
| 553 | someone other than you. |
| 554 | - **`chatMessage` action lines get attributed automatically.** When a group |
| 555 | has your app added, an `{ text, action: true }` projection is shown prefixed |
| 556 | with the actor's name, or **โYouโ** when the reader is the one who acted โ |
| 557 | both entirely automatic. Write `text` as if it always follows a name |
| 558 | (`added โbuy milkโ`, never `Ciara added โbuy milkโ`) โ the host supplies |
| 559 | the name/โYouโ, so baking one in yourself produces a doubled-up line. |
| 560 | - **`chatMessage` receives a second argument in a group: `chatMessage(event, |
| 561 | { group: true })`.** Use it only if wording needs to change for many |
| 562 | simultaneous readers (e.g. a reminder's `"you asked me"` phrasing only makes |
| 563 | sense one-to-one, so a group projection drops it). Ignore the second |
| 564 | argument if your wording doesn't depend on it. |
| 565 | - **`groupFeedMessage(event)` splits "projects into a group's feed" from |
| 566 | "wants its own Chat view."** Implementing `chatMessage` makes your app a |
| 567 | companion (Chat view + toggle on its personal thread) โ always. If your |
| 568 | personal surface shouldn't grow a second (redundant, or privacy-sensitive) |
| 569 | view of its own events, but a group that shares an instance should still |
| 570 | notice activity without opening the app, implement `groupFeedMessage` |
| 571 | INSTEAD of `chatMessage`: the host calls it only when folding a group's |
| 572 | own feed, so the personal thread stays a pure takeover while the group |
| 573 | gets an attributed line โ e.g. a shared secrets vault that announces |
| 574 | "Ciara added a secret" without ever listing secret names in a Chat view. |
| 575 | Implementing BOTH gives a companion app group-specific wording (and a |
| 576 | `null` from `groupFeedMessage` keeps it silent in groups); with only |
| 577 | `chatMessage`, the group fold falls back to it with `{ group: true }`. |
| 578 | - **`preview`/`unread` work exactly the same** for a group instance as a |
| 579 | personal one โ no group-specific argument, just whatever `event.name` / |
| 580 | `event.uid` the event already carries. |
| 581 | |
| 582 | ## Allowed imports |
| 583 | |
| 584 | Import **only** from these two modules, and only these exports. |
| 585 | |
| 586 | From `"../apps.js"`: |
| 587 | |
| 588 | | export | what it is | |
| 589 | | ------ | ---------- | |
| 590 | | `defineApp(spec)` | registers the app โ call exactly once | |
| 591 | | `namespace(name)` | one-declaration prefix helper: `N.ev("add")` โ `"name.add"` (event type), `N.cls("row")` โ `"name-row"` (CSS class) โ see Namespacing | |
| 592 | | `addItem(list, item)` | immutable append, deduped by `id` | |
| 593 | | `updateItem(list, id, patch)` | immutable in-place patch | |
| 594 | | `removeItem(list, id)` | immutable removal | |
| 595 | | `partitionByGrace(items, doneAtOf, now, graceMs?)` | split items into `{ active, grace, archived }` by a completion timestamp โ for "linger then tuck away" UI | |
| 596 | | `DONE_GRACE_MS` | the standard grace-period length (ms) | |
| 597 | | `keyedList(el, items, opts)` | incremental DOM reconciliation for `incremental: true` surfaces | |
| 598 | | `currentUserId()` | the reader's own user id โ for a "You" check in `preview` (see above) or `render` | |
| 599 | |
| 600 | (`apps.js` exports other functions โ `setEmitTransport`, `foldEvent`, |
| 601 | `runCommand`, โฆ โ those are host/framework wiring. An app must not call them.) |
| 602 | |
| 603 | From `"../format.js"` (all pure text helpers): |
| 604 | |
| 605 | | export | what it is | |
| 606 | | ------ | ---------- | |
| 607 | | `tokenizeText(text)` | split text into `{ text }` / `{ text, href }` tokens โ XSS-safe linkifying | |
| 608 | | `richTokens(text)` | tokens with inline formatting too: `{ text, mark }`, mark โ `b`/`i`/`s`/`code` | |
| 609 | | `blockTokens(text)` | block structure: `{ type: "p"\|"code"\|"quote", text }` | |
| 610 | | `firstLink(text)` | the first http(s) link in a message, skipping code blocks | |
| 611 | | `youtubeId(url)` | parse a YouTube URL into `{ id, start }`, or `null` | |
| 612 | | `replySnippet(payload, max?)` | short plain-text preview of a message | |
| 613 | | `emojiOnlyCount(text)` | how many emoji, if the text is emoji-only (else 0) | |
| 614 | | `typingSummary(names)` | "X is typingโฆ" one-liner | |
| 615 | | `autoGroupTitle(names)` | member names joined into a group title | |
| 616 | | `formatBytes(n)` | human-readable file size | |
| 617 | |
| 618 | Render tokens as DOM nodes (`document.createTextNode` for plain text, an |
| 619 | element per marked token) โ that's what keeps them XSS-safe. |
| 620 | |
| 621 | ## A complete app |
| 622 | |
| 623 | A real, working app โ copy it, rename it, and change the guts. It's a personal |
| 624 | "Notes" list: `/note <text>` from any chat files a note; the app's own sidebar |
| 625 | pane lists them. |
| 626 | |
| 627 | ```js |
| 628 | // note.js |
| 629 | import { defineApp, namespace, addItem, removeItem } from "../apps.js"; |
| 630 | |
| 631 | // One declaration of the app's prefix โ N.ev("add") โ "note.add" (event type), |
| 632 | // N.cls("item") โ "note-item" (CSS class). See Namespacing above. |
| 633 | const N = namespace("note"); |
| 634 | |
| 635 | export const app = defineApp({ |
| 636 | name: "note", // unique id, lowercase โ also the default namespace |
| 637 | title: "Notes", // shown in the sidebar and header |
| 638 | glyph: "๐", // avatar glyph |
| 639 | places: ["you"], // its own sidebar thread, rendered full-pane |
| 640 | |
| 641 | // The empty state. A factory (not a value) so every thread gets its own. |
| 642 | initialState: () => ({ items: [] }), |
| 643 | |
| 644 | // The events map: KEYS are the event types this app owns, VALUES fold each |
| 645 | // event into new state. Handlers must be PURE: return new state, never |
| 646 | // mutate the old. The collection helpers keep adds idempotent. |
| 647 | events: { |
| 648 | [N.ev("add")]: (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text, by: e.name || "" }) } : s), |
| 649 | [N.ev("remove")]: (s, e) => ({ items: removeItem(s.items, e.id) }), |
| 650 | }, |
| 651 | |
| 652 | // A slash command a user can type in any chat. It emits an event; because |
| 653 | // this is a personal app, the event is routed into the user's own Notes |
| 654 | // thread wherever they typed it, with a private confirmation reply. |
| 655 | commands: { |
| 656 | "/note": { |
| 657 | desc: "File a note", |
| 658 | args: "<text>", |
| 659 | run: (args, ctx) => { |
| 660 | const text = args.trim(); |
| 661 | if (!text) return ctx.reply({ text: "Usage: /note <text> โ e.g. /note call the plumber" }); |
| 662 | ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text }); |
| 663 | ctx.reply({ text: `Filed โ${text}โ.`, short: `filed โ${text}โ` }); |
| 664 | }, |
| 665 | }, |
| 666 | }, |
| 667 | |
| 668 | // The app's CSS travels with it โ injected once by the framework. Read the |
| 669 | // theme's CSS variables so it follows the theme. The stylesheet stays a plain |
| 670 | // literal; ctx.cls() below keeps the JS references in step with it. |
| 671 | styles: ` |
| 672 | .note-item { display: flex; gap: 8px; padding: 6px 2px; border-bottom: 1px solid var(--border); } |
| 673 | .note-text { flex: 1 1 auto; overflow-wrap: anywhere; } |
| 674 | `, |
| 675 | |
| 676 | // Personal place: `el` is the whole thread pane. Build DOM nodes and set |
| 677 | // text with textContent โ NEVER innerHTML for anything a user typed. |
| 678 | render(state, el, ctx) { |
| 679 | for (const item of (state && state.items) || []) { |
| 680 | const row = document.createElement("div"); |
| 681 | row.className = ctx.cls("item"); // "note-item" |
| 682 | const text = document.createElement("span"); |
| 683 | text.className = ctx.cls("text"); // "note-text" |
| 684 | text.textContent = item.text; // user content โ textContent, never innerHTML |
| 685 | const rm = document.createElement("button"); |
| 686 | rm.textContent = "ร"; |
| 687 | rm.onclick = () => ctx.emit({ type: ctx.ev("remove"), id: item.id }); |
| 688 | row.append(text, rm); |
| 689 | el.appendChild(row); |
| 690 | } |
| 691 | }, |
| 692 | }); |
| 693 | ``` |
| 694 | |
| 695 | ## Invariants (the things that fail silently if you skip them) |
| 696 | |
| 697 | 1. **Idempotent fold.** The message window is re-folded from scratch on every |
| 698 | thread reopen, so folding the same add twice must produce one item. |
| 699 | `addItem` handles this by deduping on `id`; a custom fold must dedupe too. |
| 700 | 2. **Pure, immutable reducers.** Never mutate prior state. The collection |
| 701 | helpers return fresh arrays; if you go custom, spread (`{ ...obj }`, |
| 702 | `[...arr]`). This is also what makes snapshots correct. |
| 703 | 3. **Don't set `name` / `ts` / `uid` on emit** โ the transport injects them. |
| 704 | Setting `ts: Date.now()` is dead code (the transport overwrites it). |
| 705 | 4. **Namespace event types** with your app `name`; they're globally unique. |
| 706 | 5. **Never `innerHTML` user content** โ `textContent` / DOM nodes only. |
| 707 | 6. **Shared apps render nothing when empty** so their panel collapses. |
| 708 | 7. **You never touch the server or keys.** State persistence, scheduling, and |
| 709 | notifications all flow through `ctx` โ if you find yourself reaching for |
| 710 | `fetch` or a key, you've taken a wrong turn. |
| 711 | 8. **Validate events in the reducer, not at the emitter.** Anything a thread |
| 712 | member (person, other device, bot) can send is a legal input to your fold โ |
| 713 | malformed or hostile events must fold to a no-op, never a crash or a lie. |
| 714 | 9. **A scheduled event will arrive later, into different state.** Fold |
| 715 | handlers for scheduled types must tolerate their target being gone |
| 716 | (dismissed while the delivery was in flight). |
| 717 | |
| 718 | ## Delivering the app |
| 719 | |
| 720 | Produce **one file**, named after the app (`note.js`, `poll.js`, โฆ), passing |
| 721 | every hard constraint above. Then hand it to the person who runs the instance: |
| 722 | custom apps are installed by the **instance admin** (from the admin console's |
| 723 | Apps section, where available) โ you don't deploy anything yourself, and the |
| 724 | file needs no changes to be installed. |
| 725 | |
| 726 | Before delivering, self-review against the hard-constraints list and the |
| 727 | invariants. The instance also serves a static checker that verifies the |
| 728 | mechanical rules (one `defineApp`, allowed imports only, no forbidden APIs, |
| 729 | event-type prefixes, โฆ). Download it from the same server this guide came |
| 730 | from and run it with Node โ it has zero dependencies: |
| 731 | |
| 732 | ```sh |
| 733 | curl -sO https://<instance>/api/check-app.mjs |
| 734 | node check-app.mjs your-app.js |
| 735 | ``` |
| 736 | |
| 737 | and iterate until it passes. (If you happen to have the lchat repository |
| 738 | instead, the same file is `tools/check-app.mjs`.) |
| 739 | |