# 🤖 Building a member app — the sandboxed authoring guide

This guide teaches you (an LLM asked to build an app, or a person) how to
write a **member app** for an Eleven Messenger / lchat instance: a small
personal tool — a tracker, a list, a counter, a tiny game — that a member
asked for in chat. It is served at `GET /api/member-app-guide`.

Member apps are the self-serve tier of the mini-app platform. Unlike the
admin-installed apps (see `/api/app-guide`), a member app is **user content**:
it is stored end-to-end encrypted and it runs inside an **isolated sandbox
frame** — its own document, with no network, no storage, and no access to the
rest of the app. Write within the rules below and your app behaves exactly
like a built-in one; step outside them and the sandbox simply refuses.

You will produce **exactly one JavaScript file** — a plain ES module. All
logic, rendering, and CSS ship in that one file.

## The one idea

An app has no server. It trades **typed events** over the member's end-to-end
encrypted personal thread and derives its state by **folding** those events.

> **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 three 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. a **render** — how state draws, full-pane, with its own input UI.

You also get, for free:

- **Time** — `ctx.schedule(event, when)` has the *server* deliver an event
  into the thread later (the server holds only ciphertext and a timestamp).
- **Memory** — folded state is snapshotted (encrypted) and re-seeded on open,
  so 1,000 items work as well as 10. Apps do nothing to opt in.
- **Voice** — an event may carry a `notice: { title, body }`; when it arrives
  it alerts the member (in-app, system notification, or push), wording
  end-to-end encrypted.

## What a member app can and can't use

The sandbox now supports **slash commands** (with a **`defaultCommand`** so plain
text captures), a **companion Chat view**, the **`places`** declaration for
sharing into chats or the whole space (see Group-capable apps and
Space-capable apps), and the pure **`../format.js`** text helpers, in addition
to the surface + events core.
These fields still do **nothing** in a member app — do not emit them:

- `messageAction`, `renderEvent`, `preview`, `unread`, `notify`, `hostEvents`,
  `requiresService`, `chatOnly`, `groupFeedMessage` — host-side hooks (member
  apps' events stay silent in a group's own feed today; the frame projects
  into the app's Chat view only).
- `scope`, `groupCapable`, `spaceCapable` — the LEGACY placement vocabulary.
  Already-deployed apps that declare them keep working (they normalize into
  `places`), but new apps declare `places` and nothing else.
- `ctx.open`, `ctx.host` — absent in the sandbox. (`ctx.reply` **is**
  available inside a command handler; see Commands.)

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

1. **Exactly one file**, a **plain ES module** — no TypeScript, no JSX, no
   `require()`, no build step.
2. **Imports are allowed ONLY from `"../apps.js"` and `"../format.js"`.**
   From `../apps.js`: `defineApp`, `addItem`, `updateItem`,
   `removeItem`, `partitionByGrace`, `DONE_GRACE_MS`, `keyedList`. From
   `../format.js` (pure
   text helpers): `tokenizeText`, `richTokens`, `blockTokens`, `firstLink`,
   `youtubeId`, `replySnippet`, `emojiOnlyCount`, `typingSummary`,
   `autoGroupTitle`, `formatBytes`. Nothing else — no other paths, no URLs, no
   dynamic `import()`.
3. **No network calls** — no `fetch`, `XMLHttpRequest`, `WebSocket`,
   `EventSource`, `sendBeacon`. (The sandbox blocks them; an app that needs
   them is broken by design.) All I/O flows through `ctx`.
4. **No browser storage** — no `localStorage`, `indexedDB`, `document.cookie`.
   Durable state lives in the fold; transient UI state (a collapsed toggle)
   lives in module-level variables.
5. **No `window.parent`, `window.top`, or `postMessage`** — the bridge is the
   runtime's business, never the app's.
6. **Never `innerHTML` user content.** Build DOM with `createElement` and set
   text with `textContent`. `innerHTML` only ever for static in-code strings.
7. **Use `ctx.interval(fn, ms)`, never bare `setInterval`, inside `render`.**
8. **Every event type is a plain string prefixed with the app's `name`.**
   `name: "habit"` → write the types out in full: `"habit.add"`, `"habit.tick"`.
   Use that same quoted string as the `events` map key, in every `ctx.emit`, and
   in every `e.type === "habit.add"` comparison. Lowercase; only letters, digits,
   `_`, `-` and `.` after the prefix. (Keep it simple — write the literal string;
   don't reach for a helper to build it.)
9. **Reducers are pure, immutable, and idempotent by entity id.** Folding the
   same add twice must produce one item (the collection helpers guarantee it).
10. **Never carry app data in `name`, `ts`, or `uid`** — the transport
    **overwrites** these three with the sender's screen name, the send time,
    and the sender's user id (every event carries them, exactly like a chat
    message's author). Read them in your reducer (`e.ts` for times, never
    `Date.now()` in a fold); never set them. *Symptom if you ignore this:* an
    event that put its payload in `name` folds back with your **own screen
    name** in it — e.g. `{ type:"habit.add", name:"drink water" }` comes back as
    `name:"James"`, so every habit is named after you. Put app data in a field
    you own — `text`, `label`, `title`, `habit` — never `name`.
11. **Validate events in the reducer, not at the emitter.** Malformed events
    fold to a no-op, never a crash.
12. **You never touch the server, keys, or tokens.**
13. **Interact only through `ctx.emit` — never the browser's own actions.** Wire
    *every* control to an emit: a `<button>`'s `onclick`, or a `<form>`'s
    `onsubmit` (which must call `ctx.emit(...)`). An `<a href>` navigation does
    **nothing** here — the sandbox has no navigation, so an unwired control is
    dead. A `<form>` **works**: native submission has nowhere to go (there's no
    `allow-forms`), so the sandbox runtime intercepts the submit — a submit
    button's click, or Enter in a field — and re-dispatches it as a scripted
    `submit`, so your `onsubmit` runs, nothing navigates, and there's no console
    error. Put the work in `onsubmit` and `ctx.emit(...)` from there. (You never
    need `preventDefault` for that reason — though it's harmless.)

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

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

| field          | required | what it is |
| -------------- | -------- | ---------- |
| `name`         | ✓ | Unique lowercase id (letters, digits, dashes; starts with a letter; ≤ 24 chars). Also the prefix for your event types (e.g. `"habit.add"`). |
| `title`        | ✓ | Human name shown in the Apps list (≤ 40 chars). |
| `glyph`        | ✓ | One emoji used as the app's avatar. |
| `initialState` | ✓ | `() => state`. A **factory**, called to seed the empty state. |
| `events`       | ✓ | `{ "<type>": (state, event, meta) => state }`. The fold. |
| `render`       | ✓ | `(state, el, ctx) => void`. Draws the whole surface. |
| `commands`     |   | `{ "/cmd": (args, ctx) => void }` or `{ "/cmd": { run, desc, args } }`. `args` passed to the handler is the **raw text string** (not a parsed object); the object form's `desc`/`args` are typeahead labels. See Commands. |
| `defaultCommand` |   | The name of one of your `commands` (e.g. `"/add"`). In the companion Chat view, plain (non-slash) text runs it — so typing "buy milk" adds an item, just like To-do. Requires that command; omit if not wanted. |
| `chatMessage`  |   | `(event) => { text, action? } \| string \| null`. Opting in makes the app a **companion** (Chat + Interface views); see Companion chat. |
| `places`       |   | WHERE copies of this app can live — any NON-EMPTY subset of `["you","chat","space"]`. Defaults to `["you"]` (your own private thread). Add `"chat"` to let members add it to a group chat, one shared instance for everyone (see Group-capable apps); add `"space"` to let a member install it FOR THE WHOLE SPACE (`/cmd @everyone`), one shared instance usable from every chat (see Space-capable apps). `"you"` is NOT required — `places: ["space"]` builds a gif-style space-only app. |
| `listed`       |   | `false` to keep the app out of the Apps catalogues — with `places: ["space"]` this is the IMPLICITLY PLACED shape: the one shared copy is used on first command, no add gesture (see "One shared setting for the whole space"). |
| `quiet`        |   | `true` if a command answering in place (a reply, a panel, or an event it emits) must never itself force your app's personal thread into view — it still exists and still receives every event, it just won't pop into the sidebar on its own. The host already adds an "Open `<title>`" chip to every command reply, so the member can still jump there with their own tap. |
| `styles`       |   | A CSS string, injected once. Use the theme variables below. |
| `incremental`  |   | `true` to keep the surface between renders and reconcile with `keyedList`. |
| `ephemeral`    |   | `(event, ctx) => void`. Relay-only events (live presence), rarely needed. |

`state` starts `undefined` and is seeded from `initialState()` on the first
fold — `render` must also tolerate `undefined` state (first open, before any
event): `const items = (state && state.items) || [];`

## The events map & the reducer

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

- **Pure & immutable.** Return new state; never mutate. Two devices fold the
  same events and must converge.
- **No `default` case** — unknown types fall through unchanged automatically.
- `meta` carries `{ id, ts }` (server message id + send time). Rarely needed.

The collection helpers keep list reducers correct by construction:

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

### Event types — plain, name-prefixed strings

Every event type is a quoted string that starts with your app's `name` and a
`.`. Write it out in full everywhere it appears — the `events` map key, the
`ctx.emit` type, and any `e.type ===` comparison — so the same literal string
is used throughout:

```js
name: "habit",
events: {
  "habit.add":    (s, e) => …,
  "habit.remove": (s, e) => …,
},
```

That's the whole rule: one app `name`, and event types are `"<name>.<thing>"`
literals. CSS classes follow the same idea by hand — `"habit-row"` in both the
`styles` blob and the `className` you set.

## Emitting events

`ctx.emit(event)` — the event needs a `type` (the full `"<name>.…"` string) and
whatever your reducer reads. **Mint `ctx.id()` once for a new entity** and reuse
that id in follow-ups:

```js
ctx.emit({ type: "habit.add", id: ctx.id(), text });
ctx.emit({ type: "habit.remove", id: item.id });
```

A form is a common input; wire its `onsubmit` (native submission does nothing
in the sandbox — see hard constraint 13):

```js
const form = document.createElement("form");
const input = document.createElement("input");
form.appendChild(input);
form.onsubmit = (e) => {
  e.preventDefault();                 // the runtime also blocks it, but be explicit
  const text = input.value.trim();
  if (!text) return;
  ctx.emit({ type: "habit.add", id: ctx.id(), text });
  input.value = "";                   // the list itself redraws when the event folds back
};
el.appendChild(form);
```

The transport injects `e.name` (sender's screen name), `e.ts` (send time,
epoch ms) and `e.uid` (sender's user id) on every event — never set them,
just read them in the reducer. Encryption is automatic.

Your UI updates when the event **echoes back** and folds (typically well
under a second). Don't hand-patch state in a click handler — emit and let the
fold redraw.

## `ctx` — what render and handlers get

| member                      | what it does |
| --------------------------- | ------------ |
| `ctx.emit(event)`           | Emit a typed event (encrypted + sent). |
| `ctx.schedule(event, when)` | Server-side future delivery at `when` (epoch ms). Resolves the schedule id `sid`, or `null` on failure — store the sid in an emitted event so any device can cancel. |
| `ctx.unschedule(sid)`       | Cancel a pending scheduled event. |
| `ctx.emitEphemeral(event)`  | Relay-only delivery — never stored, never folded. |
| `ctx.id()`                  | A fresh unique id for a new entity. |
| `ctx.me` / `ctx.meName`     | The member's user id / screen name. |
| `ctx.getState()`            | The latest folded state. |
| `ctx.threadId`              | The app's thread id. |
| `ctx.reply({ text, short })`| **Commands only** — a private "only visible to you" confirmation where the user typed. |
| `ctx.interval(fn, ms)`      | A ticker cleared for you on each redraw. |
| `ctx.onCleanup(fn)`         | Run `fn` before the next redraw. |

For time-based alerts, put a `notice` on the **scheduled** event:

```js
const id = ctx.id();
const sid = await ctx.schedule(
  { type: "habit.remind", id, notice: { title: "💧 Water break", body: "Time for a glass." } },
  Date.now() + 60 * 60 * 1000,
);
ctx.emit({ type: "habit.set", id, sid: sid || "" });
```

## Commands — quick capture from any chat

A member app can offer slash commands. A command typed in **any** chat is
routed into the app's own thread, so `/habit water` files a habit without
opening the app. A command handler runs the same way as a click: it emits
events and optionally replies with a private "only visible to you"
confirmation via `ctx.reply({ text, short? })`.

```js
commands: {
  "/habit": (args, ctx) => {
    const name = args.trim();
    if (!name) return ctx.reply({ text: "Usage: /habit <name> — e.g. /habit drink water" });
    ctx.emit({ type: "habit.add", id: ctx.id(), text: name });
    ctx.reply({ text: `Tracking “${name}”.`, short: `tracking ${name}` });
  },
},
```

**The handler always receives `args` as a raw string** — everything typed after
the command word, already trimmed (`"/habit drink water"` → `args` is
`"drink water"`). There is **no structured or typed argument parsing**: no
options schema, no `args.name`, no parsed object. If you want fields, split the
string yourself (`const [first, ...rest] = args.split(/\s+/)`). This is
deliberately simpler than Discord/Slack slash-command *options* — do not model
those here, and never write a handler that expects a parsed argument object.

The richer object form is **`{ run, desc, args }`**, and every field is display
or handler — none of them is a schema:

- `run(args, ctx)` — the handler, same signature as the bare function (`args`
  is still the raw string).
- `desc` — a one-line description for the `/` typeahead.
- `args` — a **usage-hint string** shown in the typeahead (e.g. `"<name>"`). It
  is a *label*, not an argument declaration; nothing parses it.

```js
commands: {
  "/habit": {
    desc: "Track a new habit",
    args: "<name>",                 // typeahead hint — a label, NOT a schema
    run: (args, ctx) => {           // args is still the raw string
      const name = args.trim();
      if (!name) return ctx.reply({ text: "Usage: /habit <name>" });
      ctx.emit({ type: "habit.add", id: ctx.id(), text: name });
      ctx.reply({ text: `Tracking “${name}”.` });
    },
  },
},
```

Rules: reply with a usage line on bad input (never fail silently); the handler
gets the same `ctx` (emit/schedule/id/getState/me), plus `ctx.reply`. The reply
automatically gets an **Open <app>** button so the member can jump in — you
don't add it. `ctx.reply` accepts only `{ text, short }` (no custom buttons).
The command's UI effect is always its emitted events folding into your state.

**Default command — type to capture.** Set `defaultCommand: "/add"` (naming one
of your `commands`) and plain, non-slash text typed into the app's companion Chat
view runs that command — so "buy milk" adds an item, exactly like typing into
To-do. It only applies in the app's own thread; when a real bot is in the thread,
plain text stays an ordinary message.

## Command panels — a temporary surface where the member typed

A command can answer with more than a reply: `ctx.panel(...)` opens a
**panel** — a transient, interactive surface in the chat the member typed in,
just above the composer, with a host-drawn header and ✕ (Escape works too).
In the sandbox the form is **declarative** — a render closure can't cross the
bridge — so it's two pieces:

1. The command handler calls `ctx.panel({ title, data })`. `data` is plain
   JSON handed to your hook (results, the typed args, whatever the panel
   needs). Optional: `position: "top"` (pin above the feed),
   `animate: false` (no open/close ease). Returns a Promise<boolean> — false
   means no panel could open (fall back to `ctx.reply`).
2. The app declares a top-level **`panel(data, el, panelCtx)`** hook — it runs
   in a fresh sandboxed frame mounted inside the real panel, and draws into
   `el` exactly like `render` does (same DOM rules, same theme variables, your
   `styles` apply).

`panelCtx` is the app `ctx` (emit / schedule / id / me / getState) plus:

- `panelCtx.close()` — dismiss the panel (e.g. after a tap did its job).
- `panelCtx.insertText(t)` — insert text at the composer's caret (clamped;
  use it in response to a tap, never spontaneously).

**The panel opens where the member typed — your `ctx` may not point there.** The
two are answers to different questions: the panel is chrome, so it always draws
in the chat the member is looking at, while `ctx` addresses the *place your app's
data lives* (`docs/space-apps.md`). For a personal or group app those are usually
the same thread and the distinction never shows. It shows for a **space-placed**
app: `ctx.emit` / `ctx.getState()` reach the one copy shared with the whole
space, while the panel is still in the chat. That split is deliberate — "app data
lives space-wide, conversation content stays under the conversation's own key" —
so don't try to `ctx.emit` a *message* into the conversation from a space-placed
app: it would land in the space's log, which renders nowhere.

**One shared setting for the whole space, asked once.** If your app needs a value
every member should share — the classic case is an API key — declare `places:
["space"]` and keep it in your ordinary fold state. The first member to run the
command is asked for it; the event folds into the space's log; everyone else (and
every later joiner) reads it and is never asked again. Adding `listed: false`
alongside makes the app **implicitly space-placed**: it stays out of the Apps
catalogue and needs no "add" gesture, which is right for a command-only utility
that has no pane of its own. `web/static/apps/gif.js` is the worked example — its
Giphy key is one value the space shares.

The catch, and it is not a small one: **fold state cannot keep a secret from a
member of the thread.** Everyone in the space decrypts every event and snapshot
by construction, so a space-shared value is readable by every member — fine for a
public client identifier like Giphy's, wrong for a real secret. Say so in your
own copy when you ask for one, the way `gif.js` does. (And a log is immutable:
replacing the value later leaves the old one readable in history.)

The whole shape, from `web/static/apps-disabled/mood.js` (the working
example — paste it into App Studio to try):

```js
commands: {
  "/mood": {
    desc: "log how you're feeling",
    run(args, ctx) {
      ctx.panel({ title: "How are you feeling?", data: { note: args.trim() } });
    },
  },
},
panel(data, el, ctx) {
  for (const mood of ["😄", "🙂", "😐"]) {
    const b = document.createElement("button");
    b.textContent = mood;
    b.onclick = async () => {
      await ctx.emit({ type: "mood.set", id: ctx.id(), mood, note: data.note });
      ctx.close();
    };
    el.appendChild(b);
  }
},
```

Rules: a panel opens only from a command the member just ran (the host
enforces it); one panel per app (opening another replaces it); the panel frame
is the same no-network, no-keys cage as the app's own surface — `data` and
everything you send back cross the bridge as validated, size-capped JSON. The
declarative form also works for compiled-in apps, so a file written this way
runs identically in both worlds.

## Companion chat — the thread reads as a conversation

By default a member app **takes over** its thread (the surface is the whole
view). Implement **`chatMessage(event)`** and the thread instead offers two
views, toggled in the header: a **Chat** conversation and the app's
**Interface**. `chatMessage` projects each event into a chat line — return
`{ text, action: true }` for a `/me`-style action line, `{ text }` for a bot
bubble, or `null` to keep the event out of the chat. Both views fold from the
one event log, so an action in the Interface shows in the Chat.

```js
chatMessage(e) {
  if (e.type === "habit.add") return { text: `started tracking “${e.text}”`, action: true };
  if (e.type === "habit.tick") return { text: `✓ done for today`, action: true };
  return null; // housekeeping events stay silent
},
```

Use companion for list-like tools where "added X" reads naturally as a
conversation; use takeover (omit `chatMessage`) for boards, games, dashboards.

## Group-capable apps — one shared instance for a whole chat

By default a member app is **personal**: only its author ever opens it, in
their own private thread. Add **`"chat"` to its `places`** and a member can
also add it to a group chat (⋮ → Apps) — the same app, but now **one shared
instance runs for everyone in that group**. Its events post into the *group
thread's* encrypted log instead of your personal one, so every member's
sandboxed frame folds the same events and arrives at the same state. Nothing
else about the app changes — same `events`, same reducer, same `render`.

```js
export const app = defineApp({
  name: "scoreboard",
  title: "Scoreboard",
  glyph: "🏆",
  places: ["you", "chat"],
  // ...initialState, events, render as normal
});
```

Adding or removing the app from a group is a member's own call — any member
can do it, like adding a person, and it's visible to everyone (an action line
in the chat), never silent or server-tracked. There is no admin review step:
the app runs whatever code its author deployed.

**What this means for state and secrecy:**

- **A group thread's copy of the app has its own state**, independent of your
  personal instance (or any other group it's added to) — same app definition,
  different thread, different fold.
- **Every member of the group can decrypt every event and every snapshot**,
  because a thread's encrypted log is readable by every member of that thread
  by construction — that's not a bug to work around, it's how E2EE threads
  work here. So a group-capable app has **no way to keep a secret from one
  member of the group while showing it to another** — there is no per-member
  privacy inside a shared thread.
- If your app wants to show different members different things (e.g. "your
  own stuff in full, everyone else's summarised"), that's a **rendering**
  choice, not a secrecy one: use `ctx.me` in `render` to decide what to draw
  for the current viewer. The underlying state — and anyone determined enough
  to inspect it directly — sees everything regardless.

## Space-capable apps — one shared instance for the whole space

One place up from `"chat"`: add **`"space"` to the app's `places`** and a
member can install it **for everyone in the space** (`/yourcmd @everyone`).
One shared instance runs for the whole space, backed by a hidden all-members
thread, and the app’s commands work from **every** chat with nobody else
installing anything — this is the place for a shared library shape
(stickers, links, a glossary). Design + trust model: `docs/space-apps.md`.

```js
export const app = defineApp({
  name: "stickers",
  title: "Stickers",
  glyph: "🖼️",
  places: ["you", "space"],
  // ...initialState, events, commands, render as normal
});
```

An app doesn't have to keep a personal copy at all: `places: ["space"]`
builds a space-ONLY app — one copy for everyone, no sidebar thread of your
own (add `listed: false` for the implicitly-placed, command-only shape; see
"One shared setting for the whole space" above).

Everything from the group-capable section applies, space-sized: the space
instance has its own state (independent of personal and group copies); every
member of the space can decrypt its events and snapshots (no per-member
secrecy — render-time choices via `ctx.me` only); and installing or removing
is any member’s call, attributed by name in the Apps sheet. The same reducer
bar too: pure, idempotent, validating — the whole space now writes to one log.

## Formatting — safe rich text from `../format.js`

Import the pure text helpers to linkify and format user text **without**
`innerHTML`. `richTokens(text)` / `tokenizeText(text)` split text into tokens
you render as DOM nodes (plain text → a text node, a `{ href }` → an `<a>`, a
`{ mark }` → a `<b>`/`<i>`/`<code>`), which is what keeps it XSS-safe:

```js
import { richTokens } from "../format.js";
// …in render:
for (const t of richTokens(item.text)) {
  if (t.href) { const a = document.createElement("a"); a.href = t.href; a.textContent = t.text; row.appendChild(a); }
  else if (t.mark) { const el = document.createElement(t.mark === "code" ? "code" : t.mark); el.textContent = t.text; row.appendChild(el); }
  else row.appendChild(document.createTextNode(t.text));
}
```

Also available: `blockTokens`, `firstLink`, `youtubeId`, `replySnippet`,
`emojiOnlyCount`, `typingSummary`, `autoGroupTitle`, `formatBytes` — all pure.

## Rendering the surface

`render(state, el, ctx)` is called whenever state changes; the runtime clears
`el` first (unless `incremental: true`). The app owns the whole pane — build
an input row on top, then the list. The pane scrolls; size with `%`/`flex`.

Style with the theme's CSS variables so the app follows light/dark and the
member's chosen theme: `--bg`, `--panel`, `--sidebar`, `--ink` (primary
text), `--muted`, `--border`, `--accent`, `--accent-ink`, `--link`,
`--bubble-me`, `--bubble-me-ink`, `--bubble-them`, `--danger`, `--warn`,
`--radius`, `--shadow`, `--font-display`; use
`cursor: var(--ui-cursor, pointer)` on anything clickable.

For long lists (hundreds of rows) set `incremental: true` and reconcile with
`keyedList(el, items, { key, rev, render })` — unchanged rows keep their DOM.

## A complete app

A real, working member app — copy the shape, change the guts. A personal
habit tracker: add habits, tick them off today, streaks derived in render.

```js
// habits.js
import { defineApp, addItem, updateItem, removeItem } from "../apps.js";

const DAY = 24 * 60 * 60 * 1000;
const dayOf = (ts) => Math.floor(ts / DAY);

export const app = defineApp({
  name: "habit",
  title: "Habit Tracker",
  glyph: "🌱",

  initialState: () => ({ items: [] }),

  // Event types are plain "<name>.…" strings — used here as keys, and again in
  // the ctx.emit calls in render.
  events: {
    "habit.add": (s, e) =>
      e.id && typeof e.text === "string" && e.text.trim()
        ? { items: addItem(s.items, { id: e.id, text: e.text.trim(), ticks: [] }) }
        : s,
    "habit.tick": (s, e) => ({
      items: s.items.map((h) => {
        if (h.id !== e.id) return h;
        const d = dayOf(e.ts || 0);
        return h.ticks.includes(d) ? h : { ...h, ticks: [...h.ticks, d].slice(-400) };
      }),
    }),
    "habit.remove": (s, e) => ({ items: removeItem(s.items, e.id) }),
  },

  styles: `
.habit-form { display: flex; gap: 8px; padding: 12px; border-bottom: 1px solid var(--border); }
.habit-form input { flex: 1; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel); color: var(--ink); }
.habit-row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border); }
.habit-name { flex: 1; overflow-wrap: anywhere; }
.habit-streak { color: var(--muted); font-size: 12px; }
.habit-row button { cursor: var(--ui-cursor, pointer); }
`,

  render(state, el, ctx) {
    const items = (state && state.items) || [];
    const form = document.createElement("form");
    form.className = "habit-form";
    const input = document.createElement("input");
    input.placeholder = "Add a habit…";
    const add = document.createElement("button");
    add.textContent = "Add";
    form.append(input, add);
    form.onsubmit = (ev) => {
      ev.preventDefault();
      const text = input.value.trim();
      if (text) ctx.emit({ type: "habit.add", id: ctx.id(), text });
      input.value = "";
    };
    el.appendChild(form);

    const today = dayOf(Date.now());
    for (const h of items) {
      const row = document.createElement("div");
      row.className = "habit-row";
      const done = h.ticks.includes(today);
      const tick = document.createElement("button");
      tick.textContent = done ? "✅" : "⬜";
      tick.disabled = done;
      tick.onclick = () => ctx.emit({ type: "habit.tick", id: h.id });
      const name = document.createElement("span");
      name.className = "habit-name";
      name.textContent = h.text; // user content → textContent, never innerHTML
      let streak = 0;
      for (let d = today; h.ticks.includes(d); d--) streak++;
      const st = document.createElement("span");
      st.className = "habit-streak";
      st.textContent = streak ? `${streak}🔥` : "";
      const rm = document.createElement("button");
      rm.textContent = "×";
      rm.onclick = () => ctx.emit({ type: "habit.remove", id: h.id });
      row.append(tick, name, st, rm);
      el.appendChild(row);
    }
  },
});
```

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

1. **Idempotent fold** — same event twice, same state (dedupe on entity id).
2. **Pure, immutable reducers** — spread, never mutate.
3. **Don't set `name`/`ts`/`uid` on emit.**
4. **Prefix every event type** with the app `name` — write them as plain
   `"<name>.…"` strings.
5. **Never `innerHTML` user content.**
6. **Tolerate `undefined` state in render** (first open).
7. **A scheduled event arrives later, into different state** — its fold must
   tolerate the target being gone.
8. **Use `e.ts` in folds, never `Date.now()`** — folds replay on every open;
   a wall-clock read in a reducer breaks convergence. (`Date.now()` in
   *render* is fine — deriving "today" at draw time is exactly right.)

## Delivering the app

Produce **one file** passing every hard constraint. There are two ways it
reaches the instance:

- **The assistant bot** deploys it with its `create_app` tool (when a member
  asks a bot to build one).
- **App Studio** — a member deploys it themselves: open **＋ New chat → Apps →
  App Studio**, paste the module, hit **Check** (it's evaluated in the sandbox
  to read back its manifest), then **Deploy**. Same encryption + sandbox as the
  bot path; no admin needed. App Studio also lists your apps and lets you edit
  or remove them.

When you are an outside LLM helping a member, hand them the file and point them
at App Studio (or suggest they ask the instance's assistant to deploy it). The
instance admin can still install a *trusted* app via the admin console — a
different tier (`/api/app-guide` documents it).
