# 🤖 Member apps: LLM-built or member-written, member-deployed, sandboxed

Members get an app two ways: ask a bot in plain chat ("build me a habit
tracker"), or write the code themselves and deploy it from **App Studio**.
Either way it lands in the Apps menu a moment later. This document is the
design: what runs where, what the server can see, and why untrusted code can't
touch anything that matters. The authoring rules live in
`docs/building-member-apps.md` (served at `/api/member-app-guide`); the
mini-app platform itself in `web/static/apps/README.md`.

## Two trust tiers

| | custom apps (existing) | member apps (this doc) |
|---|---|---|
| who ships it | the instance **admin** | any **member** (usually via a bot) |
| what it is | CODE the admin vetted | USER CONTENT |
| stored as | plaintext JS (`custom_apps`) | instance-key-encrypted blob (`member_apps`) |
| served as | an executable ES module | never — the server can't even read it |
| runs | in the page, full trust | in an opaque-origin sandboxed iframe |
| install gate | admin token | member token; author-only overwrite |
| moderation | admin delete | author or admin delete |

The existing tier is unchanged. The new tier exists because "anyone can add
code" and "code runs with access to everything a device can decrypt" cannot
coexist — so member apps get a cage instead of a vetting step.

## The loop, end to end

1. **Ask.** A member tells a model-enabled bot what they want. The bot's
   system prompt teaches it to interview briefly, then call its `create_app`
   tool with a name/title/glyph/spec (`internal/bot/brain.go`).
2. **Build.** The sidecar runs a dedicated generation call against the
   instance's own authoring guide (fetched from `/api/member-app-guide`, so
   bot and server can never disagree), then statically validates the module —
   allowed imports only, no network/storage APIs, one `defineApp`, namespaced
   event types — and regenerates on failure (`internal/bot/appbuild.go`).
   This validation is a *quality* gate; the sandbox below is the security
   boundary.
3. **Deploy.** The bot — an ordinary E2EE member holding its own wrapped copy
   of the instance key — encrypts `{ source, manifest }` under that key and
   `PUT`s the blob to `/api/member-apps/{id}` with its member token
   (`memberapps.go`). The id is random; the server learns nothing but
   (id, author, size, time). `apps_changed` broadcasts and clients reload.
4. **Launch.** At boot the client fetches the list, decrypts each blob with
   the instance key, validates the manifest, and registers a **proxy app**
   through the ordinary `defineApp` (`web/static/sandbox.js`) — so the app
   appears in New chat's Apps list, gets its own personal thread, folds
   events, and snapshots exactly like a built-in. Opening it mounts the
   sandbox frame; the member sees their custom UI full-pane in the shell.

## Fixing an existing app: the bot's eyes

Building is only half the loop — members come back with "the done button does
nothing, fix it". The server can't help (it holds ciphertext), so the bot's
sight is client-side, built from the same instance key it deploys with
(`internal/bot/appbuild.go`):

- **Discovery.** Every model reply carries a per-sender system note listing
  the apps *that member owns* — exact `app_id`, title, name, version, builder
  — decrypted from the deployed list (briefly cached; the sidecar's own
  deploys invalidate it). Without this, the model only ever knew an id that
  happened to appear in the recent conversation, and "fix my habit tracker"
  from a fresh chat silently became a blind second build.
- **Reading.** A `read_app_source` tool returns one app's manifest + current
  source (owner-gated, exactly like updating), run in a short bounded tool
  loop inside the reply — so the bot can diagnose a bug or explain the app's
  behaviour before (or without) rebuilding.
- **Updating.** `create_app` with the `app_id` from the note; the build call
  regenerates from the app's current source, so the fix is an edit, not a
  from-scratch rewrite. The deploy verifies and announces the new version,
  and the old one stays in App Studio's History to revert to.

All three stay inside the ownership gate: the note lists only the sender's
apps, reads and updates check the manifest's `owner` against the requester,
and other members' apps are invisible and untouchable.

## The sandbox

The proxy's `render` mounts:

```
<iframe src="/app-sandbox" sandbox="allow-scripts">
```

- **Opaque origin.** `sandbox` without `allow-same-origin` gives the document
  a null origin: no cookies, no localStorage/IndexedDB of the instance
  origin (where the session token and private key live), no DOM access to
  the parent page, no popups, no top navigation.
- **Own CSP, not the page's.** `/app-sandbox` is a real page (memberapps.go)
  served with `default-src 'none'; connect-src 'none'; script-src
  '<sha256>' 'unsafe-eval'; img-src data: blob:; style-src 'unsafe-inline';
  frame-src 'none'; form-action 'none'`. No fetch/XHR/WebSocket/beacon to
  anywhere — including our own server. The script-src is a **hash of the one
  inline runtime, deliberately NOT a nonce**: a nonce is readable by
  same-document JS (`document.querySelector('script').nonce`), and a nonced
  `<script src>` bypasses the host allow-list — so hostile app code could
  read the nonce and pull remote code / exfiltrate despite `connect-src
  'none'`. A hash authorises exactly that one script and nothing the app can
  mint, so every `<script src>` (and every other inline script) is refused.
  (A `srcdoc` frame would have *inherited* the chat page's CSP; a real page
  carries its own.)
- **One trusted script.** The page inlines `web/static/sandbox-frame.js`
  (its bytes are what the CSP hash covers). It receives `{ source, manifest,
  seed, me, theme }` over the bridge, rewrites the module's imports into an
  SDK shim (`transformModuleSource`), evals it (`'unsafe-eval'` is scoped to
  this caged document), folds events with the app's real reducer, renders
  into the frame's own DOM, and reports state back.
- **The bridge is the only doorway.** A `MessageChannel` the host establishes
  on the iframe's **`load` event** — an unforgeable signal that fires on every
  genuine (re)load, unlike a `postMessage` "ready" which app code could spoof
  to hijack the port. The frame verifies init comes from `window.parent` with
  a real (non-null) origin, so one sandboxed app can't initialise a sibling.
  Everything a frame sends is validated host-side (`web/static/sandbox.js`):
  - **emit/schedule/ephemeral** — the event must be one of the app's own
    manifest types (all prefixed `<name>.`), deep-cleaned to plain JSON (no
    `__proto__`/`constructor` keys, depth- and size-capped), transport
    fields (`name`/`ts`/`uid`) stripped, `notice` clamped to plain strings
    with action chips removed, and rate-limited. A member app can never
    spoof `chat.*`, another app's events, or a plain chat message.
  - **state** — size-capped, JSON-normalized, installed only through
    `installMemberAppState` (apps.js), which is gated on the proxy marker.

What's inside the frame when it runs: the app's source, **its own thread's**
decrypted events and folded state, the member's uid/screen name, and the
theme variables. What is never inside: keys, tokens, other threads, the
roster, message content from anywhere else. The worst a malicious member app
can do is lie about or destroy *its own* state — the same power its reducer
always had.

Known residual channels, accepted and bounded: the frame can navigate itself
back to `/app-sandbox` (a self-reload — it just resets its own cage, gaining
nothing); cross-origin navigation and remote subresource loads are blocked by
the parent's `frame-src 'self'` and the frame's own hash-pinned `script-src` +
`connect-src 'none'` + `img-src data: blob:`. `postMessage` from app code is
validated away (init rides the load event, not a message). Nothing touches the
E2EE core: keys, tokens, other threads, and the parent DOM never enter the
frame, so the worst a hostile app does is corrupt or spoof its *own* pane.

## State: `{ v, vUpTo, pend }`

The host never runs the untrusted reducer, but the framework's snapshot
machinery needs *some* fold. The proxy's fold is a trusted generic queue:

- every wire event of the app's types appends `{ e, meta }` to `pend`
  (bounded; overflow drops oldest and flags `lost`);
- the frame folds those events with the real reducer and reports
  `{ state, upTo }`; the host swaps that in as `v`/`vUpTo` and trims `pend`.

At every moment `v + pend` *is* the fold at the thread's horizon — the swap
is horizon-neutral — so `takeSnapshot`/`seedThread` persist and restore
sandboxed state through the standard encrypted `thread_state` path, and a
closed app still accumulates events safely (they queue in `pend`, fold on
next open). Scheduled events and `notice` pushes ride the normal wire paths
untouched.

## Server surface (`memberapps.go`)

| route | gate | notes |
|---|---|---|
| `GET /api/member-apps` | member token | blobs included (only clients can decrypt) |
| `PUT /api/member-apps/{id}` | member token | new id: any member (cap 200); existing: author, owner, or an agent asserting that same recorded owner |
| `DELETE /api/member-apps/{id}` | author or admin | admin = moderation without readability |
| `GET /app-sandbox` | none (static runtime) | the caged frame document |
| `GET /api/member-app-guide` | none | the authoring guide the bot builds against |

Admins can switch the whole tier off with the `member_apps_enabled` setting
(Admin → Features → "Let members make their own apps", default on) — narrower
than the `apps_enabled` gate, which covers the entire apps feature. Off means:
the `PUT` above 403s (new deploys *and* edits), the guide 404s (which flips the
bot sidecar's `create_app` builder off — it treats a failed guide fetch as
"building disabled"), and App Studio hides from the app pickers (an
already-open App Studio thread drops its editor and says why; the deployed-apps
list stays, minus Edit code / Revert). Apps already deployed keep listing and
running, and `DELETE` stays open — the gate stops new use, never cleanup or
moderation.

Ownership: the server's author is whoever deployed (the bot, for bot-built
apps). The *requester* is recorded inside the encrypted manifest (`owner`),
and the bot refuses to update an app for anyone but its requester — the gate
lives where the plaintext lives. The server's overwrite gate mirrors that
split: author or owner always may, and an **agent** that is neither may
overwrite *on the recorded owner's behalf* by asserting that same `owner_id`
(the visible half of the manifest check it already ran) — which is what lets
a bot fix an app the member wrote in App Studio, or one a different bot built
for them. Ownership never transfers via an overwrite, and owner-less legacy
rows stay closed to agents. Residual: chat payloads aren't
sender-authenticated (the thread key is symmetric — every member can encrypt
one), so a determined member could assert another's uid to redirect an
update, exactly as they could impersonate anyone in plain chat. That's a
property of the messaging layer, not this gate; the blast radius is capped by
the sandbox (a hijacked update only alters that app's own caged pane, never
keys or data). What the gate does enforce firmly: an unknown requester (no
uid) can't build or update anything, and an app with no recorded owner can't
be updated by anyone — so it never silently opens.

Caps: a global bound (200 apps) and a per-author bound (40) are enforced in
one transaction so concurrent deploys can't race past them; the per-author
bound stops one member starving the shared global cap.

## App Studio — self-serve deploy (`web/static/apps/appstudio.js`)

App Studio is the member-facing manager: a compiled-in **trusted** app (it
holds the token + instance key to encrypt/deploy, so it can't be a sandboxed
member app) that lists deployed apps and deploys pasted source directly — no
bot. It's the same doorway the bot uses (`PUT /api/member-apps`), driven by a
member instead. The privileged work lives in `web/static/sandbox.js`
(`listDeployedApps`, `deployMemberApp`, `retireMemberApp`, `extractManifest`,
`openMemberApp`); shell.js injects the token/instance-key/opener via
`setMemberAdmin`.

The load-bearing piece is **manifest extraction**: rather than re-parse source
with regexes, App Studio runs the pasted code in a throwaway sandbox frame
(`extract` mode in `sandbox-frame.js` → `evalMemberApp` → `manifestOf`) and
reads back the *real* `defineApp` spec's name/title/glyph/types/commands/chat.
Untrusted code is evaluated only in the cage, and the extractor is the
authoritative source of truth (no drift with the runtime). `deployMemberApp`
then validates that manifest, encrypts `{ source, manifest }` under the
instance key, and PUTs it — minting a fresh id for a new app, or reusing the id
to update one you own.

A **new** app registers live on deploy (usable immediately, no reload) and
suppresses this client's own `apps_changed` reload (`recentLocalDeploy`); an
**update** changes already-loaded code, so it rides the normal reload.

## Picking up a new app: offer, don't force

When `apps_changed` arrives (a bot build, an App Studio deploy on another
device, or an admin custom-app install), the client does NOT reload out from
under you. It registers any new *member* apps live (so they work at once), then
surfaces a subtle, dismissible **"A new app is available — Reload / Later"**
nudge in the sidebar (`offerAppReload`, styled like the backup banner — inline,
never a floating toast). Reload is the reliable pickup for every tier (custom
apps are ES modules that need a fresh load); "Later" just hides it. The bot's
deploy confirmation reinforces the same low-pressure framing.

## Native

Member apps ride the web shell, so the embedded webview (iOS/Mac render one
thread per webview) runs them without native code: `bootEmbed` registers the
proxies before rendering. A native Apps launcher entry is follow-up work;
until then an app thread created on the web appears in the native thread
list like any thread.

## Commands, companion chat, and formatting (in the frame)

Three capabilities cross the sandbox boundary by running the app's untrusted
logic in the frame and letting the host act on the (validated) result:

- **`../format.js`** — the pure text helpers are bundled *ahead of* the runtime
  into the one `/app-sandbox` module (memberapps.go `sandboxBundle`), so a
  member app's `import { richTokens } from "../format.js"` resolves with no
  extra fetch. The CSP hash covers the whole bundle.
- **Slash commands** — the manifest carries command *metadata* (names, for the
  `/` typeahead); the handlers live in the source and run in a frame. When the
  app's thread is open its live frame runs the command; otherwise the host
  mounts a transient hidden frame (seeded from the snapshot) just to execute
  it. The handler's `ctx.emit` bridges out; its `ctx.reply({text,short})` comes
  back and shows as the private confirmation where the user typed, with a
  host-owned "Open <app>" chip (the app can't ship clickable code into the
  host). Bridge: host→frame `{type:"command",…}`, frame→host
  `{type:"command-reply",spec}`.
- **Companion chat** — the manifest flags `chat:true` (derived by the bot from
  the source's `chatMessage`); the proxy gains a `chatMessage` so the framework
  treats it as a companion (Chat + Interface views). The real projection runs
  in the frame: it computes `chatMessage(event)` on fold and reports the chat
  lines alongside state (snapshotted in the proxy's `{v,vUpTo,pend,chat}` so the
  Chat view survives the window). The host folds each line into the thread's
  Chat view via the same `chat.msg` path a trusted companion uses, deduped by
  server message id.

All three stay inside the trust model: only the app's own thread data, its own
manifest event types, and the projected/replied strings ever cross, all
size-capped and rate-limited; keys, tokens, other threads, and the parent DOM
never enter the frame.

## Legacy formats: the compat policy

Member-app blobs are instance-key-encrypted user content: the server can't
read them, there is no census of what exists across (self-hosted, offline)
instances, and only the OWNER's client may rewrite one. So superseded
authoring vocabulary can't simply be dropped — but it doesn't get to sprawl
either. The policy, in deprecation-ladder order:

1. **Writers emit only the current vocabulary.** The bot's builder,
   `manifestOf` (App Studio's extractor), and the `check-app.mjs` linter all
   produce/teach the current form — nothing mints a legacy blob, ever.
2. **Readers accept legacy at exactly ONE tested funnel per boundary** —
   today: `normalizePlaces` in `defineApp` (the scope enum and the
   personal/groupCapable/spaceCapable flags normalize into `places` and are
   deleted) and the manifest→places derivation in `sandbox.js`. When a
   concept is superseded, all first-party code migrates and the internal
   reads are DELETED in the same change, so the funnel is the only place the
   old vocabulary exists. (A concept that never reached the stored-manifest
   boundary can be deleted outright with no funnel at all — `groupChatOnly`.)
3. **Upgrades are opportunistic and owner-driven.** Any touch modernizes an
   app: bot updates regenerate source against the current guide, and App
   Studio flags legacy-format apps ("older format") with a one-tap
   **Upgrade** — redeploying the same source re-mints a current manifest,
   with the old version kept in History. Never rewrite a member's SOURCE
   automatically: it's user content, and a transform bug bricks an app we
   can't inspect.
4. **The floor may eventually rise — but the accepted set is bounded and
   written down, never infinite.** The full legacy set is currently:
   manifests with `scope: "personal"` and/or the capability booleans in
   place of `places`, and sources declaring the same fields in `defineApp`.
   After a long, announced deprecation window (upgrade offers shipped well
   before), `validateManifest` — the load gate — may refuse forms below the
   floor; a refused app fails to register with a reason, exactly like any
   invalid manifest, and its owner upgrades from App Studio. Raising the
   floor is a deliberate, documented decision, never a side effect.

## Follow-ups (deliberately out of scope)

- `messageAction` / `notify` / `preview` / `unread` for sandboxed apps.
- ~~Shared-scope (group) member apps~~ — SHIPPED since: a member app's
  `places` may name any non-empty subset of you/chat/space, so chat-shared,
  space-wide, and even space-only (gif-shaped, `listed: false`) member apps
  are all legal (`docs/building-member-apps.md`).
- An admin-console list of member apps (App Studio lists a member's own; an
  admin can delete any via the API today).
- In-place update of a running app without a reload (updates currently reload
  to swap code).

## 🤖 Command panels (member apps)

A member app's command may open a command panel (`ctx.panel` — the transient
surface above the composer). The trust posture is unchanged:

- The panel BODY is a fresh `/app-sandbox` frame — the same opaque-origin,
  no-network, no-keys cage as the app's own surface. The host never runs the
  app's panel code in the page.
- Everything crosses the bridge as validated JSON: the request
  (`sanitizePanelSpec` — clamped title, 64 KB data cap), emits from the panel
  frame (the standard sanitize + rate limit), composer inserts (length-capped),
  and content-height reports (clamped).
- A panel opens only while one of the app's commands is actually running (the
  host resolves the opener by the command's sequence number), so a frame can't
  pop panels spontaneously.
- The panel HEADER and ✕ are host-drawn, outside the frame — dismissal can't
  be trapped. A panel frame's `state` reports are ignored; the app-thread's
  own frame stays the single writer.

## 🤖 Platform limitations: what member apps structurally can't do (yet)

Building a full pack of *real* apps (a 21-app "family" set;
`web/static/apps-disabled/family/`) surfaced a consistent boundary worth
recording so it isn't rediscovered each time: member apps are well-suited to
**personal, cooperative, single-thread** tools (lists, trackers, journals,
games) and structurally **can't express roles/authority, per-member privacy,
targeted notification, or enforcement** — even though the *server* already holds
the primitives for most of it.

- **No authority reaches apps.** Access levels (tiers, `docs/community-and-tiers.md`)
  are admin-assignable roster roles — not just paid — *each carrying its own
  key*: a ready-made "parents/children" or "moderators/members" primitive with
  cryptographic backing. But `ctx` exposes no tiers and the sandbox withholds the
  roster, so an app can gate nothing on a role. (In the family pack, an "ask a
  parent" app degrades to "anyone but the asker".)
- **No per-member privacy in a thread.** One symmetric thread key → every member
  decrypts every event and snapshot. An app can only *render-hide* (a courtesy,
  not a vault); asymmetric secrecy needs cryptographic sealing the keyless
  sandbox can't perform.
- **The fold can't authorise.** Pure reducers see only `e.uid`; every member can
  emit every event, so "only X may do Y" is a render suggestion, never enforced.
- **No roster, no targeting, no directed notice.** Apps rebuild a pseudo-roster
  from participation (who has emitted an event); a `notice` reaches everyone who
  receives the event, not a chosen person or role.
- **Convergent fold vs asymmetry.** "The same events fold to the same state"
  (load-bearing for snapshots + native parity + the event vectors) is exactly
  what makes per-viewer or per-role state impossible except as a render illusion.

**Widen-it path (roadmap, not built).** Expose tier membership to `ctx` for
admin-defined **role gating** (Level 1 — courtesy, but backed by a real role);
add host-mediated **seal-to-tier / seal-except** primitives for real enforcement
and privacy (Level 2 — the keyless sandbox can't do it alone); give `notice` a
target so an app can reach a person or a role. Fuller write-up, with per-app
evidence and the three-level "who sees what" model, lives in the family
reference pack's `DESIGN.md` (§ "Fit for purpose?" and § "Viewer-aware
rendering").
