# 🤖 Building a mini-app — a self-contained authoring guide

This guide teaches you (a person, or an LLM asked to help) how to write a
**mini-app** for an Eleven Messenger / lchat instance: a small client-side
program that lives alongside chats — a to-do list, a poll, a tracker, a game.
It is fully self-contained: you do not need the server's source code, a
repository checkout, or any build tooling. Every instance serves this document
at `GET /api/app-guide`.

You will produce **exactly one JavaScript file** — a plain ES module — that the
instance admin installs. Everything the app is (logic, rendering, CSS) ships in
that one file.

## The one idea

An app has no server. It trades **typed events** over the same end-to-end
encrypted message stream that chat uses, 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.

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).

On top of that model the framework gives every app three capabilities for free
(each has its own section 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 items 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: an in-app announcement, a system
  notification when hidden, a push notification when closed. All wording stays
  encrypted end-to-end.

## Hard constraints — self-check every one before delivering

An app that breaks any of these will be rejected or will misbehave. Check your
finished file against each rule:

1. **Exactly one file.** All logic, rendering, and CSS live in a single
   `<name>.js` — no companion files, no assets, no build step.
2. **A plain ES module.** No bundler output, no TypeScript, no JSX, no
   `require()` — just standard `import`/`export` syntax a browser runs as-is.
3. **Imports are allowed ONLY from `"../apps.js"` and `"../format.js"`.**
   Nothing else — no other relative paths, no URLs, no npm packages, no dynamic
   `import()`. The exact exports you may use are enumerated in
   "Allowed imports" below.
4. **No network calls.** Never `fetch`, `XMLHttpRequest`, or `WebSocket`. All
   I/O flows through `ctx` (emit, schedule, snapshots) — if you're reaching for
   the network, you've taken a wrong turn.
5. **No browser storage.** Never `localStorage`, `indexedDB`, or
   `document.cookie`. Durable state lives in the fold (it's encrypted and
   synced for free); transient UI state (an expanded/collapsed toggle) lives in
   module-level variables.
6. **Never `innerHTML` user content.** Build DOM with `createElement` and set
   text with `textContent`. `innerHTML` is acceptable only for static,
   in-code string literals — never anything that includes user input.
7. **Use `ctx.interval(fn, ms)`, never bare `setInterval`, inside `render`.**
   The framework stops `ctx.interval` tickers on redraw and thread reset; a
   bare `setInterval` leaks one timer per redraw.
8. **Every event type is prefixed with the app's `name`.** App `name: "note"`
   → types `note.add`, `note.remove`. Event types are globally unique across
   all apps.
9. **Reducers are pure, immutable, and idempotent by entity id.** Return new
   state, never mutate the old; folding the same add twice must produce one
   item (the collection helpers guarantee this).
10. **Never set `name`, `ts`, or `uid` on an emitted event** — the transport
    injects them.
11. **Validate events in the reducer, not at the emitter.** Malformed or
    hostile events must fold to a no-op, never a crash.
12. **You never touch the server, keys, or tokens.** Persistence, scheduling,
    and notifications all flow through `ctx`.

## Interview — ask the user these first

Before writing any code, ask the person who wants the app:

1. **Where should it live?** — the `places` declaration, any non-empty subset
   of `["you", "chat", "space"]`:
   - *Personal* (`places: ["you"]`) — their own private tool (a to-do list,
     bookmarks), its own thread in the sidebar, full-pane.
   - *Group-shareable* (`places: ["you", "chat"]`) — the same private tool,
     ALSO addable to a specific group chat, where one shared instance runs
     for that group (see "Group-scoped apps" below). 🤖 Someone asking for
     "a group tool everyone in the chat uses together" usually means THIS.
     A group tool with no personal copy at all is `places: ["chat"]` (Poll,
     the Whiteboard): its command auto-adopts it into the group on first use.
   - *Space-wide* (add `"space"`) — one shared copy for the WHOLE space,
     installed once (`/cmd @everyone`), commands usable from every chat: the
     shared-library shape (stickers, links, a glossary). `places: ["space"]`
     alone is the space-only shape — one copy for everyone, no personal
     thread (`/gif`).
   (The legacy `scope` enum is retired: `scope: "personal"` still normalizes
   into `places: ["you"]` for old sources, and the `"shared"` ambient panel
   is gone — a legacy `scope: "shared"` app degrades to command-only.)
2. **Takeover or companion?** For a personal app: should its surface *be* the
   whole thread (takeover — the default; right for boards, games, dashboards),
   or should the thread also offer a Chat view where the app's actions read as
   a conversation (companion — implement `chatMessage(event)`; right for
   list-like tools where "added X" reads naturally as a chat line)?
3. **Which slash commands, and what syntax?** e.g. `/note buy milk` — what's
   the command name, what arguments does it take, and what should happen on
   empty/bad input? (Reply with a usage line, never fail silently.)
4. **Anything time-based?** Should something happen *later* — a reminder, an
   expiry, a recurring nudge? That's `ctx.schedule`.
5. **Should anything alert people?** Which events deserve a notification, and
   what should it say? That's a `notice` on the event (or a `notify` hook for
   state-dependent wording). Everything else stays silent.
6. **What is the core entity, and what is its lifecycle?** A note: created,
   removed. A task: created, completed, reopened, removed. A vote: cast,
   changed. **Derive the events map directly from the answer** — one event
   type per lifecycle transition, e.g. `task.add`, `task.done`, `task.reopen`,
   `task.remove`.
7. **How big can the list get?** If a surface can grow long (hundreds of
   rows), plan for `incremental: true` + `keyedList` so redraws stay cheap.

## The shape of an app: `defineApp(spec)`

Your file calls `defineApp` once, at module load, and exports the result:

```js
import { defineApp } from "../apps.js";
export const app = defineApp({ /* fields below */ });
```

| field          | required | what it is |
| -------------- | -------- | ---------- |
| `name`         | ✓ | Unique lowercase id. Also the prefix for your event types + CSS classes. |
| `namespace`    |   | The event-type / CSS-class prefix. Defaults to `name`; drives `namespace()`, `ctx.ev`, `ctx.cls` — see Namespacing. Leave off unless it must differ from `name`. |
| `title`        | ✓ | Human name (sidebar, header). |
| `glyph`        | ✓ | One emoji/character used as the app's avatar. |
| `places`       | ✓ | WHERE copies can live — any non-empty subset of `["you", "chat", "space"]`; see the interview above. (Legacy `scope: "personal"` still normalizes in for old sources.) |
| `groupFeedMessage` |   | `(event) => { text, action? } \| string \| null` — the FEED-ONLY projection: narrates events in a GROUP's chat feed WITHOUT giving your app a companion Chat view (that's `chatMessage`'s meaning). Implement it instead of `chatMessage` for a takeover app whose group activity should still be visible; alongside it for group-specific wording. See "Group-scoped apps" below. |
| `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 }` or `{ "/cmd": { run, desc, args } }` — `desc`/`args` feed the `/` typeahead. |
| `render`       |   | `(state, el, ctx) => void`. Draws the surface. |
| `chatMessage`  |   | `(event, opts?) => { text, action? } \| null`. Opting in makes the app a **companion**: projects each event into a Chat view (a bot bubble, or a `/me`-style action line via `{ text, action: true }`). No `chatMessage` → the app **takes over** the thread's full surface. `opts.group` is `true` when called for a GROUP's own chat feed — see "Group-scoped apps" below. |
| `chatIntro`    |   | The empty-state intro line shown the first time the companion Chat view opens. |
| `messageAction`|   | `(msg) => { label, glyph, run(ctx) } \| 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). |
| `preview`      |   | `(event) => string \| null`. Sidebar preview line for an event (default: silent). |
| `unread`       |   | `(event) => boolean`. Whether a live event lights the thread's unread dot (default: no). |
| `snapshot`     |   | Set `false` to opt out of state snapshots (only for 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. Style with the theme variables (below). |

`defineApp` returns the app object with `.types` (the owned event types) and
`.reduce` (the synthesised fold function) derived from `events` — handy for
testing the reducer in Node.

## 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 reopen.
- **No `default` case.** The framework returns state unchanged for any type
  your app doesn't handle. Your map lists only what you own.
- **Event types are globally unique.** Prefix every type with your app `name`
  (`note.add`, `poll.vote`) — or let `namespace()` do it (below).
- `meta` carries `{ id, ts }` where `id` is the server message id (used for
  dedupe) and `ts` the sender's send time. You rarely need it.

### Namespacing — declare the prefix once

Your app prepends the same string onto its **event types** (`note.add`, joined
with `.`) and its **CSS classes** (`note-item`, joined with `-`). Write that
prefix by hand everywhere and it drifts. `namespace(name)` derives both from one
declaration:

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

const N = namespace("note");           // one declaration

export const app = defineApp({
  name: "note",
  events: {
    [N.ev("add")]: (s, e) => …,        // key is "note.add"
    [N.ev("remove")]: (s, e) => …,     // key is "note.remove"
  },
  render(state, el, ctx) {
    const row = document.createElement("div");
    row.className = ctx.cls("item");   // "note-item"  (ctx.cls === N.cls)
    ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text }); // "note.add"
  },
});
```

- **`N.ev(short)`** → `"note.short"` for the `events` keys (`[N.ev("add")]`),
  `ctx.emit` types, and `e.type === …` comparisons.
- **`N.cls(short)`** → `"note-short"` for `className` / selectors. The `styles`
  string stays a plain literal — the helper keeps the JS references in step.
- Inside `render`/`commands` it's on `ctx` as **`ctx.ev`** / **`ctx.cls`**.
- It's **optional** (`namespace` defaults to `name`), and a name already
  containing a `.` passes through untouched, so adopting it is a safe rename.

### 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 unless the id is already present; always a fresh array
updateItem(list, id, patch) // replace the 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. Not every fold is a flat list — a per-entity map is fine too,
as long as you update it immutably:

```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 — never 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. a poll 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.

## `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.emitEphemeral(event)`   | anywhere      | Relay-only delivery — never stored, never folded (see Ephemeral events). |
| `ctx.id()`                   | anywhere      | A fresh unique id for a new entity. |
| `ctx.me`                     | anywhere      | The current user's id. |
| `ctx.meName`                 | anywhere      | The current user's screen name. |
| `ctx.getState()`             | anywhere      | The app's latest folded state for this thread. |
| `ctx.threadId`               | anywhere      | The thread the app is acting in. |
| `ctx.reply(spec)`            | commands only | A private "Only visible to you" confirmation rendered where the user typed. `spec` is `{ text, short?, actions? }` — `short` is the terse one-liner it calms down to; `actions` is `[{ label, run }]` chips. |
| `ctx.open()`                 | action chips  | Navigate to the app's thread — offer it as an action chip; **never** call it as a command's side effect. |
| `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.host` also exists — a narrow privileged bridge the host injects for
built-in connected-service apps. Leave it alone.)

### Commands answer in place

Running a command produces a **reply in the current context** — not a UI
change, not a navigation. On bad input, reply with a usage line:

```js
commands: {
  "/note": {
    desc: "File a note",
    args: "<text>",
    run: (args, ctx) => {
      const text = args.trim();
      if (!text) return ctx.reply({ text: "Usage: /note <text> — e.g. /note call the plumber" });
      ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text }); // "note.add"
      ctx.reply({ text: `Filed “${text}”.`, short: `filed “${text}”`, actions: [{ label: "View notes", run: ctx.open }] });
    },
  },
},
```

For a personal app, the event is routed into the user's own app-thread no
matter which chat they typed in; for a shared app it targets the current chat.
If a command should move the user somewhere, offer that as an **action chip**
on the reply — leaving the current context is always the user's tap.

## 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 see it live. 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:

- **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.
- **The scheduled event is still just an event.** Its fold handler must be
  idempotent like any other — a cancel can race a delivery, so a fire folding
  onto an already-removed id must be a no-op.
- 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 the state — encrypted under the thread key — to the server, and re-seeds
it when the thread opens, folding only newer events 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 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).
- **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 announces 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 — an add from your other device never
buzzes anyone.

## 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`).

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
  (`"myapp.cursor": (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; folding one would break convergence. Keep transient
  UI in module-level state and redraw it yourself.

## 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.
- **Never `innerHTML` user content.** Build nodes with `createElement` and set
  text with `textContent`.
- Ship your CSS in the `styles` field and read the theme's variables so the
  app follows light/dark and the user's chosen theme.

### Theme CSS variables

These custom properties are defined by every theme — style with them instead
of hard-coded colours:

| variable | what it is |
| -------- | ---------- |
| `--bg` | page background |
| `--panel` | panel / card background |
| `--sidebar` | sidebar background |
| `--ink` | primary text colour |
| `--muted` | secondary / de-emphasised text |
| `--border` | hairline borders and dividers |
| `--accent` | the theme's accent colour (buttons, highlights) |
| `--accent-ink` | text colour that sits on `--accent` |
| `--link` | link colour |
| `--bubble-me` | own-message bubble background |
| `--bubble-me-ink` | text colour on `--bubble-me` |
| `--bubble-them` | others' bubble background — also the standard hover/selected tint |
| `--danger` | destructive-action colour |
| `--warn` | warning colour |
| `--radius` | the standard corner radius |
| `--shadow` | the standard shadow colour |
| `--font-display` | the theme's display font stack |

For anything clickable, use `cursor: var(--ui-cursor, pointer)` so the app
respects the platform's cursor convention.

## 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 set
`incremental: true` (the framework stops wiping) and reconcile with
`keyedList` from `"../apps.js"`:

```js
keyedList(listEl, state.items, {
  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
(a form, headers) on first render by checking whether it already exists.

## Optional hooks

### `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:

```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 || "" }); // "bookmark.add"
    },
  };
},
```

### `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.

### `chatMessage(event)` — companion presentation

A personal app that implements `chatMessage` gets a **Chat view** alongside
its **Interface view**, toggled in the thread header. Project each event into
a chat line — return `{ text }` for a bot bubble, `{ text, action: true }` for
a `/me`-style action line, or `null` to keep the event out of the chat:

```js
chatMessage(e) {
  if (e.type === "todo.add") return { text: `added “${e.text}”`, action: true };
  return null;
},
```

Both views fold from the same event log — one source of truth — so an action
in one shows in the other. No `chatMessage` → the app takes over the whole
thread surface (the default).

🤖 If your app's `places` include `"chat"` and it gets added to a GROUP,
its events can show up in THAT group's own chat feed too — through
`chatMessage(event, { group: true })` by default, or the feed-only
`groupFeedMessage` hook — see "Group-scoped apps" below for what changes
(attribution, the `opts.group` argument, and the takeover case).

### `preview(event)` / `unread(event)`

How your events appear in the chat list: `preview` returns the sidebar
preview line (or `null` to leave the existing preview), `unread` says whether
a live event lights the thread's unread dot. Both default to silent — opt in
only for events a user would want surfaced (a new poll: yes; every vote: no).

🤖 `preview` gets no ctx — it's a pure `event => string`. If you want it to say
“You” for the reader's own event (rather than always naming the actor), import
`currentUserId` 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} added “${e.text}”`;
}
```

## Group-scoped apps: personal AND shared with a group

🤖 An app whose `places` include both `"you"` and `"chat"` can, IN ADDITION
to everyone's own private instance, be **added to a specific group chat**
(members do this from that chat's ⋮ menu → Apps — no code of yours makes
that happen, it's host UI). Once added, **one shared instance runs for that
whole group**: every member's `ctx.emit` posts into the GROUP's own encrypted
event log instead of their personal thread, and every member folds the same
state. Nothing about your `render`, `events`, or `commands` changes — `ctx` is
just bound to whichever thread it's acting in, so the exact same code renders
a to-do list whether it's yours alone or the group's shared one.

What changes once your app can be group-instanced:

- **Any group member's event is now a first-class input**, not just yours.
  Hard constraint #11 ("validate in the reducer, never trust the emitter") is
  no longer a defensive nicety — a malformed or adversarial event from ANY
  member of the group must fold to a no-op, because it can now come from
  someone other than you.
- **`chatMessage` action lines get attributed automatically.** When a group
  has your app added, an `{ text, action: true }` projection is shown prefixed
  with the actor's name, or **“You”** when the reader is the one who acted —
  both entirely automatic. Write `text` as if it always follows a name
  (`added “buy milk”`, never `Ciara added “buy milk”`) — the host supplies
  the name/“You”, so baking one in yourself produces a doubled-up line.
- **`chatMessage` receives a second argument in a group: `chatMessage(event,
  { group: true })`.** Use it only if wording needs to change for many
  simultaneous readers (e.g. a reminder's `"you asked me"` phrasing only makes
  sense one-to-one, so a group projection drops it). Ignore the second
  argument if your wording doesn't depend on it.
- **`groupFeedMessage(event)` splits "projects into a group's feed" from
  "wants its own Chat view."** Implementing `chatMessage` makes your app a
  companion (Chat view + toggle on its personal thread) — always. If your
  personal surface shouldn't grow a second (redundant, or privacy-sensitive)
  view of its own events, but a group that shares an instance should still
  notice activity without opening the app, implement `groupFeedMessage`
  INSTEAD of `chatMessage`: the host calls it only when folding a group's
  own feed, so the personal thread stays a pure takeover while the group
  gets an attributed line — e.g. a shared secrets vault that announces
  "Ciara added a secret" without ever listing secret names in a Chat view.
  Implementing BOTH gives a companion app group-specific wording (and a
  `null` from `groupFeedMessage` keeps it silent in groups); with only
  `chatMessage`, the group fold falls back to it with `{ group: true }`.
- **`preview`/`unread` work exactly the same** for a group instance as a
  personal one — no group-specific argument, just whatever `event.name` /
  `event.uid` the event already carries.

## Allowed imports

Import **only** from these two modules, and only these exports.

From `"../apps.js"`:

| export | what it is |
| ------ | ---------- |
| `defineApp(spec)` | registers the app — call exactly once |
| `namespace(name)` | one-declaration prefix helper: `N.ev("add")` → `"name.add"` (event type), `N.cls("row")` → `"name-row"` (CSS class) — see Namespacing |
| `addItem(list, item)` | immutable append, deduped by `id` |
| `updateItem(list, id, patch)` | immutable in-place patch |
| `removeItem(list, id)` | immutable removal |
| `partitionByGrace(items, doneAtOf, now, graceMs?)` | split items into `{ active, grace, archived }` by a completion timestamp — for "linger then tuck away" UI |
| `DONE_GRACE_MS` | the standard grace-period length (ms) |
| `keyedList(el, items, opts)` | incremental DOM reconciliation for `incremental: true` surfaces |
| `currentUserId()` | the reader's own user id — for a "You" check in `preview` (see above) or `render` |

(`apps.js` exports other functions — `setEmitTransport`, `foldEvent`,
`runCommand`, … — those are host/framework wiring. An app must not call them.)

From `"../format.js"` (all pure text helpers):

| export | what it is |
| ------ | ---------- |
| `tokenizeText(text)` | split text into `{ text }` / `{ text, href }` tokens — XSS-safe linkifying |
| `richTokens(text)` | tokens with inline formatting too: `{ text, mark }`, mark ∈ `b`/`i`/`s`/`code` |
| `blockTokens(text)` | block structure: `{ type: "p"\|"code"\|"quote", text }` |
| `firstLink(text)` | the first http(s) link in a message, skipping code blocks |
| `youtubeId(url)` | parse a YouTube URL into `{ id, start }`, or `null` |
| `replySnippet(payload, max?)` | short plain-text preview of a message |
| `emojiOnlyCount(text)` | how many emoji, if the text is emoji-only (else 0) |
| `typingSummary(names)` | "X is typing…" one-liner |
| `autoGroupTitle(names)` | member names joined into a group title |
| `formatBytes(n)` | human-readable file size |

Render tokens as DOM nodes (`document.createTextNode` for plain text, an
element per marked token) — that's what keeps them XSS-safe.

## A complete app

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
// note.js
import { defineApp, namespace, addItem, removeItem } from "../apps.js";

// One declaration of the app's prefix — N.ev("add") → "note.add" (event type),
// N.cls("item") → "note-item" (CSS class). See Namespacing above.
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"],       // its own sidebar thread, rendered full-pane

  // 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. Handlers must be PURE: return new state, never
  // mutate the old. The collection helpers keep adds idempotent.
  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, with a private confirmation reply.
  commands: {
    "/note": {
      desc: "File a note",
      args: "<text>",
      run: (args, ctx) => {
        const text = args.trim();
        if (!text) return ctx.reply({ text: "Usage: /note <text> — e.g. /note call the plumber" });
        ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text });
        ctx.reply({ text: `Filed “${text}”.`, short: `filed “${text}”` });
      },
    },
  },

  // The app's CSS travels with it — injected once by the framework. Read the
  // theme's CSS variables so it follows the theme. The stylesheet stays a plain
  // literal; ctx.cls() below keeps the JS references in step with it.
  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 place: `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 && 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);
    }
  },
});
```

## 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.
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 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.
5. **Never `innerHTML` user content** — `textContent` / DOM nodes only.
6. **Shared apps render nothing when empty** so their panel collapses.
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).

## Delivering the app

Produce **one file**, named after the app (`note.js`, `poll.js`, …), passing
every hard constraint above. Then hand it to the person who runs the instance:
custom apps are installed by the **instance admin** (from the admin console's
Apps section, where available) — you don't deploy anything yourself, and the
file needs no changes to be installed.

Before delivering, self-review against the hard-constraints list and the
invariants. The instance also serves a static checker that verifies the
mechanical rules (one `defineApp`, allowed imports only, no forbidden APIs,
event-type prefixes, …). Download it from the same server this guide came
from and run it with Node — it has zero dependencies:

```sh
curl -sO https://<instance>/api/check-app.mjs
node check-app.mjs your-app.js
```

and iterate until it passes. (If you happen to have the lchat repository
instead, the same file is `tools/check-app.mjs`.)
