# 🤖 Building a mini-app

A **mini-app** is a small client-side program that lives alongside chats — a
to-do list, a poll, reminders. This guide is written for both humans and LLMs:
follow it top to bottom and you can add a new app in one self-contained file.

> **This directory holds apps and nothing else.** Every `.js` file in
> `web/static/apps/` calls `defineApp` — one app per file. Shared machinery an
> app leans on (the schema engine + renderer, the conversation-flow engine,
> Eleven's scripted-conversation feel, the Vault's lock crypto) is a sibling
> module in `web/static/`, imported as `../<name>.js`. So listing this
> directory lists the apps.

## The one idea

An app has no server. It trades **typed events** over the same end-to-end
encrypted thread stream that chat messages use, and derives its state by
**folding** those events. The server only ever sees ciphertext, so it can't run
or even recognise app logic — everything happens in the browser.

> **The encrypted thread log IS the app's database.** You never store state; you
> emit events and fold them back into state. Two devices that fold the same
> events arrive at the same state by construction.

> **The app schema.** To-do and Reminders are SCHEMA-DESCRIBED: a declarative
> JSON schema (`schemas/<name>.json`, spec: `docs/app-schema.md`) declares
> their state, folds, actions, and views, and BOTH platforms interpret it —
> the web via `../schema-engine.js` + `../schema-render.js` (their app files wire
> `schemaEvents`/`schemaRender` into defineApp and keep only the glue:
> commands, preview/unread, chat wording, named parsers), the native apps via
> the Swift twin engine with SwiftUI components. To change those apps' events,
> folds, or view behaviour, change the schema (and its byte-identical copy in
> `ios/LChatCore/Sources/LChatCore/Schemas/`) in the same PR; `npm test` holds
> the twins together.
>
> **Views** (`views.js`, design: `docs/views.md`) ride this same engine from
> the other direction: a bot posts SCHEMA + DATA (never code) as thread
> events, and the views app expands each into a schema list pinned above the
> feed — opened from an `open_view` chip on the bot's reply, reopened with
> `/views`. Apps and bots in the chat can read and replace a view's data
> through ordinary `view.create`/`view.update` events.

An app is four small things:

1. an **events map** — the event types it owns and how each folds into state,
2. an **initialState** — the empty state to start from,
3. **commands** and/or a **message action** — how a user creates events,
4. a **surface** — how state renders (a full pane, or a panel above a chat).

Everything ships in **one file** under `web/static/apps/`, including the app's
CSS. No build step; these are plain ES modules.

On top of that model the framework gives every app three capabilities for free
(details in their own sections below):

- **Time** — `ctx.schedule(event, when)` has the *server* deliver an event into
  the thread later, so things happen when no client is open (reminders that
  actually fire). The server holds only ciphertext and a timestamp.
- **Memory** — folded state is snapshotted (encrypted) to the server and
  re-seeded on open, so an app's state survives beyond the loaded message
  window. 1,000 todos work as well as 10. Apps do nothing to opt in.
- **Voice** — an event can carry a `notice` (or the app a `notify` hook) to
  alert the user the moment it arrives: a toast in the open app, a system
  notification when hidden, a push notification when closed. All wording stays
  encrypted end-to-end.

## A complete app

This is a real, working app — copy it, rename it, and change the guts. It's a
personal "Notes" list: `/note <text>` from any chat files a note; the app's own
sidebar pane lists them.

```js
// web/static/apps/note.js
import { defineApp, namespace, addItem, removeItem } from "../apps.js";

// One declaration of the app's prefix — N.ev("add") is the "note.add" event
// type, N.cls("item") is the "note-item" CSS class. See "Namespacing" below.
const N = namespace("note");

export const app = defineApp({
  name: "note",          // unique id, lowercase — also the default namespace
  title: "Notes",        // shown in the sidebar and header
  glyph: "📝",           // avatar glyph
  places: ["you"],       // where copies can live: just for you (its own sidebar thread)

  // The empty state. A factory (not a value) so every thread gets its own.
  initialState: () => ({ items: [] }),

  // The events map: KEYS are the event types this app owns, VALUES fold each
  // event into new state. This one declaration is the whole model — the owned
  // types and the reducer are both derived from it. Handlers must be PURE:
  // return new state, never mutate the old. The collection helpers make that
  // easy and keep adds idempotent (see "Invariants" below).
  events: {
    [N.ev("add")]: (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text, by: e.name || "" }) } : s),
    [N.ev("remove")]: (s, e) => ({ items: removeItem(s.items, e.id) }),
  },

  // A slash command a user can type in any chat. It emits an event; because
  // this is a personal app, the event is routed into the user's own Notes
  // thread wherever they typed it, and they get a toast confirming it landed.
  commands: {
    "/note": (args, ctx) => {
      const text = args.trim();
      if (text) ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text });
    },
  },

  // The app's CSS travels with it — injected once by the framework. Read the
  // theme's CSS variables (var(--ink), var(--accent), …) so it follows the theme.
  // The stylesheet stays a plain literal; ctx.cls() below keeps the JS in step.
  styles: `
.note-item { display: flex; gap: 8px; padding: 6px 2px; border-bottom: 1px solid var(--border); }
.note-text { flex: 1 1 auto; overflow-wrap: anywhere; }
`,

  // Personal, full-pane: `el` is the whole thread pane. Build DOM nodes and set
  // text with textContent — NEVER innerHTML for anything a user typed.
  render(state, el, ctx) {
    for (const item of state.items) {
      const row = document.createElement("div");
      row.className = ctx.cls("item");        // "note-item"
      const text = document.createElement("span");
      text.className = ctx.cls("text");       // "note-text"
      text.textContent = item.text; // user content → textContent, never innerHTML
      const rm = document.createElement("button");
      rm.textContent = "×";
      rm.onclick = () => ctx.emit({ type: ctx.ev("remove"), id: item.id });
      row.append(text, rm);
      el.appendChild(row);
    }
  },
});
```

Then register it (one line) in `web/static/shell.js`, next to the others:

```js
import "/static/apps/note.js";
```

That's a whole app. Everything below is reference detail and the optional hooks.

## Pick your app's shape first

Before the field reference, decide **which archetype you're building**. An
app's shape is two independent choices — where copies can LIVE, and how each
copy PRESENTS:

1. **Places** (`places: ["you", "chat", "space"]`, any subset) — where a copy
   of this app can live: your own sidebar thread, a chat everyone in it
   shares, one copy for the whole space. A place is a thread; place = privacy
   (docs/space-apps.md). `[]` = host-mounted/utility, never instantiable.
   When a place is off on purpose, say why with `placeNote` — the Apps sheet
   shows it where the Add button would be.
2. **Does it project into chat?** (a `chatMessage` hook) — with it, the app is a
   **companion** (a Chat view beside its Interface); without it, a **takeover**
   (its `render` IS the whole surface). See "Views" in CLAUDE.md.

Those compose into the archetypes you'll actually build:

| You want… | declare | surface | examples |
| --------- | ------- | ------- | -------- |
| A private tool that IS its own thread | `places: ["you"]` (no `chatMessage`) | full-pane takeover | a secrets vault |
| A private tool you also chat with | `places: ["you"]` + `chatMessage` | full-pane companion (Chat ⇄ Interface) | `todo`, `remind` |
| …either of those, shared with a chat or the whole space | add `"chat"` / `"space"` to `places` | the widget bar's Interface pane, beside any conversation | `todo`, `remind`, `bookmarks` |
| A group tool with no personal copy | `places: ["chat"]` | widget pane; its command auto-adopts it in a group | `poll`, `board` |
| Not user-instantiable (host mounts it) | `places: []` | host decides | the chat feed, `setup`, `/emoji` |

The old `scope` enum and the `personal`/`groupCapable`/`spaceCapable` flags
are COMPAT-ONLY inputs now (#405 closed): already-deployed custom and member
apps that declare them keep working — `defineApp` normalizes either
generation into `places` and then DELETES the old fields from the app object
— so new apps declare `places`, and nothing else in the product can even
read the old vocabulary. (How long "keep working" lasts, who upgrades
legacy apps, and how the floor may eventually rise:
`docs/member-apps.md § Legacy formats: the compat policy`.) The one legacy archetype `scope` carried, the
always-on ambient panel above every feed, is retired: Poll and Whiteboard
live in chats now (`places: ["chat"]`), and a legacy `scope:"shared"` custom
app degrades to command-only.

### Layout patterns — and the example app that shows each

Once you've picked a shape, copy the closest working app rather than starting cold:

- **Full-pane list** (`todo.js`, `remind.js`) — an add form on top,
  then the list, optionally a collapsed archive/history section. Rebuilt from state
  on every change (NOT `incremental`); it's the simplest correct thing, and it's
  what makes a group instance converge — another member's edit just refolds and
  redraws. Keep transient UI (a reveal toggle, a grace-period ring) on the row DOM,
  never in reducer state.
- **Ambient panel** (`poll.js`) — **render nothing when empty** so the panel
  collapses (invariant #6), and set `incremental: true` + use `keyedList` so a
  half-interacted card (an armed "Close?" button) survives the refolds that votes
  trigger. Handlers read `card._ctx` (refreshed each render), because the one
  `#app-surface` is reused across threads — a stashed thread id would target the
  wrong chat.
- **Long / scroll-back lists** (`chat.js`, `board.js`) — `incremental: true` +
  `keyedList` with a `rev`, so unchanged rows keep their exact nodes (scroll
  position, focus, decrypted images survive).
- **Companion projection** — implement `chatMessage(event, { group })`: return a
  bot bubble (a string) or a `/me`-style action line (`{ text, action: true }`),
  or `null` for silence. The `{ group }` hint lets wording differ when many people
  read it (`remind.js` drops "you asked me" in a group).
- **Two-step inline confirm** (`poll.js`) — embedded webviews have no
  `confirm()` panel (it silently answers false), so never call it: a first click
  arms the button ("Remove?"), a second within a few seconds commits.

A shape decision worth making deliberately: **a takeover secrets/notes tool should
usually NOT implement `chatMessage`**, so its events don't spill names into the
chat feed, previews, and push notifications. 🤖 When a group still needs to *see
that something happened* (without the app becoming a companion), implement
**`groupFeedMessage(event)`** instead: the feed-only projection hook.
`foldGroupAppEventBubble` prefers it when folding a group's own feed — and only
there, so the personal thread stays a pure takeover (no Chat view ever grows on
it; `appWantsChatView` reads `chatMessage` alone). `vault.js` uses this for
add/remove, deliberately without naming the secret in the line. Companion apps
need no second hook — with no `groupFeedMessage`, the group fold falls back to
`chatMessage(event, { group: true })` (that's the `{ group }` hint above); a
companion that wants *different* group wording implements both, and returning
`null` from `groupFeedMessage` keeps it silent in groups.

## `defineApp(spec)` — the fields

| field          | required | what it is |
| -------------- | -------- | ---------- |
| `name`         | ✓ | Unique lowercase id. Also the conventional prefix for your event types. |
| `namespace`    |   | The prefix for event types + CSS classes. Defaults to `name` (leave it off unless you need them to differ). Drives the `ctx.ev` / `ctx.cls` helpers and `app.ns` — see [Namespacing](#namespacing-one-prefix-declared-once). |
| `title`        | ✓ | Human name (sidebar, header). |
| `glyph`        | ✓ | One emoji/character used as the app's avatar. |
| `places`       | ✓ | WHERE copies of this app can live — the one placement declaration: any subset of `["you", "chat", "space"]`. `"you"` = a personal, self-thread instance (Apps menu, personal-command routing, `defaultCommand`); `"chat"` = addable to a group chat (⋮ → Apps), one shared instance for everyone there (see "Group-scoped apps"); `"space"` = installable for the WHOLE space, one shared instance in the hidden space thread (see "Space-wide apps" + `docs/space-apps.md`). `[]` = host-mounted/utility. (Formally optional — omitting it means `["you"]`, and the legacy flags/scope normalize into it — but every new app declares it.) |
| `groupFeedMessage` |   | 🤖 `(event) => { text, action? } \| string \| null`. The FEED-ONLY projection: narrates the app's events in a GROUP chat's feed without opting the app into a companion Chat view (that's `chatMessage`'s meaning). Implement it *instead of* `chatMessage` for a takeover app whose group activity should still be visible (`vault.js`); alongside it for group-specific wording; absent, the group fold falls back to `chatMessage(event, { group: true })`. See "Companion projection" above. |
| `listed`       |   | `false` for a host-mounted app that is never user-instantiable (the chat feed, `setup`, the `groupapps` registry). Defaults to `places.length > 0`. |
| `quiet`        |   | 🤖 `true` if a command answering in place must never itself force this app's PERSONAL thread into view. The thread still exists and still gets every event — that's the only durable record of the interaction — this only governs visibility. See "Quiet apps" below and issue #227. |
| `requiresService`| | A connected-service name (e.g. `"dnsimple"`). The app is hidden from New chat until that service is linked — how a service "installs" its companion app. See `docs/connected-services.md`. |
| `initialState` | ✓ | `() => state`. A **factory**, called to seed each thread's empty state. |
| `events`       | ✓ | `{ "<type>": (state, event, meta) => state }`. The fold. |
| `commands`     |   | `{ "/cmd": (args, ctx) => void }`. Slash commands. The richer form `{ run, desc, args, local }` feeds the `/` typeahead; `local: true` scopes a command to the app's **own thread** (see "Local & default commands"). |
| `defaultCommand` |  | The name of one of the app's commands that plain, non-slash text typed into the app's thread runs — a local verb (To-do's `"/add"`) or, when the global verb IS the in-thread grammar, that global (Reminders' `"/remind"`). Only honoured while no bot member is in the thread. |
| `composerPlaceholder` | | The composer placeholder shown in the app's thread while `defaultCommand` is active (e.g. `"Add a to-do…"`) — it's how the implicit routing announces itself; set it whenever you set `defaultCommand`. |
| `render`       |   | `(state, el, ctx) => void`. Draws the surface. |
| `messageAction`|   | `(msg) => spec \| null`. Adds an item to the chat message menu. |
| `renderEvent`  |   | `(event, meta) => Node \| null`. An inline line in the message feed. |
| `notify`       |   | `(event, ctx) => {title, body} \| null`. Speak on a live event (see Voice). |
| `ephemeral`    |   | `(event, ctx) => void`. Handle relay-only events (see Ephemeral events). |
| `hostEvents`   |   | Like `events`, but HOST-ONLY: folded via `foldHostEvent`, never registered as wire types — a member can't trigger them by posting a typed message. For translating server-enforced mechanisms (chat's reactions/edits/deletes) into fold state. |
| `snapshot`     |   | Set `false` to opt out of state snapshots (an app whose state IS the log). |
| `incremental`  |   | Set `true` to keep the surface between renders and reconcile it yourself (see `keyedList`). |
| `styles`       |   | A CSS string, injected once. |

`defineApp` returns the app object (with `.types` and `.reduce` derived from
`events` — handy in tests).

### Places & discovery (`places`, `listed`)

`scope` used to be a single required enum, and then three capability flags,
but both quietly conflated the one real question — WHERE can copies of this
app live? — with how the app is discovered (#405). The model is now exactly
two fields:

- **`places`** — the placement declaration (see the table above). `"you"`
  gives the app a **personal instance**: its own single-member thread in the
  sidebar, rendered **full-pane** (`el` is the whole thread view); a command
  or message action typed in *any* chat routes into it. Private tools live
  here: to-do, reminders, bookmarks, vault. Adding `"chat"` lets it *also* be
  **added to a group chat**, one shared instance for everyone there (next
  section) — a group tool isn't a separate archetype, just a place. `"space"`
  is the same idea, one scope up.
- **`listed: false`** — the app is **not user-instantiable**; the host mounts
  it explicitly (the chat feed, the `setup`/`welcome` conversations, the
  `groupapps` registry). Never appears in the Apps menu.

The legacy inputs still normalize in (`scope: "personal"` → `["you"]`,
`scope: "system"` → `[] + listed: false`, and each capability flag →
its place) because stored member-app manifests carry them — but `defineApp`
deletes them after normalizing, so no code path reads them.

Not every full-pane thread surface should be an app, though — a pending (not-yet-
joined) DM shows a waiting card instead of the chat app's pane, but that's a
host-level concern, not an app archetype. See `docs/mini-apps-architecture.html`
§5 for why, and the design question to revisit once the classic feed is retired.

### Group-scoped apps: the same app, personal AND in a group

A personal app whose `places` include **`"chat"`** (To-do) can also be **added to a
group chat** (⋮ → Add apps), where one shared instance runs for the whole
group — at the same time as everyone's personal copies. This is not a new
model; it's the existing one pointed at a different thread:

- **Same events, same reducer, different log.** The group's instance folds the
  app's ordinary events (`todo.add`, …) from the *group thread's* encrypted
  log instead of your personal app-thread's. Every member folds the same
  events, so everyone sees the same list; snapshots, scheduling, and live
  delivery already work per-thread and need nothing new.
- **Which apps a group has added is itself fold state** — the framework's
  `groupapps` registry app owns `groupapps.add` / `groupapps.remove` wire
  events (`{ app: "todo" }`), folded per thread like any other. It syncs to
  every member and the server never learns which app was added (or that one
  was). Any member can add or remove, like adding people; both acts render as
  action lines ("Ana added To-do to this chat").
- **The widget bar.** A group with apps added gets the header view toggle:
  Chat | Split (desktop) | one glyph button per added app. Tapping an app's
  button shows it beside the chat; a second tap gives it the full pane (on
  mobile, where there's no split, the first tap does); tapping a different
  app's button swaps the pane in place. The app renders in the same Interface
  pane a companion app-thread uses, with `ctx` bound to the group thread —
  `ctx.emit` lands in the group log. Nothing about your `render` changes.
- **Chat projection is attributed.** In a group, a `chatMessage` ACTION
  projection leads with the actor's name ("Ciara added “buy milk”" — a group
  has many hands); a MESSAGE projection (a reminder firing) stays a bubble in
  the app's voice. Write `chatMessage` exactly as for the companion view; the
  host adds the prefix. In a group it's called with `{ group: true }` as a
  second argument, for wording that must differ when many people read it
  (Reminders drops "you asked me" there) — apps that don't care ignore it.
  🤖 The host also substitutes **“You”** for the reader's own action
  (`foldGroupAppEventBubble` in `shell.js`, matching `membershipLine`'s
  join/leave convention) — this is automatic and free; write your `text` as if
  it always follows a name (`added “buy milk”`), never bake in “You” yourself,
  or a bystander reading someone else's event will see “You added “buy milk””.
  This attribution is unique to `chatMessage` — a `preview` hook (below) gets
  no such help and must do its own “You” check.
- **@-mention runs the default command.** "@To-do buy milk" typed in the
  conversation runs the app's `defaultCommand` with the rest of the text — the
  event is what lands, shown as an action line, and the app joins the `@`
  typeahead. This is the *explicit* form of defaultCommand: a group's plain
  text is real conversation, so a group app must never eat it implicitly
  (`defaultCommand`'s automatic form stays personal-thread-only).
- **Slash commands scope to the nearest instance.** In a chat that has the app
  added, its global command acts on the GROUP instance — "/todo buy milk"
  there files into the shared list, exactly like "@To-do" (see `runCommand`).
  Everywhere else it keeps routing to your personal app-thread, so the same
  verb always addresses the instance you're looking at.
- **`@here` installs and shares in one gesture.** In a group *without* the
  app, "/todo @here buy milk" adds the app to the group (the same
  `groupapps.add` the sheet emits, so everyone gets the widget bar and the
  action line) and files the item into the new shared instance; a bare
  "/todo @here" just installs. Anywhere the flag can't apply — not a group,
  or an app that can't be shared — it answers with a private explanation
  instead of mis-filing the text. The "/" typeahead makes all of this
  discoverable: a group-capable command's description says where it will
  land — "for this group" when the app is added, "just for you" when it
  isn't — and in the fresh case the flags that would install it ride as
  extra rows that say so in the same sentence shape ("/todo @here" · "Add a
  to-do to a new To-do app for this group"; likewise "@everyone" for the
  space, when the policy lets you), so the install is never hidden. (Since
  the places grammar landed,
  EVERY app command names its place — personal ones read "just for you"
  everywhere — and /help closes with the one routing sentence: a command
  acts on the nearest copy of its app.)

What the `"chat"` place asks of your app: nothing beyond the standard invariants —
a pure, idempotent, validating reducer (strangers' events are now first-class
inputs), a `render` that works off `ctx.threadId` (never a stashed thread id),
and per-user logic keyed on `e.uid`. If your app satisfies those, the place is
the whole opt-in. Vectors: `test/event-vectors/groupapps.json`.

Currently chat-placed: To-do, Reminders, Bookmarks, the Vault — plus Poll and
the Whiteboard, which are chat-place-ONLY (`places: ["chat"]`, no personal
copy): their commands auto-adopt the app into the group on first use (the
ambient panel they used to ride is retired — #405). Deliberately NOT
flagged: apps whose state derives from one member's private service
credentials (Integrations, DNSimple) — sharing those needs its own design
(whose credentials fetch? who may see the result?), so the per-app gate is
the point, not a limitation.

### Space-wide apps: install once, use everywhere

The third place up: an app whose `places` include **`"space"`** can be **installed
for the whole space** (⋮ → Apps → "For everyone in this space", or
`/app @everyone`) — ONE shared instance, its commands available in every chat,
nobody else installs anything. Design + trust model: `docs/space-apps.md`.
**Currently parked for shipped apps** (`docs/space-apps.md § The pullback`):
the built-ins declare `places: ["you", "chat"]` and the multi-place UI hides
behind the `PLACES_UI` device flag — the machinery below stays live and
tested, and an app rejoins by adding `"space"` to its `places` line.
Again not a new model, the same one pointed at a different thread:

- **The space thread.** Every instance gets (lazily, on first install) a
  hidden, all-members thread; the shared instance folds the app's ordinary
  events from ITS encrypted log. Its key travels like an open group's —
  `open_key`, wrapped under the instance key — so every member, including
  late joiners, can already unwrap it; the server still can't. The thread has
  no chat view and never pushes.
- **Which apps the space installed is fold state** — the `spaceapps` registry
  app owns `spaceapps.add` / `spaceapps.remove` (`{ app: "stickers" }`),
  folded from the space thread exactly like `groupapps` from a group's. Any
  member installs or removes; the Apps sheet's "Installed for everyone by …"
  line is the visible record. Vectors: `test/event-vectors/spaceapps.json`.
- **Commands route to the nearest instance** (see `runCommand`): local verbs →
  the app's OWN thread → this chat's group instance → the SPACE instance →
  your personal thread. So a space-installed app's global verb acts on the
  shared instance from every chat — the "/" typeahead says so ("for this
  space") — while the same verb typed inside the app's own
  personal thread, or in a group that added it, keeps addressing the instance
  you're looking at. `@App text` mentions work in any chat (and the `@`
  typeahead suggests them) and target the space instance too.
- **`@everyone` installs and uses in one gesture** — `/stickers @everyone`
  installs for the space (creating the space thread on first use) and runs any
  trailing text against the fresh shared instance; a bare `@everyone` is just
  the install, confirmed privately (`@space` is an accepted alias). An app
  without the space place gets an explanation, never a mis-filed event.
- **`@me` completes the flag family, pointing at privacy** — `/todo @me
  dentist` files into YOUR copy even where a chat or space copy claims the
  bare verb. No install side effects, ever; an app with no personal place
  explains (its `placeNote`) instead of mis-filing. The `/` typeahead teaches
  the flags itself: when several copies are reachable it offers the flagged
  variants as extra rows (`/todo @me · just for you`), ranked after the
  default route — nobody reads docs, everybody reads the menu.
- **Each space app gets a sidebar row — its home.** The space band (badged
  "space", subtitle "For everyone in this space") opens your `render`
  full-pane with `ctx` bound to the space thread; your `preview`/`unread`
  hooks drive the row's activity line and dot, exactly as they do for chats.
  Space apps deliberately do NOT appear in other chats' widget bars — the
  widget bar always means "this chat's own copies" (place = location). A
  group's own add of the same app is a separate copy, in that chat.
- **Message actions stay personal.** "Save link" files into YOUR bookmarks
  even when Bookmarks is space-installed — saving somebody's message privately
  is the point of that gesture; the shared instances are reached by command or
  mention, always explicit.

What the `"space"` place asks of your app: the `"chat"` bar, verbatim — a
pure, idempotent, validating reducer (the whole space now writes to one log),
`render` off `ctx.threadId`, per-user logic keyed on `e.uid`. Member apps opt
in through their manifest — `places` including `"space"`, or the legacy
`spaceCapable: true` boolean, which normalizes into it (see
`docs/building-member-apps.md`). No shipped app currently declares the space
place (§ The pullback); the built-ins that had it — Bookmarks, To-do,
Reminders, the Vault — are one places-line edit from returning.

### Local & default commands

A command is **global** by default: it's in the `/` typeahead of every thread,
and (for a personal app) routes into your app-thread from wherever you typed it —
that's `/todo`, the *routing* verb. Mark a command `local: true` and it becomes an
**in-thread** verb instead: it resolves — and appears in the typeahead — only
inside the app's own thread, never elsewhere, so short names like `/add` can't
collide across apps. In its own thread the local commands lead the typeahead and
the app's global aliases drop out as redundant. An app with **no** local commands
leads with its globals instead — Reminders' `/remind` heads its own thread's list
(the global verb is the in-thread verb; nothing supersedes it).

`defaultCommand` names one of the app's commands that **plain text runs**: with
no bot member in the thread, typing `buy milk` into To-do's chat is `/add buy
milk`, and typing `call mum in 2h` into Reminders is `/remind call mum in 2h` —
because sending a chat message no one can answer is a dead end, the message
becomes the action instead. The command can push back: Reminders replies "When
should I remind you?" for text with no parseable time. (In-thread, replies
carrying a `short` — transient confirmations — are suppressed, since the event's
action line is feedback enough; replies without one, content and errors, always
render.) Three rules keep this honest:

- **Advertise it.** Set `composerPlaceholder` (and write your `chatIntro` to
  say "just type") — implicit routing a user can't see is magic, not delight.
- **It steps aside for an LLM.** The gate is "no bot member here"
  (`defaultCommandFor`): if a bot later joins the thread, plain text goes back
  to being a real message for it to answer, and your commands become things it
  can invoke on the user's behalf.
- **Personal app-threads only.** A shared app in a group chat must never eat
  plain messages — those are real conversation. Slash text never falls through
  to the default either: a leading `/` always means "command".

A default-command thread is text-only: the composer hides its attach affordances
(there's no one to read a file), and they return if a bot joins.

### Quiet apps

🤖 A personal command answers **in place** by design (CLAUDE.md), but by
default it still has a side effect on your sidebar: the first time you run
a personal app's global command, its app-thread is created — via
`resolveAppThread`/`createAppThread` in `shell.js` — and appears in the
thread list, whether or not the command actually did anything. That's right
for To-do or Notes (the thread IS where the item now lives, so surfacing it
is the point), but wrong for an app whose commands mostly just answer a
question or offer an action chip — it shouldn't spawn a sidebar entry the
user never asked for.

Set **`quiet: true`** and the app's personal thread stops forcing its own way
into view. The thread still exists — it's still the only durable record of
the interaction, and every event you `ctx.emit` still lands there exactly as
before — `quiet` only changes *visibility*, never storage:

- **Brand new** (never interacted with before): the thread is created (so
  your command has somewhere to emit into) but stays hidden — no new sidebar
  row.
- **Already visible** (the user has it open or it's in their list): nothing
  changes. Quiet is a no-op once the thread is already showing.
- **Was visible, then the user closed it**: your command's activity — a
  reply, a panel, or an emitted event — must not reopen it. Only the user's
  own tap (an action chip's `ctx.open`, or finding it via the Apps menu)
  brings it back, and when it does, every event sent while it was hidden is
  already there waiting.

You don't have to do anything else to get this — no different `ctx.emit`,
no different reply shape. The enforcement lives entirely in the host
(`createAppThread` creates it pre-hidden; the WS message handler reasserts
the hide instead of letting the server's default "new activity reopens a
closed chat" rule apply) because the **server never learns which app owns a
thread** (see CLAUDE.md), so only the client can make this call. See issue
#227. `quiet` only means something for a **personal** app-thread — it has no
effect on a chat-placed app's shared group instance, since that thread is
the chat the user already has open, never one the command creates.

### Command panels — a transient results surface

A command can answer with more than a private reply: `ctx.panel(spec)` opens a
**panel** — a temporary, interactive surface in the chat you *typed* in, sitting
just above the composer (or pinned above the feed with `position: "top"`). It's
still the "answer in place" doctrine: nothing navigates, nothing overlays what
you're reading (the panel takes layout space), and leaving is always the user's
tap. Two working references: `apps/emoji.js` (`/emoji cat` opens a picker; a
tap drops the emoji into your message via `panel.insertText`) and
`apps/gif.js` (`/gif high five` shows Giphy results — the first member to run it
supplies a free Giphy key, which then belongs to the space and nobody is asked
again; a tap posts the GIF
into the chat as an ordinary encrypted attachment and closes the panel — its
network + upload go through the host's scoped `ctx.host.gif` doorway, because
apps never fetch).

```js
"/emoji": {
  run(args, ctx) {
    const results = searchEmoji(args);
    if (!results.length) return ctx.reply({ text: "No matches." });
    const opened = ctx.panel({
      title: `Emoji matching “${args}”`,   // header line, next to the app's glyph
      // position: "top",                  // pin above the feed instead (default: above the composer)
      // animate: false,                   // appear/vanish instantly (default: ease up on open, roll down on dismiss)
      render(el, panel) {                  // build DOM into el — textContent for anything user-typed
        for (const r of results) {
          const b = document.createElement("button");
          b.textContent = r.ch;
          b.onclick = () => { panel.insertText(r.ch); panel.close(); };
          el.appendChild(b);
        }
      },
      onClose() {},                        // optional: told when the panel is replaced or dismissed
    });
    if (!opened) ctx.reply({ text: "…" }); // no chat surface here → answer with a reply
  },
},
```

The `panel` handle a render gets:

| member | what it does |
| ------ | ------------ |
| `panel.close()` | Dismiss the panel (the app deciding it's done). |
| `panel.refresh()` | Re-run `render` (e.g. async results arrived — render a spinner first, refresh when ready). |
| `panel.insertText(t)` | Insert `t` at the caret of the composer the panel sits above (forwarded to the native composer in the embedded shell). |
| `panel.attachFiles(files)` | Stage files on that composer — the same encrypt-and-upload path the attach button uses. |
| `panel.threadId` | The thread the panel is showing in (where you typed). |

**A panel is generic UI, not a picker widget.** `render` gets a bare container
and full control: build any DOM you like, own its clicks, and — because the
handler's `ctx` is your app's real context — a button can dispatch a typed
event straight into the thread's encrypted log, where it folds like any other
app event:

```js
"/lunch": (args, ctx) => {
  ctx.panel({
    title: "Start the lunch poll?",
    render(el, panel) {
      for (const q of ["Pizza or noodles?", "Eat out or order in?"]) {
        const b = document.createElement("button");
        b.textContent = q;
        b.onclick = async () => {
          // An ordinary app event — encrypted, attributed, folded by every
          // member. The panel was just the UI that chose it.
          await ctx.emit({ type: "poll.create", id: ctx.id(), q, options: ["👍", "👎"] });
          panel.close();
        };
        el.appendChild(b);
      }
    },
  });
},
```

`/gif` composes the same pieces the other way round: its tiles call a
privileged **host doorway** (`ctx.host.gif` — search/download/upload) and then
`ctx.emit` a chat message with the uploaded attachment. If your panel needs
the network or another privileged capability, don't reach for `fetch` (apps
never fetch — Invariants #7): add a narrow, named doorway to `setHostBridge`
in `shell.js`, the way `gif` and the connected services do, and route any new
external host through the server's CSP deliberately (CLAUDE.md documents the
Giphy exception).

**The declarative form (and member apps).** Instead of a `render` closure you
may declare a top-level **`panel(data, el, panelCtx)`** hook on the app and
call `ctx.panel({ title, data })` — the host synthesizes the render, handing
your hook the JSON `data` plus a panelCtx of `{ close, insertText, emit, id,
me, getState }`. This is the ONLY form the member-app sandbox supports (a
closure can't cross its bridge — the hook runs in a fresh sandboxed frame
mounted inside the panel), and it works identically compiled-in, so one file
runs in both worlds: see `apps-disabled/mood.js` and
`docs/building-member-apps.md § Command panels`.

**On the native apps.** The Mac app runs panel commands through a hidden
per-thread *command-host* webview — your panel code runs there unchanged
(`insertText` reaches the native composer; `ctx.emit` posts for real). One
extra step: add your command to `WebPanelCommands.catalog`
(`ios/LChatCore/Sources/LChatCore/SlashCommands.swift`) so the native composer
forwards it and lists it in the typeahead. Only commands that act in the
CURRENT thread (or need no thread) belong there — the native page holds a
single thread's key, so a command that routes elsewhere (/todo) can't resolve
in it.

Rules that keep panels honest:

- **A panel is client-only chrome.** Never sent, never folded, gone on dismissal
  or when the pane closes — state that matters goes through events, as ever.
- **One per thread.** Opening another replaces it (the outgoing one's `onClose`
  fires). The user can always dismiss it — its ✕, or Escape.
- **It's a response, not an ambush.** Only open one from a command the user just
  ran (`ctx.panel` exists only in command/mention handlers) — never spontaneously.
- **`ctx.panel` returns `false`** when the thread has no mounted chat surface to
  draw in — fall back to `ctx.reply`.
- **It eases open and shut by default** (and honours reduce-motion); pass
  `animate: false` for an instant panel. On the Mac the native shell drives the
  one animation, from the same flag.
- The usual surface rules apply: build DOM nodes, `textContent` for user
  content, theme variables for styling (ship the CSS in your app's `styles`).

## The events map & the reducer

Each handler is `(state, event, meta) => newState`. Rules:

- **Pure & immutable.** Return new state; never mutate `state`. (Two devices fold
  the same events and must converge; the UI also re-folds on every reopen.)
- **You don't write a `default` case.** The framework returns state unchanged for
  any type your app doesn't handle. Your map only lists what you own.
- **Event types must be globally unique.** Two apps can't share a type — prefix
  every type with your app `name` (`note.add`, `poll.vote`).
- `meta` carries `{ id, ts }` where `id` is the *server message id* (used for
  dedupe — see Invariants) and `ts` the sender's send time. You rarely need it.

### Namespacing: one prefix, declared once

Every app prefixes the same string onto two families of names: its **event
types** (`board.stroke`, joined with `.` — the wire string, part of the
[cross-platform contract](#chat-as-an-app--the-default-chat-experience), so the
helper only *spells* it, it never changes it) and its **CSS classes**
(`board-canvas`, joined with `-`). Writing that prefix by hand in a dozen places
is where it drifts. So declare it once with `namespace(name)` and derive the rest:

```js
import { defineApp, namespace, addItem } from "../apps.js";

const N = namespace("board");           // one declaration of the prefix

export const app = defineApp({
  name: "board",
  events: {
    [N.ev("stroke")]: (s, e) => …,      // key is "board.stroke"
    [N.ev("clear")]: (s, e) => …,       // key is "board.clear"
  },
  render(state, el, ctx) {
    const canvas = document.createElement("canvas");
    canvas.className = ctx.cls("canvas"); // "board-canvas"  (ctx.cls === N.cls)
    ctx.emit({ type: ctx.ev("stroke"), id: ctx.id(), points }); // "board.stroke"
    if (e.type === N.ev("stroke")) …    // compare against the same string
  },
});
```

- **`N.ev(short)`** → `"board.short"` — use it for the `events` map keys
  (computed: `[N.ev("stroke")]`), every `ctx.emit({ type })`, and every
  `e.type === …` comparison.
- **`N.cls(short)`** → `"board-short"` — use it for `className` and selector
  strings. The `styles` blob stays a plain literal (it's static text); the helper
  keeps the *JS* references to those classes in lock-step with them.
- Inside `render`/`commands` the same resolver is on `ctx` as **`ctx.ev`** /
  **`ctx.cls`**, bound to your app — no module-level `N` needed there.
- `namespace` **defaults to `name`**, so it's fully opt-in: an app that never
  calls it behaves exactly as before, and `board.js` is the migrated reference.
- A name that already contains a `.` passes through untouched
  (`N.ev("chat.msg")` → `"chat.msg"`), so referencing a full or foreign type
  still works — which makes converting an existing app a safe find-and-replace.

`board.js` is the worked example. (`chat.js` deliberately does *not* namespace its
classes — it reuses the original feed's `.msg`/`.bubble` markup on purpose; the
helper is opt-in precisely so it can. `todo.js`/`remind.js` are schema-described
(docs/app-schema.md): their `events` map keys come from the JSON schema, not
`namespace()` — but the hand-written glue around a schema app (commands,
`chatMessage`/`preview`/`unread`) still compares against the wire type by hand,
so it namespaces too: `const N = namespace(schema.name)`, then `N.ev("add")`
instead of a second hardcoded `"todo.add"`.)

### Collection helpers

Most apps fold into a list of `{ id, … }` items. Import these from `../apps.js`
and your reducer is correct by construction — every op returns a **new array**,
and `addItem` **dedupes by id**:

```js
addItem(list, item)        // append item unless its id is already present; always a fresh array
updateItem(list, id, patch)// replace matching item with { ...item, ...patch }
removeItem(list, id)       // drop the matching item
```

Because `addItem` always returns a fresh array, you can `.sort()` its result in
place safely:

```js
"remind.add": (s, e) => {
  if (!e.id || !e.text || !e.when) return s;
  const items = addItem(s.items, { id: e.id, text: e.text, when: e.when, fired: false });
  items.sort((a, b) => a.when - b.when); // safe: items is a fresh copy
  return { items };
},
```

Not every fold is a flat list. `poll.js` keeps a per-poll `votes` map and writes
it with a manual immutable update — the helpers are a convenience, not a
requirement. When you go custom, keep it pure:

```js
"poll.vote": (s, e) =>
  ({ polls: s.polls.map((p) => (p.id === e.pollId ? { ...p, votes: { ...p.votes, [e.uid]: e.choice } } : p)) }),
```

## Emitting events — and the three free fields

You create events with `ctx.emit(event)`. The event needs a `type` and whatever
data your reducer reads. **Mint `ctx.id()` once for a new entity** and reuse that
id in follow-up events (done / remove / vote):

```js
ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text });  // "note.add" — new entity → new id
ctx.emit({ type: ctx.ev("remove"), id: item.id });      // "note.remove" — follow-up → existing id
```

Three fields are **added for you** by the transport on every emit — don't set
them yourself, just read them in your reducer:

| field    | value |
| -------- | ----- |
| `e.name` | the sender's screen name |
| `e.ts`   | the send time (epoch ms) — use this for sorting, not `Date.now()` |
| `e.uid`  | the sender's user id — e.g. `poll.vote` keys votes by `e.uid` |

Encryption is automatic: `emit` encrypts the event to the thread key and sends it
as a normal message. You never touch keys, and never send plaintext to the
server.

### Optimistic sends (`optimistic: true`)

By default an emitted event only appears once the server broadcasts it back — a
round-trip's wait, and for a drawing a visible *flash* (the line vanishes on
pen-up, then reappears). Set **`optimistic: true`** on your app and the framework
folds each emitted event into your state **immediately**, locally, then lets the
authoritative echo quietly replace it:

```js
export const app = defineApp({
  name: "board",
  optimistic: true,   // a stroke shows the instant you lift the pen
  // …
});
```

How it stays correct: the transport tucks a client id (`_cid`) inside every
event; the server relays the same ciphertext back, so the echo carries it
verbatim. The optimistic copy folds with **no server message id**, and the echo
re-folds the *same event* with the real id. That means your reducer runs on the
event **twice** — so `optimistic: true` is only safe when your reducer is
**idempotent by the event's own `id`** (mint it with `ctx.id()`, land it with a
collection helper like `addItem`). This is the same property every reducer
already needs — the message window re-folds on each thread re-open — so most apps
qualify already; opt in explicitly rather than assume it. An app whose reducer
*counts* or otherwise applies an event more than once per id must **not** set the
flag. (Chat messages get this treatment built in — an outgoing bubble shows at
once and wears a subtle "Sending…" state if it's still unconfirmed after 5s.)

## `ctx` — what a handler and a render get

| member                       | in            | what it does |
| ---------------------------- | ------------- | ------------ |
| `ctx.emit(event)`            | anywhere      | Emit a typed event (encrypted + sent). Returns `Promise<boolean>`. |
| `ctx.schedule(event, when)`  | anywhere      | Have the **server** deliver `event` into the thread at `when` (epoch ms). Returns `Promise<sid \| null>`. |
| `ctx.unschedule(sid)`        | anywhere      | Cancel a pending scheduled event. |
| `ctx.id()`                   | anywhere      | A fresh unique id for a new entity. |
| `ctx.ev(name)`               | anywhere      | Your event type from a short name — `ctx.ev("add")` → `"todo.add"` (see [Namespacing](#namespacing-one-prefix-declared-once)). |
| `ctx.cls(name)`              | anywhere      | Your CSS class from a short name — `ctx.cls("item")` → `"todo-item"`. |
| `ctx.me`                     | anywhere      | The current user's id. |
| `ctx.getState()`             | anywhere      | The app's latest folded state for this thread. |
| `ctx.threadId`               | anywhere      | The thread the app is acting in. |
| `ctx.place`                  | anywhere      | Where this ctx's copy lives: `"you"` (the app's own thread), `"chat"` (a shared copy in a conversation), or `"space"` (the one space-wide instance). |
| `ctx.open()`                 | anywhere      | Show this app **instance** — for an action chip, so leaving the current context is always the user's tap. The host picks the surface per place (the app's own thread, a chat copy's Interface pane, the space home). |
| `ctx.interval(fn,ms)`        | render only   | A ticker the framework stops for you on redraw / reset. |
| `ctx.onCleanup(fn)`          | render only   | Run `fn` before the next redraw or on thread reset. |
| `ctx.panel(spec)`            | commands only | Open a transient results panel where the command was typed (see "Command panels"). Returns `false` when the thread can't draw one. |
| `ctx.closePanel()`           | commands only | Dismiss that thread's open command panel. |

`ctx.interval` is how `remind.js` keeps its relative labels live without leaking
a timer — never call `setInterval` directly in a render, or you'll leak one per
redraw.

**Replies disclose their place for you.** When a command routes away from the
thread it was typed in (a personal copy reached from a chat, or the space-wide
instance), the framework stamps the reply with the routed place and the notice
renders it as a muted footer in the stand-alone place words ("just for you" /
"for everyone in this space" — `placePhrase`; the `/` typeahead's row labels
use the flowing sibling `placeRowPhrase`). You get this without doing anything.
If you want to *word* the destination yourself, read `ctx.place` — `todo.js`
("Added … to the space to-do list.") and `remind.js` ("I'll remind everyone in
this space: …") are the reference examples — but never rely on apps doing so:
the framework stamp is the disclosure of record.

## Time — scheduled events

`ctx.schedule(event, whenMs)` is `ctx.emit` shifted into the future, executed by
the server: the event is encrypted **now**, the server holds the ciphertext, and
at the due moment it lands in the thread's message log and fans out exactly like
a posted message — every device folds it, closed apps get a push, open apps can
toast it. The server learns only *(thread, time)*, never what was scheduled.

```js
// Schedule first — the sid is part of the reminder's record.
const id = ctx.id();
const sid = await ctx.schedule(
  { type: ctx.ev("fire"), id, notice: { title: "⏰ Reminder", body: text } }, // "remind.fire"
  whenMs,
);
ctx.emit({ type: ctx.ev("add"), id, text, when: whenMs, sid: sid || "" });    // "remind.add"
```

Rules of thumb (all visible in `remind.js`, the reference implementation):

- **Store the returned `sid` inside an emitted event.** It's how *any* device —
  not just the one that scheduled — can later `ctx.unschedule(sid)`.
- **Give a scheduled event a `notice`** (see Voice) — it's usually the whole
  point of scheduling one.
- **Handle `sid === null`** (offline / error): degrade gracefully (remind falls
  back to a best-effort client-side ticker for sid-less items).
- **The scheduled event is still just an event.** Its fold handler must be
  idempotent like any other — a cancel can race a delivery, so e.g. a
  `remind.fire` folding onto an already-removed id must be a no-op (it is, with
  `updateItem`).
- Cancelling an id that already fired succeeds quietly; that race is expected.

## Memory — snapshots (automatic)

The client only holds a window of a thread's messages (~100), but folded app
state is **snapshotted automatically**: after new events fold, the framework
saves `{ states, upTo }` — encrypted under the thread key — to the server
(`thread_state`, versioned against concurrent writers), and re-seeds it when the
thread opens, folding only events newer than `upTo` on top.

You get this for free. It works *because* of the reducer rules: folds are pure
and deterministic, so `snapshot + newer events ≡ folding all of history`. What
it means for you:

- **Don't bound your state by the window.** A 1,000-item todo list is fine.
- **State is durable, so treat it with respect**: never put anything in reducer
  state you wouldn't put in an event (it's all encrypted, but it's persisted).
  Credentials for a third-party service an app talks to can live here — the
  server sees only ciphertext — but prefer an event (`service.connect`) so all
  devices learn them through the normal fold.
- **Schema-evolve defensively.** A snapshot written by an old version of your
  app will be seeded into a new version. Reducers and render must tolerate
  missing fields (`e.note || ""` — the same rule as for old events).

## Voice — talking to the user

Apps can speak the moment something happens, wherever the user is:

1. **`notice` on an event** — the simple, usual way. Any event (typically a
   scheduled one) may carry `notice: { title, body }`. When it arrives live, the
   framework toasts it in-app and raises a system notification if the page is
   hidden; when the app is **closed**, the service worker decrypts the push and
   shows the same notice. One field, every situation, end-to-end encrypted.

2. **A `notify(event, ctx)` hook** — for wording that depends on state. Called
   only for **live** events (never during history refolds — replaying the past
   must not re-alert it). Return `{ title, body }` or `null` for silence:

   ```js
   notify(e, ctx) {
     if (e.type !== "poll.create" || e.uid === ctx.me) return null;
     return { title: "📊 New poll", body: e.q };
   },
   ```

App events with neither are silent — a `todo.add` from your other device never
buzzes anyone. (Chat messages have their own notification path; this section is
only about app events.)

## Ephemeral events — presence without a trace

`ctx.emitEphemeral(event)` relays an event to the thread's connected members and
**forgets it**: never stored, never pushed, never folded. It's for presence
traffic — typing indicators, live cursors — where persistence would be wrong,
not just wasteful. Receivers handle it in the app's `ephemeral(event, ctx)`
hook; senders get their own echo (check `e.uid !== ctx.me` or filter by name).

Two rules:

- **Own the type.** Routing is by event type, so an ephemeral type must appear
  in your `events` map — claim it with a no-op fold (`"chat.typing": (s) => s`).
  That also makes it harmless if a hostile client stores one as a real message.
- **Never touch reducer state from the hook.** Ephemeral events are allowed to
  differ between devices (some were offline when it fired); folding one would
  break convergence. Keep transient UI in module-level state and redraw it
  yourself (see `shell.js`'s typing line).

## Incremental surfaces — `keyedList`

By default the framework wipes and rebuilds an app's surface on every state
change — the right default for small surfaces. For long lists (a chat feed, a
big checklist) set `incremental: true` (the framework stops wiping) and
reconcile with `keyedList`:

```js
keyedList(listEl, state.msgs, {
  key: (m) => m.id,          // stable identity
  rev: (m) => m.editedAt,    // re-render an item only when this changes
  render: (m) => buildRow(m) // build one item's DOM
});
```

Unchanged items keep their exact DOM nodes — scroll position, focus, and loaded
images survive; appends build only the new item. The container must be fully
managed by `keyedList` (no other children). Build any once-only skeleton
(composer, headers) on first render by checking whether it already exists.

## Rendering a surface

`render(state, el, ctx)` is called whenever state changes. The framework clears
`el` first, so **always rebuild from `state`** — don't diff.

- **Personal** apps render full-pane: `el` is the thread view. Usually an add
  form on top, then the list.
- **Shared** apps render a panel above the chat feed. **Render nothing when state
  is empty** (just `return`) so the panel collapses until there's something to
  show. See `poll.js`.
- **Never `innerHTML` user content.** Build nodes with `createElement` and set
  text with `textContent`. `innerHTML` is only OK for static in-code strings.
- Style with the theme's CSS variables (`--ink`, `--muted`, `--accent`,
  `--border`, `--panel`, `--bubble-them`, …) so the app follows light/dark.

### Optional: `messageAction(msg)`

Contribute an item to a chat message's long-press / hover menu. Given the
decrypted message `{ text, from }`, return `{ label, glyph, run(ctx) }` to offer
the action, or `null` to sit this message out. The `run` gets a `ctx` already
bound to your app-thread (for a personal app), so the action files into *your*
app. This is how `bookmarks.js` offers "Save link" on any message with a link:

```js
// import { firstLink } from "../format.js";
messageAction(msg) {
  const url = firstLink(msg.text || "");
  if (!url) return null;
  return {
    label: "Save link",
    glyph: "🔖",
    run(ctx) {
      ctx.emit({ type: ctx.ev("add"), id: ctx.id(), url, from: msg.from || "" });
    },
  };
},
```

### Optional: `renderEvent(event, meta)`

Return a DOM node to show an inline line in the message feed for one of your
events (e.g. "Poll created"), or `null` to keep it silent. Most apps return
nothing and render only in their surface.

## What the server provides (and what it can see)

Everything an app touches goes through `ctx` — you never call these endpoints
yourself — but knowing the surface keeps the trust model clear. In every row the
blob is encrypted client-side under the thread key; the server can never open it.

| capability     | endpoint(s)                              | server learns |
| -------------- | ---------------------------------------- | ------------- |
| Event log      | `POST /api/messages`, `GET /api/messages`, WS `msg` | thread, time, size |
| Scheduling     | `POST/GET /api/schedule`, `DELETE /api/schedule/{id}` | thread, delivery time |
| State snapshots| `GET/PUT /api/threads/{id}/state`        | thread, size, write times |
| Attachments    | `POST /api/upload`, `GET /api/blob/{id}` | size |
| Push fan-out   | (server-initiated)                       | who is subscribed |

All endpoints are token-gated and thread-membership-gated. Server code lives in
`miniapps.go` (scheduling + state), `hub.go` (fan-out), `push.go` (Web Push).

The app UI itself is client-side ES modules under `web/static/` (this folder),
embedded in the binary via `go:embed` — as are the HTML pages in `web/pages/`
(served with the group name substituted). There is currently no per-app
server-rendered HTML; an app IS its module. (If an app ever needs a public,
unauthenticated page, that's a new, deliberate surface — design it with the
same rule: the server serves only what it's allowed to see.)

## Mixing deterministic and LLM behaviour

An app's deterministic core is the reducer + commands: same events in, same
state out, unit-testable in Node. When you want judgment, language, or agency,
you don't bolt an API call into the app — you **invite a bot into the thread**.

Bots (see `docs/bots.md`) are ordinary members with keys: a bot in a thread
decrypts its events like any member, and its sidecar can emit app events back
through the same `POST /api/messages` doorway (`lchat-post` encrypts in the
sidecar's trust domain). That composes with everything above, with no new
machinery:

- the bot **reads** your app's events as they happen (they're just messages),
- the bot **writes** events your reducer already understands — to your fold,
  an LLM's contribution is indistinguishable from a human's,
- your reducer stays the deterministic arbiter: it validates every event the
  same way regardless of who emitted it (it already must — see Invariants).

So "add AI to an app" = define the events, let the bot emit them. Example: a
`/plan dinner party` app where a bot member watches `plan.create` and responds
with `plan.suggest` events the reducer folds into the list — every suggestion
encrypted, attributed (`e.uid` is the bot), and replayable.

## Chat as an app — the default chat experience

`shell.js` (scope `system`) is the chat feed: group/DM threads render it
full-pane by default. It is the original chat retooled onto the framework, not
a parallel one. There is no new wire protocol: its composer emits the same
untyped `{ text }` payload every client has always understood, and the host
feeds the app exactly what the original feed consumed:

- each stored message's decrypted payload (text, attachment metadata, replyTo)
  folds as a host event keyed by its server message id — history, live, and
  scroll-back pages alike,
- the server's reaction / edit / delete frames fold as host translations
  (`hostEvents`, so no wire payload can spoof them — the server-enforced
  authorship and dedupe rules keep applying),
- typing rides the one wire type the app owns, as a relay-only ephemeral event,
- everything outbound (send, react, edit, unsend, upload, unfurl, older pages)
  goes back through the host machinery via a narrow bridge (`setHost`) — the
  app never touches crypto or tokens.

The app renders the ORIGINAL MARKUP: every element uses the same class names
and structure the original `appendMessage` builds (`.msg`/`.content`/`.bubble`,
`.time-sep`, `.msg-actions` hover strip with the exact time, `.reply-quote`,
`.reactions`, `.emoji-picker`, the original composer classes), so style.css
styles both feeds identically and there is no duplicated CSS — hover reveals,
spacing, gestures all match by construction. The app's own stylesheet is a few
lines of layout glue (a `display: contents` item wrapper; a mirror of the one
id-based `#feed` rule). Feature surface: rich text (same tokenizers), @mentions
with the member menu, jumbo emoji, time separators and sender runs, images
(box reserved pre-decrypt, lightbox) and file chips, YouTube cards and
unfurls, replies (quote + banner + tallies + swipe-right on touch), editing
(15-min window, ↑-to-edit, ↓-to-cancel, full payload rebuild), reactions
(hover icon, press-hold / double-tap picker, chips), unsend, attachment
sending, paste/drop, typing rows in-feed, slash commands, helper action chips,
the :emoji/@mention composer typeahead (one shared ranking function), and
scroll-back pagination.

What stays deliberately OUTSIDE the event model — the boundaries of the fold,
worth knowing when you design any app:

- **Unsend / edit** — real deletion removes ciphertext from the server's log
  (author-verified); an append-only fold could only tombstone. The app renders
  these but the *mechanism* is the server's, reached via the host bridge.
- **Finding and inviting people** — roster, invites, key wrapping: identity
  machinery, not thread events. (A pending DM also stays on the original path
  until the invitee joins — its waiting-card gating lives there.)
- **Reactions' emoji live in a plaintext server column** (pre-app mechanism);
  migrating them to encrypted fold events is a candidate follow-up.

One deliberate restyle: the original touch action sheet was a full-screen
long-press menu; the app's long-press opens the compact emoji picker (same
actions, Copy included) instead.

### Sidebar presence hooks

An app decides how its events appear in the chat list: `preview(event)`
returns the sidebar preview line, `unread(event)` says whether a live event
lights the thread's unread dot. Both default to silent (the old behaviour);
poll opts in — a new poll previews as `📊 <question>` and marks unread, while
votes stay quiet. Push wording for app events is the `notice` field (Voice).

🤖 `preview` gets no `{ group }`-style hint and no ctx — it's a pure
`event => string`, called for BOTH your personal thread's row and a group's.
Two things worth knowing before you write one (learned adding vault.js's):

- **It runs your event through, not a generic formatter.** Before `preview`
  existed for a given type, the host fell back to treating the raw typed event
  as if it were a plain chat message (reading `.text`) — which a typed event
  doesn't have, so a group row read as the sender's name, a colon, and nothing
  (`"Ciara: "`). This is fixed at the host level (both the live path and the
  cold-load path in `shell.js`'s `loadThreads` now call `preview()` for any
  typed event, updating the row only when it returns non-null) — but it means
  **any event type your reducer owns should either have a `preview` line or
  stay silent on purpose**; there's no longer a "raw" fallback to fall into by
  accident, so don't leave a type unhandled by assuming something reasonable
  will show.
- **"You" is on you.** Unlike `chatMessage` (attributed by the host, see
  above), `preview` has to do its own "your own event" check — import
  `currentUserId` from `../apps.js` and compare it to `event.uid`:
  ```js
  import { defineApp, currentUserId } from "../apps.js";
  preview(e) {
    const who = e.uid && e.uid === currentUserId() ? "You" : e.name || "Someone";
    return `${who} did the thing`;
  }
  ```
  `currentUserId()` exists for exactly this — a narrow door to "who's reading"
  for the pure hooks (`preview`, and any future one) that don't get a `ctx`.

### Embedded (native shell) mode

The iOS/macOS shell renders one thread per webview and draws its own composer,
speaking a small contract: `window.lchatNative` inbound (sendText / typing /
stageAttachment / cancelReply / typeahead / didAttach) and `nativePost`
outbound ({replying}, {staged}, {error}). The chat app implements the same
contract and is the default in the webview too; the host routes
`window.lchatNative` to the app's composer handlers, the app hides its own
composer chrome, and reply banners / staged state / errors post back to the
shell. A shell that needs the original feed sets `originalChat: true` on the
injected `__lchatEmbed` config.

### Switching engines

The chat app is the default. A device switches to the original feed with the
⋮ menu → “Use original chat” (or
`localStorage.setItem("lchat-original-chat", "1")`) — the same menu item
switches back, and on the original feed the composer placeholder reads
“Message (original chat)” so you always know which engine you're on. Once the
original path has soaked out of rotation, it (~2k lines of shell.js plus its
CSS) can be deleted.

The design rule stands: **don't add app capabilities chat couldn't use**, and
prefer generalising an existing chat mechanism (as `ephemeral` generalised the
typing frame) over inventing an app-only parallel.

## Invariants (the things that fail silently if you skip them)

1. **Idempotent fold.** The message window is re-folded from scratch on every
   thread reopen, so folding the same add twice must produce one item. `addItem`
   handles this by deduping on `id`; a custom fold must dedupe too. (The
   framework also skips re-folding the same *server message* within a session,
   but that doesn't cover a genuinely duplicated emit — dedupe on your entity id.)
2. **Pure, immutable reducers.** Never mutate prior state. The collection helpers
   return fresh arrays; if you go custom, spread (`{ ...obj }`, `[...arr]`).
   This is also what makes snapshots (Memory, above) correct.
3. **Don't set `name` / `ts` / `uid` on emit** — the transport injects them.
   Setting `ts: Date.now()` is dead code (the transport overwrites it).
4. **Namespace event types** with your app `name`; they're globally unique.
   `namespace(name)` (see [Namespacing](#namespacing-one-prefix-declared-once))
   derives them for you so the prefix can't drift.
5. **Never `innerHTML` user content** — `textContent` / DOM nodes only.
6. **Widget-pane apps render an honest empty state** (a one-line hint beats a
   blank pane — see poll.js).
7. **You never touch the server or keys.** State persistence, scheduling, and
   notifications all flow through `ctx` — if you find yourself reaching for
   `fetch` or a key, you've taken a wrong turn.
8. **Validate events in the reducer, not at the emitter.** Anything a thread
   member (person, other device, bot) can send is a legal input to your fold —
   malformed or hostile events must fold to a no-op, never a crash or a lie.
9. **A scheduled event will arrive later, into different state.** Fold handlers
   for scheduled types must tolerate their target being gone (dismissed while
   the delivery was in flight).

## Testing

Pure logic (reducers, parsers) is unit-tested in Node — see `test/apps.test.mjs`.
Import the app object and test its synthesised reducer directly:

```js
import { app as noteApp } from "../web/static/apps/note.js";
const reduce = noteApp.reduce;
// reduce(undefined, { type: "note.add", id: "a", text: "hi" }) → { items: [ … ] }
```

Keep any non-trivial parsing pure and exported (like `parseReminder` in
`remind.js`) so it's testable without the DOM.

## Shipping checklist

- [ ] `web/static/apps/<name>.js` — one file, self-contained (logic + styles).
- [ ] `import "/static/apps/<name>.js";` added in `web/static/shell.js`.
- [ ] `node --check web/static/apps/<name>.js` passes.
- [ ] Reducer / parser tests added to `test/apps.test.mjs`; `npm test` green.
- [ ] A panel-opening command? Add it to `WebPanelCommands.catalog`
      (`ios/LChatCore/.../SlashCommands.swift`) so the native apps forward it.
- [ ] A user-facing changelog entry in `changelog/<date>-<slug>.md`.
- [ ] No trailing whitespace; user content rendered with `textContent`.

## 🤖 Voice: how one event speaks on every surface

An app's events can surface in three places, each with its own hook — and a
defaulting chain so declaring less still reads well everywhere:

1. **The chat feed** — `chatMessage(e, opts)` / `groupFeedMessage(e)`.
   Action lines are actor-neutral (`added “X” to the to-dos`); the HOST
   prefixes the actor ("You" for the reader's own act, their name
   otherwise) — never the app. Group lines should say where the data went
   ("to the to-dos"); the personal thread IS the list, so it stays terse.
2. **The thread-list preview** — `preview(e, opts)`, with `opts.you` when
   the host resolved the actor as the reader. No hook → the chat
   projection speaks for the sidebar too, actor-prefixed.
3. **Notifications** — the event's `notice: {title, body}` when set;
   otherwise the same projection chain as the preview. State-only events
   (a whiteboard stroke) project null and stay silent everywhere.

Native clients honour the same chain: schema apps project natively
(AppChatProjection mirrors the wording — change both sides + the vectors
together), everything else answers through the app host
(`projectEvent`/`previewEvent`, docs/native-apps.md).
