eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

๐Ÿค– 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.

// 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:

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

"/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:

"/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, 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:

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:

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:

"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:

"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):

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:

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

// 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.
  1. 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:

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:

// 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) 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:

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