| 1 | # ๐ค Native apps ร the app platform: apps as first-class citizens |
| 2 | |
| 3 | > Status: **SHIPPED โ phases 0โ5 + the voice surfaces merged to `main` |
| 4 | > 2026-07-23** (#1256, #1314โ#1318; field fixes from live evaluation rode the |
| 5 | > same PRs). What runs today: the app-host protocol below (all rows marked |
| 6 | > shipped), `AppRegistry` (bundled seed from `tools/gen-app-catalog.mjs`, |
| 7 | > drift-gated by `test/appcatalog-seed.test.mjs`, live `appCatalog()` pulls), |
| 8 | > `SpaceApps`/`SpaceAppsStore` (installs survive relaunch), the platform-shared |
| 9 | > command host on both platforms (full forwarding, per-thread typeahead rows, |
| 10 | > the `needKey`/`needAppThread` bridge), space routing + the space band, |
| 11 | > projection/preview queries (feeds and sidebars narrate any app's events), |
| 12 | > and the voice contract (`web/static/apps/README.md ยง Voice`). Only phase 6 |
| 13 | > ("make it good", below) remains open. This note is the standing design |
| 14 | > reference โ the same role `docs/native-chat.md` plays for the native feed. |
| 15 | |
| 16 | ## The position (accepted, load-bearing) |
| 17 | |
| 18 | **Member apps โ every tier of them โ are first-class citizens in the native |
| 19 | apps.** Concretely, the native apps (Mac and iOS, as one product) must: |
| 20 | |
| 21 | 1. **Recognise and use any app available in a space** โ built-in, admin-installed |
| 22 | custom, or member-built โ with **zero per-app native code**. Native |
| 23 | special-casing (the schema engine, hand-native surfaces) is a progressive |
| 24 | enhancement selected per app, never a gate on availability. |
| 25 | 2. **Offer one consistent way to discover, add, and open apps** โ personal |
| 26 | ("you"), per-group ("chat"), and space-wide ("space") โ identical in |
| 27 | behaviour across Mac and iOS. |
| 28 | 3. **Support the full app capability set the web does** โ posting into chat, |
| 29 | commands and replies, panels, scheduling, snapshots, notices โ generally, |
| 30 | not as per-app special cases. |
| 31 | 4. **Expect the app set to differ per space and change over time** โ folding |
| 32 | the install registries and reacting to the server's change prods, with the |
| 33 | result surviving restart. |
| 34 | 5. **Expect the command/completion set to differ per chat and per server** โ |
| 35 | assembled dynamically, never hard-coded. |
| 36 | |
| 37 | "Make it work" means every app runs on native through the web implementation |
| 38 | (embedded, bridged); "make it good" means native surfaces replace webviews |
| 39 | per app **behind the same registry**, invisible to the rest of the system. |
| 40 | |
| 41 | ## What the app platform actually is (the contract a client implements) |
| 42 | |
| 43 | Everything below already exists and is what the *web* client implements. A |
| 44 | native client is simply **the second implementation of the same contract** โ |
| 45 | none of it requires server changes (see "What the server provides" below). |
| 46 | |
| 47 | **Three tiers of apps, one registry.** Built-in apps are ES modules compiled |
| 48 | into the binary (statically imported, `shell.js`). Admin-installed **custom |
| 49 | apps** are plaintext JS: discovered via public `GET /api/apps` โ |
| 50 | `{apps:[{name,url}]}`, served at `/static/apps-custom/<name>` |
| 51 | (`customapps.go`). **Member apps** are user content: instance-key-encrypted |
| 52 | blobs from `GET /api/member-apps`, executed only inside the opaque-origin |
| 53 | sandbox (`/app-sandbox`, `sandbox.js` / `sandbox-frame.js`). All three tiers |
| 54 | register through one `defineApp()` into one in-page registry (`apps.js`), and |
| 55 | the server broadcasts a content-free `{type:"apps_changed", kind}` WS frame |
| 56 | whenever the custom/member set changes. |
| 57 | |
| 58 | **Install state is fold state.** Which apps live where is end-to-end encrypted |
| 59 | and client-derived โ the server never learns it: |
| 60 | |
| 61 | - *Personal* ("you"): implicit โ a `kind:"self"` thread whose `name_blob` |
| 62 | decrypts to `{app,title}`. Created on first use. |
| 63 | - *Group* ("chat"): `groupapps.add/remove` events in that group's own log, |
| 64 | folded by the `installs` registry app (`apps.js`), pinned by |
| 65 | `test/event-vectors/groupapps.json`. |
| 66 | - *Space-wide* ("space"): `spaceapps.add/remove` events in the hidden |
| 67 | all-members **space thread** (`kind:"space"`; fetched explicitly via |
| 68 | `GET /api/space-thread` โ it never appears in `/api/threads`; its AES key |
| 69 | rides `open_key`, wrapped under the *instance key*, so no private key is |
| 70 | needed to open it). Pinned by `test/event-vectors/spaceapps.json`; design |
| 71 | `docs/space-apps.md`. Creation is lazy + race-safe; the server prods online |
| 72 | clients with a content-free `{type:"space_thread"}` frame. |
| 73 | |
| 74 | Both registries filter **at read time** to apps the client actually has |
| 75 | registered and that are capable of that place โ unknown installs fold but stay |
| 76 | inert, so clients on different versions converge on the same log. |
| 77 | |
| 78 | **Commands are assembled per thread.** `commandCatalog(threadId)` (`apps.js`) |
| 79 | builds the typeahead/`/help` list dynamically: the owning app's local verbs |
| 80 | first, then every registered app's global commands stamped with *where the |
| 81 | data lands* ("chat"/"space"/"you", ranked by nearness), alt rows for the |
| 82 | `@me`/`@here`/`@everyone` flags, then floor commands, `/me`, and bot commands. |
| 83 | Invocation is `runCommand(text, threadId, reply)` โ parse, route to the |
| 84 | nearest instance (`instanceThread`), build a ctx, run. Replies are private |
| 85 | in-place notices; a command may also open a **panel** (`ctx.panel`) โ a |
| 86 | transient in-chat surface. |
| 87 | |
| 88 | **Presentation is an app's own declaration.** `appWantsChatView(name)` โ i.e. |
| 89 | "does the app implement `chatMessage(event)`" โ decides companion |
| 90 | (Chat | Split | Interface) vs takeover; `defaultView` / `chatOnly` / |
| 91 | `chatReadOnly` refine it; `groupFeedMessage(event)` narrates a takeover app's |
| 92 | events into a *group's* feed without growing a Chat view. These are facts of |
| 93 | the app spec, not of the client. |
| 94 | |
| 95 | **The capability set (`ctx`)** an app runs against: `emit` (encrypt + post |
| 96 | into the bound thread), `schedule`/`unschedule` (server-fired future events, |
| 97 | `/api/schedule`), `emitEphemeral` (relay-only), `getState`, `id`, `me`, |
| 98 | `meName`, `threadId`, `place`, `reply`, `panel`, `open`, `interval`, |
| 99 | `onCleanup`. **Memory** is automatic: folded state snapshots to |
| 100 | `GET/PUT /api/threads/{id}/state` (encrypted, optimistic-versioned, 409 โ |
| 101 | refold + retry). **Voice** is `notice`/`notify` on events. |
| 102 | |
| 103 | ### What the server provides โ and why this needs no server changes |
| 104 | |
| 105 | The initial hunch list ("server APIs to communicate which apps are available", |
| 106 | "event folds doing rollup to a current state") turns out to already exist, in |
| 107 | the E2EE-shaped form the one rule demands: |
| 108 | |
| 109 | | hunch | what exists | |
| 110 | |---|---| |
| 111 | | "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. | |
| 112 | | "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. | |
| 113 | | "app added / app removed events" | `groupapps.*` / `spaceapps.*` wire events, already vector-pinned for native. | |
| 114 | | "a bridge like the web ctx" | Client-side by definition โ the app-host protocol below. | |
| 115 | | "custom commands invoking across the bridge" | `commandCatalog` + `runCommand` exist; the gap is native consuming them. | |
| 116 | |
| 117 | The whole plan is client work. The server's part โ blobs, prods, schedules, |
| 118 | state slots, the space thread โ is finished and in production. |
| 119 | |
| 120 | ## Where native was before this work (the honest map, kept as the record) |
| 121 | |
| 122 | > ๐ค Historical: this section described native as of 2026-07-22, and is what |
| 123 | > the phases below were designed against. Every symptom in it is fixed on |
| 124 | > `main` except the vault passkey row (phase 6). Kept because the root-cause |
| 125 | > table is the best explanation of WHY the architecture looks the way it does. |
| 126 | |
| 127 | Three parallel app subsystems grew independently in `ios/`: |
| 128 | |
| 129 | 1. **Schema apps** โ `SchemaEngine/SchemaAppStore/SchemaAppView` (LChatCore) |
| 130 | interpret `Schemas/{todo,remind}.json` natively. Generic and good โ but |
| 131 | fed only by two bundled files (`AppSchemaRegistry`). |
| 132 | 2. **Webview embeds** โ everything else renders the web client in a |
| 133 | `WKWebView` seeded with `window.__lchatEmbed` (one thread, one thread key). |
| 134 | iOS: `ChatView`+`WebBridge`+`FeedWebCache`; Mac: `AppThreadWebView`+ |
| 135 | `MacWebBridge`+`AppWebViewCache`, plus a Mac-only per-thread **command |
| 136 | host** (`CommandPanelWebView`, `mode:"command"`) used for `/emoji`/`/gif`. |
| 137 | *(Since resolved on iOS: `ChatView` โ the embed that could carry a |
| 138 | thread's CHAT โ is deleted. `AppThreadScreen` renders app-thread chat |
| 139 | native and pins every surviving embed to `view:"interface"`; a webview |
| 140 | now only ever carries an app's own interface, on both platforms.)* |
| 141 | 3. **Flow apps** โ `FlowEngine` for `setup`/`welcome`. |
| 142 | |
| 143 | Overlaid: a native `groupapps` fold (`GroupApps.swift`) and **~10 hand-kept |
| 144 | per-app lookup tables** that each re-state a fragment of app metadata: |
| 145 | `AppIdentity.symbols/marks`, `GroupApps.catalog` (4 apps), |
| 146 | `ThreadStore.appTitles/appGlyphs/appDescriptions/personalApps` (12/12/12/5), |
| 147 | `AppChatProjection.intros/title` (2), `AppDefaultCommand.specs` (2), |
| 148 | `NativeChatView.companionApps` (2), `ComposerTypeahead.botCommands`, |
| 149 | `SlashCommands.WebPanelCommands.catalog`. The same fact ("is this app a |
| 150 | companion?") lives in **two** independent sets (iOS keys off |
| 151 | `AppChatProjection.intros`, Mac off `companionApps`); the app catalogue exists |
| 152 | in **three** different-length lists. |
| 153 | |
| 154 | ### Symptoms โ root causes |
| 155 | |
| 156 | | symptom | root cause | |
| 157 | |---|---| |
| 158 | | 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. | |
| 159 | | 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. | |
| 160 | | 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. | |
| 161 | | 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. | |
| 162 | | 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. | |
| 163 | | 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. | |
| 164 | | Vault passkey lock dead on native | WebAuthn inside an embedded `WKWebView` โ needs a host bridge (deliberately deferred; "make it good"). | |
| 165 | |
| 166 | ## Target architecture |
| 167 | |
| 168 | Three layers, all platform-shared in LChatCore; iOS and Mac keep only thin UI. |
| 169 | |
| 170 | ### Layer 1 โ `AppRegistry`: one source of truth (native) |
| 171 | |
| 172 | A LChatCore store that merges, and persists across launches: |
| 173 | |
| 174 | - **The catalogue** โ per-app manifest records reported by the app host |
| 175 | (below): `name, title, glyph, places, listed, quiet, companion, chatOnly, |
| 176 | chatReadOnly, defaultView, defaultCommand, composerPlaceholder, |
| 177 | commands[{name, desc, args, local}]`. Cached to disk; invalidated by |
| 178 | `apps_changed` โ `catalogChanged`. |
| 179 | - **Install state** โ personal app threads (`ThreadStore` already folds |
| 180 | these); per-group `groupapps` folds (`ChatFeedModel` already); and the new |
| 181 | **`SpaceAppsStore`**: fetch `/api/space-thread`, unwrap `open_key` with the |
| 182 | native instance key, seed from its `slot:"apps"` snapshot + fold |
| 183 | `spaceapps.*` per the existing vectors, subscribe to the |
| 184 | `space_thread`/`apps_changed` frames in `ChatSocket`, persist. |
| 185 | - **Native enhancements** โ `AppSchemaRegistry.hasSchema`, SF-symbol/brand-mark |
| 186 | overrides (`AppIdentity` becomes a *decoration* keyed by registry id, with |
| 187 | the manifest `glyph` as the universal fallback โ unknown apps get their |
| 188 | glyph, never a blank). |
| 189 | |
| 190 | Every current lookup table collapses into a registry read. The two |
| 191 | companion-sets, three catalogues, and per-app titles/glyphs/blurbs all become |
| 192 | one record type with one source. **An app the registry doesn't know still |
| 193 | works**: title/glyph/commands come from the manifest, surface is a webview โ |
| 194 | that is the first-class-citizen guarantee. |
| 195 | |
| 196 | ### Layer 2 โ the app host: the web engine, bridged (the generalized command host) |
| 197 | |
| 198 | The one component that can *execute* app JS is the web framework โ so native |
| 199 | runs it, headless, and talks to it over a small protocol. This is not new |
| 200 | machinery: it is the Mac command host (`mode:"command"`), generalized and made |
| 201 | first-class on both platforms. The embed page already loads **all three app |
| 202 | tiers** (`bootEmbed` runs `loadCustomApps()` + `loadMemberAppsNow()`), already |
| 203 | runs any command, and already reports panels/replies/errors. |
| 204 | |
| 205 | **The app-host protocol** (native โ `window.lchatNative` / `"lchat"` posts): |
| 206 | |
| 207 | | direction | call / post | meaning | status | |
| 208 | |---|---|---|---| |
| 209 | | native โ js | `appCatalog()` โ `[manifestโฆ]` | the full registered-app catalogue (all tiers) | shipped | |
| 210 | | native โ js | `commandItems()` โ `[rowโฆ]` | `commandCatalog(threadId)` for the embed's thread โ the typeahead/`/help` source | shipped | |
| 211 | | native โ js | `runCommand(text)` โ handled? | run any slash command; replies/panels post back | exists | |
| 212 | | native โ js | `dismissPanel()` | close the open panel | exists | |
| 213 | | js โ native | `{catalogChanged: kind}` | the app set changed (`apps_changed`); re-pull `appCatalog`, recreate stale webviews | shipped | |
| 214 | | js โ native | `{commandHostReady}`, `{panel}`, `{panelHeight}`, `{notice}`, `{composerInsert}`, `{error}` | command-host lifecycle | exists | |
| 215 | | 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 | |
| 216 | | 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 | |
| 217 | | 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 | |
| 218 | | 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 | |
| 219 | |
| 220 | Trust posture: the app host is a privileged surface *by job description* โ it |
| 221 | is the command engine, so its reach is the union of the member's app threads, |
| 222 | group threads it's asked about, and the space thread. It still **never** |
| 223 | holds the user's private key (native unwraps and hands narrow thread keys), |
| 224 | and the server still only ever sees ciphertext. Member-app code inside the |
| 225 | host stays inside the `/app-sandbox` iframe exactly as on the web. The |
| 226 | `needKey` grant is native-policied: only keys native itself can unwrap, only |
| 227 | on request, never the private key or admin token. |
| 228 | |
| 229 | Two host-shape decisions: |
| 230 | |
| 231 | - **Per-thread hosts first** (the existing Mac shape, LRU-cached): minimal |
| 232 | delta, and `appCatalog()` is thread-independent so *any* live embed can |
| 233 | serve it. A single per-instance host (`mode:"apphost"`, threadless boot) is |
| 234 | a later consolidation, not a prerequisite. |
| 235 | - **Snapshots come for free**: a host that folds a thread with real keys runs |
| 236 | the framework's automatic snapshot writer โ which fixes "native households |
| 237 | never write snapshots" without new code. (Safe by construction: snapshot |
| 238 | writes are optimistic-versioned, and racing writers refold + retry.) |
| 239 | |
| 240 | The embed's space-thread bail (`loadSpaceThread`: `if (embed || !instanceKey) |
| 241 | return`) lifts for hosts seeded with the instance key, so space-installed |
| 242 | apps' commands route correctly inside the host too (phase 4). |
| 243 | |
| 244 | ### Layer 3 โ registry-driven surfaces |
| 245 | |
| 246 | - **Surface routing**: iOS `ThreadListView.chatSurface` and Mac |
| 247 | `NativeChatView.feed` stop switching on hard-coded names. One shared |
| 248 | decision: `registry.presentation(appId)` โ *native-schema* (schema exists) / |
| 249 | *flow* (`setup`/`welcome`) / *companion* (Chat|Split|Interface on Mac, |
| 250 | Chat|Interface on iOS โ the Chat pane is the NATIVE feed on both, honouring |
| 251 | `defaultView`, `chatOnly`, `chatReadOnly`) / *takeover* (the app's |
| 252 | interface, full pane) โ with the interface-only webview as the universal |
| 253 | fallback. Chat itself never renders in a webview: a webview may only carry |
| 254 | an app's own interface (iOS `AppThreadScreen`/`AppInterfacePane`, Mac |
| 255 | `companionSplit`/`appInterfacePane`). |
| 256 | - **Pickers**: New-chat โธ Apps (personal = `places` โ "you"), group โฎ โธ Apps |
| 257 | (chat-capable, so the board appears), and the space band (space installs, |
| 258 | from `SpaceAppsStore`) all render from the registry โ custom and member |
| 259 | apps included, identically on both platforms. |
| 260 | - **Typeahead**: `ComposerTypeahead` consumes registry commands (per-thread |
| 261 | rows via `commandItems()`, cached + refreshed on fold changes) instead of |
| 262 | its hard-coded floor; selection routes to the command host. The floor |
| 263 | (`/me`, `/help`, theme verbs) stays native. |
| 264 | - **Group instances**: the added-apps switcher and the group feed's app-event |
| 265 | narration come from the registry + projection queries, one design on both |
| 266 | platforms (Mac keeps Split; iOS keeps toggle). |
| 267 | |
| 268 | ## Phases (all shipped 2026-07-23 except phase 6; each landed as its own PR) |
| 269 | |
| 270 | - **Phase 0 โ protocol groundwork (web-side, testable in Node). SHIPPED.** |
| 271 | `appCatalogReport()` in `apps.js`; `appCatalog()` / `commandItems()` on the |
| 272 | embed bridge; `{catalogChanged}` post on `apps_changed` in embeds; |
| 273 | `test/appcatalog.test.mjs` pins the shapes. |
| 274 | - **Phase 1 โ installs survive: `SpaceAppsStore` + registry persistence. SHIPPED.** |
| 275 | LChatCore: space-thread fetch/unwrap, `spaceapps` fold (vectors exist: |
| 276 | `spaceapps.json`), `space_thread`/`apps_changed` in `ChatSocket`, disk |
| 277 | persistence for catalogue + installs. Acceptance: kill + relaunch โ a |
| 278 | space's installed apps (vault included) are still listed. |
| 279 | - **Phase 2 โ one registry, registry-driven routing. SHIPPED.** Collapse the lookup |
| 280 | tables into `AppRegistry`; route surfaces and pickers from it. Acceptance: |
| 281 | Admin gets Chat|View on Mac and opens as the app on iOS; the board is |
| 282 | addable to a group and loads; unknown/custom apps appear with glyph+title. |
| 283 | - **Phase 3 โ commands everywhere. SHIPPED.** iOS gains the command host; both |
| 284 | platforms forward *every* non-floor slash command to it; typeahead rows |
| 285 | from `commandItems()`; `needKey` bridge for cross-thread routing. |
| 286 | Acceptance: `/todo buy milk` works from any chat on both platforms; |
| 287 | `/emoji`/`/gif` stop dead-posting on iOS; a custom app's command appears in |
| 288 | the typeahead with its desc/args. |
| 289 | - **Phase 4 โ space routing in the host. SHIPPED.** Lift the embed space-thread bail |
| 290 | when the instance key is present; space band UI. Acceptance: `/gif` (space |
| 291 | app) works natively where installed. |
| 292 | - **Phase 5 โ projection queries. SHIPPED.** Native feeds narrate app events for apps |
| 293 | without native projections (`projectEvent`); `AppChatProjection` becomes the |
| 294 | fast path for schema apps, same outputs, vector-pinned. |
| 295 | - **Phase 6 โ make it good. OPEN.** More schemas; native panel rendering; the |
| 296 | vault passkey/WebAuthn host bridge; per-instance host consolidation; native |
| 297 | snapshot writing direct from `SchemaAppStore` if the host path isn't enough; |
| 298 | the sandbox-projection relay, so MEMBER apps' events narrate in native feeds |
| 299 | too (their `chatMessage` runs only inside the `/app-sandbox` iframe, so |
| 300 | `projectEvent` deliberately answers null for them today). |
| 301 | |
| 302 | ## Deliberately out of scope |
| 303 | |
| 304 | Bots and webhooks (already native-blind by design โ sidecar processes); |
| 305 | changing any wire format or server behaviour; SDUI for the chat feed |
| 306 | (`docs/native-chat.md` locked that out). The schema engine keeps growing |
| 307 | *within* its archetype โ it is the "make it good" tier, never the |
| 308 | availability gate. |
| 309 | |