eleven / messenger Public

Member apps: LLM-built & member-written, deployed and sandboxed (#397)

* server,web,bot: member apps — LLM-built, member-deployed, sandboxed

Members ask a bot for an app in chat; the bot generates a single-file
mini-app against the served authoring guide, encrypts {source,manifest}
under the instance key, and deploys it via the new member-token-gated
/api/member-apps. Clients decrypt at boot and register a proxy through
defineApp — launcher, personal thread, folds, and snapshots all ride the
existing framework — while the untrusted code itself runs only inside an
opaque-origin sandboxed iframe (/app-sandbox) with a no-network CSP and
an audited postMessage bridge (own event types only, deep-sanitized,
rate-limited; no keys, tokens, or other-thread data ever cross).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* member apps: harden sandbox + deploy after adversarial review

Fixes from a multi-lens security/correctness review of the feature:

- SANDBOX ESCAPE (high): the frame CSP used a nonce, which app code can
  read (document.querySelector('script').nonce) and reuse on a <script src>
  to bypass connect-src 'none' — remote code + exfil of the app's own thread
  data. Replace the nonce with a sha256 hash of the inline runtime: it
  authorises exactly that one script and no <script src> the app can mint.
- ready-handshake DoS (low): drive frame init off the unforgeable iframe
  load event instead of a spoofable postMessage 'ready'.
- ownership gate (med): refuse updates when the requester uid is empty or
  the app has no recorded owner, closing the empty-owner hole; document the
  residual (chat payloads aren't sender-authenticated, sandbox-capped).
- deploy cap TOCTOU (low): count+insert in one transaction; add a per-author
  cap so one member can't starve the global bound.
- lost-flag erasure (low): carry the MAX_PEND overflow flag through
  installMemberAppState so the data-loss warning isn't silently wiped.
- bot builder quality gates (low): boundary-aware forbidden-API scan
  (no more refetch/retrieval false-trips), catch camelCase event types
  instead of dropping them, validate export forms, presence-based name check.

Adds tests for each; CSP hash verified against the live binary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* member apps: enable slash commands, companion chat, and shared formatting

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

- Formatting: ../format.js is bundled ahead of the runtime into the one
  /app-sandbox module (sandboxBundle), so an app's `import { richTokens }
  from "../format.js"` resolves with no extra fetch; the CSP hash covers the
  whole bundle. transform + validator accept the format import surface.
- Slash commands: the manifest carries command metadata (names → the /
  typeahead); handlers run in the frame. In-thread uses the live frame,
  off-thread mounts a transient hidden frame seeded from the snapshot. The
  handler's ctx.emit bridges out; ctx.reply({text,short}) comes back as the
  private confirmation where the user typed, with a host-owned "Open <app>"
  chip. Bridge: host→frame command, frame→host command-reply.
- Companion chat: manifest chat:true (derived by the bot from the source's
  chatMessage) makes the proxy a companion; the frame projects each event via
  chatMessage on fold and reports chat lines alongside state (snapshotted in
  {v,vUpTo,pend,chat}); the host folds them into the Chat view via the same
  chat.msg path a trusted companion uses, deduped by message id.

Trust model unchanged: only own-thread data, own manifest types, and the
projected/replied strings cross — size-capped and rate-limited; no keys,
tokens, other threads, or parent DOM enter the frame.

Tests: frame bridge driven end-to-end over a real MessageChannel with a DOM
shim (test/sandbox-bridge.test.mjs); host validation/sanitization units; Go
validator + two build-loop e2e (plain and companion+command+format); bundle
integrity + CSP-hash pinned. Guide (served to the bot), design doc, and
changelog updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* member apps: bound companion chat in the snapshot size guard

The companion chat lines are snapshotted alongside state, so the pre-install
size guard must measure state + chat together (not just state) or a large
chat could silently exceed the server's thread_state cap and break persistence.
Include chat in the guard, drop the plaintext budget to 150KB (leaving room
for base64 + pend + other apps under the 256KB blob cap), and tighten the chat
caps (200 lines x 400 chars) in both the frame and the host sanitizer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* member apps: harden command + companion paths after review

Three low-severity robustness fixes from an adversarial review of the new
cross-boundary surface (no escapes found; trust model holds):

- Companion chat: namespace projected message ids (`c:<mid>`) host-side when
  folding into the Chat view, so a hostile app can't report a mid matching a
  real message id in its own thread to suppress the installer's later notes via
  the chat app's first-writer dedupe.
- Commands: tear down a transient hidden frame on all paths (finally) — a
  /app-sandbox load-event timeout previously skipped teardown and leaked a
  stranded hidden iframe.
- State: ignore a stale lower-upTo report in installMemberAppState — a transient
  command frame briefly coexisting with the thread's visible frame could report
  an older horizon and regress state; monotonic horizon only.

Reviews confirmed the format bundle (hash-exact, collision-free) and the
command/companion trust boundaries (own-thread + own event types only; no keys,
other threads, cross-user, or XSS reach). Tests added for the monotonic guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* bot: retry transient Anthropic API failures; add lchat-dev-bot helper

- anthropic client retries transient transport drops (unexpected EOF) and
  5xx/429 up to 3 attempts with backoff, so a single dropped connection on the
  long app-build call no longer fails the whole build. Deterministic 4xx
  (bad request/auth) still fail fast. Tests cover both paths.
- tools/lchat-dev-bot.mjs: mint an agent invite, provision + run the Claude
  bot against a local server in one command (mirrors lchat-dev-up.mjs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* bot: a nonexistent app_id creates a new app instead of failing

The model sometimes invents an app_id for a brand-new app (e.g. 'hello-button'
for 'build me a hello button'). That id doesn't exist, so the update path
failed with 'no app with id …'. Now a not-found app_id falls through to a
normal create (fresh minted id), and the tool description spells out that
app_id is an opaque 'app-…' id from an earlier reply, never invented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* web: App Studio — write + deploy your own apps; gentle new-app reload offer

App Studio (web/static/apps/appstudio.js) is a compiled-in, trusted app that
lets a member deploy their own member app directly — no bot. It lists deployed
apps (yours editable/removable), and takes pasted source: Check evaluates it in
a throwaway sandbox frame to read back the real defineApp manifest (new
'extract' mode → manifestOf), then Deploy validates + encrypts { source,
manifest } under the instance key and PUTs it (same doorway the bot uses).
Privileged helpers live in sandbox.js (list/deploy/retire/extract/open), gated
by shell.js's setMemberAdmin injection; sandboxed apps can't reach them.

New apps register live (usable at once) and suppress the deployer's own reload;
updates ride a reload to swap code.

apps_changed no longer force-reloads: it registers new member apps live, then
offers a subtle, dismissible 'A new app is available — Reload / Later' sidebar
nudge (never a floating toast). The bot's deploy confirmation reinforces the
same no-pressure framing.

Also: a nonexistent create_app app_id now creates a new app instead of failing
'no app with id …' (the model sometimes invents one for a new app).

Tests: extract mode driven in a real frame + manifestOf unit; the bogus-id
build path; full Go + Node suites green. Guide, design doc, changelog updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* tools: persist local-dev config in a dotfile + add --clean

lchat-dev-up now keeps its instance instead of throwing it away on exit:

- Writes .lchat-dev.json (repo root, gitignored) recording the admin token,
  db path, port, base URL, and the seeded "you" account + group thread.
- Reuses all of it by default on the next run: same admin token (so the admin
  URL keeps working), stable port (so the browser stays signed in — localStorage
  is keyed by origin, no re-provisioning), and a persistent .lchat-dev.db that
  survives restarts. On reuse it skips seeding and re-mints a fresh sign-in link
  for the stored account.
- --clean deletes the dotfile + its db files (db, -wal, -shm) and starts fresh.
- The binary is still rebuilt from the checkout each run, so code changes take
  effect on restart; Ctrl-C keeps the db + config (only --clean wipes them).

The written config is also the handoff to lchat-dev-bot: with no --base/--admin
(and no env), it now reads the server URL + admin token straight from
.lchat-dev.json, so a bare `tools/lchat-dev-bot.mjs` just works against the
running local server.

New tools/dev-config.mjs holds the shared paths + read/write/clear helpers so
both scripts agree on the format (config written 0600 — it holds the token).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* tools: persist the dev bot session too, with a --clean reset

lchat-dev-bot now keeps its identity across restarts instead of minting a new
bot every run:

- The session defaults to .lchat-dev-bot.json (repo root, gitignored, 0600 —
  it holds the bot's private key + device token). If it exists, the bot is
  reused (no new invite/provision, no roster clutter); otherwise it's minted +
  provisioned once. --session PATH still picks a different file.
- --clean drops the stored session and provisions a fresh bot.
- Ctrl-C keeps the session (only the throwaway build dir is removed).

Because that identity is tied to the server's db, a fresh lchat-dev-up instance
invalidates it: lchat-dev-up now clears .lchat-dev-bot.json on any fresh start
(--clean, or when the db has gone missing), so the bot re-provisions against the
new db rather than failing to auth as a user that no longer exists. The shared
path + clear helper live in tools/dev-config.mjs so both scripts agree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* web: seed member-app initialState before first render

A freshly-deployed member app opened before any event was rendered with
state === undefined: the sandbox reducer only seeds initialState() on the first
fold, and with zero events to fold there was no first fold. Any render that
reads a state field then threw — e.g. the App Studio starter's `state.count`
surfaced as "This app's view hit an error (Cannot read properties of undefined
(reading 'count'))".

Materialise initialState in the frame right after processing the seed's pending
events (folding a benign unhandled event returns the seeded state untouched), so
the first render and the reported snapshot see the real empty state. Bridge test
pins it: a starter-style app with no events now reports its initialState.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* bot: build progress heartbeats + guard against duplicate builds

A member-app build takes about a minute, during which the thread showed only the
typing indicator. That reads as "hung", so members re-ask — and because the
create_app executor spawned a fresh goroutine each time with no in-flight guard,
a second request built (and deployed) the app a second time.

Two fixes:
- Progress heartbeats: alongside the 10s typing beat, post a couple of gentle,
  capped "still on it" checkpoints (~25s apart) so a long build shows visible
  proof of life without spamming the thread.
- In-flight guard: beginBuild/endBuild reserve one build per (thread, requester
  uid). A re-ask while a build is running now gets a "still building, hang tight"
  reply instead of kicking off a duplicate; the slot releases on completion or
  failure so retries still work. A different requester or thread is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* bot: build status as a single in-place action line (+ reusable primitive)

Instead of stacking "still working" bubbles during a build, the bot now keeps ONE
/me-style action line and updates it in place: start → progress → result are all
the same subtle centered line (action:true), so the thread never fills with bot
messages and the flow reads as live activity, not chat. Only the initial post
notifies; the edits are silent, so no push-per-heartbeat.

The update-in-place mechanism is factored out as a general, reusable primitive
rather than being baked into the build flow:
- postText / editText / postJSON (runtime.go): post a chat message and get its id
  back (the REST endpoint returns the stored message), and edit a message this bot
  authored in place over the socket. General messaging helpers.
- actionLine + (*Runtime).newActionLine + (*ThreadCtx).actionLine + set():
  a bot's handle to a status line it posts once and edits as work progresses.
  Nothing build-specific — any future bot flow (a slow fetch, a background job)
  can report live status the same way. Reuses one timestamp so the line never
  jumps; best-effort throughout; drive one line from a single goroutine.

runAppBuild uses it: the heartbeat goroutine advances the line, and finish()
halts the heartbeat (stop+done handshake) before writing the terminal result, so
a late tick can't clobber it. brain.go drops its now-redundant "on it" ack — the
action line is the acknowledgement.

Verified the round-trip against a live server: REST post returns the id, a
same-author WS edit is accepted and broadcast, and action:true survives the edit
(so the web keeps rendering it as a centered line). go test -race clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* web: sandbox apps match the theme instead of a black background

Member apps ran on a black background regardless of theme, from two bugs:

- The frame never painted its own background, so it fell back to the UA canvas —
  which under color-scheme:dark is pure black. applyTheme now sets the frame
  background to var(--bg) (the theme's page colour); colorScheme resolves the
  light-dark() value to the right side.
- themeSnapshot decided dark with (colorScheme).includes("dark"), but the app
  writes "light dark" for auto/OS-follow mode, so that read as dark and forced
  every app dark even in light mode. Factored the decision into resolveDark:
  explicit "light"/"dark" win; the ambiguous "light dark"/unset case follows the
  OS media query. Unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* bot: stream the build call; 5s status cadence; failures as a real message

Three fixes from testing the app builder:

1. Recurring "unexpected EOF" on the build call. The generation call is long
   (large max_tokens + thinking); as a non-streamed request it sits idle until an
   intermediary cuts the connection, and retrying the same long request just hits
   the same wall. Added SSE streaming to the Anthropic client (create → shared
   retry loop; new createStream + doStream that reassemble text/tool_use from the
   deltas) and switched generateApp to it, so bytes flow the whole time and the
   request completes. The mock in bot_appbuild_test now honours stream:true and
   serves SSE, mirroring the real API.

2. Status line now refreshes every 5s (was ~20s), advancing the phase and showing
   elapsed seconds so there's always fresh proof of life, not a line that looks
   frozen.

3. A FAILED build is now a proper chat message, not an edit of the status line: a
   failure is actionable ("try again") and deserves a real, notifying bubble one
   won't miss. Success still morphs the status line in place (it reads well as the
   line settling). Added actionLine.remove() (retract the line) + a deleteText
   helper to support this; both are general, not build-specific.

Reworked the build-test reply assertions to be order-independent (the status line
and the ack race to post) and to wait for the in-place edit carrying the app id.
go test -race clean over repeated runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* bot: on build failure, keep a breadcrumb line + post the detail as a message

A failed build now settles the status line into a short breadcrumb
("❌ Couldn't build <app>.") that stays in the timeline marking where the attempt
was, and posts the actionable detail ("… <reason>. Feel free to ask me to try
again.") as a proper chat bubble — so the important, notifying message is a real
message, with the breadcrumb for context. (Success still morphs the status line
into the result in place.)

Drops the now-unused actionLine.remove() + deleteText helper the earlier
retract-on-failure approach needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* web: frame background via a base html,body rule, mirroring the shell

The earlier fix painted the background inline on <html> only; make the frame
match the shell exactly instead. The sandbox document is now served with the
same base rule the app uses — `html, body { background: var(--bg); color:
var(--ink) }` — so its background is the theme's page colour by default, from
var(--bg) on the body, same as outside the iframe. applyTheme just forwards the
vars and sets color-scheme on <html> (so the light-dark() in --bg resolves to the
right side); the inline background/colour it used to set are gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* member apps: owner-managed apps + build result as a proper message

Two changes from testing the builder.

1. Own your Claude-built apps. App Studio wouldn't let you view/edit/remove an app
   a bot built for you, because the server records the BOT as the author (it
   deployed it) and gates overwrite/delete on the author — and it can't read the
   encrypted manifest to see that you're the owner. So record ownership in the
   clear alongside the deployer:
   - member_apps gains owner_id (nullable; additive migration). A hand-deployed
     app is author-owned; a bot sets owner_id to the requester (only an agent may
     set it to someone else — a regular member's is forced to itself, no
     spoofing). Ownership is set at creation and preserved across updates.
   - Overwrite is now author-OR-owner; delete is author-OR-owner-or-admin. The
     per-app cap counts by owner, so a member's bot-built apps count against them,
     not against the bot.
   - Client `mine` flags owner-or-author, so your Claude-built apps show
     Open / Edit code / Remove like your own.
   The blob stays E2EE; the app's code/name are still never server-readable — the
   server just learns one more opaque id (owner) per app, like it already learns
   the deployer.

2. The build status line is only for progress. Once the outcome is known it stops
   moving and settles into a short breadcrumb ("✅ …"/"❌ …"), and the real
   result (or failure detail) is posted as a proper chat message — a notifying
   bubble carrying the app id, not a muted /me line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* sandbox: forms don't break; guide apps to interact via ctx.emit

A member app with a <form> looked dead: native form submission has nowhere to go
in the sandbox (no allow-forms, and the CSP forbids it), so the browser blocked
it with "Blocked form submission … 'allow-forms' is not set" and the submit did
nothing. Interaction already works — ctx.emit sends a typed event to the server,
which folds it back into the app — the app just relied on the browser instead.

- Frame: a global capturing `submit` handler calls preventDefault, so a <form> is
  harmless (no scary console error, no attempted navigation) while the app's own
  onsubmit still runs and can ctx.emit.
- Guide (docs/building-member-apps.md, served to the bot): a new hard constraint
  ("interact only through ctx.emit, never the browser's own actions") plus a form
  example, so bot-built apps wire onsubmit → emit instead of a bare submit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* bot: pick a unique app name instead of colliding with an existing one

A new bot-built app whose name matched an existing app would collide (the client
keys apps by name + its event-type prefix). The bot holds the instance key, so it
now reads the deployed apps' names and, if the model's chosen name is taken,
picks the first free variant ("habits" → "habits2") BEFORE generating — so the
event types are prefixed with the final name from the start. The requester never
chose the name and the title they see is unchanged, so the rename is invisible.

No server-side change: uniqueness is enforced with the instance key the bot
already holds, so the server never sees (or stores) an app name — not even a hash.
(App Studio already blocks a duplicate name on the member's own deploy.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* web: shared .eleven-input surface; App Studio composer matches the chat entry

Lift the chat composer's lifted-panel input fill into a --input-bg variable and a
reusable .eleven-input class (background + border + radius + colour + focus). The
composer now references --input-bg (same look, one source of truth), and App
Studio's code textarea uses .eleven-input so it matches the chat entry's
background, plus a little vertical breathing room above and below it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* member apps: defaultCommand — plain text in the Chat view runs a command

Member apps can now declare `defaultCommand` (naming one of their `commands`), so
plain non-slash text in the companion Chat view runs it — "buy milk" adds an
item, exactly like typing into the trusted To-do. This is the last gap between a
member app's companion chat and a trusted app's: slash commands already worked;
this adds the type-to-capture default.

Touchpoints (no change to the sandbox trust boundary — it rides the existing
command bridge): manifestOf derives it from the app's spec; validateManifest
checks it names a declared command; registerMemberApp sets app.defaultCommand on
the proxy (so defaultCommandFor routes plain text to it); the bot derives it from
the generated source and validates it; the authoring guide documents it. Tests on
both sides.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* docs: note defaultCommand in the member-app guide capability summary

The authoring guide is current with this session's changes — the forms/interaction
hard constraint (13) and defaultCommand were added inline; this refreshes the
top-of-guide capability summary to mention defaultCommand too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* member apps: record version history on every deploy + a versions endpoint

Every deploy of a member app (new, member update, or bot update) now records its
blob in a new member_app_versions table, keyed by (app_id, version); member_apps
still holds the current blob + version. History is pruned to the last 25 per app
and reclaimed when the app is deleted. GET /api/member-apps/{id}/versions returns
the history (author-or-owner gated) with the current-version pointer.

Restore is deliberately NOT a new route: the client re-deploys a chosen version's
source through the normal PUT, which records it as a NEW version — so reverting is
non-destructive and the trail keeps growing. Blobs stay E2EE; the server stores
opaque history like it stores the current blob. (App Studio UI next.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* web: App Studio History (restore) + Download source

App Studio now surfaces the version history and lets you retrieve source:

- History: a per-app view listing every stored version (newest first, current
  flagged, with timestamps), decrypted client-side via listAppVersions. Restore
  re-deploys that version's source through the normal deploy path, so it lands as
  a NEW version — non-destructive, and the app reloads to apply.
- Download: an icon button on each of your apps (and each history version) saves
  the source as a .js file.

Both are owner/author-gated by the server (the versions endpoint and deploy). No
new trust surface — App Studio is the trusted page and already holds the key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* web: App Studio flags AI-generated apps with a bot icon (ownership ≠ authorship)

Owning an app and having written it are independent — a bot can build one for you
that you then own. App Studio's list now shows both: "by you" (ownership) plus a
subtle muted bot icon when the app was AI-generated (a bot deployed it for you,
i.e. the deployer differs from the owner). Hand-written apps get no icon.
listDeployedApps exposes the `byBot` signal (author_id ≠ owner_id).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* member apps: backfill the current version into history for pre-feature apps

An app deployed before version tracking existed had no history rows, so App
Studio's History showed nothing until the app was next updated (and even then
missed the original). At startup, backfill each app's current version from
member_apps into member_app_versions (idempotent — INSERT OR IGNORE), so History
always shows at least the current version. Pre-feature intermediate versions are
unrecoverable, but the trail is no longer empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* web: sandbox frame background is --panel (the chat panel), not --bg

The app pane is embedded inside the thread view (.tt { background: var(--panel) }),
so the frame document should default to --panel to match the chat panel around it
— the earlier fix used --bg, which is the page/sidebar colour, leaving the app
pane a shade off from the chat feed. applyTheme already forwards --panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* member apps: "you + <bot>" authorship label + "Revert to this" version

Two App Studio refinements:

- Authorship label: an AI-generated app you own now reads "you + <bot>" (e.g.
  "you + Claude") with a small bot icon, instead of "by you" + an icon — ownership
  and authorship are both shown. The bot records its name in the manifest
  (BuiltBy); hand-written apps stay "by you".

- Revert: the History view's button is now "Revert to this" — it re-deploys the
  chosen version's source AS a new version (non-destructive, as before) and marks
  it "based on version N". The lineage is stored as a plain integer (based_on) on
  member_app_versions — a version number, no text — so nothing content-ish is
  written server-side; History shows "· based on version N".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* web: carry based_on through listAppVersions so History shows the revert lineage

listAppVersions re-maps each server version into a client object but dropped the
based_on field, so App Studio's "based on version N" never rendered even though
the server stored and returned it. Carry it through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* web: live-push theme changes to sandboxed member apps

Sandbox frames only picked up a theme/appearance change when they were
re-mounted (e.g. by revisiting the thread), so switching theme left any
currently-visible member app on its stale init-time palette.

sandbox.js gains an exported pushTheme() that snapshots the current theme
and posts it to every live frame's port; the frame already handles the
{type:"theme"} message. shell.js calls pushTheme() at the end of its own
applyTheme(), after the new theme is written to <html>, so frames re-tint
in place the moment the member switches theme, appearance, or the OS flips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

* tools: mark lchat-dev-bot.mjs executable

It ships a `#!/usr/bin/env node` shebang and is meant to be run directly,
matching tools/lchat-dev-up.mjs which is already 0755.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes

---------
Co-authored-by: Claude <noreply@anthropic.com>
James Adam 75fa9069a09351bdef3ed5e779b003139e71c7c4 parent 2ac8d17
28 files changed, +6809 −15
  • CLAUDE.md +7 −0
  • bot_appbuild_test.go +425 −0
  • changelog/2026-07-07-member-apps.md +7 −0
  • changelog/2026-07-08-app-studio.md +8 −0
  • changelog/2026-07-08-member-app-commands-chat.md +7 −0
  • db.go +257 −0
  • docs/building-member-apps.md +407 −0
  • docs/member-apps.md +241 −0
  • internal/bot/anthropic.go +187 −3
  • internal/bot/anthropic_test.go +186 −0
  • internal/bot/appbuild.go +875 −0
  • internal/bot/appbuild_test.go +338 −0
  • internal/bot/brain.go +60 −1
  • internal/bot/runtime.go +144 −1
  • main.go +27 −2
  • memberapps.go +324 −0
  • memberapps_test.go +424 −0
  • package.json +1 −1
  • test/sandbox-bridge.test.mjs +244 −0
  • test/sandbox.test.mjs +450 −0
  • tools/lchat-dev-bot.mjs +0 −0
  • web/pages/index.html +3 −0
  • web/static/apps.js +45 −0
  • web/static/apps/appstudio.js +381 −0
  • web/static/sandbox-frame.js +675 −0
  • web/static/sandbox.js +944 −0
  • web/static/shell.js +97 −4
  • web/static/style.css +45 −3
CLAUDE.md

Loading diff…

bot_appbuild_test.go

Loading diff…

changelog/2026-07-07-member-apps.md

Loading diff…

changelog/2026-07-08-app-studio.md

Loading diff…

changelog/2026-07-08-member-app-commands-chat.md

Loading diff…

db.go

Loading diff…

docs/building-member-apps.md

Loading diff…

docs/member-apps.md

Loading diff…

internal/bot/anthropic.go

Loading diff…

internal/bot/anthropic_test.go

Loading diff…

internal/bot/appbuild.go

Loading diff…

internal/bot/appbuild_test.go

Loading diff…

internal/bot/brain.go

Loading diff…

internal/bot/runtime.go

Loading diff…

main.go

Loading diff…

memberapps.go

Loading diff…

memberapps_test.go

Loading diff…

package.json

Loading diff…

test/sandbox-bridge.test.mjs

Loading diff…

test/sandbox.test.mjs

Loading diff…

tools/lchat-dev-bot.mjs

Loading diff…

web/pages/index.html

Loading diff…

web/static/apps.js

Loading diff…

web/static/apps/appstudio.js

Loading diff…

web/static/sandbox-frame.js

Loading diff…

web/static/sandbox.js

Loading diff…

web/static/shell.js

Loading diff…

web/static/style.css

Loading diff…