eleven / messenger Public
๐ค Native apps ร the app platform: apps as first-class citizens
> Status: SHIPPED โ phases 0โ5 + the voice surfaces merged to main > 2026-07-23 (#1256, #1314โ#1318; field fixes from live evaluation rode the > same PRs). What runs today: the app-host protocol below (all rows marked > shipped), AppRegistry (bundled seed from tools/gen-app-catalog.mjs, > drift-gated by test/appcatalog-seed.test.mjs, live appCatalog() pulls), > SpaceApps/SpaceAppsStore (installs survive relaunch), the platform-shared > command host on both platforms (full forwarding, per-thread typeahead rows, > the needKey/needAppThread bridge), space routing + the space band, > projection/preview queries (feeds and sidebars narrate any app's events), > and the voice contract (web/static/apps/README.md ยง Voice). Only phase 6 > ("make it good", below) remains open. This note is the standing design > reference โ the same role docs/native-chat.md plays for the native feed.
The position (accepted, load-bearing)
Member apps โ every tier of them โ are first-class citizens in the native apps. Concretely, the native apps (Mac and iOS, as one product) must:
- Recognise and use any app available in a space โ built-in, admin-installed custom, or member-built โ with zero per-app native code. Native special-casing (the schema engine, hand-native surfaces) is a progressive enhancement selected per app, never a gate on availability.
- Offer one consistent way to discover, add, and open apps โ personal ("you"), per-group ("chat"), and space-wide ("space") โ identical in behaviour across Mac and iOS.
- Support the full app capability set the web does โ posting into chat, commands and replies, panels, scheduling, snapshots, notices โ generally, not as per-app special cases.
- Expect the app set to differ per space and change over time โ folding the install registries and reacting to the server's change prods, with the result surviving restart.
- Expect the command/completion set to differ per chat and per server โ assembled dynamically, never hard-coded.
"Make it work" means every app runs on native through the web implementation (embedded, bridged); "make it good" means native surfaces replace webviews per app behind the same registry, invisible to the rest of the system.
What the app platform actually is (the contract a client implements)
Everything below already exists and is what the web client implements. A native client is simply the second implementation of the same contract โ none of it requires server changes (see "What the server provides" below).
Three tiers of apps, one registry. Built-in apps are ES modules compiled into the binary (statically imported, shell.js). Admin-installed custom apps are plaintext JS: discovered via public GET /api/apps โ {apps:[{name,url}]}, served at /static/apps-custom/<name> (customapps.go). Member apps are user content: instance-key-encrypted blobs from GET /api/member-apps, executed only inside the opaque-origin sandbox (/app-sandbox, sandbox.js / sandbox-frame.js). All three tiers register through one defineApp() into one in-page registry (apps.js), and the server broadcasts a content-free {type:"apps_changed", kind} WS frame whenever the custom/member set changes.
Install state is fold state. Which apps live where is end-to-end encrypted and client-derived โ the server never learns it:
- Personal ("you"): implicit โ a
kind:"self"thread whosename_blobdecrypts to{app,title}. Created on first use. - Group ("chat"):
groupapps.add/removeevents in that group's own log, folded by theinstallsregistry app (apps.js), pinned bytest/event-vectors/groupapps.json. - Space-wide ("space"):
spaceapps.add/removeevents in the hidden all-members space thread (kind:"space"; fetched explicitly viaGET /api/space-threadโ it never appears in/api/threads; its AES key ridesopen_key, wrapped under the instance key, so no private key is needed to open it). Pinned bytest/event-vectors/spaceapps.json; designdocs/space-apps.md. Creation is lazy + race-safe; the server prods online clients with a content-free{type:"space_thread"}frame.
Both registries filter at read time to apps the client actually has registered and that are capable of that place โ unknown installs fold but stay inert, so clients on different versions converge on the same log.
Commands are assembled per thread. commandCatalog(threadId) (apps.js) builds the typeahead//help list dynamically: the owning app's local verbs first, then every registered app's global commands stamped with where the data lands ("chat"/"space"/"you", ranked by nearness), alt rows for the @me/@here/@everyone flags, then floor commands, /me, and bot commands. Invocation is runCommand(text, threadId, reply) โ parse, route to the nearest instance (instanceThread), build a ctx, run. Replies are private in-place notices; a command may also open a panel (ctx.panel) โ a transient in-chat surface.
Presentation is an app's own declaration. appWantsChatView(name) โ i.e. "does the app implement chatMessage(event)" โ decides companion (Chat | Split | Interface) vs takeover; defaultView / chatOnly / chatReadOnly refine it; groupFeedMessage(event) narrates a takeover app's events into a group's feed without growing a Chat view. These are facts of the app spec, not of the client.
The capability set (ctx) an app runs against: emit (encrypt + post into the bound thread), schedule/unschedule (server-fired future events, /api/schedule), emitEphemeral (relay-only), getState, id, me, meName, threadId, place, reply, panel, open, interval, onCleanup. Memory is automatic: folded state snapshots to GET/PUT /api/threads/{id}/state (encrypted, optimistic-versioned, 409 โ refold + retry). Voice is notice/notify on events.
What the server provides โ and why this needs no server changes
The initial hunch list ("server APIs to communicate which apps are available", "event folds doing rollup to a current state") turns out to already exist, in the E2EE-shaped form the one rule demands:
| hunch | what exists | |---|---| | "APIs to say which apps are available" | GET /api/apps (custom-app code), GET /api/member-apps (member-app blobs), apps_changed prod. Which apps are installed where is deliberately not a server API โ it's encrypted fold state (registries above), because the server must never learn the app set. | | "rollup to a current state: recent messages + installed apps + app states" | Exactly the thread_state snapshot system: clients fold, encrypt, and PUT the rolled-up state per (thread, slot); boot = snapshot + newer events. The server can't do the rollup (ciphertext), and doesn't need to. | | "app added / app removed events" | groupapps.* / spaceapps.* wire events, already vector-pinned for native. | | "a bridge like the web ctx" | Client-side by definition โ the app-host protocol below. | | "custom commands invoking across the bridge" | commandCatalog + runCommand exist; the gap is native consuming them. |
The whole plan is client work. The server's part โ blobs, prods, schedules, state slots, the space thread โ is finished and in production.
Where native was before this work (the honest map, kept as the record)
> ๐ค Historical: this section described native as of 2026-07-22, and is what > the phases below were designed against. Every symptom in it is fixed on > main except the vault passkey row (phase 6). Kept because the root-cause > table is the best explanation of WHY the architecture looks the way it does.
Three parallel app subsystems grew independently in ios/:
- Schema apps โ
SchemaEngine/SchemaAppStore/SchemaAppView(LChatCore) interpretSchemas/{todo,remind}.jsonnatively. Generic and good โ but fed only by two bundled files (AppSchemaRegistry). - Webview embeds โ everything else renders the web client in a
WKWebViewseeded withwindow.__lchatEmbed(one thread, one thread key). iOS:ChatView+WebBridge+FeedWebCache; Mac:AppThreadWebView+MacWebBridge+AppWebViewCache, plus a Mac-only per-thread command host (CommandPanelWebView,mode:"command") used for/emoji//gif. (Since resolved on iOS:ChatViewโ the embed that could carry a thread's CHAT โ is deleted.AppThreadScreenrenders app-thread chat native and pins every surviving embed toview:"interface"; a webview now only ever carries an app's own interface, on both platforms.) - Flow apps โ
FlowEngineforsetup/welcome.
Overlaid: a native groupapps fold (GroupApps.swift) and ~10 hand-kept per-app lookup tables that each re-state a fragment of app metadata: AppIdentity.symbols/marks, GroupApps.catalog (4 apps), ThreadStore.appTitles/appGlyphs/appDescriptions/personalApps (12/12/12/5), AppChatProjection.intros/title (2), AppDefaultCommand.specs (2), NativeChatView.companionApps (2), ComposerTypeahead.botCommands, SlashCommands.WebPanelCommands.catalog. The same fact ("is this app a companion?") lives in two independent sets (iOS keys off AppChatProjection.intros, Mac off companionApps); the app catalogue exists in three different-length lists.
Symptoms โ root causes
| symptom | root cause | |---|---| | Space restarts forget which apps are installed | Native has zero space-thread support: no GET /api/space-thread call, no spaceapps fold, no handling of the space_thread/apps_changed frames (grep spaceapps ios/ โ nothing). Group installs survive only because the web writes the slot:"apps" snapshot native re-seeds from. | | No app slash commands anywhere native | The native typeahead floor is hard-coded (ComposerTypeahead); apps' commands are never learned. The Mac command host can already run every command (window.lchatNative.runCommand โ the real runCommand) but the Mac shell only forwards /emoji//gif to it; iOS has no command host at all, and its native feed posts a dead literal "/emoji" message. | | Admin: no Chat\|View on Mac | companionApps = {todo, remind} hard-codes the companion set; Admin (a companion with defaultView:"interface", whose Chat view now carries the changelog stream) isn't in it. | | Admin on iOS shows chat, not the app | ThreadListView.chatSurface routes non-schema apps to ChatView, which seeds view:"chat" โ wrong for interface-first and takeover apps. | | Whiteboard won't load / can't be added to a group | The board is places:["chat"] with an openEvent โ but native's group Apps sheet is the 4-app GroupApps.catalog (no board), and GroupApps.known filters unknown installs out. | | Group todo on iOS: no chat part, janky menu | Group-app selection replaces the feed entirely (activeGroupAppID); there's no companion projection for group instances and no consistent switcher. | | Vault passkey lock dead on native | WebAuthn inside an embedded WKWebView โ needs a host bridge (deliberately deferred; "make it good"). |
Target architecture
Three layers, all platform-shared in LChatCore; iOS and Mac keep only thin UI.
Layer 1 โ AppRegistry: one source of truth (native)
A LChatCore store that merges, and persists across launches:
- The catalogue โ per-app manifest records reported by the app host (below):
name, title, glyph, places, listed, quiet, companion, chatOnly, chatReadOnly, defaultView, defaultCommand, composerPlaceholder, commands[{name, desc, args, local}]. Cached to disk; invalidated byapps_changedโcatalogChanged. - Install state โ personal app threads (
ThreadStorealready folds these); per-groupgroupappsfolds (ChatFeedModelalready); and the newSpaceAppsStore: fetch/api/space-thread, unwrapopen_keywith the native instance key, seed from itsslot:"apps"snapshot + foldspaceapps.*per the existing vectors, subscribe to thespace_thread/apps_changedframes inChatSocket, persist. - Native enhancements โ
AppSchemaRegistry.hasSchema, SF-symbol/brand-mark overrides (AppIdentitybecomes a decoration keyed by registry id, with the manifestglyphas the universal fallback โ unknown apps get their glyph, never a blank).
Every current lookup table collapses into a registry read. The two companion-sets, three catalogues, and per-app titles/glyphs/blurbs all become one record type with one source. An app the registry doesn't know still works: title/glyph/commands come from the manifest, surface is a webview โ that is the first-class-citizen guarantee.
Layer 2 โ the app host: the web engine, bridged (the generalized command host)
The one component that can execute app JS is the web framework โ so native runs it, headless, and talks to it over a small protocol. This is not new machinery: it is the Mac command host (mode:"command"), generalized and made first-class on both platforms. The embed page already loads all three app tiers (bootEmbed runs loadCustomApps() + loadMemberAppsNow()), already runs any command, and already reports panels/replies/errors.
The app-host protocol (native โ window.lchatNative / "lchat" posts):
| direction | call / post | meaning | status | |---|---|---|---| | native โ js | appCatalog() โ [manifestโฆ] | the full registered-app catalogue (all tiers) | shipped | | native โ js | commandItems() โ [rowโฆ] | commandCatalog(threadId) for the embed's thread โ the typeahead//help source | shipped | | native โ js | runCommand(text) โ handled? | run any slash command; replies/panels post back | exists | | native โ js | dismissPanel() | close the open panel | exists | | js โ native | {catalogChanged: kind} | the app set changed (apps_changed); re-pull appCatalog, recreate stale webviews | shipped | | js โ native | {commandHostReady}, {panel}, {panelHeight}, {notice}, {composerInsert}, {error} | command-host lifecycle | exists | | js โ native | {needKey: threadId} โ native calls __lchatProvideKey(threadId, keyB64) | cross-thread routing: a personal command typed in a group must emit into the personal app-thread, whose key the single-key embed doesn't hold. Native unwraps (it can only unwrap threads the user is in) and provides. | shipped | | js โ native | {needAppThread: {app, title}} โ native calls __lchatProvideAppThread(app, threadId, keyB64) | the appโthread resolution itself: the embed can't decrypt other threads' name_blobs, so resolving there would mint a duplicate app thread. Native opens-or-creates (openOrCreateAppThread) and answers with the id + key. | shipped | | native โ js | previewEvent(event, you) โ {text} \| null | the sidebar line for one app event โ the app's own preview(e, {you}) hook, defaulting in the engine to the actor-prefixed chat projection (the voice chain: notice โ preview โ chat projection โ silent). you = the actor is the reader ("You addedโฆ"). | shipped | | native โ js | projectEvent(event, group) โ {text, action, title} \| null | chat projection of an app event, for the native feed (companion Chat views, group narration) โ the web's chatMessage/groupFeedMessage answered by the engine that owns them. Ownership resolves in the engine via appForType (native never prefix-guesses โ webhooks.* belongs to webhook-monitor); title rides along so native names the bubble. | shipped |
Trust posture: the app host is a privileged surface by job description โ it is the command engine, so its reach is the union of the member's app threads, group threads it's asked about, and the space thread. It still never holds the user's private key (native unwraps and hands narrow thread keys), and the server still only ever sees ciphertext. Member-app code inside the host stays inside the /app-sandbox iframe exactly as on the web. The needKey grant is native-policied: only keys native itself can unwrap, only on request, never the private key or admin token.
Two host-shape decisions:
- Per-thread hosts first (the existing Mac shape, LRU-cached): minimal delta, and
appCatalog()is thread-independent so any live embed can serve it. A single per-instance host (mode:"apphost", threadless boot) is a later consolidation, not a prerequisite. - Snapshots come for free: a host that folds a thread with real keys runs the framework's automatic snapshot writer โ which fixes "native households never write snapshots" without new code. (Safe by construction: snapshot writes are optimistic-versioned, and racing writers refold + retry.)
The embed's space-thread bail (loadSpaceThread: if (embed || !instanceKey) return) lifts for hosts seeded with the instance key, so space-installed apps' commands route correctly inside the host too (phase 4).
Layer 3 โ registry-driven surfaces
- Surface routing: iOS
ThreadListView.chatSurfaceand MacNativeChatView.feedstop switching on hard-coded names. One shared decision:registry.presentation(appId)โ native-schema (schema exists) / flow (setup/welcome) / companion (Chat|Split|Interface on Mac, Chat|Interface on iOS โ the Chat pane is the NATIVE feed on both, honouringdefaultView,chatOnly,chatReadOnly) / takeover (the app's interface, full pane) โ with the interface-only webview as the universal fallback. Chat itself never renders in a webview: a webview may only carry an app's own interface (iOSAppThreadScreen/AppInterfacePane, MaccompanionSplit/appInterfacePane). - Pickers: New-chat โธ Apps (personal =
placesโ "you"), group โฎ โธ Apps (chat-capable, so the board appears), and the space band (space installs, fromSpaceAppsStore) all render from the registry โ custom and member apps included, identically on both platforms. - Typeahead:
ComposerTypeaheadconsumes registry commands (per-thread rows viacommandItems(), cached + refreshed on fold changes) instead of its hard-coded floor; selection routes to the command host. The floor (/me,/help, theme verbs) stays native. - Group instances: the added-apps switcher and the group feed's app-event narration come from the registry + projection queries, one design on both platforms (Mac keeps Split; iOS keeps toggle).
Phases (all shipped 2026-07-23 except phase 6; each landed as its own PR)
- Phase 0 โ protocol groundwork (web-side, testable in Node). SHIPPED.
appCatalogReport()inapps.js;appCatalog()/commandItems()on the embed bridge;{catalogChanged}post onapps_changedin embeds;test/appcatalog.test.mjspins the shapes. - Phase 1 โ installs survive:
SpaceAppsStore+ registry persistence. SHIPPED. LChatCore: space-thread fetch/unwrap,spaceappsfold (vectors exist:spaceapps.json),space_thread/apps_changedinChatSocket, disk persistence for catalogue + installs. Acceptance: kill + relaunch โ a space's installed apps (vault included) are still listed. - Phase 2 โ one registry, registry-driven routing. SHIPPED. Collapse the lookup tables into
AppRegistry; route surfaces and pickers from it. Acceptance: Admin gets Chat|View on Mac and opens as the app on iOS; the board is addable to a group and loads; unknown/custom apps appear with glyph+title. - Phase 3 โ commands everywhere. SHIPPED. iOS gains the command host; both platforms forward every non-floor slash command to it; typeahead rows from
commandItems();needKeybridge for cross-thread routing. Acceptance:/todo buy milkworks from any chat on both platforms;/emoji//gifstop dead-posting on iOS; a custom app's command appears in the typeahead with its desc/args. - Phase 4 โ space routing in the host. SHIPPED. Lift the embed space-thread bail when the instance key is present; space band UI. Acceptance:
/gif(space app) works natively where installed. - Phase 5 โ projection queries. SHIPPED. Native feeds narrate app events for apps without native projections (
projectEvent);AppChatProjectionbecomes the fast path for schema apps, same outputs, vector-pinned. - Phase 6 โ make it good. OPEN. More schemas; native panel rendering; the vault passkey/WebAuthn host bridge; per-instance host consolidation; native snapshot writing direct from
SchemaAppStoreif the host path isn't enough; the sandbox-projection relay, so MEMBER apps' events narrate in native feeds too (theirchatMessageruns only inside the/app-sandboxiframe, soprojectEventdeliberately answers null for them today).
Deliberately out of scope
Bots and webhooks (already native-blind by design โ sidecar processes); changing any wire format or server behaviour; SDUI for the chat feed (docs/native-chat.md locked that out). The schema engine keeps growing within its archetype โ it is the "make it good" tier, never the availability gate.