eleven / messenger Public
๐ค Building a member app โ the sandboxed authoring guide
This guide teaches you (an LLM asked to build an app, or a person) how to write a member app for an Eleven Messenger / lchat instance: a small personal tool โ a tracker, a list, a counter, a tiny game โ that a member asked for in chat. It is served at GET /api/member-app-guide.
Member apps are the self-serve tier of the mini-app platform. Unlike the admin-installed apps (see /api/app-guide), a member app is user content: it is stored end-to-end encrypted and it runs inside an isolated sandbox frame โ its own document, with no network, no storage, and no access to the rest of the app. Write within the rules below and your app behaves exactly like a built-in one; step outside them and the sandbox simply refuses.
You will produce exactly one JavaScript file โ a plain ES module. All logic, rendering, and CSS ship in that one file.
The one idea
An app has no server. It trades typed events over the member's end-to-end encrypted personal thread and derives its state by folding those events.
> The encrypted thread log IS the app's database. You never store state; > you emit events and fold them back into state. Two devices that fold the > same events arrive at the same state by construction.
An app is three small things:
- an events map โ the event types it owns and how each folds into state,
- an initialState โ the empty state to start from,
- a render โ how state draws, full-pane, with its own input UI.
You also get, for free:
- Time โ
ctx.schedule(event, when)has the server deliver an event into the thread later (the server holds only ciphertext and a timestamp). - Memory โ folded state is snapshotted (encrypted) and re-seeded on open, so 1,000 items work as well as 10. Apps do nothing to opt in.
- Voice โ an event may carry a
notice: { title, body }; when it arrives it alerts the member (in-app, system notification, or push), wording end-to-end encrypted.
What a member app can and can't use
The sandbox now supports slash commands (with a defaultCommand so plain text captures), a companion Chat view, the places declaration for sharing into chats or the whole space (see Group-capable apps and Space-capable apps), and the pure ../format.js text helpers, in addition to the surface + events core. These fields still do nothing in a member app โ do not emit them:
messageAction,renderEvent,preview,unread,notify,hostEvents,requiresService,chatOnly,groupFeedMessageโ host-side hooks (member apps' events stay silent in a group's own feed today; the frame projects into the app's Chat view only).scope,groupCapable,spaceCapableโ the LEGACY placement vocabulary. Already-deployed apps that declare them keep working (they normalize intoplaces), but new apps declareplacesand nothing else.ctx.open,ctx.hostโ absent in the sandbox. (ctx.replyis available inside a command handler; see Commands.)
Hard constraints โ self-check every one before delivering
- Exactly one file, a plain ES module โ no TypeScript, no JSX, no
require(), no build step. - Imports are allowed ONLY from
"../apps.js"and"../format.js". From../apps.js:defineApp,addItem,updateItem,removeItem,partitionByGrace,DONE_GRACE_MS,keyedList. From../format.js(pure text helpers):tokenizeText,richTokens,blockTokens,firstLink,youtubeId,replySnippet,emojiOnlyCount,typingSummary,autoGroupTitle,formatBytes. Nothing else โ no other paths, no URLs, no dynamicimport(). - No network calls โ no
fetch,XMLHttpRequest,WebSocket,EventSource,sendBeacon. (The sandbox blocks them; an app that needs them is broken by design.) All I/O flows throughctx. - No browser storage โ no
localStorage,indexedDB,document.cookie. Durable state lives in the fold; transient UI state (a collapsed toggle) lives in module-level variables. - No
window.parent,window.top, orpostMessageโ the bridge is the runtime's business, never the app's. - Never
innerHTMLuser content. Build DOM withcreateElementand set text withtextContent.innerHTMLonly ever for static in-code strings. - Use
ctx.interval(fn, ms), never baresetInterval, insiderender. - Every event type is a plain string prefixed with the app's
name.name: "habit"โ write the types out in full:"habit.add","habit.tick". Use that same quoted string as theeventsmap key, in everyctx.emit, and in everye.type === "habit.add"comparison. Lowercase; only letters, digits,_,-and.after the prefix. (Keep it simple โ write the literal string; don't reach for a helper to build it.) - Reducers are pure, immutable, and idempotent by entity id. Folding the same add twice must produce one item (the collection helpers guarantee it).
- Never carry app data in
name,ts, oruidโ the transport overwrites these three with the sender's screen name, the send time, and the sender's user id (every event carries them, exactly like a chat message's author). Read them in your reducer (e.tsfor times, neverDate.now()in a fold); never set them. Symptom if you ignore this: an event that put its payload innamefolds back with your own screen name in it โ e.g.{ type:"habit.add", name:"drink water" }comes back asname:"James", so every habit is named after you. Put app data in a field you own โtext,label,title,habitโ nevername. - Validate events in the reducer, not at the emitter. Malformed events fold to a no-op, never a crash.
- You never touch the server, keys, or tokens.
- Interact only through
ctx.emitโ never the browser's own actions. Wire every control to an emit: a<button>'sonclick, or a<form>'sonsubmit(which must callctx.emit(...)). An<a href>navigation does nothing here โ the sandbox has no navigation, so an unwired control is dead. A<form>works: native submission has nowhere to go (there's noallow-forms), so the sandbox runtime intercepts the submit โ a submit button's click, or Enter in a field โ and re-dispatches it as a scriptedsubmit, so youronsubmitruns, nothing navigates, and there's no console error. Put the work inonsubmitandctx.emit(...)from there. (You never needpreventDefaultfor that reason โ though it's harmless.)
The shape of an app: defineApp(spec)
import { defineApp, addItem, removeItem } from "../apps.js";
export const app = defineApp({ /* fields below */ });| field | required | what it is | | -------------- | -------- | ---------- | | name | โ | Unique lowercase id (letters, digits, dashes; starts with a letter; โค 24 chars). Also the prefix for your event types (e.g. "habit.add"). | | title | โ | Human name shown in the Apps list (โค 40 chars). | | glyph | โ | One emoji used as the app's avatar. | | initialState | โ | () => state. A factory, called to seed the empty state. | | events | โ | { "<type>": (state, event, meta) => state }. The fold. | | render | โ | (state, el, ctx) => void. Draws the whole surface. | | commands | | { "/cmd": (args, ctx) => void } or { "/cmd": { run, desc, args } }. args passed to the handler is the raw text string (not a parsed object); the object form's desc/args are typeahead labels. See Commands. | | defaultCommand | | The name of one of your commands (e.g. "/add"). In the companion Chat view, plain (non-slash) text runs it โ so typing "buy milk" adds an item, just like To-do. Requires that command; omit if not wanted. | | chatMessage | | (event) => { text, action? } \| string \| null. Opting in makes the app a companion (Chat + Interface views); see Companion chat. | | places | | WHERE copies of this app can live โ any NON-EMPTY subset of ["you","chat","space"]. Defaults to ["you"] (your own private thread). Add "chat" to let members add it to a group chat, one shared instance for everyone (see Group-capable apps); add "space" to let a member install it FOR THE WHOLE SPACE (/cmd @everyone), one shared instance usable from every chat (see Space-capable apps). "you" is NOT required โ places: ["space"] builds a gif-style space-only app. | | listed | | false to keep the app out of the Apps catalogues โ with places: ["space"] this is the IMPLICITLY PLACED shape: the one shared copy is used on first command, no add gesture (see "One shared setting for the whole space"). | | quiet | | true if a command answering in place (a reply, a panel, or an event it emits) must never itself force your app's personal thread into view โ it still exists and still receives every event, it just won't pop into the sidebar on its own. The host already adds an "Open <title>" chip to every command reply, so the member can still jump there with their own tap. | | styles | | A CSS string, injected once. Use the theme variables below. | | incremental | | true to keep the surface between renders and reconcile with keyedList. | | ephemeral | | (event, ctx) => void. Relay-only events (live presence), rarely needed. |
state starts undefined and is seeded from initialState() on the first fold โ render must also tolerate undefined state (first open, before any event): const items = (state && state.items) || [];
The events map & the reducer
Each handler is (state, event, meta) => newState:
- Pure & immutable. Return new state; never mutate. Two devices fold the same events and must converge.
- No
defaultcase โ unknown types fall through unchanged automatically. metacarries{ id, ts }(server message id + send time). Rarely needed.
The collection helpers keep list reducers correct by construction:
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 itemEvent types โ plain, name-prefixed strings
Every event type is a quoted string that starts with your app's name and a .. Write it out in full everywhere it appears โ the events map key, the ctx.emit type, and any e.type === comparison โ so the same literal string is used throughout:
name: "habit",
events: {
"habit.add": (s, e) => โฆ,
"habit.remove": (s, e) => โฆ,
},That's the whole rule: one app name, and event types are "<name>.<thing>" literals. CSS classes follow the same idea by hand โ "habit-row" in both the styles blob and the className you set.
Emitting events
ctx.emit(event) โ the event needs a type (the full "<name>.โฆ" string) and whatever your reducer reads. Mint ctx.id() once for a new entity and reuse that id in follow-ups:
ctx.emit({ type: "habit.add", id: ctx.id(), text });
ctx.emit({ type: "habit.remove", id: item.id });A form is a common input; wire its onsubmit (native submission does nothing in the sandbox โ see hard constraint 13):
const form = document.createElement("form");
const input = document.createElement("input");
form.appendChild(input);
form.onsubmit = (e) => {
e.preventDefault(); // the runtime also blocks it, but be explicit
const text = input.value.trim();
if (!text) return;
ctx.emit({ type: "habit.add", id: ctx.id(), text });
input.value = ""; // the list itself redraws when the event folds back
};
el.appendChild(form);The transport injects e.name (sender's screen name), e.ts (send time, epoch ms) and e.uid (sender's user id) on every event โ never set them, just read them in the reducer. Encryption is automatic.
Your UI updates when the event echoes back and folds (typically well under a second). Don't hand-patch state in a click handler โ emit and let the fold redraw.
ctx โ what render and handlers get
| member | what it does | | --------------------------- | ------------ | | ctx.emit(event) | Emit a typed event (encrypted + sent). | | ctx.schedule(event, when) | Server-side future delivery at when (epoch ms). Resolves the schedule id sid, or null on failure โ store the sid in an emitted event so any device can cancel. | | ctx.unschedule(sid) | Cancel a pending scheduled event. | | ctx.emitEphemeral(event) | Relay-only delivery โ never stored, never folded. | | ctx.id() | A fresh unique id for a new entity. | | ctx.me / ctx.meName | The member's user id / screen name. | | ctx.getState() | The latest folded state. | | ctx.threadId | The app's thread id. | | ctx.reply({ text, short })| Commands only โ a private "only visible to you" confirmation where the user typed. | | ctx.interval(fn, ms) | A ticker cleared for you on each redraw. | | ctx.onCleanup(fn) | Run fn before the next redraw. |
For time-based alerts, put a notice on the scheduled event:
const id = ctx.id();
const sid = await ctx.schedule(
{ type: "habit.remind", id, notice: { title: "๐ง Water break", body: "Time for a glass." } },
Date.now() + 60 * 60 * 1000,
);
ctx.emit({ type: "habit.set", id, sid: sid || "" });Commands โ quick capture from any chat
A member app can offer slash commands. A command typed in any chat is routed into the app's own thread, so /habit water files a habit without opening the app. A command handler runs the same way as a click: it emits events and optionally replies with a private "only visible to you" confirmation via ctx.reply({ text, short? }).
commands: {
"/habit": (args, ctx) => {
const name = args.trim();
if (!name) return ctx.reply({ text: "Usage: /habit <name> โ e.g. /habit drink water" });
ctx.emit({ type: "habit.add", id: ctx.id(), text: name });
ctx.reply({ text: `Tracking โ${name}โ.`, short: `tracking ${name}` });
},
},The handler always receives args as a raw string โ everything typed after the command word, already trimmed ("/habit drink water" โ args is "drink water"). There is no structured or typed argument parsing: no options schema, no args.name, no parsed object. If you want fields, split the string yourself (const [first, ...rest] = args.split(/\s+/)). This is deliberately simpler than Discord/Slack slash-command options โ do not model those here, and never write a handler that expects a parsed argument object.
The richer object form is { run, desc, args }, and every field is display or handler โ none of them is a schema:
run(args, ctx)โ the handler, same signature as the bare function (argsis still the raw string).descโ a one-line description for the/typeahead.argsโ a usage-hint string shown in the typeahead (e.g."<name>"). It is a label, not an argument declaration; nothing parses it.
commands: {
"/habit": {
desc: "Track a new habit",
args: "<name>", // typeahead hint โ a label, NOT a schema
run: (args, ctx) => { // args is still the raw string
const name = args.trim();
if (!name) return ctx.reply({ text: "Usage: /habit <name>" });
ctx.emit({ type: "habit.add", id: ctx.id(), text: name });
ctx.reply({ text: `Tracking โ${name}โ.` });
},
},
},Rules: reply with a usage line on bad input (never fail silently); the handler gets the same ctx (emit/schedule/id/getState/me), plus ctx.reply. The reply automatically gets an Open <app> button so the member can jump in โ you don't add it. ctx.reply accepts only { text, short } (no custom buttons). The command's UI effect is always its emitted events folding into your state.
Default command โ type to capture. Set defaultCommand: "/add" (naming one of your commands) and plain, non-slash text typed into the app's companion Chat view runs that command โ so "buy milk" adds an item, exactly like typing into To-do. It only applies in the app's own thread; when a real bot is in the thread, plain text stays an ordinary message.
Command panels โ a temporary surface where the member typed
A command can answer with more than a reply: ctx.panel(...) opens a panel โ a transient, interactive surface in the chat the member typed in, just above the composer, with a host-drawn header and โ (Escape works too). In the sandbox the form is declarative โ a render closure can't cross the bridge โ so it's two pieces:
- The command handler calls
ctx.panel({ title, data }).datais plain JSON handed to your hook (results, the typed args, whatever the panel needs). Optional:position: "top"(pin above the feed),animate: false(no open/close ease). Returns a Promise<boolean> โ false means no panel could open (fall back toctx.reply). - The app declares a top-level
panel(data, el, panelCtx)hook โ it runs in a fresh sandboxed frame mounted inside the real panel, and draws intoelexactly likerenderdoes (same DOM rules, same theme variables, yourstylesapply).
panelCtx is the app ctx (emit / schedule / id / me / getState) plus:
panelCtx.close()โ dismiss the panel (e.g. after a tap did its job).panelCtx.insertText(t)โ insert text at the composer's caret (clamped; use it in response to a tap, never spontaneously).
The panel opens where the member typed โ your ctx may not point there. The two are answers to different questions: the panel is chrome, so it always draws in the chat the member is looking at, while ctx addresses the place your app's data lives (docs/space-apps.md). For a personal or group app those are usually the same thread and the distinction never shows. It shows for a space-placed app: ctx.emit / ctx.getState() reach the one copy shared with the whole space, while the panel is still in the chat. That split is deliberate โ "app data lives space-wide, conversation content stays under the conversation's own key" โ so don't try to ctx.emit a message into the conversation from a space-placed app: it would land in the space's log, which renders nowhere.
One shared setting for the whole space, asked once. If your app needs a value every member should share โ the classic case is an API key โ declare places: ["space"] and keep it in your ordinary fold state. The first member to run the command is asked for it; the event folds into the space's log; everyone else (and every later joiner) reads it and is never asked again. Adding listed: false alongside makes the app implicitly space-placed: it stays out of the Apps catalogue and needs no "add" gesture, which is right for a command-only utility that has no pane of its own. web/static/apps/gif.js is the worked example โ its Giphy key is one value the space shares.
The catch, and it is not a small one: fold state cannot keep a secret from a member of the thread. Everyone in the space decrypts every event and snapshot by construction, so a space-shared value is readable by every member โ fine for a public client identifier like Giphy's, wrong for a real secret. Say so in your own copy when you ask for one, the way gif.js does. (And a log is immutable: replacing the value later leaves the old one readable in history.)
The whole shape, from web/static/apps-disabled/mood.js (the working example โ paste it into App Studio to try):
commands: {
"/mood": {
desc: "log how you're feeling",
run(args, ctx) {
ctx.panel({ title: "How are you feeling?", data: { note: args.trim() } });
},
},
},
panel(data, el, ctx) {
for (const mood of ["๐", "๐", "๐"]) {
const b = document.createElement("button");
b.textContent = mood;
b.onclick = async () => {
await ctx.emit({ type: "mood.set", id: ctx.id(), mood, note: data.note });
ctx.close();
};
el.appendChild(b);
}
},Rules: a panel opens only from a command the member just ran (the host enforces it); one panel per app (opening another replaces it); the panel frame is the same no-network, no-keys cage as the app's own surface โ data and everything you send back cross the bridge as validated, size-capped JSON. The declarative form also works for compiled-in apps, so a file written this way runs identically in both worlds.
Companion chat โ the thread reads as a conversation
By default a member app takes over its thread (the surface is the whole view). Implement chatMessage(event) and the thread instead offers two views, toggled in the header: a Chat conversation and the app's Interface. chatMessage projects each event into a chat line โ return { text, action: true } for a /me-style action line, { text } for a bot bubble, or null to keep the event out of the chat. Both views fold from the one event log, so an action in the Interface shows in the Chat.
chatMessage(e) {
if (e.type === "habit.add") return { text: `started tracking โ${e.text}โ`, action: true };
if (e.type === "habit.tick") return { text: `โ done for today`, action: true };
return null; // housekeeping events stay silent
},Use companion for list-like tools where "added X" reads naturally as a conversation; use takeover (omit chatMessage) for boards, games, dashboards.
Group-capable apps โ one shared instance for a whole chat
By default a member app is personal: only its author ever opens it, in their own private thread. Add "chat" to its places and a member can also add it to a group chat (โฎ โ Apps) โ the same app, but now one shared instance runs for everyone in that group. Its events post into the group thread's encrypted log instead of your personal one, so every member's sandboxed frame folds the same events and arrives at the same state. Nothing else about the app changes โ same events, same reducer, same render.
export const app = defineApp({
name: "scoreboard",
title: "Scoreboard",
glyph: "๐",
places: ["you", "chat"],
// ...initialState, events, render as normal
});Adding or removing the app from a group is a member's own call โ any member can do it, like adding a person, and it's visible to everyone (an action line in the chat), never silent or server-tracked. There is no admin review step: the app runs whatever code its author deployed.
What this means for state and secrecy:
- A group thread's copy of the app has its own state, independent of your personal instance (or any other group it's added to) โ same app definition, different thread, different fold.
- Every member of the group can decrypt every event and every snapshot, because a thread's encrypted log is readable by every member of that thread by construction โ that's not a bug to work around, it's how E2EE threads work here. So a group-capable app has no way to keep a secret from one member of the group while showing it to another โ there is no per-member privacy inside a shared thread.
- If your app wants to show different members different things (e.g. "your own stuff in full, everyone else's summarised"), that's a rendering choice, not a secrecy one: use
ctx.meinrenderto decide what to draw for the current viewer. The underlying state โ and anyone determined enough to inspect it directly โ sees everything regardless.
Space-capable apps โ one shared instance for the whole space
One place up from "chat": add "space" to the app's places and a member can install it for everyone in the space (/yourcmd @everyone). One shared instance runs for the whole space, backed by a hidden all-members thread, and the appโs commands work from every chat with nobody else installing anything โ this is the place for a shared library shape (stickers, links, a glossary). Design + trust model: docs/space-apps.md.
export const app = defineApp({
name: "stickers",
title: "Stickers",
glyph: "๐ผ๏ธ",
places: ["you", "space"],
// ...initialState, events, commands, render as normal
});An app doesn't have to keep a personal copy at all: places: ["space"] builds a space-ONLY app โ one copy for everyone, no sidebar thread of your own (add listed: false for the implicitly-placed, command-only shape; see "One shared setting for the whole space" above).
Everything from the group-capable section applies, space-sized: the space instance has its own state (independent of personal and group copies); every member of the space can decrypt its events and snapshots (no per-member secrecy โ render-time choices via ctx.me only); and installing or removing is any memberโs call, attributed by name in the Apps sheet. The same reducer bar too: pure, idempotent, validating โ the whole space now writes to one log.
Formatting โ safe rich text from ../format.js
Import the pure text helpers to linkify and format user text without innerHTML. richTokens(text) / tokenizeText(text) split text into tokens you render as DOM nodes (plain text โ a text node, a { href } โ an <a>, a { mark } โ a <b>/<i>/<code>), which is what keeps it XSS-safe:
import { richTokens } from "../format.js";
// โฆin render:
for (const t of richTokens(item.text)) {
if (t.href) { const a = document.createElement("a"); a.href = t.href; a.textContent = t.text; row.appendChild(a); }
else if (t.mark) { const el = document.createElement(t.mark === "code" ? "code" : t.mark); el.textContent = t.text; row.appendChild(el); }
else row.appendChild(document.createTextNode(t.text));
}Also available: blockTokens, firstLink, youtubeId, replySnippet, emojiOnlyCount, typingSummary, autoGroupTitle, formatBytes โ all pure.
Rendering the surface
render(state, el, ctx) is called whenever state changes; the runtime clears el first (unless incremental: true). The app owns the whole pane โ build an input row on top, then the list. The pane scrolls; size with %/flex.
Style with the theme's CSS variables so the app follows light/dark and the member's chosen theme: --bg, --panel, --sidebar, --ink (primary text), --muted, --border, --accent, --accent-ink, --link, --bubble-me, --bubble-me-ink, --bubble-them, --danger, --warn, --radius, --shadow, --font-display; use cursor: var(--ui-cursor, pointer) on anything clickable.
For long lists (hundreds of rows) set incremental: true and reconcile with keyedList(el, items, { key, rev, render }) โ unchanged rows keep their DOM.
A complete app
A real, working member app โ copy the shape, change the guts. A personal habit tracker: add habits, tick them off today, streaks derived in render.
// habits.js
import { defineApp, addItem, updateItem, removeItem } from "../apps.js";
const DAY = 24 * 60 * 60 * 1000;
const dayOf = (ts) => Math.floor(ts / DAY);
export const app = defineApp({
name: "habit",
title: "Habit Tracker",
glyph: "๐ฑ",
initialState: () => ({ items: [] }),
// Event types are plain "<name>.โฆ" strings โ used here as keys, and again in
// the ctx.emit calls in render.
events: {
"habit.add": (s, e) =>
e.id && typeof e.text === "string" && e.text.trim()
? { items: addItem(s.items, { id: e.id, text: e.text.trim(), ticks: [] }) }
: s,
"habit.tick": (s, e) => ({
items: s.items.map((h) => {
if (h.id !== e.id) return h;
const d = dayOf(e.ts || 0);
return h.ticks.includes(d) ? h : { ...h, ticks: [...h.ticks, d].slice(-400) };
}),
}),
"habit.remove": (s, e) => ({ items: removeItem(s.items, e.id) }),
},
styles: `
.habit-form { display: flex; gap: 8px; padding: 12px; border-bottom: 1px solid var(--border); }
.habit-form input { flex: 1; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel); color: var(--ink); }
.habit-row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border); }
.habit-name { flex: 1; overflow-wrap: anywhere; }
.habit-streak { color: var(--muted); font-size: 12px; }
.habit-row button { cursor: var(--ui-cursor, pointer); }
`,
render(state, el, ctx) {
const items = (state && state.items) || [];
const form = document.createElement("form");
form.className = "habit-form";
const input = document.createElement("input");
input.placeholder = "Add a habitโฆ";
const add = document.createElement("button");
add.textContent = "Add";
form.append(input, add);
form.onsubmit = (ev) => {
ev.preventDefault();
const text = input.value.trim();
if (text) ctx.emit({ type: "habit.add", id: ctx.id(), text });
input.value = "";
};
el.appendChild(form);
const today = dayOf(Date.now());
for (const h of items) {
const row = document.createElement("div");
row.className = "habit-row";
const done = h.ticks.includes(today);
const tick = document.createElement("button");
tick.textContent = done ? "โ
" : "โฌ";
tick.disabled = done;
tick.onclick = () => ctx.emit({ type: "habit.tick", id: h.id });
const name = document.createElement("span");
name.className = "habit-name";
name.textContent = h.text; // user content โ textContent, never innerHTML
let streak = 0;
for (let d = today; h.ticks.includes(d); d--) streak++;
const st = document.createElement("span");
st.className = "habit-streak";
st.textContent = streak ? `${streak}๐ฅ` : "";
const rm = document.createElement("button");
rm.textContent = "ร";
rm.onclick = () => ctx.emit({ type: "habit.remove", id: h.id });
row.append(tick, name, st, rm);
el.appendChild(row);
}
},
});Invariants (the things that fail silently if you skip them)
- Idempotent fold โ same event twice, same state (dedupe on entity id).
- Pure, immutable reducers โ spread, never mutate.
- Don't set
name/ts/uidon emit. - Prefix every event type with the app
nameโ write them as plain"<name>.โฆ"strings. - Never
innerHTMLuser content. - Tolerate
undefinedstate in render (first open). - A scheduled event arrives later, into different state โ its fold must tolerate the target being gone.
- Use
e.tsin folds, neverDate.now()โ folds replay on every open; a wall-clock read in a reducer breaks convergence. (Date.now()in render is fine โ deriving "today" at draw time is exactly right.)
Delivering the app
Produce one file passing every hard constraint. There are two ways it reaches the instance:
- The assistant bot deploys it with its
create_apptool (when a member asks a bot to build one). - App Studio โ a member deploys it themselves: open ๏ผ New chat โ Apps โ App Studio, paste the module, hit Check (it's evaluated in the sandbox to read back its manifest), then Deploy. Same encryption + sandbox as the bot path; no admin needed. App Studio also lists your apps and lets you edit or remove them.
When you are an outside LLM helping a member, hand them the file and point them at App Studio (or suggest they ask the instance's assistant to deploy it). The instance admin can still install a trusted app via the admin console โ a different tier (/api/app-guide documents it).