eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

๐Ÿค– Building a mini-app โ€” a self-contained authoring guide

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

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

The one idea

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

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

An app is four small things:

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

On top of that model the framework gives every app three capabilities for free (each has its own section below):

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

Hard constraints โ€” self-check every one before delivering

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

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

Interview โ€” ask the user these first

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

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

The shape of an app: defineApp(spec)

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

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

| field | required | what it is | | -------------- | -------- | ---------- | | name | โœ“ | Unique lowercase id. Also the prefix for your event types + CSS classes. | | namespace | | The event-type / CSS-class prefix. Defaults to name; drives namespace(), ctx.ev, ctx.cls โ€” see Namespacing. Leave off unless it must differ from name. | | title | โœ“ | Human name (sidebar, header). | | glyph | โœ“ | One emoji/character used as the app's avatar. | | places | โœ“ | WHERE copies can live โ€” any non-empty subset of ["you", "chat", "space"]; see the interview above. (Legacy scope: "personal" still normalizes in for old sources.) | | groupFeedMessage | | (event) => { text, action? } \| string \| null โ€” the FEED-ONLY projection: narrates events in a GROUP's chat feed WITHOUT giving your app a companion Chat view (that's chatMessage's meaning). Implement it instead of chatMessage for a takeover app whose group activity should still be visible; alongside it for group-specific wording. See "Group-scoped apps" below. | | initialState | โœ“ | () => state. A factory, called to seed each thread's empty state. | | events | โœ“ | { "<type>": (state, event, meta) => state }. The fold. | | commands | | { "/cmd": (args, ctx) => void } or { "/cmd": { run, desc, args } } โ€” desc/args feed the / typeahead. | | render | | (state, el, ctx) => void. Draws the surface. | | chatMessage | | (event, opts?) => { text, action? } \| null. Opting in makes the app a companion: projects each event into a Chat view (a bot bubble, or a /me-style action line via { text, action: true }). No chatMessage โ†’ the app takes over the thread's full surface. opts.group is true when called for a GROUP's own chat feed โ€” see "Group-scoped apps" below. | | chatIntro | | The empty-state intro line shown the first time the companion Chat view opens. | | messageAction| | (msg) => { label, glyph, run(ctx) } \| null. Adds an item to the chat message menu. | | renderEvent | | (event, meta) => Node \| null. An inline line in the message feed. | | notify | | (event, ctx) => { title, body } \| null. Speak on a live event (see Voice). | | ephemeral | | (event, ctx) => void. Handle relay-only events (see Ephemeral events). | | preview | | (event) => string \| null. Sidebar preview line for an event (default: silent). | | unread | | (event) => boolean. Whether a live event lights the thread's unread dot (default: no). | | snapshot | | Set false to opt out of state snapshots (only for an app whose state IS the log). | | incremental | | Set true to keep the surface between renders and reconcile it yourself (see keyedList). | | styles | | A CSS string, injected once. Style with the theme variables (below). |

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

The events map & the reducer

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

  • Pure & immutable. Return new state; never mutate state. Two devices fold the same events and must converge; the UI also re-folds on reopen.
  • No default case. The framework returns state unchanged for any type your app doesn't handle. Your map lists only what you own.
  • Event types are globally unique. Prefix every type with your app name (note.add, poll.vote) โ€” or let namespace() do it (below).
  • meta carries { id, ts } where id is the server message id (used for dedupe) and ts the sender's send time. You rarely need it.

Namespacing โ€” declare the prefix once

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

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

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

export const app = defineApp({
  name: "note",
  events: {
    [N.ev("add")]: (s, e) => โ€ฆ,        // key is "note.add"
    [N.ev("remove")]: (s, e) => โ€ฆ,     // key is "note.remove"
  },
  render(state, el, ctx) {
    const row = document.createElement("div");
    row.className = ctx.cls("item");   // "note-item"  (ctx.cls === N.cls)
    ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text }); // "note.add"
  },
});
  • N.ev(short) โ†’ "note.short" for the events keys ([N.ev("add")]), ctx.emit types, and e.type === โ€ฆ comparisons.
  • N.cls(short) โ†’ "note-short" for className / selectors. The styles string stays a plain literal โ€” the helper keeps the JS references in step.
  • Inside render/commands it's on ctx as ctx.ev / ctx.cls.
  • It's optional (namespace defaults to name), and a name already containing a . passes through untouched, so adopting it is a safe rename.

Collection helpers

Most apps fold into a list of { id, โ€ฆ } items. Import these from "../apps.js" and your reducer is correct by construction โ€” every op returns a new array, and addItem dedupes by id:

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

Because addItem always returns a fresh array, you can .sort() its result in place safely. Not every fold is a flat list โ€” a per-entity map is fine too, as long as you update it immutably:

"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 โ€” never set them yourself, just read them in your reducer:

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

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

ctx โ€” what a handler and a render get

| member | in | what it does | | ---------------------------- | ------------- | ------------ | | ctx.emit(event) | anywhere | Emit a typed event (encrypted + sent). Returns Promise<boolean>. | | ctx.schedule(event, when) | anywhere | Have the server deliver event into the thread at when (epoch ms). Returns Promise<sid \| null>. | | ctx.unschedule(sid) | anywhere | Cancel a pending scheduled event. | | ctx.emitEphemeral(event) | anywhere | Relay-only delivery โ€” never stored, never folded (see Ephemeral events). | | ctx.id() | anywhere | A fresh unique id for a new entity. | | ctx.me | anywhere | The current user's id. | | ctx.meName | anywhere | The current user's screen name. | | ctx.getState() | anywhere | The app's latest folded state for this thread. | | ctx.threadId | anywhere | The thread the app is acting in. | | ctx.reply(spec) | commands only | A private "Only visible to you" confirmation rendered where the user typed. spec is { text, short?, actions? } โ€” short is the terse one-liner it calms down to; actions is [{ label, run }] chips. | | ctx.open() | action chips | Navigate to the app's thread โ€” offer it as an action chip; never call it as a command's side effect. | | ctx.interval(fn, ms) | render only | A ticker the framework stops for you on redraw / reset. | | ctx.onCleanup(fn) | render only | Run fn before the next redraw or on thread reset. |

(ctx.host also exists โ€” a narrow privileged bridge the host injects for built-in connected-service apps. Leave it alone.)

Commands answer in place

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

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

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

Time โ€” scheduled events

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

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

Rules of thumb:

  • Store the returned sid inside an emitted event. It's how any device โ€” not just the one that scheduled โ€” can later ctx.unschedule(sid).
  • Give a scheduled event a notice (see Voice) โ€” it's usually the whole point of scheduling one.
  • Handle sid === null (offline / error): degrade gracefully.
  • The scheduled event is still just an event. Its fold handler must be idempotent like any other โ€” a cancel can race a delivery, so a fire folding onto an already-removed id must be a no-op.
  • Cancelling an id that already fired succeeds quietly; that race is expected.

Memory โ€” snapshots (automatic)

The client only holds a window of a thread's messages (~100), but folded app state is snapshotted automatically: after new events fold, the framework saves the state โ€” encrypted under the thread key โ€” to the server, and re-seeds it when the thread opens, folding only newer events on top.

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

  • Don't bound your state by the window. A 1,000-item list is fine.
  • State is durable, so treat it with respect: never put anything in reducer state you wouldn't put in an event (it's all encrypted, but it's persisted).
  • Schema-evolve defensively. A snapshot written by an old version of your app will be seeded into a new version. Reducers and render must tolerate missing fields (e.note || "" โ€” the same rule as for old events).

Voice โ€” talking to the user

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

  1. notice on an event โ€” the simple, usual way. Any event (typically a scheduled one) may carry notice: { title, body }. When it arrives live, the framework announces it in-app and raises a system notification if the page is hidden; when the app is closed, the service worker decrypts the push and shows the same notice. One field, every situation, end-to-end encrypted.
  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 โ€” an add from your other device never buzzes anyone.

Ephemeral events โ€” presence without a trace

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

Two rules:

  • Own the type. Routing is by event type, so an ephemeral type must appear in your events map โ€” claim it with a no-op fold ("myapp.cursor": (s) => s). That also makes it harmless if a hostile client stores one as a real message.
  • Never touch reducer state from the hook. Ephemeral events are allowed to differ between devices; folding one would break convergence. Keep transient UI in module-level state and redraw it yourself.

Rendering a surface

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

  • Personal apps render full-pane: el is the thread view. Usually an add form on top, then the list.
  • Shared apps render a panel above the chat feed. Render nothing when state is empty (just return) so the panel collapses until there's something to show.
  • Never innerHTML user content. Build nodes with createElement and set text with textContent.
  • Ship your CSS in the styles field and read the theme's variables so the app follows light/dark and the user's chosen theme.

Theme CSS variables

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

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

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

Incremental surfaces โ€” keyedList

By default the framework wipes and rebuilds an app's surface on every state change โ€” the right default for small surfaces. For long lists set incremental: true (the framework stops wiping) and reconcile with keyedList from "../apps.js":

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

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

Optional hooks

messageAction(msg)

Contribute an item to a chat message's long-press / hover menu. Given the decrypted message { text, from }, return { label, glyph, run(ctx) } to offer the action, or null to sit this message out. The run gets a ctx already bound to your app-thread (for a personal app), so the action files into your app:

import { firstLink } from "../format.js";
// โ€ฆ
messageAction(msg) {
  const url = firstLink(msg.text || "");
  if (!url) return null;
  return {
    label: "Save link",
    glyph: "๐Ÿ”–",
    run(ctx) {
      ctx.emit({ type: ctx.ev("add"), id: ctx.id(), url, from: msg.from || "" }); // "bookmark.add"
    },
  };
},

renderEvent(event, meta)

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

chatMessage(event) โ€” companion presentation

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

chatMessage(e) {
  if (e.type === "todo.add") return { text: `added โ€œ${e.text}โ€`, action: true };
  return null;
},

Both views fold from the same event log โ€” one source of truth โ€” so an action in one shows in the other. No chatMessage โ†’ the app takes over the whole thread surface (the default).

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

preview(event) / unread(event)

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

๐Ÿค– preview gets no ctx โ€” it's a pure event => string. If you want it to say โ€œYouโ€ for the reader's own event (rather than always naming the actor), import currentUserId and compare it to event.uid:

import { defineApp, currentUserId } from "../apps.js";
preview(e) {
  const who = e.uid && e.uid === currentUserId() ? "You" : e.name || "Someone";
  return `${who} added โ€œ${e.text}โ€`;
}

Group-scoped apps: personal AND shared with a group

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

What changes once your app can be group-instanced:

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

Allowed imports

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

From "../apps.js":

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

(apps.js exports other functions โ€” setEmitTransport, foldEvent, runCommand, โ€ฆ โ€” those are host/framework wiring. An app must not call them.)

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

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

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

A complete app

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

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

// One declaration of the app's prefix โ€” N.ev("add") โ†’ "note.add" (event type),
// N.cls("item") โ†’ "note-item" (CSS class). See Namespacing above.
const N = namespace("note");

export const app = defineApp({
  name: "note",          // unique id, lowercase โ€” also the default namespace
  title: "Notes",        // shown in the sidebar and header
  glyph: "๐Ÿ“",           // avatar glyph
  places: ["you"],       // its own sidebar thread, rendered full-pane

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

  // The events map: KEYS are the event types this app owns, VALUES fold each
  // event into new state. Handlers must be PURE: return new state, never
  // mutate the old. The collection helpers keep adds idempotent.
  events: {
    [N.ev("add")]: (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text, by: e.name || "" }) } : s),
    [N.ev("remove")]: (s, e) => ({ items: removeItem(s.items, e.id) }),
  },

  // A slash command a user can type in any chat. It emits an event; because
  // this is a personal app, the event is routed into the user's own Notes
  // thread wherever they typed it, with a private confirmation reply.
  commands: {
    "/note": {
      desc: "File a note",
      args: "<text>",
      run: (args, ctx) => {
        const text = args.trim();
        if (!text) return ctx.reply({ text: "Usage: /note <text> โ€” e.g. /note call the plumber" });
        ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text });
        ctx.reply({ text: `Filed โ€œ${text}โ€.`, short: `filed โ€œ${text}โ€` });
      },
    },
  },

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

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

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

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

Delivering the app

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

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

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

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