# 🤖 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:

1. **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.
2. **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.
3. **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.
4. **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.
5. **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 whose `name_blob`
  decrypts to `{app,title}`. Created on first use.
- *Group* ("chat"): `groupapps.add/remove` events in that group's own log,
  folded by the `installs` registry app (`apps.js`), pinned by
  `test/event-vectors/groupapps.json`.
- *Space-wide* ("space"): `spaceapps.add/remove` events in the hidden
  all-members **space thread** (`kind:"space"`; fetched explicitly via
  `GET /api/space-thread` — it never appears in `/api/threads`; its AES key
  rides `open_key`, wrapped under the *instance key*, so no private key is
  needed to open it). Pinned by `test/event-vectors/spaceapps.json`; design
  `docs/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/`:

1. **Schema apps** — `SchemaEngine/SchemaAppStore/SchemaAppView` (LChatCore)
   interpret `Schemas/{todo,remind}.json` natively. Generic and good — but
   fed only by two bundled files (`AppSchemaRegistry`).
2. **Webview embeds** — everything else renders the web client in a
   `WKWebView` seeded with `window.__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. `AppThreadScreen` renders app-thread chat
   native and pins every surviving embed to `view:"interface"`; a webview
   now only ever carries an app's own interface, on both platforms.)*
3. **Flow apps** — `FlowEngine` for `setup`/`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 by
  `apps_changed` → `catalogChanged`.
- **Install state** — personal app threads (`ThreadStore` already folds
  these); per-group `groupapps` folds (`ChatFeedModel` already); and the new
  **`SpaceAppsStore`**: fetch `/api/space-thread`, unwrap `open_key` with the
  native instance key, seed from its `slot:"apps"` snapshot + fold
  `spaceapps.*` per the existing vectors, subscribe to the
  `space_thread`/`apps_changed` frames in `ChatSocket`, persist.
- **Native enhancements** — `AppSchemaRegistry.hasSchema`, SF-symbol/brand-mark
  overrides (`AppIdentity` becomes a *decoration* keyed by registry id, with
  the manifest `glyph` as 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.chatSurface` and Mac
  `NativeChatView.feed` stop 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, honouring
  `defaultView`, `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 (iOS `AppThreadScreen`/`AppInterfacePane`, Mac
  `companionSplit`/`appInterfacePane`).
- **Pickers**: New-chat ▸ Apps (personal = `places` ∋ "you"), group ⋮ ▸ Apps
  (chat-capable, so the board appears), and the space band (space installs,
  from `SpaceAppsStore`) all render from the registry — custom and member
  apps included, identically on both platforms.
- **Typeahead**: `ComposerTypeahead` consumes registry commands (per-thread
  rows via `commandItems()`, 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()` in `apps.js`; `appCatalog()` / `commandItems()` on the
  embed bridge; `{catalogChanged}` post on `apps_changed` in embeds;
  `test/appcatalog.test.mjs` pins the shapes.
- **Phase 1 — installs survive: `SpaceAppsStore` + registry persistence. SHIPPED.**
  LChatCore: space-thread fetch/unwrap, `spaceapps` fold (vectors exist:
  `spaceapps.json`), `space_thread`/`apps_changed` in `ChatSocket`, 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()`; `needKey` bridge for cross-thread routing.
  Acceptance: `/todo buy milk` works from any chat on both platforms;
  `/emoji`/`/gif` stop 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`); `AppChatProjection` becomes 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 `SchemaAppStore` if the host path isn't enough;
  the sandbox-projection relay, so MEMBER apps' events narrate in native feeds
  too (their `chatMessage` runs only inside the `/app-sandbox` iframe, so
  `projectEvent` deliberately 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.
