---
title: Mobile Access — when something is wrong (part 3)
---

# Mobile Access — when something is wrong (part 3)

## What it is

This is part 3 of the [Mobile Access](mobile-remote-access.md) page. It is the half you reach for when the phone will not connect or will not stay connected.

## Where to find it

The troubleshooting section is for reading while you are in front of a phone that is misbehaving; the internals are agent- and developer-facing.

## How it behaves

### Troubleshooting

When something is wrong, the **Settings → Remote Access** pane in Omniscio shows one of four banners with verbatim copy. Match what the user is seeing on screen against the headers below to pick the right fix. Omniscio polls Tailscale every 5 seconds while the pane is open, so any state change (login, online/offline, ACL grant) takes up to 5s to reflect — close and re-open the pane to force an immediate re-check.

### Banner: "Tailscale is required for mobile access"

Body text: _"Tailscale creates a secure private network so you can access Omniscio..."_

Cause: Omniscio can't find the `tailscale` binary. Omniscio resolves it against the **live system / login-shell `PATH`** first (Windows registry; `sh -lc 'echo $PATH'` on macOS/Linux), then falls back to standard install paths: `C:\Program Files\Tailscale\tailscale.exe` and `C:\Program Files (x86)\Tailscale\tailscale.exe` on Windows; `/usr/bin/tailscale`, `/usr/local/bin/tailscale`, `/opt/homebrew/bin/tailscale` (Apple-Silicon Homebrew), `/usr/sbin/tailscale` on macOS/Linux. The `PATH` hit is **version-checked before it's trusted**, so a dead wrapper (e.g. a leftover `/usr/local/bin/tailscale` shim pointing at an uninstalled `/Applications/Tailscale.app`) is skipped and detection continues to the working binary.

Fix: Install Tailscale from `https://tailscale.com/download`. Omniscio re-reads the live `PATH` on each detection (on **every** platform — a GUI app launched from Finder/Dock or the tray doesn't inherit the shell `PATH`, so a Homebrew / nvm / custom-prefix install you can run in a terminal is found this way), so an install done _after_ Omniscio launched is picked up automatically — but if the banner doesn't clear within ~5 seconds, close and re-open the Settings pane to force a re-check, or restart Omniscio. If the banner persists with Tailscale clearly installed, the binary is in a non-standard path; reinstall to the default location.

### Banner: "Tailscale needs sign-in"

Body text: _"Open Tailscale and sign in to enable mobile access."_

Cause: The Tailscale daemon reports `BackendState: NeedsLogin` or `NoState`. Tailscale is installed but no user is signed in.

Fix: Click the Tailscale icon in your system tray → **Log in**. A browser window opens; sign in with the Google or Microsoft account on your tailnet. The banner clears within ~5s of successful login.

### Banner: "Tailscale is offline"

Body text: _"Check your internet connection. Mobile access will be available when Tailscale..."_

Cause: Logged in, but the daemon reports `Self.Online: false`. Usually transient network loss, sleep/resume, or the Tailscale daemon paused.

Fix: Confirm internet works on the PC. Right-click the Tailscale tray icon — if it shows **Disconnected** or **Paused**, click Connect/Resume. If the daemon is wedged, Exit Tailscale from the tray and re-launch from the Start menu / Applications folder.

### My phone can't open the private address

Mobile Access is **private by default**: the `https://<machine>.<tailnet>.ts.net/` address only opens on devices signed in to the **same Tailscale account** as the desktop. Anything else (a phone without the Tailscale app, one signed in to another account, or one with Tailscale switched off) gets no connection at all, which is the point.

Fix: install the Tailscale app on the phone, sign in to the same account, turn it on, then scan the pairing QR again. If a device genuinely cannot run Tailscale, either pair over the same Wi-Fi, or turn on **Allow access from the public internet** in Settings → Remote Access → Advanced (desktop only), which switches to Tailscale Funnel.

### Notice: "HTTPS certificates are turned off"

Body text: _"Tailscale access is off because HTTPS certificates are turned off for your Tailscale network. Turn them on, and Mobile Access connects automatically."_

Cause: private (and public) Tailscale access needs a certificate for `<machine>.<tailnet>.ts.net`, and the tailnet's **HTTPS certificates** setting is off. Tailscale answers `tailscale serve --bg` with an enable-HTTPS prompt instead of registering the address. Omniscio treats this as a permanent setup problem: it shows the notice (also after a failed start at boot), offers **Open Tailscale admin to fix this** (Tailscale's own one-click link when it printed one, otherwise `https://login.tailscale.com/admin/dns`), and the background reconciler backs off for 10 minutes instead of retrying every minute.

Fix: open the link, turn on **HTTPS certificates** in the Tailscale admin console's DNS page, then wait (or toggle Mobile Access off and on). No change on the phone is needed.

### Banner: "Tailscale Funnel needs approval"

Body text: _"Toggle Mobile Access on and your browser will open to approve Funnel access. Just click approve and you're set."_

This appears **only when public access is turned on** (Settings → Remote Access → Advanced → Allow access from the public internet). Private access, the default, needs no Funnel permission, so a tailnet without it still reaches the ready state and starts privately.

This is the **most common point of confusion** in public mode. Funnel requires _two_ independent permissions, both of which can be missing:

1. **Device capability** — the machine must have the `funnel` capability advertised in the tailnet (visible in `tailscale status --json` under `Self.CapMap`, key `https://tailscale.com/cap/funnel`).
2. **Tailnet ACL policy** — the tailnet's admin policy must explicitly grant Funnel to this device. On free Tailscale plans this is a one-time toggle.

When the user clicks the tunnel toggle and gets this state, Omniscio also surfaces a clickable **Open Tailscale admin to fix this** link. Omniscio parses that URL out of the Tailscale CLI's stderr from `tailscale funnel --bg <port>` and falls back to `https://login.tailscale.com/admin/acls` if no URL is embedded in the error.

Fix: Click the admin link in Omniscio's banner, approve Funnel for this device in the Tailscale admin console, then return to Omniscio and toggle the tunnel again. If the admin link doesn't appear, visit `https://login.tailscale.com/admin/acls` directly and confirm the policy grants Funnel to this device's tag or user.

### Tunnel toggle won't stay on, no clear banner

The toggle flips on then back off without an obvious banner. Likely causes:

- **Port conflict** — default `9876` is already bound by another process. Try a different port (range 1024–65535; uncommon picks like `19876` or `28765` avoid most conflicts).
- **First-time certificate provisioning** — Tailscale provisions a LetsEncrypt cert the first time the address is used over HTTPS (private or public), which can take 30–60 seconds. Retry once after a minute.
- **Tailscale daemon mid-restart** — the daemon is briefly unreachable; wait 30 seconds and retry.

### Just restarted the PC and the tunnel isn't up yet

On app launch, if `tunnelEnabled` is on, Omniscio tries to start the tunnel in the background. Tailscale's own daemon (and the system PATH) may still be initializing — production app launches frequently beat Tailscale to ready state. Omniscio retries up to **8 times** with exponential backoff — 7 inter-attempt delays of (3s, 6s, 12s, 24s, 48s, 60s, 60s), a ~3.5-minute window. The retry decision lives in `isRetryableTunnelStartError()` ([src/main/services/tunnel-service.ts](/src/main/services/tunnel-service.ts)), called from the boot loop in [src/main/index.ts](/src/main/index.ts). It is a **permanent-error deny-list**, not a retryable allow-list: the only failures that stop immediately are **Funnel not enabled** in the tailnet admin console (public mode; an ACL decision a retry can't change) and **HTTPS certificates turned off** (private or public; an admin-console setting, see the notice above). Every "not ready yet" shape retries — `tailscale status` failing before the daemon's local API answers (`Failed to get Tailscale status: …`), the binary not yet on PATH (`not installed`), not-logged-in, offline, and a not-yet-populated DNS name.

**A busy machine can't fake "not installed" anymore.** Right after a restart the PC is often slammed (dozens of sessions resuming), and the 5-second `tailscale version` check Omniscio runs during binary discovery can time out even though Tailscale is installed and healthy. Discovery ([src/main/services/tunnel-service.ts](/src/main/services/tunnel-service.ts)) now distinguishes the two: a probe that was **killed on timeout** for a binary that **exists on disk** is trusted provisionally (used immediately, re-verified on the next check), while a fast failure — missing file, dead shim — is still rejected. Before this (2026-07-02), one slow probe made Omniscio conclude "Tailscale is not installed" for 8 minutes, blinding both the boot retry and the reconciler; see the [discovery-timeout postmortem](/.claude/memory/postmortems/mobile-access-restart-discovery-timeout-postmortem.md). Concurrent start attempts also now share one run instead of bouncing off each other with "already in progress" errors.

**Beyond that boot window, a background Tunnel Reconciler keeps it covered.** The 8-attempt boot loop is only the _fast_ path. If Tailscale isn't ready within those ~3.5 minutes — e.g. it's mid auto-update right after a reboot, so it reports "not logged in" or its status command keeps failing past the window — the boot loop gives up, but a separate **Tunnel Reconciler** ([src/main/services/web/tunnel-reconciler.ts](/src/main/services/web/tunnel-reconciler.ts)) takes over: every 60 s, while `tunnelEnabled` is on and the funnel is down, it re-attempts `startTunnel()` until Tailscale is ready and the funnel comes up on its own — no toggle, no restart. It reuses the same permanent-vs-transient classifier (a genuine **Funnel not enabled** ACL error backs off 10 minutes instead of hammering the CLI), is a quiet self-heal (no inbox alert — the funnel just reappears), and is default-on with an `AMC_DISABLE_TUNNEL_RECONCILER=1` kill switch. This closes the gap where rebooting during a Tailscale update left mobile dead with `tailscale funnel status` showing "No serve config" even though Tailscale itself was healthy. Invariants: [tunnel-reconciler-contract.md](/.claude/memory/contracts/tunnel-reconciler-contract.md).

**And it no longer trusts "the tunnel started" to mean "your phone can reach it" (2026-07-05; split by mode 2026-09-26).** In **private** mode (the default) the reconciler checks Tailscale's own record instead ([tunnel-registration-probe.ts](/src/main/services/web/tunnel-registration-probe.ts)): `tailscale serve status --json` must list our address, pointing at our port, and not public. Missing, pointing elsewhere, or public counts as not serving and is repaired after the usual 3 checks; a record it cannot read counts as "couldn't check" and never triggers a teardown. The **public** mode check below is unchanged. `tailscale funnel --bg` can _return success_ while Tailscale's public ingress never actually routes to the machine — observed when the command runs while the box is still CPU-starved right after a restart. The old reconciler trusted that "the funnel is up" flag and went silent; the phone stayed dead for 12 minutes despite a logged "Tunnel started", until a manual off/on. Now, when the funnel is _registered_, the reconciler **verifies the real public path your phone uses** ([funnel-health-probe.ts](/src/main/services/web/funnel-health-probe.ts)) — a check that deliberately bypasses a trap: on the host, MagicDNS resolves the `ts.net` name to the machine's _own_ tailnet IP, so a naive probe would pass even when the outside world can't connect, so the probe resolves the public ingress via public DNS and dials it with the funnel's SNI. If a registered funnel isn't actually serving for **3 consecutive** checks (~3 min — a lone blip never triggers it), it **re-establishes cleanly** — `funnel off` then `funnel --bg`, the automated equivalent of toggling it off and on — with a 5-minute anti-flap cooldown, and keeps going until your phone can reach it. Invariants I10/I11 in the same contract.

**And it will not tear a working tunnel down just because it couldn't check (2026-09-09).** That check answered a plain yes/no, so "I couldn't tell" and "it's broken" were the same answer — and the response to both was the rebuild, which disconnects every phone. On a busy machine the public address lookup failed in roughly hour-long stretches, and a tunnel that was demonstrably fine (200s straight through both public addresses, and a real phone loading the app mid-window) was rebuilt 30-plus times a day, with nothing in the log saying why. Rebuilding cannot fix an address that won't resolve, so the remedy was powerless against the actual cause and maximally destructive. The check now answers **yes / no / couldn't-tell**, always with a written reason; only a *proven* "no" rebuilds anything. The lookup also retries, tries every address it gets back rather than only the first, is capped by a hard overall deadline, and remembers the last address that actually worked so a bad patch of lookups still yields a real answer. If it genuinely can't verify for ~30 minutes it makes one rebuild attempt anyway, so a truly dead tunnel still can't sit unhealed. See the [funnel false-teardown postmortem](/.claude/memory/postmortems/funnel-probe-false-teardown-on-unverifiable-dns-postmortem.md).

**On Windows it also recovers a dead Tailscale GUI (2026-07-27).** Tailscale's background service runs headless, but on Windows its backend needs the per-user **Tailscale GUI** (`tailscale-ipn.exe`) connected to reach `Running`/online — and a Windows Update to the **WindowsAppRuntime** (the GUI's own runtime) can kill that GUI so it never relaunches, leaving the backend wedged in `BackendState: NoState`, offline, with the funnel gone. A plain Tailscale-service restart or `tailscale up` does **not** clear this — only relaunching the GUI does (confirmed live: service restart + `tailscale up` both failed; relaunching `tailscale-ipn.exe` took the backend `NoState → Running` in ~15 s). So when the reconciler finds the funnel down and the backend in `NoState` on Windows, it **relaunches `tailscale-ipn.exe` itself** (no admin needed) before re-attempting the start; the backend comes online within seconds and the next tick brings the funnel back — no tray-icon fiddling required. Windows-only, cooldown-guarded (won't relaunch more than once every 2 min), quiet (log-only). Invariant I12 in the same contract.

Fix: Just wait — the tunnel self-heals on its own: first the boot retry (~3.5 min), then the background reconciler (every 60 s thereafter), so a slow or mid-update Tailscale is covered without any action once it finishes coming up. You only need to step in if Tailscale is genuinely misconfigured — e.g. Funnel isn't enabled in your admin console; fix that and the next reconcile tick brings the funnel up (toggling the tunnel switch off then on forces an immediate attempt). **History:** the retry filter has twice been too narrow and reintroduced an HTTP-421 boot race — the phone shows "Misdirected Request" because the tunnel hostname never reaches the web-access server's DNS-rebinding allow-list. Once for `not installed` (pre-`91361ed4b`, 2026-05-14) and again for the status-command failure (2026-06-24); inverting to a deny-list closes the whole class. See [remote-access-schema-and-tunnel-boot-race postmortem](/.claude/memory/postmortems/remote-access-schema-and-tunnel-boot-race-postmortem.md).

**A second HTTP-421 case — reaching the machine directly by name+port on the tailnet (2026-08-02).** Separate from the boot race above: from another Tailscale device you can reach Omniscio directly at `http://<machine>.<tailnet>.ts.net:<port>/` (over the tailnet, NOT the public Funnel). Previously only the numeric form `http://100.x.y.z:<port>/` passed and the name form returned 421 "Misdirected Request" — the `100.x` Tailscale address is a detected NIC (added to the allow-list bare AND with the bound port), but the MagicDNS name entered the allow-list only in its bare form (correct for the public Funnel, which terminates TLS on 443 so the browser strips the port). `buildHostAllowList()` ([web-access-guards.ts](/src/main/services/web/web-access-guards.ts)) now also adds the `<hostname>:<boundPort>` variant, so name+port direct-tailnet access passes while a rebound attacker host (with or without a port) is still rejected.

### Keeping the mobile launcher current

The mobile app checks the renderer build id whenever it starts or reconnects.
It accepts the first build it sees without interruption. If Omniscio later
serves a different build, the phone records that build and refreshes once, so
new launcher options and other UI changes arrive automatically without a reload
loop. A phone already running a build from before this protection was installed
needs one manual refresh to receive it; later builds synchronize automatically.

### URL works on PC but not on phone

- **Phone isn't on Tailscale**: install Tailscale from the App Store / Play Store and sign in with the **same account** used on the PC. Mixing Google login on PC with Microsoft login on phone creates separate tailnets that can't see each other.
- **Token mismatch**: if the user regenerated the token in Omniscio, the old bookmarked link carries the old token and returns HTTP 401. Copy the new URL from Omniscio and re-bookmark.
- **HTTPS browser warning**: the public `*.ts.net` URL has a real LetsEncrypt cert; a cert warning usually means the phone is hitting an old IP-only LAN URL on cellular instead of the Tailscale URL.

### Page opens but stays on the loading screen

If the document itself opens but Omniscio never mounts, this is different from a dead tunnel. A
cold phone boot requests many compressed JavaScript chunks at once; older builds compressed each
uncached chunk synchronously, which could block the mobile listener long enough for Tailscale requests
to time out. Static Brotli/Gzip cache misses now compress off the listener's event loop, so other
boot requests keep moving while the cache warms. Restart Omniscio after installing the fixed build,
then refresh the phone once. Do not clear browser data or regenerate the pairing token unless the
page explicitly reports an authentication problem.

### Phone says "Connection lost" forever, and reloading never helps (fixed 2026-09-08)

This was **not** a dead tunnel and **not** a slow boot — it was a rejected credential wearing a
network error's clothes, and it was unrecoverable from the phone.

**What the user saw.** The phone shows "Connection lost — tap to reload". Tapping reload
re-shows the same screen, forever. Nothing appears on the desktop to approve the device, because
a phone is only recorded as a device *after* its socket authenticates — a phone that never
authenticates is invisible. Measured on a real device 2026-09-07: five rejected auth attempts,
an IP lockout, then a permanent dead screen.

**Why it happened.** Two independent faults stacked:

1. The WS transport treated a `4001` close (the server refusing this device's token — rotated,
   expired, or never valid) as the **same** terminal state as a dead network, so it raised the
   reload banner. But a reload cannot fix an auth problem.
2. Worse, the reload could not even reach the server: the service worker answered every
   top-level navigation from the **cached app shell**, so `/login` returned the app instead of
   the sign-in page. That also silently broke the **pairing QR code and the pairing link** on
   any phone that had ever loaded the app — every route back in was dead at once.

**What happens now.**

- A rejected credential raises the **"Pair this device"** card *inside the already-loaded app*,
  with a box for the access token. It exchanges the token through the existing `POST /login` and
  reconnects **in place** — a `fetch`, never a navigation, so nothing in the cache can defeat it.
  This is what rescues a phone that is already stuck (it still runs the old service worker until
  its cache updates).
- The service worker now lets `/login` go to the network, so the QR code, the pairing link, and
  a hand-typed address work again for everyone. That stops new phones getting stuck.
- The card accepts a **pasted sign-in link** as well as a bare token, since that is usually what
  the user has in hand.
- A wrong token, a lockout, an unreachable computer, and a server fault each get their own
  plain-English message, and the card stays usable so the user can retry. A genuine network drop
  still behaves exactly as before — this does not swallow real outages.

Contracts: [mobile-pairing-rescue-contract.md](/.claude/memory/contracts/mobile-pairing-rescue-contract.md)
(P1–P10) and [mobile-service-worker-cache-contract.md](/.claude/memory/contracts/mobile-service-worker-cache-contract.md)
(S1-serverowned).

### Attaching a photo refreshes the page the first time after an update (fixed 2026-09-24)

**What the user saw.** Right after the desktop app restarted on a new version, the first photo
attached on the phone — usually to a new session — made the page refresh and the photo vanish.
Attaching again worked.

**Why it happened.** The phone moves itself onto a new version by reloading quietly the next time
the page is hidden, on the idea that nobody is looking. But opening the photo picker also hides the
page, so that one reload fired while the user was choosing a photo. The reload also brought the
phone up to date, which is why the second try worked.

**What happens now.** A page hidden by its own photo/file picker no longer counts as "nobody is
looking": the update waits and happens the next time the user actually leaves the app. The phone's
diagnostic breadcrumbs also reach the desktop log again, so a case like this now leaves a trace.
Contract: [mobile-service-worker-cache-contract.md](/.claude/memory/contracts/mobile-service-worker-cache-contract.md)
(S1a-picker).

### Manual debug commands

Run these in a terminal on the user's PC to confirm what Omniscio is seeing — these are the exact commands Omniscio runs internally:

- `tailscale status --json` — full status dump. Inspect `BackendState`, `Self.Online`, `Self.DNSName`, `Self.CapMap`.
- `tailscale serve status --json` — what Tailscale is serving. A private entry is a `Web` entry for `<machine>.<tailnet>.ts.net:443` proxying to `http://127.0.0.1:<port>`; if the same address also appears under `AllowFunnel` with `true`, it is public.
- **Private mode (default):** `tailscale serve --bg <port>` to start (replace `<port>` with the value in Settings → Remote Access; default `9876`) and `tailscale serve off` to stop. These are the exact commands Omniscio runs; if the start prints an enable-HTTPS prompt, HTTPS certificates are off for the tailnet.
- **Public mode:** `tailscale funnel --bg <port>` to start and `tailscale funnel off` to stop; `tailscale funnel status` lists active funnels.

If the matching command works in a terminal but Omniscio's toggle fails, capture the stderr and check Omniscio's main log at `%APPDATA%\omniscio\logs\main.log` (Windows) or `~/Library/Logs/omniscio/main.log` (macOS) for `[tunnel-service]` lines.

### Token regeneration behavior

Clicking **Regenerate** in Settings → Remote Access generates a new UUID token, saves it to `config.json`, and restarts the web-access server. All open phone connections disconnect on the next WebSocket reconnect (~10s). Old bookmarks return HTTP 401 — re-copy the URL from Omniscio.

### App quit cleanup

When Omniscio quits, it releases its Tailscale entry with `tailscale serve off` (private) or `tailscale funnel off` (public), matching the mode the tunnel was started in. If the Tailscale daemon has crashed, that command can hang briefly — app quit waits up to ~10s before forcing close.

## For agents

### How it works

The PWA manifest is [/src/renderer/public/manifest.webmanifest](/src/renderer/public/manifest.webmanifest) (icons at `/icon-192.png` and `/icon-512.png` in the same directory); `<link rel="manifest">` lives in [/src/renderer/index.html](/src/renderer/index.html). The web-access server allowlists `/manifest.webmanifest` and the icon paths for unauthenticated fetch — Chrome must read the manifest and icons before the user has presented a token, otherwise it never offers "Install app". Any change to the manifest fields (name, start_url, display, icons) is locked in by [/tests/unit/lint/pwa-manifest.test.ts](/tests/unit/lint/pwa-manifest.test.ts) — drop `display: "standalone"` or remove the `<link rel="manifest">` tag and CI fails.

The HTTP + WebSocket server is [/src/main/services/web/web-access-server.ts](/src/main/services/web/web-access-server.ts). Each WebSocket client declares a **subscription scope** — `all` (full desktop parity), `inbox` (sidebar awareness, the mobile default), or `session` (per-session events + inbox awareness). The `INBOX_ALLOWED_CHANNELS` allow-list — now in [/src/main/services/web/web-access-push-filter.ts](/src/main/services/web/web-access-push-filter.ts) (push-subscription filtering was split out of `web-access-server.ts`) — filters the push stream to what the mobile UI actually needs. It has grown well past its original core set (`SESSION_STATUS_CHANGED`, `SESSION_ENDED`, `SESSION_RENAMED`, `SESSION_LAUNCHED`, `SESSIONS_REORDERED`, `PROJECTS_CHANGED`, `SETTINGS_CHANGED`, `ACCOUNTS_CHANGED`, `ACCOUNT_USAGE_UPDATED`, …) to ~109 channels — recovery-queue overlays, weekly summary, AI-manager overlays, approval families, board integrations, and more — so most push streams the mobile UI cares about now reach the phone. Push events are gzip/brotli compressed and batched in a 150 ms flush window. The bootstrap payload (`WEB_BOOTSTRAP` in [/src/main/ipc/web-access-handlers.ts](/src/main/ipc/web-access-handlers.ts)) keeps only a trimmed slice — UI/feature settings (theme, feature flags, and tour dismissals — the `WEB_BOOTSTRAP_SETTING_KEYS` tuple, so a tour dismissed on desktop won't re-prompt on the web client), projects, active sessions, accounts, and a VAPID key — about 3–5 KB instead of the full ~160 KB desktop hydration — and the remaining sessions are fetched on idle via `requestIdleCallback`. The settings UI is [/src/renderer/src/features/settings/sections/web-access/WebAccessSettings.tsx](/src/renderer/src/features/settings/sections/web-access/WebAccessSettings.tsx) (it also drives the Tailscale install/login/Funnel detection). The mobile session list is [/src/renderer/src/features/dashboard/MobileSessionsList.tsx](/src/renderer/src/features/dashboard/MobileSessionsList.tsx) (virtualized, lazy-loaded). Full design + triage flow: [feedback_web_access_mobile.md](/.claude/memory/feedback_web_access_mobile.md); user-facing marketing page: [/docs/intro-sandbox/landing-page.html](../intro-sandbox/landing-page.html).

### Bootstrap LAM (last-agent-message seed)

The bootstrap payload's `sessions[]` rows carry an extra field — `lastAgentMessageContent: string | null` — populated for **two** status groups (a single correlated subquery with two `CASE` branches in [/src/main/db/queries-sessions/lifecycle.ts](/src/main/db/queries-sessions/lifecycle.ts) `listActiveSessions`), so a mobile tap shows content instantly instead of a spinner:

- **inbox-attention** sessions (`needs_you`, `error`, `stalled`): the final agent message's prose body (`summary_prose` preferred, `content` fallback, asides excluded via `sidechain_group_id IS NULL`), capped at `LAM_BOOTSTRAP_CAP_CHARS = 51200` (~50 KB).
- **live working** sessions (`running`, `starting`): the last **settled** agent message — `summary_prose` ONLY (no `content` fallback), gated on `summary_prose IS NOT NULL`. That filter is load-bearing: the in-flight partial row has null prose mid-stream, so it is skipped and a streaming session never seeds a raw, half-streamed, tool-marker-laden snippet. Capped tighter at `LAM_LIVE_SEED_CAP_CHARS = 4096` (4 KB) because many live sessions can ship at once and the bootstrap re-runs on every mobile resume — a generous cap × N live sessions would bloat the very payload the seed exists to speed up.

For every other status (and for any seeded row with no eligible agent message yet) it is `null`. The Zod schema field is `.optional()` (rather than required) so an old mobile client talking to a new server still parses: the consumer reads `?? null`. The status sets are derived literals (`LAM_ATTENTION_STATUS_LITERALS`, `LAM_LIVE_STATUS_LITERALS`) — static `SessionStatus` members, no injection surface.

The renderer uses this to **eliminate the cold-mount blank screen**. On mobile over Tailscale the lite history fetch can take 1–3 s to round-trip — longer when the single main thread is saturated under a many-session storm — so without a seed, tapping an attention OR running session shows an empty panel with a spinner. With the seed, [`SessionPanel.tsx`](/src/renderer/src/features/sessions/SessionPanel.tsx) builds a synthetic `ConversationMessage` (id `lam-seed-<sessionId>`, `source: 'agent'`) from `session.lastAgentMessageContent` and renders it via the normal `MessageBubble` path the moment `messages.length === 0` and the LAM is non-empty — and it is status-agnostic, so extending the seed to `running`/`starting` needed no renderer change. As soon as the real history fetch resolves and `messages.length > 0`, the normal virtual list takes over and the seed disappears naturally — no merge logic, no flicker. The seed is purely a render-time short-circuit; the `conversationCache` is never touched. If the LAM is absent (a status outside the seeded set, or a seeded row with no eligible settled agent message yet — e.g. a running session whose only agent output so far is the in-flight partial), the panel falls back to the existing spinner. Backend tests pin the SQL invariants ([tests/unit/db/queries/queries-sessions.test.ts § lastAgentMessageContent (bootstrap LAM)](/tests/unit/db/queries/queries-sessions.test.ts)); renderer tests pin the seed → real-message handoff ([tests/unit/features/sessions/SessionPanel-bootstrap-lam-seed.test.tsx](/tests/unit/features/sessions/SessionPanel-bootstrap-lam-seed.test.tsx)).

**Running-session taps prioritize freshness.** A mobile tap now selects the session before it
reads the foreground seed, which establishes the session-scoped WebSocket subscription first. A
running session also bypasses the settled-session transcript deferral and immediately loads its
bounded recent history. That one reconciliation fetch closes the gap for output emitted before the
new subscription existed; output emitted afterward continues over the live push stream. Settled
sessions keep the cheaper seed-first glance path, so this freshness guarantee does not restore eager
transcript work across every mobile tap. The ordering is locked by
[`use-dashboard-search-handlers.test.tsx`](/tests/unit/features/dashboard/use-dashboard-search-handlers.test.tsx),
and the running-versus-settled deferral decision is locked by
[`mobile-lazy-transcript-flag.test.ts`](/tests/unit/lib/mobile-lazy-transcript-flag.test.ts).

### Bootstrap agent-board seed

The bootstrap payload also carries `agentBoard: AgentBoardEntry[]` — the Agent Status Board rows (one per live agent), gated on the opt-in `agentStatusBoardEnabled` (ships `[]` when off, so a disabled board costs nothing on the hot path — `getBoard()` is never called). The renderer seeds `useAgentStatusBoardStore` from it **synchronously at first paint** (`hydrateFromBootstrap`), at BOTH the initial-load ([useAppBootstrap.ts](/src/renderer/src/app/useAppBootstrap.ts)) and reconnect ([App.tsx](/src/renderer/src/App.tsx)) bootstrap sites. Without it the per-session **Agent Board** strip — a sibling ABOVE the scroll container in [`SessionPanel`](/src/renderer/src/features/sessions/SessionPanel.tsx) — got its data only from the async `AGENT_STATUS_BOARD_GET` invoke, which on mobile resolves over the WS link AFTER the panel painted and the scroll engine settled; the strip then mounted late (`entryFor` undefined→entry) and shoved the just-positioned message down ~30px ("opened a session, then it scrolled up a little"). Seeding at first paint removes the late mount. The boot `hydrate()` in [useAppPushListeners.ts](/src/renderer/src/app/useAppPushListeners.ts) still refetches afterward (convergent — same `getBoard()` source), now into an already-rendered strip. Live board updates now reach **mobile too** — `AGENT_STATUS_BOARD_CHANGED` is in `INBOX_ALLOWED_CHANNELS`, so after this initial seed the board refreshes live via the App-level `usePushListener` (debounced) on every client. Locked by the [web-bootstrap producer-payload test](/tests/unit/web-bootstrap-handlers.test.ts) + the store [`hydrateFromBootstrap` test](/tests/unit/stores/agent-status-board-store.test.ts) + the [push-filter delivery test](/tests/unit/services/web-access-subscriptions.test.ts). Full invariant: [agent-status-board-contract.md](/.claude/memory/contracts/agent-status-board-contract.md) `the-live-push-reaches-mobile-too` / `the-per-session-strip-is-seeded-at-first-paint`.

## Related

- [Mobile Access](mobile-remote-access.md) — the overview page this continues.
- [Mobile Access part 2](mobile-remote-access-part-2.md) — the connection behaviour that usually explains what you are seeing.
- [mobile-perf-prevention.md](mobile-perf-prevention.md) — how the phone app is kept fast to open.

