| 1 | # ๐ค Building a member app โ the sandboxed authoring guide |
| 2 | |
| 3 | This guide teaches you (an LLM asked to build an app, or a person) how to |
| 4 | write a **member app** for an Eleven Messenger / lchat instance: a small |
| 5 | personal tool โ a tracker, a list, a counter, a tiny game โ that a member |
| 6 | asked for in chat. It is served at `GET /api/member-app-guide`. |
| 7 | |
| 8 | Member apps are the self-serve tier of the mini-app platform. Unlike the |
| 9 | admin-installed apps (see `/api/app-guide`), a member app is **user content**: |
| 10 | it is stored end-to-end encrypted and it runs inside an **isolated sandbox |
| 11 | frame** โ its own document, with no network, no storage, and no access to the |
| 12 | rest of the app. Write within the rules below and your app behaves exactly |
| 13 | like a built-in one; step outside them and the sandbox simply refuses. |
| 14 | |
| 15 | You will produce **exactly one JavaScript file** โ a plain ES module. All |
| 16 | logic, rendering, and CSS ship in that one file. |
| 17 | |
| 18 | ## The one idea |
| 19 | |
| 20 | An app has no server. It trades **typed events** over the member's end-to-end |
| 21 | encrypted 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 | |
| 27 | An app is three small things: |
| 28 | |
| 29 | 1. an **events map** โ the event types it owns and how each folds into state, |
| 30 | 2. an **initialState** โ the empty state to start from, |
| 31 | 3. a **render** โ how state draws, full-pane, with its own input UI. |
| 32 | |
| 33 | You 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 | |
| 45 | The sandbox now supports **slash commands** (with a **`defaultCommand`** so plain |
| 46 | text captures), a **companion Chat view**, the **`places`** declaration for |
| 47 | sharing into chats or the whole space (see Group-capable apps and |
| 48 | Space-capable apps), and the pure **`../format.js`** text helpers, in addition |
| 49 | to the surface + events core. |
| 50 | These 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 | |
| 64 | 1. **Exactly one file**, a **plain ES module** โ no TypeScript, no JSX, no |
| 65 | `require()`, no build step. |
| 66 | 2. **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()`. |
| 74 | 3. **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`. |
| 77 | 4. **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. |
| 80 | 5. **No `window.parent`, `window.top`, or `postMessage`** โ the bridge is the |
| 81 | runtime's business, never the app's. |
| 82 | 6. **Never `innerHTML` user content.** Build DOM with `createElement` and set |
| 83 | text with `textContent`. `innerHTML` only ever for static in-code strings. |
| 84 | 7. **Use `ctx.interval(fn, ms)`, never bare `setInterval`, inside `render`.** |
| 85 | 8. **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.) |
| 91 | 9. **Reducers are pure, immutable, and idempotent by entity id.** Folding the |
| 92 | same add twice must produce one item (the collection helpers guarantee it). |
| 93 | 10. **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`. |
| 102 | 11. **Validate events in the reducer, not at the emitter.** Malformed events |
| 103 | fold to a no-op, never a crash. |
| 104 | 12. **You never touch the server, keys, or tokens.** |
| 105 | 13. **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 |
| 119 | import { defineApp, addItem, removeItem } from "../apps.js"; |
| 120 | export 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 |
| 142 | fold โ `render` must also tolerate `undefined` state (first open, before any |
| 143 | event): `const items = (state && state.items) || [];` |
| 144 | |
| 145 | ## The events map & the reducer |
| 146 | |
| 147 | Each 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 | |
| 154 | The collection helpers keep list reducers correct by construction: |
| 155 | |
| 156 | ```js |
| 157 | addItem(list, item) // append unless the id is already present; always a fresh array |
| 158 | updateItem(list, id, patch) // replace the matching item with { ...item, ...patch } |
| 159 | removeItem(list, id) // drop the matching item |
| 160 | ``` |
| 161 | |
| 162 | ### Event types โ plain, name-prefixed strings |
| 163 | |
| 164 | Every 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 |
| 167 | is used throughout: |
| 168 | |
| 169 | ```js |
| 170 | name: "habit", |
| 171 | events: { |
| 172 | "habit.add": (s, e) => โฆ, |
| 173 | "habit.remove": (s, e) => โฆ, |
| 174 | }, |
| 175 | ``` |
| 176 | |
| 177 | That's the whole rule: one app `name`, and event types are `"<name>.<thing>"` |
| 178 | literals. 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 |
| 184 | whatever your reducer reads. **Mint `ctx.id()` once for a new entity** and reuse |
| 185 | that id in follow-ups: |
| 186 | |
| 187 | ```js |
| 188 | ctx.emit({ type: "habit.add", id: ctx.id(), text }); |
| 189 | ctx.emit({ type: "habit.remove", id: item.id }); |
| 190 | ``` |
| 191 | |
| 192 | A form is a common input; wire its `onsubmit` (native submission does nothing |
| 193 | in the sandbox โ see hard constraint 13): |
| 194 | |
| 195 | ```js |
| 196 | const form = document.createElement("form"); |
| 197 | const input = document.createElement("input"); |
| 198 | form.appendChild(input); |
| 199 | form.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 | }; |
| 206 | el.appendChild(form); |
| 207 | ``` |
| 208 | |
| 209 | The transport injects `e.name` (sender's screen name), `e.ts` (send time, |
| 210 | epoch ms) and `e.uid` (sender's user id) on every event โ never set them, |
| 211 | just read them in the reducer. Encryption is automatic. |
| 212 | |
| 213 | Your UI updates when the event **echoes back** and folds (typically well |
| 214 | under a second). Don't hand-patch state in a click handler โ emit and let the |
| 215 | fold 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 | |
| 233 | For time-based alerts, put a `notice` on the **scheduled** event: |
| 234 | |
| 235 | ```js |
| 236 | const id = ctx.id(); |
| 237 | const 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 | ); |
| 241 | ctx.emit({ type: "habit.set", id, sid: sid || "" }); |
| 242 | ``` |
| 243 | |
| 244 | ## Commands โ quick capture from any chat |
| 245 | |
| 246 | A member app can offer slash commands. A command typed in **any** chat is |
| 247 | routed into the app's own thread, so `/habit water` files a habit without |
| 248 | opening the app. A command handler runs the same way as a click: it emits |
| 249 | events and optionally replies with a private "only visible to you" |
| 250 | confirmation via `ctx.reply({ text, short? })`. |
| 251 | |
| 252 | ```js |
| 253 | commands: { |
| 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 |
| 264 | the command word, already trimmed (`"/habit drink water"` โ `args` is |
| 265 | `"drink water"`). There is **no structured or typed argument parsing**: no |
| 266 | options schema, no `args.name`, no parsed object. If you want fields, split the |
| 267 | string yourself (`const [first, ...rest] = args.split(/\s+/)`). This is |
| 268 | deliberately simpler than Discord/Slack slash-command *options* โ do not model |
| 269 | those here, and never write a handler that expects a parsed argument object. |
| 270 | |
| 271 | The richer object form is **`{ run, desc, args }`**, and every field is display |
| 272 | or 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 |
| 281 | commands: { |
| 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 | |
| 295 | Rules: reply with a usage line on bad input (never fail silently); the handler |
| 296 | gets the same `ctx` (emit/schedule/id/getState/me), plus `ctx.reply`. The reply |
| 297 | automatically gets an **Open <app>** button so the member can jump in โ you |
| 298 | don't add it. `ctx.reply` accepts only `{ text, short }` (no custom buttons). |
| 299 | The 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 |
| 302 | of your `commands`) and plain, non-slash text typed into the app's companion Chat |
| 303 | view runs that command โ so "buy milk" adds an item, exactly like typing into |
| 304 | To-do. It only applies in the app's own thread; when a real bot is in the thread, |
| 305 | plain text stays an ordinary message. |
| 306 | |
| 307 | ## Command panels โ a temporary surface where the member typed |
| 308 | |
| 309 | A 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, |
| 311 | just above the composer, with a host-drawn header and โ (Escape works too). |
| 312 | In the sandbox the form is **declarative** โ a render closure can't cross the |
| 313 | bridge โ so it's two pieces: |
| 314 | |
| 315 | 1. 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`). |
| 320 | 2. 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 |
| 332 | two are answers to different questions: the panel is chrome, so it always draws |
| 333 | in the chat the member is looking at, while `ctx` addresses the *place your app's |
| 334 | data lives* (`docs/space-apps.md`). For a personal or group app those are usually |
| 335 | the same thread and the distinction never shows. It shows for a **space-placed** |
| 336 | app: `ctx.emit` / `ctx.getState()` reach the one copy shared with the whole |
| 337 | space, while the panel is still in the chat. That split is deliberate โ "app data |
| 338 | lives space-wide, conversation content stays under the conversation's own key" โ |
| 339 | so don't try to `ctx.emit` a *message* into the conversation from a space-placed |
| 340 | app: 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 |
| 343 | every 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 |
| 345 | command is asked for it; the event folds into the space's log; everyone else (and |
| 346 | every later joiner) reads it and is never asked again. Adding `listed: false` |
| 347 | alongside makes the app **implicitly space-placed**: it stays out of the Apps |
| 348 | catalogue and needs no "add" gesture, which is right for a command-only utility |
| 349 | that has no pane of its own. `web/static/apps/gif.js` is the worked example โ its |
| 350 | Giphy key is one value the space shares. |
| 351 | |
| 352 | The catch, and it is not a small one: **fold state cannot keep a secret from a |
| 353 | member of the thread.** Everyone in the space decrypts every event and snapshot |
| 354 | by construction, so a space-shared value is readable by every member โ fine for a |
| 355 | public client identifier like Giphy's, wrong for a real secret. Say so in your |
| 356 | own copy when you ask for one, the way `gif.js` does. (And a log is immutable: |
| 357 | replacing the value later leaves the old one readable in history.) |
| 358 | |
| 359 | The whole shape, from `web/static/apps-disabled/mood.js` (the working |
| 360 | example โ paste it into App Studio to try): |
| 361 | |
| 362 | ```js |
| 363 | commands: { |
| 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 | }, |
| 371 | panel(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 | |
| 384 | Rules: a panel opens only from a command the member just ran (the host |
| 385 | enforces it); one panel per app (opening another replaces it); the panel frame |
| 386 | is the same no-network, no-keys cage as the app's own surface โ `data` and |
| 387 | everything you send back cross the bridge as validated, size-capped JSON. The |
| 388 | declarative form also works for compiled-in apps, so a file written this way |
| 389 | runs identically in both worlds. |
| 390 | |
| 391 | ## Companion chat โ the thread reads as a conversation |
| 392 | |
| 393 | By default a member app **takes over** its thread (the surface is the whole |
| 394 | view). Implement **`chatMessage(event)`** and the thread instead offers two |
| 395 | views, 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 |
| 398 | bubble, or `null` to keep the event out of the chat. Both views fold from the |
| 399 | one event log, so an action in the Interface shows in the Chat. |
| 400 | |
| 401 | ```js |
| 402 | chatMessage(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 | |
| 409 | Use companion for list-like tools where "added X" reads naturally as a |
| 410 | conversation; use takeover (omit `chatMessage`) for boards, games, dashboards. |
| 411 | |
| 412 | ## Group-capable apps โ one shared instance for a whole chat |
| 413 | |
| 414 | By default a member app is **personal**: only its author ever opens it, in |
| 415 | their own private thread. Add **`"chat"` to its `places`** and a member can |
| 416 | also add it to a group chat (โฎ โ Apps) โ the same app, but now **one shared |
| 417 | instance runs for everyone in that group**. Its events post into the *group |
| 418 | thread's* encrypted log instead of your personal one, so every member's |
| 419 | sandboxed frame folds the same events and arrives at the same state. Nothing |
| 420 | else about the app changes โ same `events`, same reducer, same `render`. |
| 421 | |
| 422 | ```js |
| 423 | export const app = defineApp({ |
| 424 | name: "scoreboard", |
| 425 | title: "Scoreboard", |
| 426 | glyph: "๐", |
| 427 | places: ["you", "chat"], |
| 428 | // ...initialState, events, render as normal |
| 429 | }); |
| 430 | ``` |
| 431 | |
| 432 | Adding or removing the app from a group is a member's own call โ any member |
| 433 | can do it, like adding a person, and it's visible to everyone (an action line |
| 434 | in the chat), never silent or server-tracked. There is no admin review step: |
| 435 | the 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 | |
| 456 | One place up from `"chat"`: add **`"space"` to the app's `places`** and a |
| 457 | member can install it **for everyone in the space** (`/yourcmd @everyone`). |
| 458 | One shared instance runs for the whole space, backed by a hidden all-members |
| 459 | thread, and the appโs commands work from **every** chat with nobody else |
| 460 | installing 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 |
| 464 | export 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 | |
| 473 | An app doesn't have to keep a personal copy at all: `places: ["space"]` |
| 474 | builds a space-ONLY app โ one copy for everyone, no sidebar thread of your |
| 475 | own (add `listed: false` for the implicitly-placed, command-only shape; see |
| 476 | "One shared setting for the whole space" above). |
| 477 | |
| 478 | Everything from the group-capable section applies, space-sized: the space |
| 479 | instance has its own state (independent of personal and group copies); every |
| 480 | member of the space can decrypt its events and snapshots (no per-member |
| 481 | secrecy โ render-time choices via `ctx.me` only); and installing or removing |
| 482 | is any memberโs call, attributed by name in the Apps sheet. The same reducer |
| 483 | bar too: pure, idempotent, validating โ the whole space now writes to one log. |
| 484 | |
| 485 | ## Formatting โ safe rich text from `../format.js` |
| 486 | |
| 487 | Import the pure text helpers to linkify and format user text **without** |
| 488 | `innerHTML`. `richTokens(text)` / `tokenizeText(text)` split text into tokens |
| 489 | you 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 |
| 493 | import { richTokens } from "../format.js"; |
| 494 | // โฆin render: |
| 495 | for (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 | |
| 502 | Also 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 |
| 509 | an input row on top, then the list. The pane scrolls; size with `%`/`flex`. |
| 510 | |
| 511 | Style with the theme's CSS variables so the app follows light/dark and the |
| 512 | member's chosen theme: `--bg`, `--panel`, `--sidebar`, `--ink` (primary |
| 513 | text), `--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 | |
| 518 | For 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 | |
| 523 | A real, working member app โ copy the shape, change the guts. A personal |
| 524 | habit tracker: add habits, tick them off today, streaks derived in render. |
| 525 | |
| 526 | ```js |
| 527 | // habits.js |
| 528 | import { defineApp, addItem, updateItem, removeItem } from "../apps.js"; |
| 529 | |
| 530 | const DAY = 24 * 60 * 60 * 1000; |
| 531 | const dayOf = (ts) => Math.floor(ts / DAY); |
| 532 | |
| 533 | export 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 | |
| 612 | 1. **Idempotent fold** โ same event twice, same state (dedupe on entity id). |
| 613 | 2. **Pure, immutable reducers** โ spread, never mutate. |
| 614 | 3. **Don't set `name`/`ts`/`uid` on emit.** |
| 615 | 4. **Prefix every event type** with the app `name` โ write them as plain |
| 616 | `"<name>.โฆ"` strings. |
| 617 | 5. **Never `innerHTML` user content.** |
| 618 | 6. **Tolerate `undefined` state in render** (first open). |
| 619 | 7. **A scheduled event arrives later, into different state** โ its fold must |
| 620 | tolerate the target being gone. |
| 621 | 8. **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 | |
| 627 | Produce **one file** passing every hard constraint. There are two ways it |
| 628 | reaches 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 | |
| 638 | When you are an outside LLM helping a member, hand them the file and point them |
| 639 | at App Studio (or suggest they ask the instance's assistant to deploy it). The |
| 640 | instance admin can still install a *trusted* app via the admin console โ a |
| 641 | different tier (`/api/app-guide` documents it). |
| 642 | |