eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1# ๐Ÿค– Building a mini-app โ€” a self-contained authoring guide
2
3This 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
5program that lives alongside chats โ€” a to-do list, a poll, a tracker, a game.
6It is fully self-contained: you do not need the server's source code, a
7repository checkout, or any build tooling. Every instance serves this document
8at `GET /api/app-guide`.
9
10You will produce **exactly one JavaScript file** โ€” a plain ES module โ€” that the
11instance admin installs. Everything the app is (logic, rendering, CSS) ships in
12that one file.
13
14## The one idea
15
16An app has no server. It trades **typed events** over the same end-to-end
17encrypted message stream that chat uses, and derives its state by **folding**
18those events. The server only ever sees ciphertext, so it can't run or even
19recognise 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
25An app is four small things:
26
271. an **events map** โ€” the event types it owns and how each folds into state,
282. an **initialState** โ€” the empty state to start from,
293. **commands** and/or a **message action** โ€” how a user creates events,
304. a **surface** โ€” how state renders (a full pane, or a panel above a chat).
31
32On 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
48An app that breaks any of these will be rejected or will misbehave. Check your
49finished file against each rule:
50
511. **Exactly one file.** All logic, rendering, and CSS live in a single
52 `<name>.js` โ€” no companion files, no assets, no build step.
532. **A plain ES module.** No bundler output, no TypeScript, no JSX, no
54 `require()` โ€” just standard `import`/`export` syntax a browser runs as-is.
553. **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.
594. **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.
625. **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.
666. **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.
697. **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.
728. **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.
759. **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).
7810. **Never set `name`, `ts`, or `uid` on an emitted event** โ€” the transport
79 injects them.
8011. **Validate events in the reducer, not at the emitter.** Malformed or
81 hostile events must fold to a no-op, never a crash.
8212. **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
87Before writing any code, ask the person who wants the app:
88
891. **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.)
1072. **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)?
1123. **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.)
1154. **Anything time-based?** Should something happen *later* โ€” a reminder, an
116 expiry, a recurring nudge? That's `ctx.schedule`.
1175. **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.
1206. **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`.
1257. **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
130Your file calls `defineApp` once, at module load, and exports the result:
131
132```js
133import { defineApp } from "../apps.js";
134export 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
163testing the reducer in Node.
164
165## The events map & the reducer
166
167Each 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
180Your app prepends the same string onto its **event types** (`note.add`, joined
181with `.`) and its **CSS classes** (`note-item`, joined with `-`). Write that
182prefix by hand everywhere and it drifts. `namespace(name)` derives both from one
183declaration:
184
185```js
186import { defineApp, namespace, addItem } from "../apps.js";
187
188const N = namespace("note"); // one declaration
189
190export 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
214Most apps fold into a list of `{ id, โ€ฆ }` items. Import these from
215`"../apps.js"` and your reducer is correct by construction โ€” every op returns
216a **new array**, and `addItem` **dedupes by id**:
217
218```js
219addItem(list, item) // append unless the id is already present; always a fresh array
220updateItem(list, id, patch) // replace the matching item with { ...item, ...patch }
221removeItem(list, id) // drop the matching item
222```
223
224Because `addItem` always returns a fresh array, you can `.sort()` its result
225in place safely. Not every fold is a flat list โ€” a per-entity map is fine too,
226as 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
235You create events with `ctx.emit(event)`. The event needs a `type` and
236whatever data your reducer reads. **Mint `ctx.id()` once for a new entity**
237and reuse that id in follow-up events (done / remove / vote):
238
239```js
240ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text }); // "note.add" โ€” new entity โ†’ new id
241ctx.emit({ type: ctx.ev("remove"), id: item.id }); // "note.remove" โ€” follow-up โ†’ existing id
242```
243
244Three fields are **added for you** by the transport on every emit โ€” never set
245them 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
253Encryption is automatic: `emit` encrypts the event to the thread key and sends
254it as a normal message. You never touch keys, and never send plaintext to the
255server.
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
276built-in connected-service apps. Leave it alone.)
277
278### Commands answer in place
279
280Running a command produces a **reply in the current context** โ€” not a UI
281change, not a navigation. On bad input, reply with a usage line:
282
283```js
284commands: {
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
298For a personal app, the event is routed into the user's own app-thread no
299matter which chat they typed in; for a shared app it targets the current chat.
300If a command should move the user somewhere, offer that as an **action chip**
301on 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
306by the server: the event is encrypted **now**, the server holds the
307ciphertext, and at the due moment it lands in the thread's message log and
308fans out exactly like a posted message โ€” every device folds it, closed apps
309get a push, open apps see it live. The server learns only *(thread, time)*,
310never what was scheduled.
311
312```js
313// Schedule first โ€” the sid is part of the reminder's record.
314const id = ctx.id();
315const sid = await ctx.schedule(
316 { type: ctx.ev("fire"), id, notice: { title: "โฐ Reminder", body: text } }, // "remind.fire"
317 whenMs,
318);
319ctx.emit({ type: ctx.ev("add"), id, text, when: whenMs, sid: sid || "" }); // "remind.add"
320```
321
322Rules 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
336The client only holds a window of a thread's messages (~100), but folded app
337state is **snapshotted automatically**: after new events fold, the framework
338saves the state โ€” encrypted under the thread key โ€” to the server, and re-seeds
339it when the thread opens, folding only newer events on top.
340
341You get this for free. It works *because* of the reducer rules: folds are pure
342and deterministic, so `snapshot + newer events โ‰ก folding all of history`. What
343it 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
355Apps can speak the moment something happens, wherever the user is:
356
3571. **`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
3642. **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
375App events with neither are silent โ€” an add from your other device never
376buzzes anyone.
377
378## Ephemeral events โ€” presence without a trace
379
380`ctx.emitEphemeral(event)` relays an event to the thread's connected members
381and **forgets it**: never stored, never pushed, never folded. It's for
382presence traffic โ€” typing indicators, live cursors โ€” where persistence would
383be 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
387Two 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
400clears `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
414These custom properties are defined by every theme โ€” style with them instead
415of 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
437For anything clickable, use `cursor: var(--ui-cursor, pointer)` so the app
438respects the platform's cursor convention.
439
440## Incremental surfaces โ€” `keyedList`
441
442By default the framework wipes and rebuilds an app's surface on every state
443change โ€” 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
448keyedList(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
455Unchanged items keep their exact DOM nodes โ€” scroll position, focus, and
456loaded images survive; appends build only the new item. The container must be
457fully 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
464Contribute an item to a chat message's long-press / hover menu. Given the
465decrypted message `{ text, from }`, return `{ label, glyph, run(ctx) }` to
466offer the action, or `null` to sit this message out. The `run` gets a `ctx`
467already bound to your app-thread (for a personal app), so the action files
468into *your* app:
469
470```js
471import { firstLink } from "../format.js";
472// โ€ฆ
473messageAction(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
488Return a DOM node to show an inline line in the message feed for one of your
489events (e.g. "Poll created"), or `null` to keep it silent. Most apps return
490nothing and render only in their surface.
491
492### `chatMessage(event)` โ€” companion presentation
493
494A personal app that implements `chatMessage` gets a **Chat view** alongside
495its **Interface view**, toggled in the thread header. Project each event into
496a chat line โ€” return `{ text }` for a bot bubble, `{ text, action: true }` for
497a `/me`-style action line, or `null` to keep the event out of the chat:
498
499```js
500chatMessage(e) {
501 if (e.type === "todo.add") return { text: `added โ€œ${e.text}โ€`, action: true };
502 return null;
503},
504```
505
506Both views fold from the same event log โ€” one source of truth โ€” so an action
507in one shows in the other. No `chatMessage` โ†’ the app takes over the whole
508thread surface (the default).
509
510๐Ÿค– If your app's `places` include `"chat"` and it gets added to a GROUP,
511its 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
518How your events appear in the chat list: `preview` returns the sidebar
519preview line (or `null` to leave the existing preview), `unread` says whether
520a live event lights the thread's unread dot. Both default to silent โ€” opt in
521only 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
528import { defineApp, currentUserId } from "../apps.js";
529preview(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
538to 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
540that happen, it's host UI). Once added, **one shared instance runs for that
541whole group**: every member's `ctx.emit` posts into the GROUP's own encrypted
542event log instead of their personal thread, and every member folds the same
543state. Nothing about your `render`, `events`, or `commands` changes โ€” `ctx` is
544just bound to whichever thread it's acting in, so the exact same code renders
545a to-do list whether it's yours alone or the group's shared one.
546
547What 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
584Import **only** from these two modules, and only these exports.
585
586From `"../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
603From `"../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
618Render tokens as DOM nodes (`document.createTextNode` for plain text, an
619element per marked token) โ€” that's what keeps them XSS-safe.
620
621## A complete app
622
623A 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
625pane lists them.
626
627```js
628// note.js
629import { 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.
633const N = namespace("note");
634
635export 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
6971. **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.
7002. **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.
7033. **Don't set `name` / `ts` / `uid` on emit** โ€” the transport injects them.
704 Setting `ts: Date.now()` is dead code (the transport overwrites it).
7054. **Namespace event types** with your app `name`; they're globally unique.
7065. **Never `innerHTML` user content** โ€” `textContent` / DOM nodes only.
7076. **Shared apps render nothing when empty** so their panel collapses.
7087. **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.
7118. **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.
7149. **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
720Produce **one file**, named after the app (`note.js`, `poll.js`, โ€ฆ), passing
721every hard constraint above. Then hand it to the person who runs the instance:
722custom apps are installed by the **instance admin** (from the admin console's
723Apps section, where available) โ€” you don't deploy anything yourself, and the
724file needs no changes to be installed.
725
726Before delivering, self-review against the hard-constraints list and the
727invariants. The instance also serves a static checker that verifies the
728mechanical rules (one `defineApp`, allowed imports only, no forbidden APIs,
729event-type prefixes, โ€ฆ). Download it from the same server this guide came
730from and run it with Node โ€” it has zero dependencies:
731
732```sh
733curl -sO https://<instance>/api/check-app.mjs
734node check-app.mjs your-app.js
735```
736
737and iterate until it passes. (If you happen to have the lchat repository
738instead, the same file is `tools/check-app.mjs`.)
739