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.
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.
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.
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.
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.
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. Neverapt upgrade/pip install -Ua running container — that mutation is ephemeral and drifts the live container away from the reproducible image.deploy-prod.shis the apply path. Full root is an honest default instance capability: the root entrypoint createsmobius ALL=(root) NOPASSWD: ALLbefore dropping privileges.MOBIUS_AGENT_SUDO=0is 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-upgradescovers the OS packages; the engine is a separate host upgrade). - Host kernel — not 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.
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 theupstreamversion 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
upstreamtip 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, agit 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-ffpointsMERGE_HEADat 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 plaingit mergewould 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 surfacesmode=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 withgit 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.
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.
| 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.
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.)
| 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-next→dist 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) |
| 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 |
| 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 |
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.
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.
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 |
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_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.
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
AppCanvas → app-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.
React + Vite. Entry is main.jsx → App.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 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.
| 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 |
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) |
| 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 |
| 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.
| 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 |
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/
├── 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.
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.
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_MSGorANCHOR_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 createPIN_USER_MSG. Reservation does not create a second kind of bottom: whenFOLLOW_BOTTOMis 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_BOTTOMthis 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 extendscrollHeightafter 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
ScrollModesettlement 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 toFOLLOW_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 toFOLLOW_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 settledPIN_USER_MSGsurvives 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 entersFOLLOW_BOTTOMso covered live output moves into view and continues following. A retired or saved pin restores only as an ordinaryANCHOR_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 throughINITIALand 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 settledANCHOR_ATtransfers 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 browserscrollevent 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 markeddata-chat-scroll-regionretains 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 claimsFOLLOW_BOTTOMbefore that no-scroll release; otherwise the browser's missingscrollevent 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-threadscrollhandler runs. A meaningful touch swipe toward the end may enterFOLLOW_BOTTOMonce 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 enterFOLLOW_BOTTOMwithout 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 ordinaryANCHOR_ATreading hold yields frombeforeinputthrough 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_BOTTOMabsorbs 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_AThold rather thanFOLLOW_BOTTOM; revealing an attention control must not manufacture future live-follow intent. Both actions route through the scroll controller instead of callingscrollIntoView, 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 resumesFOLLOW_BOTTOMso 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
ResizeObserveris 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_MSGkeeps the sent row at its pinned offset while reservation remains,ANCHOR_ATkeeps the same row offset, andFOLLOW_BOTTOMfollows 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 emitscrollwhile 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 asANCHOR_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 ordinaryANCHOR_AThold 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 returnsstartedcreates 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:
ChatViewmay readmodeReffor a submit snapshot but must not assign it. It emits send, queue, pagination, and lifecycle events through the semantic methods returned byuseScrollMode.ChatViewdeclares whether its physical surface owns the chat's durable reading coordinate.useScrollModealone 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 inChatView.- 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-ownedscrollTopwrite goes throughwriteMode. The exportedapplyModeexecutor is for the controller and pure unit tests, not a second live writer. useScrollModeis the sole writer of.spacer-dynamicheight and the composer-clearance CSS geometry. Those indirect writes and everywriteModecall 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
scrollevent 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.__mobiusChatScrollTracekeeps 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
cidwhen 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\nblob). The request binds to specific queued rows by their stablecid(consume_pending_cids;_selected_force_steer_pendingselects 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; theserverTsflag). 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 inchat.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 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 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.
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
.readypointer to an immutable generation containingmocs/,notes/, andgraph.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.
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.
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 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 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.
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
awaitonQuestionCommit/Finalize/AnswerQuestion/Barrier/DrainAndStopdoesn't unblock until the commit succeeds;PersistTranscriptandPersistErrorare 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-enterssubmit()/stop()can't deadlock. Do not move an ack resolution back inside awithblock.
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.
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.
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.
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.
- 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.
- Do not reparent keyed app iframes. Visible app panes count against the global app cache. Stable render ownership avoids reloads and initialization timeouts.
- App ids remain numeric at the navigation boundary. Every open flows
through
tabModel.tabNavTarget; string/number divergence can double-mount an app frame. - History entries carry pane ownership.
useNavigation.jstags 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. - 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.
- 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.
On narrow layouts the drawer is modeled as a virtual route: opening it pushes one history entry but keeps the URL at / (openDrawer → pushNavEntry('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.
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.
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.
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.
- Build / test / run commands and the dev loop:
CONTRIBUTING.md. (The #1 deploy gotcha — a stale/data/platform/frontend/distmasking 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.)