Skip to content

Latest commit

 

History

History
1252 lines (1082 loc) · 118 KB

File metadata and controls

1252 lines (1082 loc) · 118 KB

Möbius architecture

Read this first if you just cloned the repo. It maps the system so you can find the file that owns a behavior in your first hour. Every row was verified against the source on main; if you find a row that no longer matches the code, fix the row.

What Möbius is

Möbius is a self-hosted PWA where one owner chats with an in-product AI agent to build mini-apps and modify the platform itself. The "agent" is a coding-agent (Claude Code or Codex) running as a subprocess inside the container; a chat message spawns a turn, the backend streams the agent's output back over SSE, and the agent can compile JSX into mini-apps, edit the shell UI, manage files, and schedule tasks. The whole platform runs in a single Docker container and installs on Android/iOS as a PWA.

The design has one line behind it: low floor, high ceiling, no walls. The agent is the product; everything else is substrate it operates on. Möbius bets on rising AI capability and inverts the usual defaults: make the good path easy — design, examples, prompts, and a clean script for any step that's identical every time — and make the bad path harder but never impossible. The owner can tell the agent to delete everything and it can; the net under it is the fallback floor, not a wall. Code empowers the agent, it does not police it: prevention lives in the instruction layer and learned memory, never in code-level validators or in removing a capability.

Intelligence over scripts. A script, validator, or fixed procedure earns its place only for the unambiguous and identical-every-time — pull a promoted recovery image, rebuild the served frontend, a deterministic migration. Everything ambiguous — why something broke, how to reach the last good state, fixing what another agent did — is the agent reasoning in context. Branching logic to cover cases, or bespoke machinery to detect-and-auto-handle a situation, is the tell that you're building the wrong thing: script the certain step, instruct the agent to run it (sharpen the prompt if it forgets), and trust intelligence for the rest. The only automation worth keeping is one a tool already ships (a real watcher, HMR) — never flimsy glue invented to avoid instructing the agent. Recovery is this made concrete: an on-demand external agent that cannot rewrite its own image but reaches and fixes the stopped target by reasoning about what broke, not from a menu of canned reversions.

Design for the next change

Solve at the core. Prefer the smallest durable solution that fixes the cause and improves the path the next related change will use. Avoid both symptom patches and abstractions for imagined needs. If a reasonable change feels awkward, treat that friction as evidence about the underlying design: challenge and simplify it rather than adding a workaround. Revisit earlier choices as understanding grows — consolidate, remove, and simplify.

Fix forward. Prefer a clean design and deliberate migration — even when it breaks an old path — over permanent shims, fallbacks, duplicated logic, or parallel systems. Preserve compatibility where it protects owner data or a genuine external contract; otherwise update every affected caller and move forward as one coherent system.

Keep the shared foundation lean. Every owner runs their own copy of Möbius and may pay directly for its compute, memory, storage, network, and agent usage. Treat meaningful efficiency gains as user-facing improvements. Pursue material, evidenced gains without sacrificing behavior, correctness, maintainability, or future flexibility. Keep the platform small, general, and composable; put domain-specific complexity in apps and reserve platform complexity for shared primitives and hard invariants.

The flip side: infrastructure the agent never sees — provider plumbing, the persistence actor, the streaming protocol, the navigation back-stack — gets whatever complexity makes it correct. Maximal expressive surface for the agent, ironclad substrate underneath.

If you're an agent building Möbius, attend to this on every change. You were trained on products for careless or adversarial users, where the job is to validate, sanitize, and prevent. Here the user is the owner and you are trusted. When you reach for a guard, a validator, or background auto-magic to prevent a mistake, stop and ask whether you're policing — or substituting brittle automation for intelligence. A change that fights this philosophy is a sign you're solving the wrong problem.

This split is why a section can read either "this is intentionally hackable, don't add a guardrail" or "this is load-bearing, don't touch it without reading the full reference." Both are true; which one applies depends on whether the agent sees the surface.

Deployment — single container

Dockerfile (root)     Single-container image: frontend build + backend + CLI tools
docker-compose.yml    Self-hosted: Caddy (TLS) + app
├── caddy             HTTPS reverse proxy — forwards everything to app:8000
└── app               FastAPI serves the API + the frontend static files

The image bundles everything the agent needs at runtime (the Claude and Codex CLIs, Rolldown, Node) so the platform works out of the box. To join an existing Caddy setup instead of the bundled one, use docker-compose.override.example.yml.

Frontend serving priority

At startup backend/app/main.py:1000 picks one static directory at module load time, not per request (though a request for a file missing from the chosen /data/platform/frontend/dist still falls back per-request to the baked /app/static):

/data/platform/frontend/dist/  ← preferred (the served platform clone's live build; persists across image rebuilds)
/app/static/                   ← fallback (baked into the image, current with git HEAD)

The /data volume persists across docker compose build && up -d, so a new image's /app/static/ is masked by an old /data/platform/frontend/dist/. After a frontend deploy, refresh both source and dist and verify the bundle hash changed in /data/platform/frontend/dist/assets/index-*.js. Because the choice is made at module load, an in-container shell rebuild does not take effect until the uvicorn process restarts. Never delete /app/static/ — it is the immutable frontend fallback and is root-owned.

Security updates — who patches what

Möbius is meant to be self-hosted on a user-provisioned host — a managed platform (Railway/Render/Fly/PikaPods) or a raw VPS — so "apply a security update" splits into three tiers by who can even act:

  • Image userspace — the Python wheels, npm globals, apt packages, and vendored mini-app libs baked into the image. The agent owns these end-to-end: change the declared constraint (Dockerfile / backend/requirements.txt / frontend/package.json), regenerate the hashed Python lock, rebuild, recreate. Never apt upgrade / pip install -U a running container — that mutation is ephemeral and drifts the live container away from the reproducible image. deploy-prod.sh is the apply path. Full root is an honest default instance capability: the root entrypoint creates mobius ALL=(root) NOPASSWD: ALL before dropping privileges. MOBIUS_AGENT_SUDO=0 is the coarse operator kill switch and ships with no sudoers rule. Changing either direction requires a clean recreation because an already-root agent could have installed persistent-in-container privilege paths.
  • Host OS userspace + the Docker engine — outside every container; patched on the host (unattended-upgrades covers the OS packages; the engine is a separate host upgrade).
  • Host kernelnot in the container; it shares the host's and cannot be patched from inside. On a managed platform the operator patches+reboots the kernel underneath you (the safe default for non-devops owners); on a raw VPS it's the owner's job, via unattended-upgrades + livepatch + a scheduled reboot window.

Two invariants follow. (1) Möbius never patches the kernel from inside the container — it only surfaces "host reboot pending / kernel CVE outstanding" to the owner; the platform/OS applies it. (2) The in-container agent cannot recreate its own container (the swap would kill its own process), so the shape is propose-in (agent scans → bumps → tests → commits) / dispose-out (a host-driven deploy-prod.sh, or blue-green, does the rebuild+recreate). Detection is the agent's leverage on every tier: pip-audit + npm audit + an image scanner (Trivy / docker scout) over the built image → triage → bump → test → deploy (tier 1) or surface a reboot window (tiers 2/3).

The in-product updater owns source reconciliation, not the deployment control plane. backend/app/platform_activation.py is the single ordered contract used by update preview, Apply/status, and the image-dependency fingerprint:

Impact Typical source Activation
live frontend source, tests, docs frontend is rebuilt or source is read on demand
server_restart backend/app/, skill/core.md restart the FastAPI process
proxy_reload Caddyfile self-hosted host checkout + Caddy reload
container_recreate Compose topology or railway.toml recreate services or trigger a managed deployment
image_rebuild Dockerfile, dependency locks, baked scripts/supervisors rebuild the image and replace the app container
host_maintenance host-operated deployment/repair tooling update and act from the host

Rules can be deployment-scoped: Railway does not pretend to reload Caddy, and a self-hosted install does not pretend to apply railway.toml. Mixed updates keep every applicable reason and order by the highest action. Settings shows this impact before Apply and never offers Restart to finish for a higher level. The backend records external activation remainders but never invokes Docker, Caddy, Railway, or host package/kernel tools. In-container sudo cannot make those changes durable, and mounting a Docker socket would weaken the container boundary rather than solve the ownership problem.

lodash is pinned to 4.18.1 via overrides. @openai/apps-sdk-ui pulls lodash transitively — only through its Slider component, which the shell does not import. The 4.17.x line sat unfixed against several advisories for a long stretch; 4.18.x restored maintenance and patched them, so frontend/package.json overrides forces the transitive lodash to 4.18.1 (npm audit is clean). As defense-in-depth, frontend/src/lib/__tests__/appsSdkLodash.test.js also fails if the shell ever imports Slider, which keeps lodash tree-shaken out of the shipped bundle regardless of the pin.

Self-update model — upstream / main, preserve local changes on update

Möbius is the rare app whose own agent edits its live code: the in-product agent customizes its mini-apps (/data/apps/<slug>) and the whole platform repo (/data/platform, a real clone of mobius-os/mobius, including the frontend) while the platform runs. A deploy then ships a new pristine version of that same code. One small model keeps every such surface up to date without clobbering the owner's customizations and without a deploy ever silently dropping them.

Mini-app updates are rebase-shaped. Each updatable mini-app is a git repo with:

  • upstream — pristine history (A → B → …). The exact bytes of each released version, committed only by the installer / image, never the agent.
  • main — the owner/agent's edits (X), and what the surface actually serves. It sits on top of the upstream version it was last updated to.

So the repo is A → X (release A, then local edits X). An update fetches the new release and does exactly what a developer would:

record the new release as a new upstream commit:   A → B   (and  A → X  locally)
rebase the local edits onto it:                    A → B → X

The owner's customizations end up on top of the current release, as if they'd just been made against it. Mechanically: commit any stray working-tree changes onto main first (app_git.commit_local, so the merge has a committed base), advance upstream to B, then compute the three-way verdict with git merge-tree --write-tree (app_git.merge_upstream) and, when clean, write the merged tree back and replay it as a single-parent commit on the new upstream tip — rebase-shaped linear history (A → B → X) without ever running git rebase.

  • Clean merge → the merged tree is replayed as a single-parent commit on the new upstream tip and the app recompiles onto the new code.
  • Conflict (the release and the local edits touched the same lines) → an owner-clicked agent chat resolves it. The update attempt records the new upstream plus a durable receipt bound to every fetched source/static/icon/seed byte, and leaves live files untouched. When the owner chooses "Resolve in chat", apps materialize standard conflict markers (start_conflict_merge, a git merge --no-commit --no-ff upstream) for the agent to edit; the platform updater leaves the live tree untouched and the resolver chat runs the merge itself. Saving marker-free source records a single-parent replay--no-ff points MERGE_HEAD at the upstream tip and the commit takes only that one parent, so even a resolved conflict stays linear (A → B → X), never the 2-parent commit a plain git merge would leave. The canonical installer then verifies the receipt and promotes source, bundle, static files, DB metadata, icon, seeds, cron, and skills through its normal lifecycle. If fetch/materialization fails after the source commit, the previous app remains served and the receipt survives for startup/user retry. Both app and platform conflicts are click-gated: the update surfaces mode=conflict / conflict paths or a Settings conflict state, and the owner chooses "Resolve in chat" before an agent turn starts. The owner never hand-merges; back out with git merge --abort.

The platform clone differs: it fetches the selected origin target and either fast-forwards local main or merges that target once, preserving both histories without rewriting local commits.

"Update available" is an ancestry question, not a version-string compare: an update is available iff upstream's tip is not yet an ancestor of main (a new release has not been incorporated). This is the content question — "does my working tree already contain this release" — that a image_sha != recorded_sha proxy can't answer on a customized instance, and it's what eliminates phantom "update available" rows after a deploy that changed nothing the owner hadn't already.

Baked boot infrastructure is outside the served clone

protected-files.txt names the root entrypoint, sudo configurator, and restart-ledger supervisor. These files are copied to /app, remain root-owned/non-writable, and are never imported from the mutable /data/platform clone. The image has one boot path and contains no recovery daemon, alternate boot mode, control-plane token, or recovery worker.

Where each surface stands

Surface Repo On the model Engine
Mini-apps (/data/apps/<slug>) .git per app (installed apps; agent-built bespoke apps have no upstream to track) yes — whole source tree on upstream, single-parent replay, so multi-file apps update cleanly backend/app/app_git.py + install.py
Platform (/data/platform — backend and frontend) .git yes — clone-native git fetch origin, then fast-forward or merge the selected target into local main (commit-stray-edits-first, conflict-abort, post-merge import probe with rollback); ancestry availability (origin/main not yet an ancestor of local main) backend/app/platform_update.py

Mini-apps use one small tree-aware engine (app_git.py): record_upstream commits the whole source tree on upstream, merge_upstream verdicts a clean-vs-conflict via git merge-tree, and a clean apply replays the merged tree as a single-parent commit on top of upstream (linear A→B→X). Mini-apps are thin callers of that primitive — they pass their own source tree. The platform (backend + frontend, one served clone) is clone-native instead: it uses git fetch origin plus a fast-forward or merge of the selected target into local main, with ancestry-based availability (origin/main not yet an ancestor of local main). Mini-app update discovery is different: the store compares the catalog manifest version against the installed App.version (the new release lives in the remote catalog, so a local ancestry check can't see it). There is no per-surface protected-file scaffolding.

Backend (backend/app/)

FastAPI app. main.py is the factory (CORS, rate limiting, routers, static serving). routes/__init__.py is a crash-tolerant import scaffold: every router is loaded through _load(name), and an import failure returns a 503 stub instead of killing uvicorn. To add a route, write the module under routes/, expose a router, and register it in routes/__init__.py (both the _load(...) line and __all__), then mount it in main.py. (One documented exception: routes/chats.py exposes a second router, app_chat_router (/api/app-chats), which main.py imports and mounts directly because _load returns only each module's primary router.)

Core app + chat runtime

File Role
main.py App factory: CORS, rate limiting, origin-owned standard headers and document CSP selection (_SecurityHeadersMiddleware), router mounting, static file serving, and GET /api/version identity
response_policy.py Validated origin sources plus the shell, embedded-chat, opaque app-frame, packaged-document, and published-site policies shared by direct and proxied deployments
frontend_watcher.py Polling watcher that auto-rebuilds the served frontend clone (/data/platform/frontend) on edit — debounced vite build, atomic .dist-nextdist swap
config.py Settings via pydantic-settings; reads .env
database.py SQLAlchemy engine, pool instrumentation, SessionLocal, Base, and get_db; contains no schema history
schema_migrations.py Append-only schema/data migrations, durable ledger primitives, and ORM/live-schema parity inspection; published functions are semantic-hash frozen
startup.py Two-phase boot: process/schema preflight first, then writer/reconciliation/database supervisors only after schema parity succeeds
models.py ORM tables: Owner, Chat, ChatRun, App, PushSubscription, Notification
schemas.py Pydantic request/response models
auth.py bcrypt hashing, JWT creation/decoding, Fernet encryption
deps.py FastAPI auth dependencies: get_current_owner (owner-only), get_current_owner_or_app (owner + app token), get_principal, require_app_permission, and reject_cross_site (CSRF)
compiler.py compile_jsx() — calls the Rolldown adapter to compile a JSX string into an ES module
providers.py BaseProvider adapters (ClaudeProvider, CodexProvider) + the PROVIDERS registry; identity/auth/env shaping for the SDK runners (build_env), and get_skill_path().
claude_sdk_runner.py Claude SDK turn runner; passes cli_path="/usr/local/bin/claude" so interactive chat and cron turns use the same pinned binary
codex_sdk_runner.py Codex SDK turn runner (Thread/TurnHandle + steer)
codex_appserver.py Small helper module: codex_sdk_runner.py imports its one surviving function, _extract_bash_command, which pulls the bash command string out of a shell tool item. The SDK runner does its own event/tool classification locally.
chat.py run_chat() background task: spawns the turn, publishes events, routes persistence through the actor
chat_writer.py Single-writer chat-persistence actor — one thread owns the DB session + a FIFO command queue; ALL Chat.messages / Chat.pending_messages mutations route through it (do not write those columns directly)
chat_queue.py Per-chat queue lock + turn-end drain_and_release / promote_pending_messages_locked + the TerminalDisposition state machine; the awaited bridge between chat.py and the writer actor
broadcast.py ChatBroadcast per-chat in-memory event bus; decouples the turn runner from SSE clients
events.py Pure data transforms accumulating streaming events into the persisted message structure
compaction.py Cross-provider chat compaction (portable plain-text summary; native SDK compaction is within-provider only)
runner_registry.py Runner lifecycle registry shared across chat backends
pending_questions.py Shared PendingQuestion dataclass for AskUserQuestion interception (split out to break the questions↔runner import cycle; the registry itself lives in questions.py)
tool_summaries.py Tool-input summary strings (shared by SDK + subprocess paths)
tool_sources.py normalize_tool_sources() — normalizes provider web-search results into bounded {title, url, snippet} metadata stored on WebSearch blocks and rendered once in the message-level Sources row; an iterative count/depth budget and HTTP(S)-only URL gate keep provider payload cost fixed before SSE or persistence
sdk_emit.py Helpers for emitting "unknown" SDK events on the SSE wire
restart_util.py restart_this_worker() — arms a daemon SIGKILL fallback, then SIGTERMs its own pid; shared by /api/admin/restart and /api/platform/restart so the two restart paths can't drift. Pairs with uvicorn's --timeout-graceful-shutdown 10 (entrypoint.sh) — without a bound, an open chat SSE stream held graceful shutdown open forever and the container never cycled (6ac51b0)

Mini-apps, storage, files

File Role
install.py Atomic install + update lifecycle for mini-apps from a manifest
app_git.py Per-app git repo (/data/apps/<slug>/.git): pristine upstream history + a local working branch
app_apply.py Explicitly validates, commits, compiles, and publishes one coherent mini-app source revision
storage_io.py Filesystem helpers for per-app + shared storage; lives apart from routes/storage.py so install.py can reuse it. Also owns etag_matches(), the If-Match CAS compare — RFC 9110-correct (wildcard, weak tags, multi-value) plus deliberate tolerance for a proxy content-encoding suffix (Caddy encode rewrites "<tok>" to "<tok>-gzip"; a strict compare would 412 every compressible CAS write)
fs_locks.py In-process async locks serializing storage-tree / source-tree mutations against app uninstall
app_compile_contract.py Canonical self-contained mini-app compiler contract, dependency list, and runtime ABI
app_runtime_inject.js React + mobius-runtime bridge injected into every compiled app bundle
runtime_types.py Shared runtime type definitions
net_utils.py SSRF-safe URL validation shared by the install fetcher and the proxy
resource_access.py Resource-access helpers, incl. live_app / live_app_or_404 (tombstone-aware app resolution)
path_utils.py Path-safety helpers

Memory, skills, activity, scheduling

File Role
memory.py build_memory_block() — assembles only bounded recent-chat Digests; graph/app data is never injected here
skills.py Skill enumeration (flat <name>.md + external-convention <name>/SKILL.md dirs), dependency-free frontmatter parsing, provenance labels (seed/agent/app:<slug>/installed:<source>), and write_index() — the generated shared/skills/skills-index.md both providers Read (regenerated on boot, app-skill sync, and skill install/uninstall)
activity.py Append-only JSONL platform-activity log (app_open, app_install, storage_write, …)
self_reminders.py Agent self-scheduling: append-only store of relational check-ins
theme.py Theme CSS management and HTML injection
push.py VAPID key management and Web Push delivery

Recovery boundary

Managed recovery lives entirely outside this repository and process. The launcher creates a short-lived worker on demand and relays a fixed-session command through Railway's native SSH endpoint to the exact running service instance. Möbius is not restarted, redeployed, reconfigured, or asked to run a recovery listener. The worker is deleted when the session finishes or expires.

Self-hosters use the authority they already own: docker compose exec -u 0 app bash. This attaches to the live container and does not replace its normal process.

Health, readiness, and schema-degraded boot

GET /api/health is reachability and remains HTTP 200 whenever the process can answer; the shell uses that distinction so a server fault never masquerades as the device being offline. GET /api/ready is serviceability: it requires both an ORM-compatible database and the single-writer persistence actor. Deployment and container probes use readiness. GET /api/health/strict retains the schema-only diagnostic contract.

Boot runs create_all, append-only migrations, and orm_schema_gaps() before starting any database owner. A remaining gap enters a bounded degraded mode: ordinary APIs return one deterministic 503, database startup tasks and supervisors do not run, cron remains disabled, and static shell plus health, version, browser-bootstrap, and authenticated restart surfaces remain. External Recovery may alter the database, but the process intentionally keeps its boot verdict until restart; promoting only part of the skipped startup plan inside a health probe would create a second, race-prone boot mechanism.

Misc shared helpers

Agent-editable general-purpose modules — several sit on live chat paths and are not part of the baked boot infrastructure.

File Role
bootstrap.py First-boot bootstrap (ensure_bootstrap_apps_installed) that auto-installs the App Store, Memory, and Reflection; called idempotently from the FastAPI lifespan
chat_log_redaction.py Server-side structural redaction for the gated chat-log read API
chat_media.py One-way startup migration that moves old chat images and stored URLs onto the canonical /media/ path
http_caching.py Range/206 hardening for revalidating FileResponses
timeutil.py now_naive_utc() + SOFT_DELETE_TTL; SQLite stores naive datetimes (mixing aware/naive TypeErrors on compare)
presence.py Chat-broadcast presence (has_watchers(chat_id)) — push.notify_owner uses it to skip a push when a live SSE subscriber is already watching
questions.py The AskUserQuestion pending-future registry + lifecycle (_pending dict; register/deliver_answer/get/claim/claim_if/cancel) — both SDK runners insert into it, POST /messages resolves, Stop cancels

Routes (backend/app/routes/)

Each module exposes a router; registration is in routes/__init__.py.

File Role
auth.py Setup, login, CLI provider auth (/api/auth/provider/*) — Claude via self-managed PKCE OAuth, Codex via a codex login --device-auth subprocess
apps.py Mini-app registry CRUD, /module and /frame serving (ETag revalidation), POST /{id}/publish (site snapshot → /sites/<token>/), and DELETE /{id}/data — wipe an app's runtime storage keeping it installed (no tombstone/recovery window; takes only the innermost app_storage_lock, liveness re-checked under it)
chat.py POST /api/chat/stop — interrupts the agent turn
chats.py Chat CRUD + reversible soft-delete with recovery; the chat-load serializer drops tool outputs >4KB to an output_truncated/output_full_len marker (read-side only — the stored message keeps the full text; blocks ≤4KB or without a message ts stay inline), lazy-fetched by ToolBlock on expand via GET /{id}/tool-output?ts=&i=; also GET /{id}/agent-context — read-only inspection of the assembled prompt (system prompt + injected memory / app-context / compaction blocks)
chats_stream.py POST /messages (starts a turn, returns 202) + GET /stream (SSE)
chat_logs.py Gated, redacted chat-log read API for mini-apps
storage.py Per-app and shared file storage, plus confined immutable blob reads from full commits reachable on a shared repository's main branch (GET /api/storage/shared-git/{repo}?revision=&file=). The Git route applies the same Memory capability gate, rejects traversal/symlinks/submodules, and never reads the mutable worktree.
secrets.py Bounded encrypted secret storage scoped to an app; an app can write/delete/check its own values, while only the owner or owner-scoped agent can decrypt them; no cross-app access or listing surface
fs.py Owner-facing filesystem + git oversight API
uploads.py Per-chat file upload management
media.py Owner-authenticated per-chat image serving from the canonical /media/ path
proxy.py Server-side CORS-bypass proxy for mini-apps
local_services.py Guarded loopback proxy plus the shared gateway-origin adapter for owner-trusted backend web apps. Each service requires explicit upstream_auth and gateway opt-in; Möbius authority headers are stripped, cookies and redirects stay confined to /services/<slug>, the gateway hostname is reserved to enabled prefixes, frame blockers are relaxed only there, and invalid configuration fails closed
standalone.py Trusted install/manifest host for mini-app PWAs. It injects inert app identity into the signed frontend, which renders the same AppCanvas opaque-frame boundary used by the workspace; app-authored code never executes in the top-level owner origin
published.py Serves published site snapshots at /sites/<token>/ — token-validated, traversal-confined static files from /data/published/<token>/ (created by POST /api/apps/{id}/publish in apps.py; token stable per project)
platform.py Owner-gated platform self-update: GET /api/platform/status, POST /apply, POST /restart (drives Settings → Updates; thin caller of platform_update.py)
notify.py System-event notifications to active broadcasts
notifications.py Push notification sending + history
push.py Web Push subscription management
theme.py GET /api/theme — effective theme CSS + bg with default fallback
settings.py Owner-level configuration
github.py GitHub connect status + read-only REST/GraphQL passthrough for in-product upstream contributions (pairs with github_auth.py + the contributing.md skill)
self_reminders.py Agent self-scheduling endpoints
skills.py GET /api/skills (installed skills + provenance + 30-day usage), POST /install (fetch a SKILL.md dir or single markdown from GitHub via the same SSRF-safe fetcher as app installs; .installed-skills.json provenance sidecar; basename collision ⇒ 409), DELETE /{name} (installed-provenance only, git-snapshot before removal). Install/uninstall gated owner-or-manage_skills (the Skills app's permission, pattern of manage_apps)
admin.py Admin / introspection endpoints (service-token gated)
debug.py Observability: active SDK clients/sessions, broadcasts, chat logs, and resource facts/pressure
client_error.py POST /api/client-error — record an uncaught client/app JS error
Note: there is no routes/ai.py and no POST /api/ai. An older mini-app AI proxy lived there and was removed; mini-apps reach the agent via window.mobius.chat, POST /api/apps/{id}/run-job, or cron — not a synchronous AI endpoint.

Resource facts and pressure

resource_pressure.py keeps observation separate from interpretation. Facts are an on-demand snapshot of /data capacity and cgroup memory; pressure classifies that snapshot as normal, constrained, critical, or unknown. The snapshot is exposed through authenticated debug status but is not polled or stored. Workload policy and owner communication remain separate future consumers.

App execution tiers

Host-mediated device/browser access uses the versioned capability broker; see CAPABILITIES.md for the manifest, app API, wire protocol, provider contract, lifecycle rules, and trust-tier escape hatches. Server-side app jobs are owner-installed, reviewed scripts. The shared runner gives each launch a short-lived app token, verifies that the job still belongs to the live app, and keeps a revocable process-group lease. The script itself runs with the Möbius process's filesystem authority, matching the platform's single-owner trust model. App-token permissions still constrain API calls; they are not presented as a process sandbox.

Tier Boundary and capability UX / standalone consequence
Ordinary mini-app AppCanvas-owned iframe without allow-same-origin; opaque origin, app-scoped JWT, memory-backed localStorage facade and window.mobius.storage Safest default in both the workspace and /apps/<slug>/. The trusted standalone host owns manifest/offline identity while app-authored code stays behind the same opaque frame
Packaged nested document /app-embeds/by-id/<id>/…; every response carries CSP sandbox without allow-same-origin, scoped Access-Control-Allow-Origin: null, and no frame denial For a game/tool build nested below an ordinary wrapper. Relative subresources work online but an opaque child is not controlled by the shell SW, so only the entry document may be SW-cached; readiness must be a source-bound post-commit heartbeat, never iframe load or a null-origin prefetch probe
Owner-trusted full web service Shared service-gateway origin distinct from the shell, shell-owned direct adapter, path-scoped host-only cookies and exact shell+gateway ancestor policy; never nested below the opaque wrapper Lowest-friction path for existing full web apps. The gateway isolates the trust group from Möbius, but services on it share an origin and can reach one another
Independent or mutually untrusted service/PWA Dedicated distinct origin (prefer a same-site subdomain), host-only cookies and exact shell+service ancestor policy Strong service-to-service isolation plus independent manifest/SW/storage identity; costs one managed origin per isolated service

For an ordinary mini-app, window.mobius.storage is implemented by a narrow RPC bridge to a runtime in the shell realm. The shell runtime owns IndexedDB, the read-through cache, and the durable outbox that the opaque child cannot open. Every request is attributed to an exact mounted contentWindow; the host runtime is keyed by both app id and immutable installation nonce, and token rotation cannot reuse an old ready or in-flight runtime. Subscriptions are host desired state, so writes from a buffered sibling frame and refreshes observed by that host runtime repaint every subscribed frame while detached documents are removed synchronously.

Opacity simplifies permissions: no ambient owner JWT, shell storage bleed, DOM reach or cross-app authority. It does not by itself improve installability, offline outboxes, cookies, media APIs or other origin-bound capabilities.

The shared service gateway is intentionally a trust-group boundary, not a virtual per-path origin. Paths do not partition localStorage, DOM authority or same-origin fetch. Deployment configures one gateway hostname once (a generated Railway domain or one self-hosted DNS record), while each service must still opt in through local-services.json. The gateway host serves no shell, API, recovery or non-enabled service paths.

Standalone host. /apps/<slug>/ is a trusted installable outer shell, not a second app runtime. It owns auth, manifest identity, offline navigation, installation and error chrome, then renders the app through the same AppCanvasapp-frame.html protocol as the workspace. The owner credential stays in signed platform code; only the short-lived app-scoped token crosses into the opaque frame. The route fails closed if the signed frontend seam is missing and the service worker evicts the retired direct-execution cache. See STANDALONE_HOST_DESIGN.md for the complete invariant and verification contract. Full backend services still use the shared service gateway or a dedicated origin according to their trust needs; they are not ordinary standalone mini-apps.

Frontend (frontend/src/)

React + Vite. Entry is main.jsxApp.jsx. App.jsx checks setup status and renders one of SetupWizard (first boot), LoginForm (no token), or Shell (authenticated). Shell owns drawer state and system-event handling; navigation and theme are extracted to hooks (useNavigation, useTheme).

Desktop density and coordinate spaces

Desktop web (the min-width: 1024px shell) intentionally uses document-level zoom: 0.9; narrower layouts stay at 1. Scaling the whole interface is both more complete and easier to maintain than hundreds of component-specific font, spacing, hit-area, and pane overrides.

Author zoom creates one boundary. Painted pointer/touch coordinates, DOMRects, and VisualViewport measurements are in client space; element client/offset/ scroll dimensions and CSS lengths are in unscaled layout space. Custom JavaScript that crosses this boundary uses frontend/src/lib/layoutSpace.js: capture the owning space once, convert layout geometry once, then keep models and writes in layout space. Gesture slop, swipe, and dismiss thresholds intentionally remain physical client pixels so density does not change how far a finger must travel.

Do not replace the root zoom with transform: scale(...): transforms do not relayout the viewport and reintroduce gutters, clipping, and hit-test problems. Do not pass shell density into mini-app frames; the browser maps a scaled host frame into each app's native document. currentCSSZoom is preferred, with the document root's computed zoom as the older-browser fallback. The document root uses offset geometry because its client dimensions can remain painted-size under author zoom. Layout-space unit tests and the desktop-density browser test protect this policy.

Top-level components (frontend/src/components/)

Component (dir) Role
Shell/Shell.jsx Logo bar, drawer, content area, system events; owns the app-iframe LRU cache (appCache, cap 4)
Drawer/Drawer.jsx Slide-in nav: current chat, new chat, collapsible history, apps; InstallSheet.jsx is the PWA install prompt
ChatView/ Chat surface (its own subtree — see below)
AppCanvas/AppCanvas.jsx Sandboxed <iframe> host for a mini-app + the postMessage init handshake
ChatEmbed/ In-app embedded chat surface (agent chat inside a mini-app). Chats can be project-scoped: window.mobius.chat({ projectId }) forwards project_id on the app-chat create (create-time only — AppChatPatch has no project_id, so the resume PATCH ignores it); chat.py then scopes the injected <app_context>/APP_STORAGE_DIR to the projects/<id>/ subdir of the app's storage dir and sets APP_PROJECT_ID. App-authored empty-state guidance can be changed on the stable mount with handle.setGuidance(text); the correlated embed protocol updates presentation without remounting or granting new authority.
SettingsView/ Theme, provider auth, owner config, and the update/restart surface: platform + shell update status/apply ("Restart to finish"), two-step confirmed server Restart, version display (sha · build date from /api/version)
SetupWizard/ First-boot: account + provider auth
LoginForm/ Subsequent logins
ProviderAuth/ Provider-auth UI: ProviderAuth.jsx (Claude OAuth), CodexAuth.jsx (Codex device-auth), ProviderRow.jsx (shared per-provider row)
ProviderModelPicker/ CLAUDE_MODELS/CODEX_MODELS constants shared with ChatSettingsPanel (the old radio-list picker was superseded by the composer popover and is no longer rendered)
ErrorBoundary/ Top-level React error boundary
Walkthrough/ First-run walkthrough
ui/ Shared primitive UI components

Chat subtree (frontend/src/components/ChatView/)

The chat is large and self-contained; its hooks live beside it, not in src/hooks/. The scroll/spacer/keyboard behavior here is load-bearing — see ChatView.css and the lock-in tests in the repo-root tests/ (spacer.spec.mjs, second-send-pin.spec.mjs).

File Role
ChatView.jsx Message history, streaming render, scroll/spacer management, handleStop
ChatInputBar.jsx Composer input
ComposerPopover.jsx The + popover: attach files, model/effort/provider picker (rendered by ChatSettingsPanel.jsx), and the agent-context inspector entry
AgentContextInspector.jsx "What the agent knows" sheet — renders GET /api/chats/{id}/agent-context; opened from the + popover
MsgContent.jsx Per-message rendering: markdown, tool blocks, attachments
ToolBlock.jsx Collapsible tool-execution block with status
StreamingMessage.jsx The live, in-progress assistant message, incl. the collapsed reasoning disclosure for thinking stream events (Claude thinking_delta and Codex reasoning deltas publish the identical provider-agnostic event via each runner's _thinking_event()); the block is promoted and persisted (streamPromotion.js + events.py) and re-rendered post-turn by MsgContent.jsx, so it is durable, not stream-only
QuestionCard.jsx AskUserQuestion UI (gates the turn)
QueuedMessages.jsx Tray of messages queued while a turn streams
CompactionCard.jsx Compaction summary affordance
Attachments.jsx File/image attachment previews
ConnectionStatus.jsx SSE reconnection indicator
ManageModelsModal.jsx Model management modal
streamReducers.js Stream-event reducers
resolveStopResend.js Stop → collapse-queue → re-send logic
chatRuntimeState.js Pure queue/stream branch helpers (canFastForwardQueue, referenced by the steer contract below)
streamPromotion.js Pure helpers sealing live stream items into durable assistant messages on promote/steer (promoteAssistantStream, streamItemsHaveRenderableContent) — ChatView owns when promotion happens, this owns how
streamSnapshotCache.js Versioned sessionStorage cache of the visible streaming items (the R4 leave-and-return restore)
msgText.js Strips <agent_experience> blocks + the hidden attachment manifest from message text
useStreamConnection.js SSE connection, text buffering, typewriter drain, sleep/wake reconnect
useScrollMode.js Scroll-mode state machine
useVoiceInput.js Web Speech API with Android-Chrome workarounds
useFileUpload.js File-upload state + API calls
hooks/usePendingQueue.js Owns the pending-queue state + all its mutations (optimistic vs server-confirmed serverTs rows); its pendingMessagesRef is what handleStop snapshots and the steer/fast-forward gate reads
hooks/useBridgePartial.js One-shot mount-time decision: REPLACE the kept partial of an in-flight turn on first promote vs APPEND a new assistant message (ts-keyed, not role-keyed)
markdown/ BlockRenderer.jsx, blocks.jsx, InlineContent.jsx, ImageLightbox.jsx, highlight.js (lazy highlight.js), math.js (KaTeX)

Hooks (frontend/src/hooks/)

Hook Role
useNavigation.js Navigation stack, pushState/popstate, the Navigation API (back-stack contract in Navigation back-stack + drawer model below)
useTheme.js Theme CSS fetch, @import extraction, CSS-variable injection
useSystemEventStream.js System-event SSE consumed by Shell
useOnlineStatus.js Connectivity verdict (page-side /api/health probe; feeds SW connectivity)
useProviderAuthStatus.js Provider auth status polling
usePushSubscription.js Web Push subscription after login
queries.js TanStack Query setup + query definitions

App runtime, service worker, libs

File Role
frontend/public/mobius-runtime.js The window.mobius runtime injected into mini-apps inside the shared opaque frame used by both workspace and standalone hosts. Offline outbox + read-through cache live here
frontend/public/app-frame.html The opaque mini-app frame: error UI, parent module broker, runtime bootstrap, and postMessage isolation
frontend/src/sw.js Service worker: precache + cache strategy, incl. the offline-capable-app handler
frontend/src/sw-cache-policy.js Authoritative cache-route policy (see Service worker + offline below)
frontend/src/lib/ Cross-cutting helpers: appToken.js, chatEmbed.js, themeService.js, onlineStatus.js, navHistory.js, errorLog.js, etc.

Mini-app modules are self-contained. app_compile_contract.py points Rolldown at the pinned production dependencies in frontend/package.json, injects React plus mobius-runtime, and bundles every used static import into one ESM artifact. Production minification intentionally does not preserve JavaScript function/class names; apps must use explicit labels and stable keys instead of Function.name. The opaque frame asks its exact controlled parent to fetch and transfer that artifact, so a cold offline load performs no dependency subrequests. A compiler banner carries both a host ABI and an artifact revision: bump the revision to rebuild installed bundles for additive runtime changes, and bump the ABI only when old and new hosts are incompatible. Public /vendor/ files remain only for true browser assets that code refers to by URL (currently the pdf.js worker, KaTeX CSS/fonts, and the D3/Pixi classic scripts); they are not a package resolver.

Where do I make a change?

Task Start here
New API route New module in backend/app/routes/ exposing router → register in routes/__init__.py (_load(...) line + __all__) → mount in main.py
New ORM table / column backend/app/models.py plus a new numbered function at the append-only end of backend/app/schema_migrations.py; run the frozen previous-release upgrade contract (create_all never alters an existing table)
Change request/response shape backend/app/schemas.py + the owning route
Add an auth dependency / change CSRF backend/app/deps.py
Persist anything chat-domain A domain command in backend/app/chat_writer.py — never write Chat.messages/Chat.pending_messages directly
Add an AI provider New BaseProvider subclass + a row in PROVIDERS (backend/app/providers.py) plus the matching SDK runner
Change chat streaming UI ChatView/ChatView.jsx + ChatView/useStreamConnection.js (+ streamReducers.js)
Change chat scroll/spacer/keyboard ChatView/ChatView.jsx + ChatView.css; run the spacer/send-pin tests in repo-root tests/
Change drawer / back-stack nav frontend/src/hooks/useNavigation.js + Shell/Shell.jsx (read Navigation back-stack + drawer model below first)
Change the mini-app iframe / cache AppCanvas/AppCanvas.jsx + Shell/Shell.jsx (appCache); ETag and frame/module serving in routes/app_runtime.py
Add an app-runtime capability frontend/public/mobius-runtime.js + app_runtime_inject.js; bump the compiler artifact revision for additive compiled-bridge changes, or the ABI only for host-incompatible changes
Add a supported app package Pin it in frontend/package.json, add it to BUNDLED_RUNTIME_LIBS, and run the compiler/offline-frame contracts
Change offline / SW behavior frontend/src/sw.js + frontend/src/sw-cache-policy.js (read Service worker + offline below first)
Change the in-product agent's instructions skill/core.md (constitution) or backend/scripts/seed-skills/*.md (per-task skills) — see below
Add/install a skill Ecosystem installs go through POST /api/skills/install (routes/skills.py; the Skills app + finding-skills.md seed drive it); new platform seeds go in backend/scripts/seed-skills/; edits that must reach existing untouched copies register their predecessor digest in init_skills.py; the index (skills-index.md) is generated — never hand-edit it
Change a bootstrap app (Store / Memory / Reflection) Change its catalog repository (mobius-os/app-<slug>). backend/app/bootstrap.py installs the canonical manifest on first boot; afterward the app is an ordinary owner-editable app under /data/apps/<slug>
Theme CSS / tokens backend/app/theme.py + routes/theme.py + frontend/src/hooks/useTheme.js

In-product agent context — three layers

The in-product agent is a first-class reader of this code, and its behavior has three layers. (1) Base constitution — the live platform checkout's skill/core.md; chat._read_skill_text() caches only this tracked platform text for the process lifetime, so edits and platform updates take effect after a server restart. /app/skill/core.md is only the image-baked degraded-boot fallback when the live checkout is unavailable. (2) Installed system-app contributions — a manifest may declare one root-level system_prompt markdown file only with explicit system_app: true. When a chat starts its first turn, live (deleted_at IS NULL) app fragments are composed in stable id order with its effective base constitution and stored as one content-addressed prompt snapshot. Every later turn, provider switch, and compaction uses those exact bytes. Install, update, and uninstall affect chats started afterwards, while an existing chat keeps the prompt it began with. (3) On-demand skills/data/shared/skills/*.md; base skills are seeded create-if-absent, while app-owned skills arrive through manifests and are deactivated/restored with their owner app. Independently of optional apps, every chat maintains its name, a bounded ## Digest, and an uncapped cumulative ## Summary under /data/shared/memory/chats/<id>/index.md. New sessions receive only recent descriptions + Digests. chat_note.py is the tool-free, compare-and-swap turn-end writer; it uses the provider captured with that settled chat only when its auth preflight passes, otherwise publishing the local deterministic fallback without spawning a dead CLI. Compaction prefers the chat's cumulative Summary. The optional Memory app owns graph instructions, its skill, reader, seeds, builder, Git publisher, and retrieval telemetry; no router/fact note is injected. Uninstall changes future chat prompts and removes the skill/jobs while leaving existing prompt snapshots and core chat summaries intact.

Data layout (/data/ volume)

/data/
├── db/ultimate.db          SQLite database
├── compiled/app-*-<sha256>.js  immutable Rolldown output selected by each App row
├── apps/<slug>/index.jsx   agent-editable JSX source (keyed by app slug)
├── apps/<slug>/...          per-app runtime data + per-app git repo
├── app-secrets/<id>/       encrypted app-scoped credentials (outside app repos)
├── shared/                 cross-app shared files (theme.css, skills/, memory/)
├── shell/                  agent's editable shell copy (src/ + dist/)
├── cli-auth/claude/        CLI credentials
├── cron-logs/              output from scheduled task scripts
├── published/<token>/      published site snapshots (shareable /sites/<token>/ URLs)
└── service-token.txt       owner JWT used only by the platform job wrapper (chmod 600)

/data is itself a git repo owned by the mobius user, tracking shared/memory/ and shared/skills/ with a nightly safety-net commit, so a bad memory consolidation or skill overwrite is recoverable. Inspect it as that user (docker exec -u mobius ... git -C /data ...) — as root it dies with "dubious ownership," which reads misleadingly as an empty/non-repo tree.

Boot, self-heal, and how each layer updates

Layers + where they live: core platform = /data/platform, a git repo whose backend is served from backend/ and frontend from frontend/dist; mini-apps (/data/apps/<slug>, each a git repo); baked boot infrastructure in the image; Runtime trees are gitignored (db, compiled, app-secrets, cli-auth).

Updates flow through git. backend/app/platform_update.py is clone-native: /data/platform is a real git clone of the canonical repo, so an update fetches origin/main, commits any stray working-tree edits, and then fast-forwards or merges that target into local main. A conflict aborts back to the last-served commit, and a post-merge import app.main probe rolls back rather than serving a broken tree. (It reuses app_git's isolated git env + commit_local but drops the pre-slice-B baked-floor upstream-record model; card refs below point at the maintainers' local .pm/ backlog, gitignored and absent from a fresh clone.) The served bundle is /data/platform/frontend/dist if a complete build exists else baked /app/static (the #1 deploy gotcha — the volume masks a new baked dist; always byte-check the served hash). Mini-apps update through each app's git repo + the store; freshness rides ETags (/module = updated_at µs; /frame = compound updated_at+content-hash). The backend has the same served-vs-baked gotcha as the shell: a new image's sha advances on every deploy even while /data/platform keeps serving the previous deploy's Python. GET /api/version (backend/app/main.py) therefore reports the image identity (sha, shell_sha) plus the SERVED-platform identity — serving_source (from the /tmp/serving-source stamp entrypoint.sh writes at boot), platform_sha, platform_dirty, baked_sha, served_frontend, and frontend_source — and scripts/deploy-prod.sh's verify step asserts these match its sync decision (it also hard-blocks deploying a checkout strictly BEHIND origin/main).

Bootstrap apps (Store, Memory, Reflection) install from their canonical catalog manifests through backend/app/bootstrap.py on first boot. Each becomes an ordinary owner-editable app under /data/apps/<slug> and follows the same update and divergence rules as any other catalog app. The bootstrap path also migrates rows left by old images whose source still points at the retired platform-core tree; no app snapshot is baked into the platform image.

Recovery and self-heal. Recovery is outside both the editable platform and the normal app process. Managed recovery attaches through Railway native SSH; self-hosted recovery attaches with docker compose exec -u 0 app bash. Neither path introduces an alternate Möbius boot mode. A broken persistent clone falls back to the baked backend, so the live container remains reachable to inspect.

Normal platform boot serves /data/platform/backend directly after an import probe. It fetches origin/main, commits stray local edits, and merges that target into local main (fast-forwarding when possible); a conflict or failed post-merge probe returns to the exact pre-reconcile commit and leaves a visible flag. An invalid existing clone serves the baked backend without overwriting, quarantining, or reseeding the broken tree. Owner-data disaster recovery is the separate backup-data.py / restore-data.py flow and is not automatically armed by installing Möbius.

Chat scroll + steer contract

Owner-authoritative contract — v1.20 (2026-08-15). This section is the canonical source of truth for how a chat scrolls and steers. When implementation, comments, and this contract disagree, the implementation/comments are the bug: fix behavior to match this contract. If a real case is unspecified or the desired behavior changes, agree the new rule with the owner first, update this versioned section explicitly, and add or change the matching regression test; never silently rewrite the contract around the behavior that happened to ship.

The Chat Issue Reporter mini-app carries an owner-readable snapshot of these rules and attaches their rule ids to new diagnostic chats. The Playwright lock-in specs (tests/send-rule, spacer, second-send-pin, steer-queued, stream-reconnect, backend/tests/test_chats_stream_steer) encode this:

  • R0 — Two modes; explicit auto-scroll entrances. A chat is either in auto-scroll (FOLLOW_BOTTOM, following the physical scroll tail as the reply streams) or hold (PIN_USER_MSG or ANCHOR_AT, staying at a pinned prompt or frozen reading position). Auto-scroll engages only through (a) the gesture-gated reader path after the user manually reaches or explicitly swipes toward the physical bottom, (b) a composer press or edit that begins at that physical bottom, or (c) the live-send pin handoff when the streaming reply has consumed its exact reserved room. Only a send may create PIN_USER_MSG. Reservation does not create a second kind of bottom: when FOLLOW_BOTTOM is active, it follows the physical tail including any remaining room. Real output first consumes that room without advancing the tail; after the room reaches zero, the same tail advances with the stream. A viewport/keyboard change, foreground return, mount, or chat restoration must never create follow intent; a resize may only complete an already-armed live-send handoff when its responsive reservation reaches zero.
  • R1 — Stable latest-turn reservation. Dynamic bottom spacer derives from the latest user row and the real tail geometry, independent of whether that row has already entered the viewport. It reserves exactly enough room for that row to reach the active scroll viewport's top and shrinks as reply, tool, image, or other content fills the deficit. When a mobile keyboard reduces the visible scroll box, now-hidden blank room is removed from the spacer first. In FOLLOW_BOTTOM this keeps the visible content fixed while room remains; after the spacer reaches zero, only the overflow that no longer fits moves upward. Closing the keyboard restores the exact larger-screen deficit. The full range for the current visible viewport must exist before a downward gesture approaches the final turn: crossing the latest-user viewport boundary must never extend scrollHeight after momentum settles. The remaining room survives turn completion when the reply is short. Expanding content consumes it; collapsing the same content restores the exact deficit. Scroll mode does not own the reservation; PIN_USER_MSG, ANCHOR_AT, FOLLOW_BOTTOM, mount/return, and disclosure settlement all see the same tail range. Only the DOM's latest user row participates—an older user row never gets a separate reservation. Durable anchor validation still rejects locations wholly inside reserved blank space, so restoring a chat lands on real conversation content. R6's transient question-submit hold is the sole calculation exception: it may reserve only the exact tail deficit required for a stable card handoff while the viewport size is unchanged. It is never persisted and must release to the unanswered card's prior mode before a keyboard or other viewport resize is laid out.
  • R2 — One send rule everywhere. The first visible user message always pins to the viewport top. Every subsequent direct, queued, promoted, or steered message pins only when its submit-time DOM snapshot is at the one physical tail. Reserved reply spacer remains part of that distance: once the reader moves upward through it—even while the latest user message remains visible—the chat is in hold and the next send must leave the viewport untouched. Subtracting spacer from the send decision creates a false second bottom and is forbidden. Physical geometry is authoritative for the send snapshot while ScrollMode settlement may trail a gesture or layout by a frame. A real user scroll after submission invalidates an automatic delayed queue promotion (a tap without scrolling does not). Explicit fast-forward reuses that snapshot through tray reflow while its reader generation remains current; after a real scroll it captures current physical geometry instead. Another scroll during the request invalidates that snapshot. Missing delayed intent degrades to hold, never to an inferred pin.
  • R3 — Pin holds until the reservation is filled. A legitimate live pin transitions to PIN_USER_MSG, not immediately to FOLLOW_BOTTOM; the response first grows below the prompt without moving it. Exactly when the streaming reply consumes the reservation (spacer reaches zero), the armed pin hands off once to FOLLOW_BOTTOM. If the reply settles while any reservation remains, that handoff is retired only after committed geometry is stable across consecutive layout frames, and the prompt stays pinned; a one-frame terminal check cannot disarm just before final buffered text fills the reservation. Later idle layout changes cannot create follow. A non-pinning send preserves the exact reading anchor. A settled PIN_USER_MSG survives the complete mobile-keyboard open/close cycle. An armed live pin keeps the sent row fixed while the resized active reservation remains; if the smaller visible viewport reduces that exact reservation to zero, the ordinary filled-reservation handoff enters FOLLOW_BOTTOM so covered live output moves into view and continues following. A retired or saved pin restores only as an ordinary ANCHOR_AT; pin ownership is never reconstructed by layout, lifecycle restoration, or a reader gesture. Terminal promotion makes this decision against the committed settled DOM, before paint, so a final browser clamp cannot race the pin or its exact filled-reservation handoff.
  • R4 — Exact leave-and-return. Leaving, backgrounding, and returning restore the same visible anchor, even if the chat had been auto-scrolling and content grew while it was inactive. Return never jumps to the new tail and does not restore auto-scroll; the user must manually reach the bottom again. If there is no saved location, or its target row is no longer available, return shows the latest real conversation content at the viewport bottom once as a settled anchor. It must not manufacture a top-of-chat location or engage live following. That automatic tail fallback is not a reader-chosen location and must not be persisted on pagehide or shell reload; only a deliberate scroll/send/pagination position earns restoration. Exactness is bounded by real content: if a viewport growth or content collapse makes the saved target unreachable, clamp it to the nearest real conversation position, then apply R1 only if that viewport shows the latest user row. The durable reading coordinate belongs to the logical chat, not to every retained DOM copy. When Standard and Builder retain separate physical ChatViews for the same chat, only the surface participating in the active handoff owns that coordinate. Its visible-to-hidden edge freezes once and relinquishes persistence authority; an initially hidden owner writes nothing. The incoming owner re-enters through INITIAL and consumes the shared saved coordinate before it can paint. Hidden owners register no page-lifecycle persistence and cannot overwrite the active owner during reload. A settled ANCHOR_AT transfers by its existing semantic address rather than being re-measured after workspace geometry changes; only live follow/pin state is frozen from physical geometry. Retained Builder owners keep their projected pane rectangle while Standard paints, so even that required live-state freeze reads the geometry the outgoing owner actually displayed.
  • R5 — Reader owns gestures and layout-only sends. From the first wheel/touch/key input until its scroll event lands, no layout path may write scrollTop: stream resize, spacer handoff, terminal promotion, catch-up, and viewport/keyboard resize all share the same ownership gate. Only an actual gesture-driven scroll invalidates delayed send intent. Send is a newer explicit action than the gesture that positioned it: after submit snapshots the synchronous geometry, a delayed browser scroll event from that pre-send gesture cannot cancel the new pin. Any input begun after submit opens fresh reader ownership and still wins. Queueing behind a live turn adds no transcript row, so it freezes the visible message before the queue tray/composer/keyboard reflow; the separately captured submit snapshot still controls the row when it is promoted or explicitly fast-forwarded, unless a newer real reader scroll replaced it. Never replace the input-to-first-scroll handoff with a fixed short window: under rendering load the browser may deliver that scroll later. Ownership begins only for inputs whose default action can scroll the transcript; ordinary typing, Enter, and control activation are not reader scroll intent. Editing controls retain their own navigation keys. A nested vertical surface marked data-chat-scroll-region retains wheel and touch input while it can scroll in that direction; only a gesture at its matching edge may chain to the transcript. Pointer/touch release handles taps, while scrolling-key input that produces no scroll releases on the next frame. Wheel input gets that early release only when its direction is exactly clamped at the matching scroll edge. An end-directed wheel or scroll-key input already clamped at the physical tail claims FOLLOW_BOTTOM before that no-scroll release; otherwise the browser's missing scroll event would discard explicit follow intent. An elapsed frame is not evidence that an in-range wheel was a no-op: renderer/compositor load can update geometry before the main-thread scroll handler runs. A meaningful touch swipe toward the end may enter FOLLOW_BOTTOM once even when the browser is already clamped at the physical tail and therefore emits no scroll event; coordinate comparison is the per-move hot path and physical geometry is read at most once for that gesture. After a real scroll lands, reader ownership remains active through a short trailing-edge quiet window. The hot scroll handler records intent and physical-tail arrival only; final anchor discovery, spacer sizing, mode transition, and persistence run once when momentum settles. Exact physical-tail intent belongs to the scroll event's geometry: reply growth during the quiet window cannot erase that the reader reached bottom. Deferred layout work may resume only after that final semantic location is committed, so a stale follow/pin cannot write in the handoff frame. A newer semantic action supersedes the pending settlement: Send and attention navigation discard the older decision after snapshotting current geometry, while a disclosure first settles any preceding gesture and then owns layout caused by its own expansion/collapse. A bounded dead-man remains the final escape hatch for any interrupted no-scroll gesture. The first actual scroll event owned by each gesture also advances one monotonic reader-intent generation. Every direct scroll write and every indirect geometry write that can clamp scrolling (dynamic spacer height and composer clearance) must commit through the scroll controller only when both its captured generation is still current and the gesture gate is open. Deferred layout captured before a newer gesture is rejected; once that gesture settles, the controller adopts the current semantic location and performs one fresh geometry reconciliation. Waiting for the timing gate to expire never gives stale work its authority back. An end-directed input already clamped at the tail may enter FOLLOW_BOTTOM without advancing that generation: no scroll occurred, so a delayed queued send retains the submit-time pin decision that the generation protects. A marked Q&A custom-answer field is the deliberate exception to "ordinary typing cannot scroll": changing its value can grow the field and cause the browser to move the transcript to keep the native caret visible. Only an ordinary ANCHOR_AT reading hold yields from beforeinput through one complete rendered frame; if no scroll lands, layout resumes immediately after that frame, and if one does, the ordinary quiet-settle path records the resulting hold. Stronger location contracts keep layout ownership: FOLLOW_BOTTOM absorbs the new line in its normal ResizeObserver pass, while pins, reserved-tail holds, and the question-submission overlay remain fixed. The controller must not restore a stale anchor between those two outcomes or interrupt live tail-follow with a delayed snap.
  • R5a — Attention nudges reveal the usable tail. Tapping an offscreen question or paused-turn nudge is an explicit one-shot reading action: it lands at the physical tail, including the list's composer-clearance padding, so the card's Submit or Resume control is visible above the overlaid composer. It becomes a settled ANCHOR_AT hold rather than FOLLOW_BOTTOM; revealing an attention control must not manufacture future live-follow intent. Both actions route through the scroll controller instead of calling scrollIntoView, because viewport intersection alone cannot detect that the absolutely-positioned composer is covering the target. The floating jump-to-latest control (owner ask, 2026-08-04) is the same explicit one-shot action through the controller's physical-tail reveal and explicitly resumes FOLLOW_BOTTOM so subsequent output remains visible. Its visibility is a pure physical-tail geometry read outside the controller's ownership gates: it renders only while the reader holds a position away from the physical tail, including after an upward move through reserved room. A fresh live-send reservation does not summon it because a correctly pinned row rests at that same physical clamp. It yields to a visible attention nudge, which navigates to the same tail with strictly more context.
  • R5b — One keyboard geometry signal; reservation-responsive resize. Shell alone reconciles a browser's visual viewport into the visible shell frame. The chat does not race Shell with a second direct visual-viewport listener: its own scroll-box ResizeObserver is the sole downstream signal that keyboard layout has actually landed. A resize recalculates the latest-turn spacer from the active scroll-box height, then reapplies the mode that already owns the chat. PIN_USER_MSG keeps the sent row at its pinned offset while reservation remains, ANCHOR_AT keeps the same row offset, and FOLLOW_BOTTOM follows the resized physical tail without moving through blank room. The only ordinary mode change is R3's existing armed-pin handoff when the responsive spacer reaches zero; a settled pin never gains follow from resize geometry. The R6 question-submission release and focused native-caret rebase remain the two explicit editing rules, not a general keyboard heuristic. Open/close cycles therefore repeat the same idempotent operation every time. Browser clamps and controller writes may emit scroll while the box is changing, but only a gesture-owned scroll may change the reader's semantic mode.
  • R6 — One lossless active assistant row. Live stream items, a persisted partial, and the settled transcript are alternate sources for one active assistant row, not separate answers. The answer response declares this ownership independently as answer_turn: "same" | "new": an in-process question answer (answer_delivered) resumes that same row and turn, so answering must not retire its source bridge. Submitting an in-message answer is also a deliberate reading action: before the card enters its pending state or output resumes, the controller snapshots the currently visible message and its exact viewport offset as ANCHOR_AT. Resumed output grows without dragging the reader, even when the chat had been following the tail before Submit. That exact hold is scoped to the viewport where Submit occurred. If the mobile keyboard changes the viewport, the controller restores the mode that owned the unanswered card before sizing the new geometry. Answering therefore adds no movement of its own, while the keyboard still moves the card exactly as it would have moved unanswered. The transient hold is stripped before persistence. A failed answer keeps that settled reading anchor for the retryable card rather than manufacturing follow intent again. While the custom-answer field is focused, a visual-viewport change may rebase an ordinary ANCHOR_AT hold to the browser's current caret-visible position instead of reapplying its stale pre-edit offset. PIN_USER_MSG, HOLD_RESERVED_TAIL, FOLLOW_BOTTOM, and the transient question-submission overlay retain their existing stronger rules. The editing lifecycle remains active through keyboard-closing focusout until the full pane height returns, preventing alternating browser/controller corrections without reserving any extra conversation tail space. The source handoff preserves the question, its answer, and every pre/post-answer thinking, tool, and text block in event order, without hiding, duplicating, or reordering them. Only a recovered answer whose POST returns started creates a new hidden continuation. Switching sources preserves the active row's anchor identity and writes no scroll. The transition table is intentionally exhaustive; adding a new send or lifecycle path means routing it through the same entries rather than inventing another rule:
Event Before After Scroll write
First direct/queued/steered user row becomes visible any PIN_USER_MSG New row to top
Later send submitted at the physical autoscroll tail (mode may be one frame stale) any PIN_USER_MSG New row to top
Later send submitted anywhere else hold or stale follow ANCHOR_AT/existing hold None
Reader reaches or explicitly swipes toward physical bottom any FOLLOW_BOTTOM User-owned; follow the one physical tail, including remaining reservation
Composer press or edit begins at physical bottom any hold FOLLOW_BOTTOM No immediate write; the next owned layout follows the existing physical tail
Reader scrolls manually away from bottom any ANCHOR_AT User-owned
Reply grows while an armed live pin still has reserved room pin hold same pin hold Keep prompt fixed
Streaming reply consumes the armed pin reservation pin hold FOLLOW_BOTTOM Follow physical tail
Short reply settles before consuming the reservation armed pin hold settled pin hold Keep prompt fixed; retire automatic handoff
Other layout grows/collapses while latest user is visible any hold same hold Consume/restore exact R1 deficit
Latest user leaves the viewport any same reader mode Collapse spacer to zero
Viewport/keyboard changes armed PIN_USER_MSG same pin while responsive room remains; FOLLOW_BOTTOM if it reaches zero Shrink blank reservation first; reapply pin or perform R3's ordinary filled-reservation handoff
Viewport/keyboard changes settled PIN_USER_MSG same PIN_USER_MSG Reapply the same pin; geometry never reclassifies it
Viewport/keyboard changes follow or anchor hold same mode Resize reservation to the visible scroll box, then reapply the physical tail or exact anchor; never create or retire follow
Chat exits/backgrounds/returns any ANCHOR_AT Restore exact saved anchor
In-process question is answered any transient ANCHOR_AT over the prior mode; same active assistant row Hold exact visible anchor through same-viewport card reflow and resumed output
Viewport/keyboard changes after question submission transient question anchor pre-submit unanswered-card mode Apply ordinary viewport behavior; answering adds no extra movement
Focused Q&A custom answer grows or its keyboard viewport changes ordinary hold current caret-visible ANCHOR_AT Browser may reveal the caret once; controller rebases instead of snapping back. Pins, reserved-tail holds, follow, and submission overlay are unchanged
Live assistant row settles to the durable transcript any same mode and row identity None (except R3's exact spacer handoff)
Offscreen question or paused-turn nudge tapped any hold ANCHOR_AT at physical tail User-requested one-shot move; clears the overlaid composer

Controller structure is part of the contract, not an implementation detail:

  • ChatView may read modeRef for a submit snapshot but must not assign it. It emits send, queue, pagination, and lifecycle events through the semantic methods returned by useScrollMode.
  • ChatView declares whether its physical surface owns the chat's durable reading coordinate. useScrollMode alone transfers that authority, persists the outgoing coordinate, and resets the incoming owner for restoration. Hidden retained owners may keep DOM geometry but are never persistence writers; do not reintroduce a second freeze call in ChatView.
  • Every live mode mutation goes through transitionMode, whose entry guard permits new pins only from send and new follow only from a physical-bottom gesture or an already-armed pin's filled-reservation handoff. Every mode-owned scrollTop write goes through writeMode. The exported applyMode executor is for the controller and pure unit tests, not a second live writer.
  • useScrollMode is the sole writer of .spacer-dynamic height and the composer-clearance CSS geometry. Those indirect writes and every writeMode call share R5's reader-generation commit gate. Spacer height is derived from the latest user row, active scroll-box height, and exact tail deficit; disclosure helpers and renderers may preserve an on-screen anchor but may never prime, enlarge, or unwind spacer themselves.
  • The gesture-gated scroll event reads physical-bottom geometry directly. Do not reintroduce a sentinel or asynchronous observer as a second bottom authority: its delayed state can contradict the viewport that caused the event.
  • Shell owns the browser visual-viewport subscription. The chat observes only its resulting scroll-box size; do not add a second direct visual-viewport listener to the scroll controller or keyboard ordering becomes registration- dependent again.
  • window.__mobiusChatScrollTrace keeps bounded, content-free transition and actual-write history for diagnosis. It records mode kinds, armed state, and geometry only—never message text, keys, or cids.

Thinking/reasoning deltas also carry a semantic segment_id end to end. Token deltas with the same id concatenate verbatim; a new provider summary/content index adds a paragraph boundary before live rendering and durable reduction. The renderer repairs the legacy glued-bold seam (****) for already-saved chats, while legacy events without ids retain raw token concatenation so mid-word fragments are never split heuristically.

The live thinking timer is runner-time, not component lifetime. Each delta keeps its server ts; catch_up_done carries the server clock at replay completion, and the frontend re-anchors only a trailing live thinking block from those two server values before committing the replay. Reconciliation may move that clock forward but never backward. Do not derive a remounted timer solely from Date.now() or the client arrival time of replayed deltas: catch-up arrives as a burst and that makes a minutes-old turn visibly restart at one second.

Only the latest user row makes R1's reservation current. Reservation lifetime follows the exact remaining tail deficit, not viewport visibility, turn completion, or a particular scroll mode.

  • A restored send is one logical message. The frontend scopes the draft identity to the chat and reuses its client-minted cid when an ambiguous failed POST restores an unchanged composer. The route checks that durable identity before queue or provider side effects; StartTurn, AppendPending, and steer persistence retain actor-level de-duplication as backstops. A cid already in the transcript is acknowledged without appending a row or waking the provider. A cid still pending keeps its existing queue position behind an active turn; an idle stale queue follows the normal single-run self-heal. If a later turn is active, retry reconciliation preserves that unrelated live stream.
  • Steer = separate rows, one turn. Steered queued messages render as separate transcript rows in send order (insertMessageBatchByTs), never one stranded after the reply. The agent receives them joined by \n\n (clean paragraphs, not a \n blob). The request binds to specific queued rows by their stable cid (consume_pending_cids; _selected_force_steer_pending selects by cid) — the earlier byte-for-byte content match existed only because no shared id crossed the wire, and is gone. The fast-forward button shows only when every queued row is server-confirmed (canFastForwardQueue; the serverTs flag). A steer landing before any renderable assistant output seals nothing — the empty/whitespace pre-steer segment is dropped symmetrically on the live path (streamPromotion.streamItemsHaveRenderableContent) and the persisted path (events.blocks_have_renderable_content, gating the seal in chat.py), so no stray empty assistant bubble precedes the steered row; a single real token still seals, correctly placed before it (card 166). Keep the two predicates aligned.
  • Regression guards (owner-observed prod bugs): an at-bottom send must not land mid-viewport; a steered row must not render after the agent's reply.

Automatic continuation after limits and planned restarts

Automatic continuation reuses one durable run transition with separate chat-local policies and cause validation. Provider-limit exits mark their exact ChatRun as parked until the parsed reset time. A planned restart creates a fresh nonce and, before provider interruption, stamps it onto every exact live run in one writer transaction. Provider stops then run concurrently; clean stops finalize and become due-now parks immediately, while a slow stop or failed terminal transcript write keeps its exact nonce-stamped running row for boot recovery. The platform process then publishes an intent and restart request; it does not terminate itself on the normal path.

The frozen root-owned entrypoint poller validates and consumes the request, records its one-shot nonce in /data/.restart-ledger, and only then terminates pid 1. At the very start of the next entrypoint invocation, the ledger binds that accepted nonce to the new MOBIUS_BOOT_ID. The app only continues a restart park when the root-owned boot acknowledgement matches the nonce on the latest exact DB run and the restart policy is on. At startup, the same authorization converts matching stranded running rows into due restart parks after finalizing their persisted partial transcript; this happens before the writer starts and before the initial continuation sweep. The supervisor attests the boot transition; the database owns run identity. An intent merely written before a crash/OOM, an acknowledgement skipped by a failed handshake, or an acknowledgement left across another boot authorizes nothing. Transcript text is presentation, never restart-cause evidence.

Event Durable result Boot/sweep result
Provider usage/rate limit exact run parked until reset notify; continue if the usage policy is on
Accepted planned restart, exact park + boot nonce match exact run parked, reason restart, nonce, due now continue immediately if the restart policy is on; preserve app attribution unless newer owner input takes over
Accepted planned restart, stop/finalize did not settle exact latest run remains running with the authenticated nonce finalize partials, convert to due restart park, then continue in the same pre-yield pass
Crash/OOM before supervisor acknowledgement unacknowledged park or generic running evidence resolve/reconcile to manual resumable interruption
Repeated/unrelated boot before claim acknowledgement is retired by boot-id mismatch manual resumable interruption
Policy off, unanswered question, or app work queued after the parked run due park resolves without an automatic send notify/manual owner action
Owner sends, switches provider, deletes the chat, or a newer run wins old park is superseded by the existing latest-run fence no stale continuation
Restart task creation fails after promotion exact promoted rows roll back; restart park becomes interrupted manual recovery; one-shot cause is not retried

Eligibility is rechecked under the per-chat transition lock immediately before promotion. Provider-limit retries are staggered one at a time; an authenticated planned restart restores the exact set that was already concurrent, so its eligible chats launch in small batches. While each pass makes progress, the supervisor promptly drains the durable remainder without waiting for launched turns to finish; a no-progress pass falls back to the ordinary retry cadence. An app-initiated restart continuation carries the same app id into the next durable run unless a newer owner send is already queued and becomes the next run's actor; provider-limit retries remain owner-only, and app work queued after a park is never absorbed. The provider still receives a synthetic user continue, but the durable row is tagged kind="auto_continuation" with reason restart or usage_limit; the UI, copy behavior, title selection, time context, compaction, provider-switch handoff, chat-note summarization, and redacted chat logs treat it as a product marker rather than owner speech.

The sweep is cheap: one indexed due-row query immediately at boot, on chat_run_finished, and on a 60-second fallback. Startup captures the boot authorization once and threads that exact value through reconciliation and every supervisor sweep, so a later ledger read cannot disagree with the boot. When a successful pass leaves a restart remainder, the same supervisor follows up after two seconds; a no-progress pass returns to the event/60-second cadence. It creates neither per-chat workers nor a permanent short poll. Paid provider-limit continuation (auto_resume_on_limit) initially defaults off; planned-restart continuation (auto_resume_on_restart) initially defaults on. Each chat stores both choices independently, and changing either choice seeds future chats without rewriting existing conversations.

Tool output rendering

Tool runs are grouped so the reader sees at a glance what is running vs finished (ToolActivityGroup folds adjacent runs into one collapsed-by-default card; per-tool status only ever goes running → done, with failure derived from a nonzero exit code, never a block status). Output is lazy: the chat-load payload ships a reduced form (outputs over ~4KB are dropped to a length marker in the routes/chats.py serializer), and the FULL output is fetched only when the block is expanded (GET /api/chats/{id}/tool-output). Small outputs stay inline; live streaming is unchanged.

Chat summary + continuity contract

Each chat maintains a growing per-chat note at /data/shared/memory/chats/<chat-id>/index.md — a bounded ## Digest, durable facts + the partner's intent, an uncapped cumulative ## Summary, and a one-line gist that IS the chat title (backend/scripts/chat_note.py summarizer subagent: transcript in the prompt, no tools). This note is core continuity — it exists and is useful even when the Memory app is not installed. Its consumers:

  • Short-term continuity into new chats. A fresh chat opens with only the gist and bounded Digest from the ~10 most-recently-modified chats (backend/app/memory.py); the fenced path lets the agent deliberately open a relevant full note. Facts and cumulative Summaries are not injected.
  • Knowledge graph (installed Memory system app). The app requests structurally redacted chat text through its declared API permission, writes a complete graph to a same-filesystem staging tree, and atomically advances a JSON .ready pointer to an immutable generation containing mocs/, notes/, and graph.json. Its confined reader pins one generation and returns cited snippets on demand. The graph is not platform code; base boot provisions only the per-chat summary surface (backend/scripts/init_chat_summaries.py).
  • Reflection. Without the Memory app, the per-chat summaries are what Reflection reads.
  • Compaction + provider switch. The cumulative Summary is the source for compacting a long chat and for the provider-switch handoff below — preferred over a from-scratch default compaction.

Two agent mechanisms with similar names deliberately remain separate. app.background_agents resolves the owner’s primary/fallback ordering for scheduled agents; installable jobs such as Memory receive the non-secret system choices through their scoped job-context, then apply any explicit app-local override. Memory executes those choices through its own tool-free, temporary-directory text boundary because retrieval and consolidation must not gain coding tools or depend on another app. The optional Subagents app instead owns explicit, bounded Claude/Codex delegation from a live chat, including provider enable switches, recursion limits and read/write task scope. Installing or pausing Subagents therefore does not enable, disable or reconfigure Memory.

Provider switch (compaction handoff)

Sessions are not portable across providers, so switching provider mid-chat uses an incoming-provider handoff: the composer confirms and POSTs the target provider, model, effort, and a stable switch id to /chats/{id}/provider-switch. A successful response is explicitly versioned as provider-switch-v1 and echoes both the switch id and target provider; a generic 2xx response is not authoritative. The bodyless /chats/{id}/compact route remains as a rolling-upgrade bridge for older clients that compact and then PATCH the provider.

The incoming provider runs a disposable, tool-free synthesis turn over the complete running ## Summary plus the complete current transcript. Large sources are folded through bounded progressive synthesis turns so no middle interval is silently omitted. The writer actor then stores that portable brief, changes provider/settings, clears the outgoing session, and supersedes outgoing parked/resume_pending runs in one conditional transaction; sends, settings PATCHes, app-chat PATCHes, and auto-resume share the same per-chat transition lock, while a Summary or transcript change invalidates the commit. Provider-switch UI state is keyed by chat outside the keyed ChatView, so navigation cannot unlock a handoff or lose its idempotent retry id. The brief is replayed into the incoming provider's first real turn as a <compacted_chat> block, so the new agent continues rather than starting cold. Same-provider model swaps skip the handoff because their session context is preserved.

Staying aligned (enforcement)

This section is the owner-authoritative source of truth for chat UX; the gitignored CLAUDE.md / docs/* copies must not diverge from it (when they do, this wins). Alignment is currently enforced by the tracked unit and Playwright lock-in specs above plus chatContract.js's pure geometry predicates. Three additional harnesses have been designed but are not present in this repository yet: a runtime chat-contract monitor on the live shell (208), a deterministic chat-states gallery with geometry goldens (209), and an SSE event-replay harness (210). Do not cite those planned harnesses as current coverage. Changing a rule here means updating a matching tracked test in the same change.

Stop-chat contract

Stop is a two-layer contract: the backend interrupts and clears, while frontend/src/components/ChatView/ChatView.jsx:handleStop owns the user-visible collapse-and-resend behavior. On entry, handleStop synchronously guards against double clicks, snapshots pendingQueue.pendingMessagesRef.current, joins queued text with a single \n, dedupes attachments by name, bumps fetchGenRef, and clears the pending queue before awaiting /api/chat/stop. The endpoint backend/app/routes/chat.py:chat_stop returns {"stopped": bool, "cleared_pending_cids": [...]} from chat.py:stop_chat (cleared-set identity is the stable cid; ts is display metadata), and the frontend runs that through resolveStopResend() for both clean-stop and timeout branches: null/missing cleared_pending_cids falls back to the whole snapshot, [] resends nothing, exact matches resend only those queued rows, and an unmatched cleared cid falls back to the whole snapshot rather than dropping work.

backend/app/chat.py:stop_chat_for is an interrupt primitive, not a queue-drain primitive. It snapshots the generation, calls bump_run_generation(chat_id) before killing handles, registers _clear_after_terminal_generation when handles exist, clears pending under chat_queue.get_lock(), cancels any live app.questions pending question, then calls each runner handle's stop(timeout=2.0). If every handle stops it unregisters them and finalizes the broadcast (discarding _starting); the stuck run-marker is cleared via the actor only on the no-handles path — active handles hand that clear to run_chat's finally block. If any handle times out it leaves the registry entry and broadcast intact for runner-side teardown and returns stopped=False — in that branch the frontend must NOT disconnect or start a second run. Instead, if resolveStopResend() returns text, handleStop calls doSend(..., { pin:false }) while isStreamingRef is still true, so the message follows the queue path and is re-persisted as pending.

The generation bump is the key invariant. A dying _run_chat_impl rechecks ownership in its terminal path; after Stop it must resolve to STALE_NO_ACTION (or the Stop-handoff cleanup), never promote pending or schedule a backend continuation behind the frontend's resend. Do not refetch pending from the server after Stop to rebuild the resend — Stop already cleared the durable queue, so the local snapshot is the only source that preserves text + attachments; and do not resend the full snapshot unconditionally on stopped:false — the natural turn-end drain may already have consumed some rows, and cleared_pending_cids is the only guard against duplicate follow-up work.

AskUserQuestion interception

AskUserQuestion is a shared pending-future lifecycle plus a shared question stream event; Claude and Codex differ only at the SDK boundary. backend/app/pending_questions.py:PendingQuestion carries question_id, questions, future, and optional run_token; backend/app/questions.py owns the module-level _pending registry (get, claim_if, cancel). Claude registers the pending question in claude_sdk_runner.py:can_use_tool for the AskUserQuestion tool, persists the card via _ChatEventSink.publish_question(), awaits the future, and returns PermissionResultAllow(updated_input={questions, answers}). Codex installs _install_request_user_input_handler() on codex._client._sync._approval_handler, enables features.default_mode_request_user_input=true, handles item/tool/requestUserInput, marshals from the SDK worker thread into the loop with run_coroutine_threadsafe (a ~420s bridge timeout), and translates Möbius's text-keyed answers into Codex's id-keyed {answers:{qid:{answers:[...]}}} shape.

The answer POST is intercepted before normal send handling in backend/app/routes/chats_stream.py:send_message whenever body.answers is truthy. The route waits ~500ms for a just-broadcast pending entry, checks question_id identity when supplied, persists the answer FIRST through the writer actor's AnswerQuestion, then questions.claim_if(chat_id, pending) before resolving the future. That ordering is load-bearing: a concurrent Stop can cancel and pop the pending entry while the answer write awaits its ack, and resolving a cancelled/superseded future would feed the answer to the wrong SDK call. On success the route publishes answers_applied and returns status:"answer_delivered" plus answer_turn:"same", which useStreamConnection.js:sendMessage treats as terminal for the POST without reconnecting the SSE. Durable-question recovery instead returns status:"started" plus answer_turn:"new". The dedicated answer_turn field owns frontend row/bridge semantics; the status fallback exists only for rolling compatibility with older backends. A stale/missing pending question returns 410 rather than falling through and sending the answer as a new user turn. Question settlement invariant: live stream items, a persisted partial, and the settled transcript are alternate sources for one active assistant row. An in-process answer resumes that same row; the live-to-durable handoff preserves the question, its answer, and all pre/post-answer thinking, tool, and text blocks in event order without hiding, duplicating, or reordering them. Only a recovered answer with answer_turn:"new" creates a separate hidden continuation. Unknown future modes fail closed to a separate boundary so an existing question row is never overwritten.

Three frontend gates must stay aligned. StreamingMessage.jsx renders live question events with QuestionCard and NO disabled prop (the runner is paused while sending/isStreaming can still be true); QuestionCard.jsx does accept a disabled prop, but only MsgContent.jsx passes it, for non-answerable persisted cards. ChatView.jsx:doSendSilent allows submissions carrying resolvedAnswers through both sendingRef and isStreamingRef, uses sendSilentInFlightRef as the synchronous double-submit guard, optimistically patches message + stream question answers, and sends a hidden message with answers + question_id. Persistence identity lives in chat_writer.py: apply_answers_to_last_question() writes by exact question_id when present, and both the live-snapshot and final-merge paths carry existing answers forward by events.question_block_key() so later streaming snapshots don't wipe them. Do not key answer carry by block position, do not resolve the pending future before the writer ack, and do not make live cards inherit global send/stream disabled state.

Chat persistence — single-writer actor

All chat-domain mutations — transcript writes, run-markers, question rows, answers, finalize, error-persist — route through the single-writer actor in chat_writer.py as domain commands (PersistTranscript, QuestionCommit, Finalize, PersistError, AnswerQuestion, Barrier, DrainAndStop). Every command allocates an ack Future, but only the strict paths (QuestionCommit, Finalize, AnswerQuestion, Barrier, DrainAndStop) await it (commit-before-ack); PersistTranscript and PersistError are submitted fire-and-forget — PersistTranscript additionally coalesces rapid streaming snapshots, while PersistError does not coalesce. One dedicated thread owns the SQLAlchemy session and a FIFO command queue; async callers submit a command and await its Future. The blocking db.commit() (which SQLite's busy_timeout can stall up to 5s) thus never runs on the event loop, and the actor never touches asyncio or ChatBroadcast (those stay loop-owned).

Streaming state is physically bounded: PersistTranscript/PersistError replace Chat.live_assistant, never the historical Chat.messages JSON blob. Read routes overlay that current assistant on immutable history. QuestionCommit merges the card into history before broadcast, Finalize performs the terminal merge and clears the live value, and startup reconciliation performs the same merge after a crash. This keeps one-second crash-resilient snapshots without quadratic transcript rewrites as chats grow.

Settled transcript reads have a separate bounded presentation contract. GET /api/chats/{id}?compact=1 keeps prose, cards, distinctive image-view beats, and small collapsed activity metadata, but replaces each multi-step thinking/tool run with an activity reference into the immutable stored message. Repeated steps are bounded by activity variety rather than raw call count. Only an explicit disclosure resolves that exact range through GET /api/chats/{id}/activity-detail; the live assistant stays self-contained. Mounted runtime reconciliation uses GET /api/chats/{id}/runtime, whose ORM projection raiseloads every unrequested field so polling can never silently decode Chat.messages. Both projections carry the row's updated_at as the detail-snapshot version. On activation, a retained ChatView reads the runtime projection first and reuses its painted transcript only when those explicit versions match; a missing or changed version fails closed to the compact detail read. Any local, streamed, or paginated message-cache mutation clears the cached version until a complete detail response proves it again. These are read projections, never a second persistence format: provider context, recovery, export, and writer commands continue to use the full transcript.

  • Commit-before-ack (strict paths): the caller's await on QuestionCommit/Finalize/AnswerQuestion/Barrier/DrainAndStop doesn't unblock until the commit succeeds; PersistTranscript and PersistError are fire-and-forget (submitted without awaiting the ack).
  • Questions commit-before-broadcast: a question row is durable before its SSE push fires, so a reconnect's catch-up burst always finds it.
  • Concurrency invariant: ack Futures are NEVER resolved while a producer lock is held — collect (ack, value) under the lock, resolve after release — so even a synchronous done-callback that re-enters submit()/stop() can't deadlock. Do not move an ack resolution back inside a with block.

GUARDRAIL — never write Chat.messages / Chat.live_assistant / Chat.pending_messages directly from a request handler or SDK runner. SQLite WAL serializes commits but NOT the app-level JSON snapshot READ: two readers both see the pre-write snapshot and one silently overwrites the other (the lost-update race the actor closes). The only justified direct writer is reconcile_interrupted_chats (chat.py, runs at boot before the actor starts); all runtime writes otherwise pass through the actor.

Multi-pane workspace

The shell ships a responsive tiled workspace. Wide layouts can show several chat and app surfaces at once; compact layouts project the focused pair; phones keep the same durable workspace but present one practical surface at a time. The shell decides geometry from the available content rectangle—callers express placement intent and never encode pane ids, split directions, or breakpoints.

State and ownership

frontend/src/components/Shell/paneModel.js is the pure workspace model. A workspace contains a binary layout tree, a map of pane records, a focused pane, a presentation mode (single or panes), and the single-screen slot. Each pane owns its ordered tabs and activeTabKey; tab identity and navigation mapping remain in tabModel.js. The model normalizes persisted input, enforces unique tabs across panes, bounds pane count/depth, collapses empty splits, and returns the same reference for no-op transitions.

useWorkspaceSession.js is the live state owner. It composes reducer transitions through a synchronous ref boundary, persists the sole versioned mobius-workspace local value, owns focused-pane presentation, and projects content geometry. The durable snapshot restores the focused tab, pane layout, and Standard/Builder world after a fully closed PWA is relaunched. A missing or invalid workspace value fails closed to a fresh empty workspace; retained active-destination keys can then restore the current chat or app through the normal navigation path. There is no parallel flat-tab persistence format.

The render path walks the projected leaves. Each visible chat pane owns its own retained ChatView surface and scroll controller. App frames remain in the global stable-id cache, so moving focus or resizing a pane does not reparent or reload an iframe. The global cache cap still applies across visible and retained apps.

Placement and interaction

workspacePlacement.js is the policy seam. Producers issue semantic requests such as placement: 'beside-source', activation: 'background', or the internal live-preview activation. resolveWorkspaceRequests combines those requests with the current model and device projection. App-build previews therefore use the same path whether they arrive live or are reconstructed after reconnect. A live preview never gives keyboard or Back ownership to the preview, nor does it replace the active tab in the pane that owns that focus. A missing app opens in a safe companion pane, an app already in an unfocused companion switches there, and an app parked beside the focused chat moves out before it is revealed. When Standard is showing the building chat, entering Builder may retarget focusedPaneId to the pane that owns that same chat; this preserves the focused content rather than focusing the preview. When no companion is feasible, the app stays parked rather than interrupting the focused surface.

Tab drag/drop, edge splitting, divider resizing, maximized-pane presentation, Builder/Standard mode changes, and undo all dispatch through the workspace owner. Feasibility is shared: at most four panes, depth at most two, and minimum projected dimensions of 280×200. Phone mode admits only top/bottom split intent and may project fewer leaves without discarding the underlying tree.

Navigation and lifecycle constraints

  1. Do not remount ChatView to re-measure. Each pane owns its scroll geometry; resizing updates that owner while preserving follow-bottom and the current reading anchor.
  2. Do not reparent keyed app iframes. Visible app panes count against the global app cache. Stable render ownership avoids reloads and initialization timeouts.
  3. App ids remain numeric at the navigation boundary. Every open flows through tabModel.tabNavTarget; string/number divergence can double-mount an app frame.
  4. History entries carry pane ownership. useNavigation.js tags shell entries with the restorable route and pane hint, and repairs that hint when focus or tab ownership changes. Back/Forward must not infer a pane from visual order.
  5. Single and Builder are two projections of one durable workspace. Mode transitions capture presentation state but never maintain a second layout model. The single-screen slot is explicit, including an intentional null New Chat destination.
  6. Test behavior at the owner boundary. Pure model tests cover invariants and reference stability; hook tests cover same-batch transition composition; browser tests cover resize, drag, navigation, and scroll continuity.

The central extension seam is semantic placement plus pure model operations. New workspace features should add an owned transition or projection rather than mutating pane-shaped state in Shell.jsx.

Navigation back-stack + drawer model

On narrow layouts the drawer is modeled as a virtual route: opening it pushes one history entry but keeps the URL at / (openDrawerpushNavEntry('drawer') + drawerPushedRef = true). On desktop, navigation is instead a persistent sidebar whose open preference belongs to Shell/useDesktopSidebar; it never reads or mutates the mobile sentinel. Shell derives the rendered navigation from those independent states. When a viewport widens while the mobile drawer is open, it keeps the modal interaction boundary in place until closeDrawer() has consumed the sentinel, then exposes the saved desktop preference. Untagged iframe-created entries encountered during that close are traversed serially before the desktop sidebar becomes interactive.

The mobile design satisfies a few hard desiderata — no "two drawers" artifact during Chrome-Android swipe-back, the 250ms slide stays visible, one back-press exits the PWA from home, and closing the drawer (overlay tap / X) must never navigate. Three load-bearing invariants in useNavigation.js enforce this: (1) navTo consumes the existing drawer-sentinel rather than pushing when the drawer is open (it pushes one 'nav' entry only if the drawer was closed), so an in-app nav reuses the drawer's history slot instead of growing the stack — keeping history pinned to a pre-drawer snapshot and killing the BFCache artifact; (2) every close path funnels through history.back()handleBack, whose drawer-first guard (if (drawerOpenRef && drawerPushedRef) { close; return }) prevents over-popping navStackRef; (3) drawerPushedRef is a ref, not state (mutated synchronously in the same task as the history call) and is the single source of truth for "is a drawer-sentinel above the current entry." Activating the already-current destination is a close/no-op and must not create a duplicate history edge. Every shell-pushed entry is tagged {__mobiusNav:true, kind} via navHistory.js and written to both the classic History store and the Navigation API entry (updateCurrentEntry); both back handlers ignore untagged pops so sandboxed-iframe phantom entries can't over-pop — do not drop the tag from any push site or genuine sentinels read as phantoms and back-nav dies. Mini-apps install their own back-targets via the moebius:nav-push postMessage protocol (per-app counts in appSentinelCountsRef, capped at 20), consumed before navStack pops; Shell.deleteChat must scrub navStackRef of the deleted chat's entries or back lands on a 404'd chat. Three architectures were tried and rejected (per-nav pushState, flushSync-before-pushState, perpetual single-sentinel) — read tests/navigation.spec.mjs before changing anything.

Transient shell surfaces that should dismiss on browser Back use the same owner through useHistoryDismiss. Opening one pushes a tagged kind:'dismissible' entry before the surface paints. An explicit close (X, backdrop, Escape) dismisses the surface SYNCHRONOUSLY and consumes that entry with history.back() as bookkeeping — it must never wait for the traversal, because a wedged WebKit Navigation store (iOS 18.4+, navHistory.mirrorCurrentEntry) can deliver it untagged or not at all, which left the chat image viewer open with a permanently dead close button. A browser Back/swipe instead reaches the registered dismissal through handleBack; both navigation-event paths recognize a dismissible source BEFORE their phantom guard and before the Navigation API's canIntercept gate, and neither pops navStackRef. Each registration captures the tagged shell cursor it was pushed from, so an untagged iframe landing restores that cursor rather than leaving it pointed at the consumed sentinel. Explicit-close traversals are also correlated with the entry that issued them: if delayed bookkeeping crosses a newer surface's sentinel, it never dismisses that surface and the navigation owner re-arms the same logical sentinel at the committed cursor. Forward traversal deliberately leaves a dismissed transient closed and treats its physical entry as a no-op sentinel; reopening it pushes a fresh entry and naturally truncates that stale Forward branch. Do not add component-local popstate listeners for these surfaces — they race the shell's indexed cursor and break Safari's source-state fallback.

Service worker + offline

Möbius uses one root-scoped service worker, frontend/src/sw.js, to keep shell and mini-app navigations same-origin when offline. The shell route is the Workbox app-shell path: NavigationRoute(createHandlerBoundToURL('/index.html')) serves the precached shell, with /apps/, /app-assets/, /app-embeds/, /shell/embed, /sites, and selected published-style paths denied so backend-owned documents don't become the SPA by accident. Mini-app code is split from that shell path: /api/apps/{id}/frame and /api/apps/{id}/module match isAppCodeRoute() and go through appCodeHandler(OFFLINE_APPS_CACHE, { gated: false }) — frame/module caching is deliberately NOT gated by offline_capable. Standalone /apps/<slug>/ navigations use the same handler with gated: true: only a 200 carrying X-Mobius-Offline: 1 is stored; a headerless 200 purges the standalone entry. The server sets that header for offline_capable apps in routes/app_runtime.py:get_frame/get_module and routes/standalone.py:standalone_shell.

appCodeHandler() normalizes the cache key by stripping token/_/install but KEEPING v; freshness rides ?v=<app.updated_at> becoming a new key, not a connectivity probe. Once a versioned entry exists, shouldServeCacheFirst() serves it immediately while event.waitUntil() refreshes in the background. Cold paths and refreshes use cache: 'reload' through boundedFetch() so browser HTTP-cache revalidation can't hand the SW a bodyless 304 (NET_TIMEOUT_MS is a 3000ms hang guard, not a latency knob). appCodeStoreAction() is the storage policy: ungated frame/module stores every 200, gated standalone stores only X-Mobius-Offline: 1, all non-200 ignored; applyAppCodeStore() tolerates quota failures and deletes superseded same-route entries with a different v.

Packaged static documents use a separate rule. /app-embeds/ entry documents retain their own response-sandboxed cache key. A response-sandboxed opaque child is not controlled by the shell worker, so its relative JS/CSS/media requests use normal network/HTTP caching and have no packaged-static offline guarantee. Only an actual SW-controlled subresource request may normalize to the ordinary /app-assets/by-id/… identity; fetch/XHR and document requests retain the embed namespace, preventing a sandboxed entry response from aliasing onto the ordinary protected lane. No recursive crawler is implied: a future offline-capable package needs an explicit manifest/static-assets warm contract. The controlled-page regression pins the cached entry as packaged content rather than shell HTML.

Install-time precache includes the Vite shell plus the D3/Pixi classic scripts Memory loads by URL. Package imports are already inside each compiled app artifact and must not be duplicated in the shell precache. Runtime /vendor/ remains CacheFirst for explicit public assets. setCatchHandler() returns precached index.html outside /apps/ and offline.html for standalone/app-asset failures, avoiding native offline chrome. Two anti-patterns: do NOT reintroduce a mobius-shell-nav HTML cache (navigations bind to the precached index.html so HTML and hashed bundles advance together), and do NOT gate in-shell frame/module reads on offline_capable (that flag gates standalone offline opens + write semantics, while frame/module speed + warmup are universal). There is no hand-edited VERSION constant: activate deletes stale runtime caches via isStaleRuntimeCache, and Workbox handles content-versioned precache cleanup separately.

Shell rebuilds apply on idle: Shell.jsx defers shell_rebuilt while the chat the owner is actively viewing is streaming, then performs the controlled SW handoff/reload. Background chat runs are server-owned and reconnect after the reload; they must not strand a repaired shell indefinitely when several agents are working. The idle boundary alone is not a transcript-persistence boundary—terminal promotion updates the in-memory TanStack cache synchronously while its normal IndexedDB mirror is throttled. Before the intentional reload, flushPersistedQueryCache() writes the current allowlisted cache directly; this normally guarantees the reloaded ChatView hydrates the terminal assistant row rather than the previous partial while its authoritative GET revalidates. The wait is bounded by awaitCacheFlushBeforeReload(): IndexedDB can be blocked by another browser lifecycle transaction, and a best-effort cache write must never strand a waiting service-worker generation. The write may still finish after the deadline.

Mini-app manifest (mobius.json)

Every mini-app ships a mobius.json; the dependency-free source of truth is backend/app/manifest_contract.py, used by Store install, explicit local apply, and backend/scripts/validate-app.py. Five required non-empty string fields are id, name, version, description, and entry; entry must be the canonical index.jsx used by the source/apply lifecycle. The id is the manifest identity and the initial slug (source dir /data/apps/<slug>/; allocate_unique_slug can diverge it on a collision, and cron registration keys off the resolved app.slug), so it uses charset a-z 0-9 - _, cannot start with -/_, and cannot be purely numeric (bare integers are reserved for the numeric-id storage tree). Optional fields the parser recognizes include previous_id, icon, colors/display, offline_capable, embeds_agent, offline, permissions, storage_seeds, static_assets, source_files, skills, system_prompt, and schedule. Decorative-only fields such as author, license, and homepage are not validated or stored. Three gotchas: (1) runtime (imports/esm_deps) is informational; dependency resolution is governed by the pinned self-contained compiler in app_compile_contract.py. (2) storage_seeds value type is a switch: a string is a repo-relative file the installer fetches; a non-string is stored inline as JSON. (3) schedule.job has dual semantics — with an exactly five-field schedule.default it installs recurring cron; without it the script is an on-demand build hook. static_assets caps at 256 files / 16 MB each / 64 MB total and logical destination x is materialized at source path static/x.

Testing — determinism principle

Flaky e2e is a SYMPTOM of app non-determinism, not slow tests. Fix at the source: (1) eliminate app races — the SW first-install reload (only reload on a genuine update), and make the SSE stream (event) authoritative over the reconcile poll so optimistic state is never clobbered mid-turn; (2) mock the clock for genuinely time-dependent behavior; (3) wait on signals/state, never setTimeout durations; (4) expose a "settled" flag from the app rather than guessing a delay. The two app fixes above took handleStop 0→3/3 and removed the steer/app-canvas deterministic failures — product improvements, not test hacks.

backend/memeval/ is the offline evaluation harness for the memory system — synthetic/real-session corpora (corpus.py, fixtures/) run through consolidation and recall metrics (runner.py, systems.py, metrics.py), including a reflection-in-the-middle stage (reflection_stage.py). It is dev tooling, never imported by the running app; backend/tests/test_memeval_*.py cover it deterministically.

See also

  • Build / test / run commands and the dev loop: CONTRIBUTING.md. (The #1 deploy gotcha — a stale /data/platform/frontend/dist masking a fresh image — is covered under Frontend serving priority above.)
  • Subsystem deep-dives are inlined above as their own sections: Stop-chat contract, AskUserQuestion interception, Chat persistence — single-writer actor, Navigation back-stack + drawer model, Service worker + offline, and Mini-app manifest (mobius.json). (The chat-persistence v2 design + staged-rollout notes remain internal/gitignored — the as-built contract is the section above.)