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 member app โ€” the sandboxed authoring guide
2
3This guide teaches you (an LLM asked to build an app, or a person) how to
4write a **member app** for an Eleven Messenger / lchat instance: a small
5personal tool โ€” a tracker, a list, a counter, a tiny game โ€” that a member
6asked for in chat. It is served at `GET /api/member-app-guide`.
7
8Member apps are the self-serve tier of the mini-app platform. Unlike the
9admin-installed apps (see `/api/app-guide`), a member app is **user content**:
10it is stored end-to-end encrypted and it runs inside an **isolated sandbox
11frame** โ€” its own document, with no network, no storage, and no access to the
12rest of the app. Write within the rules below and your app behaves exactly
13like a built-in one; step outside them and the sandbox simply refuses.
14
15You will produce **exactly one JavaScript file** โ€” a plain ES module. All
16logic, rendering, and CSS ship in that one file.
17
18## The one idea
19
20An app has no server. It trades **typed events** over the member's end-to-end
21encrypted personal thread and derives its state by **folding** those events.
22
23> **The encrypted thread log IS the app's database.** You never store state;
24> you emit events and fold them back into state. Two devices that fold the
25> same events arrive at the same state by construction.
26
27An app is three small things:
28
291. an **events map** โ€” the event types it owns and how each folds into state,
302. an **initialState** โ€” the empty state to start from,
313. a **render** โ€” how state draws, full-pane, with its own input UI.
32
33You also get, for free:
34
35- **Time** โ€” `ctx.schedule(event, when)` has the *server* deliver an event
36 into the thread later (the server holds only ciphertext and a timestamp).
37- **Memory** โ€” folded state is snapshotted (encrypted) and re-seeded on open,
38 so 1,000 items work as well as 10. Apps do nothing to opt in.
39- **Voice** โ€” an event may carry a `notice: { title, body }`; when it arrives
40 it alerts the member (in-app, system notification, or push), wording
41 end-to-end encrypted.
42
43## What a member app can and can't use
44
45The sandbox now supports **slash commands** (with a **`defaultCommand`** so plain
46text captures), a **companion Chat view**, the **`places`** declaration for
47sharing into chats or the whole space (see Group-capable apps and
48Space-capable apps), and the pure **`../format.js`** text helpers, in addition
49to the surface + events core.
50These fields still do **nothing** in a member app โ€” do not emit them:
51
52- `messageAction`, `renderEvent`, `preview`, `unread`, `notify`, `hostEvents`,
53 `requiresService`, `chatOnly`, `groupFeedMessage` โ€” host-side hooks (member
54 apps' events stay silent in a group's own feed today; the frame projects
55 into the app's Chat view only).
56- `scope`, `groupCapable`, `spaceCapable` โ€” the LEGACY placement vocabulary.
57 Already-deployed apps that declare them keep working (they normalize into
58 `places`), but new apps declare `places` and nothing else.
59- `ctx.open`, `ctx.host` โ€” absent in the sandbox. (`ctx.reply` **is**
60 available inside a command handler; see Commands.)
61
62## Hard constraints โ€” self-check every one before delivering
63
641. **Exactly one file**, a **plain ES module** โ€” no TypeScript, no JSX, no
65 `require()`, no build step.
662. **Imports are allowed ONLY from `"../apps.js"` and `"../format.js"`.**
67 From `../apps.js`: `defineApp`, `addItem`, `updateItem`,
68 `removeItem`, `partitionByGrace`, `DONE_GRACE_MS`, `keyedList`. From
69 `../format.js` (pure
70 text helpers): `tokenizeText`, `richTokens`, `blockTokens`, `firstLink`,
71 `youtubeId`, `replySnippet`, `emojiOnlyCount`, `typingSummary`,
72 `autoGroupTitle`, `formatBytes`. Nothing else โ€” no other paths, no URLs, no
73 dynamic `import()`.
743. **No network calls** โ€” no `fetch`, `XMLHttpRequest`, `WebSocket`,
75 `EventSource`, `sendBeacon`. (The sandbox blocks them; an app that needs
76 them is broken by design.) All I/O flows through `ctx`.
774. **No browser storage** โ€” no `localStorage`, `indexedDB`, `document.cookie`.
78 Durable state lives in the fold; transient UI state (a collapsed toggle)
79 lives in module-level variables.
805. **No `window.parent`, `window.top`, or `postMessage`** โ€” the bridge is the
81 runtime's business, never the app's.
826. **Never `innerHTML` user content.** Build DOM with `createElement` and set
83 text with `textContent`. `innerHTML` only ever for static in-code strings.
847. **Use `ctx.interval(fn, ms)`, never bare `setInterval`, inside `render`.**
858. **Every event type is a plain string prefixed with the app's `name`.**
86 `name: "habit"` โ†’ write the types out in full: `"habit.add"`, `"habit.tick"`.
87 Use that same quoted string as the `events` map key, in every `ctx.emit`, and
88 in every `e.type === "habit.add"` comparison. Lowercase; only letters, digits,
89 `_`, `-` and `.` after the prefix. (Keep it simple โ€” write the literal string;
90 don't reach for a helper to build it.)
919. **Reducers are pure, immutable, and idempotent by entity id.** Folding the
92 same add twice must produce one item (the collection helpers guarantee it).
9310. **Never carry app data in `name`, `ts`, or `uid`** โ€” the transport
94 **overwrites** these three with the sender's screen name, the send time,
95 and the sender's user id (every event carries them, exactly like a chat
96 message's author). Read them in your reducer (`e.ts` for times, never
97 `Date.now()` in a fold); never set them. *Symptom if you ignore this:* an
98 event that put its payload in `name` folds back with your **own screen
99 name** in it โ€” e.g. `{ type:"habit.add", name:"drink water" }` comes back as
100 `name:"James"`, so every habit is named after you. Put app data in a field
101 you own โ€” `text`, `label`, `title`, `habit` โ€” never `name`.
10211. **Validate events in the reducer, not at the emitter.** Malformed events
103 fold to a no-op, never a crash.
10412. **You never touch the server, keys, or tokens.**
10513. **Interact only through `ctx.emit` โ€” never the browser's own actions.** Wire
106 *every* control to an emit: a `<button>`'s `onclick`, or a `<form>`'s
107 `onsubmit` (which must call `ctx.emit(...)`). An `<a href>` navigation does
108 **nothing** here โ€” the sandbox has no navigation, so an unwired control is
109 dead. A `<form>` **works**: native submission has nowhere to go (there's no
110 `allow-forms`), so the sandbox runtime intercepts the submit โ€” a submit
111 button's click, or Enter in a field โ€” and re-dispatches it as a scripted
112 `submit`, so your `onsubmit` runs, nothing navigates, and there's no console
113 error. Put the work in `onsubmit` and `ctx.emit(...)` from there. (You never
114 need `preventDefault` for that reason โ€” though it's harmless.)
115
116## The shape of an app: `defineApp(spec)`
117
118```js
119import { defineApp, addItem, removeItem } from "../apps.js";
120export const app = defineApp({ /* fields below */ });
121```
122
123| field | required | what it is |
124| -------------- | -------- | ---------- |
125| `name` | โœ“ | Unique lowercase id (letters, digits, dashes; starts with a letter; โ‰ค 24 chars). Also the prefix for your event types (e.g. `"habit.add"`). |
126| `title` | โœ“ | Human name shown in the Apps list (โ‰ค 40 chars). |
127| `glyph` | โœ“ | One emoji used as the app's avatar. |
128| `initialState` | โœ“ | `() => state`. A **factory**, called to seed the empty state. |
129| `events` | โœ“ | `{ "<type>": (state, event, meta) => state }`. The fold. |
130| `render` | โœ“ | `(state, el, ctx) => void`. Draws the whole surface. |
131| `commands` | | `{ "/cmd": (args, ctx) => void }` or `{ "/cmd": { run, desc, args } }`. `args` passed to the handler is the **raw text string** (not a parsed object); the object form's `desc`/`args` are typeahead labels. See Commands. |
132| `defaultCommand` | | The name of one of your `commands` (e.g. `"/add"`). In the companion Chat view, plain (non-slash) text runs it โ€” so typing "buy milk" adds an item, just like To-do. Requires that command; omit if not wanted. |
133| `chatMessage` | | `(event) => { text, action? } \| string \| null`. Opting in makes the app a **companion** (Chat + Interface views); see Companion chat. |
134| `places` | | WHERE copies of this app can live โ€” any NON-EMPTY subset of `["you","chat","space"]`. Defaults to `["you"]` (your own private thread). Add `"chat"` to let members add it to a group chat, one shared instance for everyone (see Group-capable apps); add `"space"` to let a member install it FOR THE WHOLE SPACE (`/cmd @everyone`), one shared instance usable from every chat (see Space-capable apps). `"you"` is NOT required โ€” `places: ["space"]` builds a gif-style space-only app. |
135| `listed` | | `false` to keep the app out of the Apps catalogues โ€” with `places: ["space"]` this is the IMPLICITLY PLACED shape: the one shared copy is used on first command, no add gesture (see "One shared setting for the whole space"). |
136| `quiet` | | `true` if a command answering in place (a reply, a panel, or an event it emits) must never itself force your app's personal thread into view โ€” it still exists and still receives every event, it just won't pop into the sidebar on its own. The host already adds an "Open `<title>`" chip to every command reply, so the member can still jump there with their own tap. |
137| `styles` | | A CSS string, injected once. Use the theme variables below. |
138| `incremental` | | `true` to keep the surface between renders and reconcile with `keyedList`. |
139| `ephemeral` | | `(event, ctx) => void`. Relay-only events (live presence), rarely needed. |
140
141`state` starts `undefined` and is seeded from `initialState()` on the first
142fold โ€” `render` must also tolerate `undefined` state (first open, before any
143event): `const items = (state && state.items) || [];`
144
145## The events map & the reducer
146
147Each handler is `(state, event, meta) => newState`:
148
149- **Pure & immutable.** Return new state; never mutate. Two devices fold the
150 same events and must converge.
151- **No `default` case** โ€” unknown types fall through unchanged automatically.
152- `meta` carries `{ id, ts }` (server message id + send time). Rarely needed.
153
154The collection helpers keep list reducers correct by construction:
155
156```js
157addItem(list, item) // append unless the id is already present; always a fresh array
158updateItem(list, id, patch) // replace the matching item with { ...item, ...patch }
159removeItem(list, id) // drop the matching item
160```
161
162### Event types โ€” plain, name-prefixed strings
163
164Every event type is a quoted string that starts with your app's `name` and a
165`.`. Write it out in full everywhere it appears โ€” the `events` map key, the
166`ctx.emit` type, and any `e.type ===` comparison โ€” so the same literal string
167is used throughout:
168
169```js
170name: "habit",
171events: {
172 "habit.add": (s, e) => โ€ฆ,
173 "habit.remove": (s, e) => โ€ฆ,
174},
175```
176
177That's the whole rule: one app `name`, and event types are `"<name>.<thing>"`
178literals. CSS classes follow the same idea by hand โ€” `"habit-row"` in both the
179`styles` blob and the `className` you set.
180
181## Emitting events
182
183`ctx.emit(event)` โ€” the event needs a `type` (the full `"<name>.โ€ฆ"` string) and
184whatever your reducer reads. **Mint `ctx.id()` once for a new entity** and reuse
185that id in follow-ups:
186
187```js
188ctx.emit({ type: "habit.add", id: ctx.id(), text });
189ctx.emit({ type: "habit.remove", id: item.id });
190```
191
192A form is a common input; wire its `onsubmit` (native submission does nothing
193in the sandbox โ€” see hard constraint 13):
194
195```js
196const form = document.createElement("form");
197const input = document.createElement("input");
198form.appendChild(input);
199form.onsubmit = (e) => {
200 e.preventDefault(); // the runtime also blocks it, but be explicit
201 const text = input.value.trim();
202 if (!text) return;
203 ctx.emit({ type: "habit.add", id: ctx.id(), text });
204 input.value = ""; // the list itself redraws when the event folds back
205};
206el.appendChild(form);
207```
208
209The transport injects `e.name` (sender's screen name), `e.ts` (send time,
210epoch ms) and `e.uid` (sender's user id) on every event โ€” never set them,
211just read them in the reducer. Encryption is automatic.
212
213Your UI updates when the event **echoes back** and folds (typically well
214under a second). Don't hand-patch state in a click handler โ€” emit and let the
215fold redraw.
216
217## `ctx` โ€” what render and handlers get
218
219| member | what it does |
220| --------------------------- | ------------ |
221| `ctx.emit(event)` | Emit a typed event (encrypted + sent). |
222| `ctx.schedule(event, when)` | Server-side future delivery at `when` (epoch ms). Resolves the schedule id `sid`, or `null` on failure โ€” store the sid in an emitted event so any device can cancel. |
223| `ctx.unschedule(sid)` | Cancel a pending scheduled event. |
224| `ctx.emitEphemeral(event)` | Relay-only delivery โ€” never stored, never folded. |
225| `ctx.id()` | A fresh unique id for a new entity. |
226| `ctx.me` / `ctx.meName` | The member's user id / screen name. |
227| `ctx.getState()` | The latest folded state. |
228| `ctx.threadId` | The app's thread id. |
229| `ctx.reply({ text, short })`| **Commands only** โ€” a private "only visible to you" confirmation where the user typed. |
230| `ctx.interval(fn, ms)` | A ticker cleared for you on each redraw. |
231| `ctx.onCleanup(fn)` | Run `fn` before the next redraw. |
232
233For time-based alerts, put a `notice` on the **scheduled** event:
234
235```js
236const id = ctx.id();
237const sid = await ctx.schedule(
238 { type: "habit.remind", id, notice: { title: "๐Ÿ’ง Water break", body: "Time for a glass." } },
239 Date.now() + 60 * 60 * 1000,
240);
241ctx.emit({ type: "habit.set", id, sid: sid || "" });
242```
243
244## Commands โ€” quick capture from any chat
245
246A member app can offer slash commands. A command typed in **any** chat is
247routed into the app's own thread, so `/habit water` files a habit without
248opening the app. A command handler runs the same way as a click: it emits
249events and optionally replies with a private "only visible to you"
250confirmation via `ctx.reply({ text, short? })`.
251
252```js
253commands: {
254 "/habit": (args, ctx) => {
255 const name = args.trim();
256 if (!name) return ctx.reply({ text: "Usage: /habit <name> โ€” e.g. /habit drink water" });
257 ctx.emit({ type: "habit.add", id: ctx.id(), text: name });
258 ctx.reply({ text: `Tracking โ€œ${name}โ€.`, short: `tracking ${name}` });
259 },
260},
261```
262
263**The handler always receives `args` as a raw string** โ€” everything typed after
264the command word, already trimmed (`"/habit drink water"` โ†’ `args` is
265`"drink water"`). There is **no structured or typed argument parsing**: no
266options schema, no `args.name`, no parsed object. If you want fields, split the
267string yourself (`const [first, ...rest] = args.split(/\s+/)`). This is
268deliberately simpler than Discord/Slack slash-command *options* โ€” do not model
269those here, and never write a handler that expects a parsed argument object.
270
271The richer object form is **`{ run, desc, args }`**, and every field is display
272or handler โ€” none of them is a schema:
273
274- `run(args, ctx)` โ€” the handler, same signature as the bare function (`args`
275 is still the raw string).
276- `desc` โ€” a one-line description for the `/` typeahead.
277- `args` โ€” a **usage-hint string** shown in the typeahead (e.g. `"<name>"`). It
278 is a *label*, not an argument declaration; nothing parses it.
279
280```js
281commands: {
282 "/habit": {
283 desc: "Track a new habit",
284 args: "<name>", // typeahead hint โ€” a label, NOT a schema
285 run: (args, ctx) => { // args is still the raw string
286 const name = args.trim();
287 if (!name) return ctx.reply({ text: "Usage: /habit <name>" });
288 ctx.emit({ type: "habit.add", id: ctx.id(), text: name });
289 ctx.reply({ text: `Tracking โ€œ${name}โ€.` });
290 },
291 },
292},
293```
294
295Rules: reply with a usage line on bad input (never fail silently); the handler
296gets the same `ctx` (emit/schedule/id/getState/me), plus `ctx.reply`. The reply
297automatically gets an **Open <app>** button so the member can jump in โ€” you
298don't add it. `ctx.reply` accepts only `{ text, short }` (no custom buttons).
299The command's UI effect is always its emitted events folding into your state.
300
301**Default command โ€” type to capture.** Set `defaultCommand: "/add"` (naming one
302of your `commands`) and plain, non-slash text typed into the app's companion Chat
303view runs that command โ€” so "buy milk" adds an item, exactly like typing into
304To-do. It only applies in the app's own thread; when a real bot is in the thread,
305plain text stays an ordinary message.
306
307## Command panels โ€” a temporary surface where the member typed
308
309A command can answer with more than a reply: `ctx.panel(...)` opens a
310**panel** โ€” a transient, interactive surface in the chat the member typed in,
311just above the composer, with a host-drawn header and โœ• (Escape works too).
312In the sandbox the form is **declarative** โ€” a render closure can't cross the
313bridge โ€” so it's two pieces:
314
3151. The command handler calls `ctx.panel({ title, data })`. `data` is plain
316 JSON handed to your hook (results, the typed args, whatever the panel
317 needs). Optional: `position: "top"` (pin above the feed),
318 `animate: false` (no open/close ease). Returns a Promise<boolean> โ€” false
319 means no panel could open (fall back to `ctx.reply`).
3202. The app declares a top-level **`panel(data, el, panelCtx)`** hook โ€” it runs
321 in a fresh sandboxed frame mounted inside the real panel, and draws into
322 `el` exactly like `render` does (same DOM rules, same theme variables, your
323 `styles` apply).
324
325`panelCtx` is the app `ctx` (emit / schedule / id / me / getState) plus:
326
327- `panelCtx.close()` โ€” dismiss the panel (e.g. after a tap did its job).
328- `panelCtx.insertText(t)` โ€” insert text at the composer's caret (clamped;
329 use it in response to a tap, never spontaneously).
330
331**The panel opens where the member typed โ€” your `ctx` may not point there.** The
332two are answers to different questions: the panel is chrome, so it always draws
333in the chat the member is looking at, while `ctx` addresses the *place your app's
334data lives* (`docs/space-apps.md`). For a personal or group app those are usually
335the same thread and the distinction never shows. It shows for a **space-placed**
336app: `ctx.emit` / `ctx.getState()` reach the one copy shared with the whole
337space, while the panel is still in the chat. That split is deliberate โ€” "app data
338lives space-wide, conversation content stays under the conversation's own key" โ€”
339so don't try to `ctx.emit` a *message* into the conversation from a space-placed
340app: it would land in the space's log, which renders nowhere.
341
342**One shared setting for the whole space, asked once.** If your app needs a value
343every member should share โ€” the classic case is an API key โ€” declare `places:
344["space"]` and keep it in your ordinary fold state. The first member to run the
345command is asked for it; the event folds into the space's log; everyone else (and
346every later joiner) reads it and is never asked again. Adding `listed: false`
347alongside makes the app **implicitly space-placed**: it stays out of the Apps
348catalogue and needs no "add" gesture, which is right for a command-only utility
349that has no pane of its own. `web/static/apps/gif.js` is the worked example โ€” its
350Giphy key is one value the space shares.
351
352The catch, and it is not a small one: **fold state cannot keep a secret from a
353member of the thread.** Everyone in the space decrypts every event and snapshot
354by construction, so a space-shared value is readable by every member โ€” fine for a
355public client identifier like Giphy's, wrong for a real secret. Say so in your
356own copy when you ask for one, the way `gif.js` does. (And a log is immutable:
357replacing the value later leaves the old one readable in history.)
358
359The whole shape, from `web/static/apps-disabled/mood.js` (the working
360example โ€” paste it into App Studio to try):
361
362```js
363commands: {
364 "/mood": {
365 desc: "log how you're feeling",
366 run(args, ctx) {
367 ctx.panel({ title: "How are you feeling?", data: { note: args.trim() } });
368 },
369 },
370},
371panel(data, el, ctx) {
372 for (const mood of ["๐Ÿ˜„", "๐Ÿ™‚", "๐Ÿ˜"]) {
373 const b = document.createElement("button");
374 b.textContent = mood;
375 b.onclick = async () => {
376 await ctx.emit({ type: "mood.set", id: ctx.id(), mood, note: data.note });
377 ctx.close();
378 };
379 el.appendChild(b);
380 }
381},
382```
383
384Rules: a panel opens only from a command the member just ran (the host
385enforces it); one panel per app (opening another replaces it); the panel frame
386is the same no-network, no-keys cage as the app's own surface โ€” `data` and
387everything you send back cross the bridge as validated, size-capped JSON. The
388declarative form also works for compiled-in apps, so a file written this way
389runs identically in both worlds.
390
391## Companion chat โ€” the thread reads as a conversation
392
393By default a member app **takes over** its thread (the surface is the whole
394view). Implement **`chatMessage(event)`** and the thread instead offers two
395views, toggled in the header: a **Chat** conversation and the app's
396**Interface**. `chatMessage` projects each event into a chat line โ€” return
397`{ text, action: true }` for a `/me`-style action line, `{ text }` for a bot
398bubble, or `null` to keep the event out of the chat. Both views fold from the
399one event log, so an action in the Interface shows in the Chat.
400
401```js
402chatMessage(e) {
403 if (e.type === "habit.add") return { text: `started tracking โ€œ${e.text}โ€`, action: true };
404 if (e.type === "habit.tick") return { text: `โœ“ done for today`, action: true };
405 return null; // housekeeping events stay silent
406},
407```
408
409Use companion for list-like tools where "added X" reads naturally as a
410conversation; use takeover (omit `chatMessage`) for boards, games, dashboards.
411
412## Group-capable apps โ€” one shared instance for a whole chat
413
414By default a member app is **personal**: only its author ever opens it, in
415their own private thread. Add **`"chat"` to its `places`** and a member can
416also add it to a group chat (โ‹ฎ โ†’ Apps) โ€” the same app, but now **one shared
417instance runs for everyone in that group**. Its events post into the *group
418thread's* encrypted log instead of your personal one, so every member's
419sandboxed frame folds the same events and arrives at the same state. Nothing
420else about the app changes โ€” same `events`, same reducer, same `render`.
421
422```js
423export const app = defineApp({
424 name: "scoreboard",
425 title: "Scoreboard",
426 glyph: "๐Ÿ†",
427 places: ["you", "chat"],
428 // ...initialState, events, render as normal
429});
430```
431
432Adding or removing the app from a group is a member's own call โ€” any member
433can do it, like adding a person, and it's visible to everyone (an action line
434in the chat), never silent or server-tracked. There is no admin review step:
435the app runs whatever code its author deployed.
436
437**What this means for state and secrecy:**
438
439- **A group thread's copy of the app has its own state**, independent of your
440 personal instance (or any other group it's added to) โ€” same app definition,
441 different thread, different fold.
442- **Every member of the group can decrypt every event and every snapshot**,
443 because a thread's encrypted log is readable by every member of that thread
444 by construction โ€” that's not a bug to work around, it's how E2EE threads
445 work here. So a group-capable app has **no way to keep a secret from one
446 member of the group while showing it to another** โ€” there is no per-member
447 privacy inside a shared thread.
448- If your app wants to show different members different things (e.g. "your
449 own stuff in full, everyone else's summarised"), that's a **rendering**
450 choice, not a secrecy one: use `ctx.me` in `render` to decide what to draw
451 for the current viewer. The underlying state โ€” and anyone determined enough
452 to inspect it directly โ€” sees everything regardless.
453
454## Space-capable apps โ€” one shared instance for the whole space
455
456One place up from `"chat"`: add **`"space"` to the app's `places`** and a
457member can install it **for everyone in the space** (`/yourcmd @everyone`).
458One shared instance runs for the whole space, backed by a hidden all-members
459thread, and the appโ€™s commands work from **every** chat with nobody else
460installing anything โ€” this is the place for a shared library shape
461(stickers, links, a glossary). Design + trust model: `docs/space-apps.md`.
462
463```js
464export const app = defineApp({
465 name: "stickers",
466 title: "Stickers",
467 glyph: "๐Ÿ–ผ๏ธ",
468 places: ["you", "space"],
469 // ...initialState, events, commands, render as normal
470});
471```
472
473An app doesn't have to keep a personal copy at all: `places: ["space"]`
474builds a space-ONLY app โ€” one copy for everyone, no sidebar thread of your
475own (add `listed: false` for the implicitly-placed, command-only shape; see
476"One shared setting for the whole space" above).
477
478Everything from the group-capable section applies, space-sized: the space
479instance has its own state (independent of personal and group copies); every
480member of the space can decrypt its events and snapshots (no per-member
481secrecy โ€” render-time choices via `ctx.me` only); and installing or removing
482is any memberโ€™s call, attributed by name in the Apps sheet. The same reducer
483bar too: pure, idempotent, validating โ€” the whole space now writes to one log.
484
485## Formatting โ€” safe rich text from `../format.js`
486
487Import the pure text helpers to linkify and format user text **without**
488`innerHTML`. `richTokens(text)` / `tokenizeText(text)` split text into tokens
489you render as DOM nodes (plain text โ†’ a text node, a `{ href }` โ†’ an `<a>`, a
490`{ mark }` โ†’ a `<b>`/`<i>`/`<code>`), which is what keeps it XSS-safe:
491
492```js
493import { richTokens } from "../format.js";
494// โ€ฆin render:
495for (const t of richTokens(item.text)) {
496 if (t.href) { const a = document.createElement("a"); a.href = t.href; a.textContent = t.text; row.appendChild(a); }
497 else if (t.mark) { const el = document.createElement(t.mark === "code" ? "code" : t.mark); el.textContent = t.text; row.appendChild(el); }
498 else row.appendChild(document.createTextNode(t.text));
499}
500```
501
502Also available: `blockTokens`, `firstLink`, `youtubeId`, `replySnippet`,
503`emojiOnlyCount`, `typingSummary`, `autoGroupTitle`, `formatBytes` โ€” all pure.
504
505## Rendering the surface
506
507`render(state, el, ctx)` is called whenever state changes; the runtime clears
508`el` first (unless `incremental: true`). The app owns the whole pane โ€” build
509an input row on top, then the list. The pane scrolls; size with `%`/`flex`.
510
511Style with the theme's CSS variables so the app follows light/dark and the
512member's chosen theme: `--bg`, `--panel`, `--sidebar`, `--ink` (primary
513text), `--muted`, `--border`, `--accent`, `--accent-ink`, `--link`,
514`--bubble-me`, `--bubble-me-ink`, `--bubble-them`, `--danger`, `--warn`,
515`--radius`, `--shadow`, `--font-display`; use
516`cursor: var(--ui-cursor, pointer)` on anything clickable.
517
518For long lists (hundreds of rows) set `incremental: true` and reconcile with
519`keyedList(el, items, { key, rev, render })` โ€” unchanged rows keep their DOM.
520
521## A complete app
522
523A real, working member app โ€” copy the shape, change the guts. A personal
524habit tracker: add habits, tick them off today, streaks derived in render.
525
526```js
527// habits.js
528import { defineApp, addItem, updateItem, removeItem } from "../apps.js";
529
530const DAY = 24 * 60 * 60 * 1000;
531const dayOf = (ts) => Math.floor(ts / DAY);
532
533export const app = defineApp({
534 name: "habit",
535 title: "Habit Tracker",
536 glyph: "๐ŸŒฑ",
537
538 initialState: () => ({ items: [] }),
539
540 // Event types are plain "<name>.โ€ฆ" strings โ€” used here as keys, and again in
541 // the ctx.emit calls in render.
542 events: {
543 "habit.add": (s, e) =>
544 e.id && typeof e.text === "string" && e.text.trim()
545 ? { items: addItem(s.items, { id: e.id, text: e.text.trim(), ticks: [] }) }
546 : s,
547 "habit.tick": (s, e) => ({
548 items: s.items.map((h) => {
549 if (h.id !== e.id) return h;
550 const d = dayOf(e.ts || 0);
551 return h.ticks.includes(d) ? h : { ...h, ticks: [...h.ticks, d].slice(-400) };
552 }),
553 }),
554 "habit.remove": (s, e) => ({ items: removeItem(s.items, e.id) }),
555 },
556
557 styles: `
558.habit-form { display: flex; gap: 8px; padding: 12px; border-bottom: 1px solid var(--border); }
559.habit-form input { flex: 1; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel); color: var(--ink); }
560.habit-row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border); }
561.habit-name { flex: 1; overflow-wrap: anywhere; }
562.habit-streak { color: var(--muted); font-size: 12px; }
563.habit-row button { cursor: var(--ui-cursor, pointer); }
564`,
565
566 render(state, el, ctx) {
567 const items = (state && state.items) || [];
568 const form = document.createElement("form");
569 form.className = "habit-form";
570 const input = document.createElement("input");
571 input.placeholder = "Add a habitโ€ฆ";
572 const add = document.createElement("button");
573 add.textContent = "Add";
574 form.append(input, add);
575 form.onsubmit = (ev) => {
576 ev.preventDefault();
577 const text = input.value.trim();
578 if (text) ctx.emit({ type: "habit.add", id: ctx.id(), text });
579 input.value = "";
580 };
581 el.appendChild(form);
582
583 const today = dayOf(Date.now());
584 for (const h of items) {
585 const row = document.createElement("div");
586 row.className = "habit-row";
587 const done = h.ticks.includes(today);
588 const tick = document.createElement("button");
589 tick.textContent = done ? "โœ…" : "โฌœ";
590 tick.disabled = done;
591 tick.onclick = () => ctx.emit({ type: "habit.tick", id: h.id });
592 const name = document.createElement("span");
593 name.className = "habit-name";
594 name.textContent = h.text; // user content โ†’ textContent, never innerHTML
595 let streak = 0;
596 for (let d = today; h.ticks.includes(d); d--) streak++;
597 const st = document.createElement("span");
598 st.className = "habit-streak";
599 st.textContent = streak ? `${streak}๐Ÿ”ฅ` : "";
600 const rm = document.createElement("button");
601 rm.textContent = "ร—";
602 rm.onclick = () => ctx.emit({ type: "habit.remove", id: h.id });
603 row.append(tick, name, st, rm);
604 el.appendChild(row);
605 }
606 },
607});
608```
609
610## Invariants (the things that fail silently if you skip them)
611
6121. **Idempotent fold** โ€” same event twice, same state (dedupe on entity id).
6132. **Pure, immutable reducers** โ€” spread, never mutate.
6143. **Don't set `name`/`ts`/`uid` on emit.**
6154. **Prefix every event type** with the app `name` โ€” write them as plain
616 `"<name>.โ€ฆ"` strings.
6175. **Never `innerHTML` user content.**
6186. **Tolerate `undefined` state in render** (first open).
6197. **A scheduled event arrives later, into different state** โ€” its fold must
620 tolerate the target being gone.
6218. **Use `e.ts` in folds, never `Date.now()`** โ€” folds replay on every open;
622 a wall-clock read in a reducer breaks convergence. (`Date.now()` in
623 *render* is fine โ€” deriving "today" at draw time is exactly right.)
624
625## Delivering the app
626
627Produce **one file** passing every hard constraint. There are two ways it
628reaches the instance:
629
630- **The assistant bot** deploys it with its `create_app` tool (when a member
631 asks a bot to build one).
632- **App Studio** โ€” a member deploys it themselves: open **๏ผ‹ New chat โ†’ Apps โ†’
633 App Studio**, paste the module, hit **Check** (it's evaluated in the sandbox
634 to read back its manifest), then **Deploy**. Same encryption + sandbox as the
635 bot path; no admin needed. App Studio also lists your apps and lets you edit
636 or remove them.
637
638When you are an outside LLM helping a member, hand them the file and point them
639at App Studio (or suggest they ask the instance's assistant to deploy it). The
640instance admin can still install a *trusted* app via the admin console โ€” a
641different tier (`/api/app-guide` documents it).
642