eleven / messenger Public

docs: native-apps.md reflects the merged stack β€” statuses, not branches

The status header narrated six in-flight branches (all deleted after the
2026-07-23 squash-merges); it now records what shipped and points phase 6
as the one open item. Protocol-table rows normalize to "shipped", the
phases list becomes the shipped record (with the sandbox-projection relay
added to phase 6 β€” member apps' feed narration, deferred deliberately in
#1317), and the "where native is today" map is banner-marked historical:
its root-cause table explains WHY the architecture looks as it does, but
every symptom in it is fixed on main except the vault passkey row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
James Adam 7a8b04d93c2262d0677838a796c2f15e6cc085d9 parent 6cb7e40
1 file changed, +38 −57
  • docs/native-apps.md +38 −57
diff --git a/docs/native-apps.md b/docs/native-apps.md
index 3d15db7..5038f33 100644
--- a/docs/native-apps.md
+++ b/docs/native-apps.md
@@ -1,44 +1,17 @@
# πŸ€– Native apps Γ— the app platform: apps as first-class citizens
-> Status: **DESIGN + PHASES 0–5 (2026-07-22)** β€” phase 5 (branch
-> `claude/native-apps-projection`) ships projection queries: app events
-> AppChatProjection can't answer (the sync, vector-pinned fast path for
-> schema apps + installs) stash during the fold and query the thread's
-> command host (`projectEvent` β€” the web's own chatMessage/groupFeedMessage),
-> folding the answered lines in as the same synthesized bubbles. Open feeds
-> only (NSE/background sync keep the fast path); member apps stay silent
-> natively (their hooks run only in the sandbox). Phase 4 (branch
-> `claude/native-apps-space`) ships space routing + the space band: the
-> embed space-thread bail lifts when the native shell seeded the instance
-> key (space-installed apps' commands β€” /gif β€” route in the command host),
-> a `space:true` embed seed renders a space app's surface through the web's
-> own openSpaceApp path, and both platforms grow a space band (Mac sidebar
-> rows / iOS chips) from SpaceAppsStore Γ— the registry β€” the manifest gains
-> `surface` (has a render) so command-only apps get no row. Phase 3 (branch
-> `claude/native-apps-commands`) ships commands everywhere: the command host
-> moves to LChatCore and iOS gains it (panel + notices in the native
-> composer), both platforms forward every non-floor slash command
-> (`CommandRouting` over live `commandItems()` rows β€” bot-answered rides to
-> the conversation, framework-owned forwards), the "/" typeahead consumes the
-> per-thread rows, and the `needKey`/`needAppThread` bridge lets a command
-> typed in one thread emit into another (native answers with one narrow
-> thread key / an open-or-created app thread β€” never the private key).
-> Phase 2 (branch
-> `claude/native-apps-registry`) ships `AppRegistry`: the bundled seed
-> catalogue generated from the web framework (`tools/gen-app-catalog.mjs`,
-> drift-gated by `test/appcatalog-seed.test.mjs`), live `appCatalog()` pulls
-> from ready embeds (+`{catalogChanged}` re-pulls), and every lookup table
-> collapsed into registry reads: pickers (incl. a NEW iOS personal-apps
-> section), surface routing (`registry.presentation`), the install
-> read-filter, and per-app titles/glyphs/blurbs. This branch ships the design,
-> the web side of the app-host protocol (`appCatalog()` / `commandItems()` /
-> `catalogChanged`, pinned by `test/appcatalog.test.mjs`), and phase 1:
-> `SpaceApps`/`SpaceAppsStore` in LChatCore (space-thread fetch + `open_key`
-> unwrap, the `spaceapps` fold pinned by `SpaceAppsVectorTests`, live-frame
-> wiring on both platforms, per-origin Keychain persistence for installs and
-> the `AppManifest` catalogue). Phases 2+ land as their own small PRs, iOS
-> first per CLAUDE.md. This note is the handoff brief for all of them β€” the
-> same role `docs/native-chat.md` played for the native feed.
+> 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)
@@ -144,7 +117,12 @@ the E2EE-shaped form the one rule demands:
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 is today (the honest map)
+## 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/`:
@@ -224,16 +202,16 @@ runs any command, and already reports panels/replies/errors.
| direction | call / post | meaning | status |
|---|---|---|---|
-| native β†’ js | `appCatalog()` β†’ `[manifest…]` | the full registered-app catalogue (all tiers) | **phase 0 (this branch)** |
-| native β†’ js | `commandItems()` β†’ `[row…]` | `commandCatalog(threadId)` for the embed's thread β€” the typeahead/`/help` source | **phase 0 (this branch)** |
+| 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 | **phase 0 (this branch)** |
+| 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. | **phase 3 (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. | **phase 3 (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…"). | **voice PR (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. | **phase 5 (shipped)** |
+| 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,
@@ -280,36 +258,39 @@ apps' commands route correctly inside the host too (phase 4).
narration come from the registry + projection queries, one design on both
platforms (Mac keeps Split; iOS keeps toggle).
-## Phases (small PRs; each stands alone; iOS first where UI diverges)
+## Phases (all shipped 2026-07-23 except phase 6; each landed as its own PR)
-- **Phase 0 β€” protocol groundwork (this branch, web-side, testable in Node).**
+- **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.**
+- **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.** Collapse the lookup
+- **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.** iOS gains the command host; both
+- **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.** Lift the embed space-thread bail
+- **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.** Native feeds narrate app events for apps
+- **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.** 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.
+- **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