---
title: Mobile Access (run and triage your sessions from a phone)
---

# Mobile Access (triage from your phone)

## What it is

### What it is

Omniscio can expose a **mobile-optimized web UI** so you can triage your agent inbox from your phone when you're away from your desktop. It's designed for the "agent needs a decision" moments: approve a plan, answer a question, acknowledge a rate-limit, or send a one-line reply. The backend is an HTTP + WebSocket server running in the main Electron process; the frontend is the same React app served in a mobile-tuned layout. Omniscio does **not** ship its own tunnel — for access from outside your LAN you use **Tailscale**, which Omniscio auto-detects and starts for you once it is installed and signed in. By **default the connection is private**: Omniscio registers its address with `tailscale serve`, so only devices signed in to your own Tailscale account can open it, and your phone needs the Tailscale app. Reaching it from a device that cannot run Tailscale is possible through **Tailscale Funnel**, but only after you turn on **Allow access from the public internet** in Settings → Remote Access → Advanced (off by default, desktop only, confirmed with a warning). Installs that already used the public tunnel before this change keep public access until their owner switches it. Contract: [web-access-tailnet-only-contract.md](../../.claude/memory/contracts/web-access-tailnet-only-contract.md). Without Tailscale you can still use it on the same Wi-Fi as your desktop — but only after switching the server to bind all interfaces (it binds loopback-only by default; see step 2 below). All access is token-gated and mobile bootstrap is optimized for speed (a ~3–5 KB first payload instead of the full desktop state).

## Where to find it

On your phone, at the address Omniscio gives you when Mobile Access is on — and on the desktop, in the mobile-access settings where that link and its token come from.

> **Two names for one pane — the sidebar row is "Remote Access".** Hold this page's steps against what is on screen: the **sidebar row you click is `Remote Access`**, while the heading *inside* that pane reads **Mobile Access**, and Settings search answers to both ("mobile access" and "remote access" are both indexed). So wherever this page writes *Settings → Remote Access*, the pane that opens is the one headed "Mobile Access". The feature itself is called Mobile Access throughout Omniscio; only the nav row differs.

## How it behaves

### How to use it

1. **Turn it on.** Open Settings → **Remote Access** (the pane headed "Mobile Access"). Flip it on. Pick a port if you care (default `9876`, range `1024–65535`) and copy the auto-generated UUID token.
2. **Choose how you'll reach it.** Two options:
   - _Same Wi-Fi only_: Omniscio binds the server to loopback (`127.0.0.1`) by **default**, so first switch its **bind host** to all interfaces (`0.0.0.0`) in **Settings → Remote Access** — otherwise the `192.168.x.x` URL below is unreachable from your phone. Then use the local URL Omniscio shows you (e.g. `http://192.168.x.x:9876/?token=...`). Unencrypted, but fine inside your house — and it now loads on **iPhone Safari** too. Plain `http://` to a LAN/Tailnet IP is an _insecure context_ where Safari hides `crypto.randomUUID`; Omniscio routes every renderer id through `safeRandomUUID()` (which falls back to `crypto.getRandomValues`, not secure-context-gated), so the page no longer blank-screens on iOS over plain http. You don't need HTTPS or Tailscale just to get it to open on your phone — only to reach it from outside your LAN. See [renderer-safe-random-uuid-contract.md](../../.claude/memory/contracts/renderer-safe-random-uuid-contract.md).
   - _Over Tailscale by tailnet IP (manual)_: when Tailscale is installed, signed in **and** online, the **Pair Your Phone** card and its QR advertise your **tailnet address** (`100.x.y.z`) instead of the `192.168.x.x` one. That is deliberate: a phone on the tailnet has no route to the LAN address, and under the default loopback bind host nothing is listening on the LAN address at all — so advertising it handed Tailscale users a QR that could never connect. You still have to make the port reachable on the tailnet: either set the bind host to `0.0.0.0`, or keep it on loopback and forward with `tailscale serve --bg --tcp 9876 tcp://127.0.0.1:9876` (raw TCP, so the client's own `Host` header survives the DNS-rebinding guard — a plain `tailscale serve` HTTPS proxy rewrites `Host` to the MagicDNS name and gets a `421`). The Advanced pane's "For devices on the same Wi-Fi (no Tailscale needed)" field keeps showing the LAN address, because that is exactly what it is for. Selection lives in `resolvePairHost()` and is shared by all three pairing surfaces (Settings card, titlebar popover, onboarding step).
   - _Anywhere, privately (the default)_: install Tailscale on the desktop **and** on your phone, sign both in to the same Tailscale account, and turn Mobile Access on. Omniscio detects Tailscale's install + login + online status and, if all green, runs `tailscale serve --bg <port>` and verifies the registration from Tailscale's own record. You get an `https://<machine>.<tailnet>.ts.net/` address that only your signed-in devices can open; the pairing QR says so, and a **Private** line appears under "Connected via Tailscale". Your Tailscale network needs **HTTPS certificates** turned on (Tailscale admin → DNS); if they are off, Mobile Access says so with a one-click link to fix it and stops retrying every minute.
   - _Anywhere, publicly (off by default)_: for a device that cannot run Tailscale, turn on **Allow access from the public internet** in Settings → Remote Access → **Advanced** (desktop only; a warning asks you to confirm). Omniscio then uses **Tailscale Funnel** instead: the same address, reachable from the public internet, still protected by the access token, Google sign-in and device approval, with a **Public** line under "Connected via Tailscale" and a monthly reminder that offers the private route. While the switch is off, any request Tailscale marks as coming from the public internet is refused on every entry point. The Tailscale address (private or public) outranks both addresses above whenever it is running.
   - _Easiest — copy the login link_: whichever way you connect, the **Pair Your Phone** card in the same pane shows a copyable **Login link** — the full URL that signs you in automatically (the same value the QR code encodes). Copy it and open it on your phone instead of typing the address and token separately. It embeds your access token, so treat it like a password and keep it private.
3. **Install as a home-screen app (recommended on Android).** Omniscio ships a PWA manifest, so Chrome on Android offers a real **Install app** option — open the URL once (with `?token=...`), then Chrome menu → **Install app** (or "Add to Home screen" on older Chrome). Tapping the resulting icon opens Omniscio in its own standalone window with no Chrome tab UI, and successive taps return to the same window instead of spawning a new tab each time. The token is saved to `localStorage` on first load, so the standalone launcher uses `start_url: "/"` and the login page auto-redirects you back in. On iOS Safari, the equivalent is "Add to Home Screen" — Apple meta tags (`apple-mobile-web-app-capable`, `apple-touch-icon`) handle the standalone behavior there. Plain bookmarks still work but each tap spawns a new Chrome tab — install via the menu to get the standalone window.
4. **Triage.** Tap a session that needs you to see its pending action (colored chip: question, plan approval, rate-limit, auth error, user-stopped). Answer questions or approve plans inline. Type a short reply and send. The inbox updates live via WebSocket.
5. **Revoke if you ever need to.** Regenerate the token in Settings → Remote Access → **Regenerate**. Any open mobile sessions disconnect on the next WebSocket reconnect.

All five **inbox approval kinds** — cron jobs, automation rules, CLI Pending mutations, recipe runs, and recipe-authoring outputs — are reachable on mobile via swipe navigation between inbox items just like sessions, SMS threads, and the daily digest. Their footer buttons (Approve / Reject / etc.) meet the 44px Apple HIG touch-target floor so taps register on the first try. Resolving the last attention item from inside a session-detail panel slides you back to the inbox list automatically (no more "All clear" dead-ends on mobile).

A polished step-by-step **Tailscale** setup walkthrough with screenshots lives at [/docs/intro-sandbox/setup-tailscale.html](../intro-sandbox/setup-tailscale.html) (also published at `https://jlstradingco.github.io/Agent-Orchestrator/setup-tailscale.html`) — link users there for first-time _anywhere-access_ setup. For the plain-English **install-as-a-home-screen-app** steps (Android + iPhone), the user-facing guide is the help site's **Install on Your Phone** page ([docs/help-site/public/index.html](/#install-on-phone)).

### Boot sequence & landing screen (2026-08-08 overhaul)

Opening the mobile app cold now follows a fixed, fast sequence — locked by the
[mobile-parallel-bootstrap contract](/.claude/memory/contracts/mobile-parallel-bootstrap-contract.md):

- **Lands on the unified Inbox.** The phone opens on the Inbox tab (everything that needs
  you, across all projects) instead of the Projects list; Projects stays one tap / one
  back-swipe away. Phones only — desktop-browser Web Access and the desktop app keep their
  own landing.
- **Boot data races the tunnel.** The client fetches its boot payload over a plain
  authenticated HTTP request (`GET /bootstrap`) in parallel with the WebSocket dial —
  whichever arrives first hydrates (first-wins seal), so first content no longer waits the
  1–5 s tunnel handshake. Any HTTP-path failure silently falls back to the WebSocket path;
  `AMC_DISABLE_HTTP_BOOTSTRAP=1` on the desktop reverts all clients to the old path.
- **No sign-in flash.** A signed-in phone whose auth cookie lapsed used to flash the
  "Mobile Access" sign-in card while silently re-authenticating; that reconnect state is
  now visually identical to the app's own splash, and the card appears only when a manual
  sign-in is genuinely required.
- **Translated from first paint.** The app waits (bounded 750 ms) for the active language
  catalog before mounting, so labels never flash raw-then-translated.
- **A phone-shaped skeleton.** The pre-JS placeholder shows a top tab bar + inbox list
  rows matching the real landing screen (the desktop-shaped skeleton is gone on ≤640 px).
- **Tappable immediately.** The session-panel code chunk and the top needs-you
  conversations warm within a bounded window (500 ms / 300 ms) after the app is visible —
  they can no longer be starved for seconds — and the warm wave itself waits for first
  paint so it never slows the boot it serves.

Boot telemetry records which path won (`bootstrapSource: http | ws`) plus HTTP fetch
timings. Investigation + measurements: the
[boot-sequence overhaul postmortem](/.claude/memory/postmortems/mobile-boot-sequence-overhaul-postmortem.md).

### Lock to portrait on mobile

**Settings → Sessions → "Lock to portrait on mobile"** (`mobilePortraitLockEnabled`, default **off**) keeps the mobile web app in portrait instead of auto-rotating. It layers **three mechanisms**: a best-effort **runtime fullscreen + `screen.orientation.lock('portrait')`** (the live lock), a declarative **CSS landscape-block overlay** (the guarantee), and the served **web manifest `orientation`** (install-time default only).

Why fullscreen + an overlay: the real device proved `screen.orientation.lock()` is **rejected** even in the installed app with _"the page needs to be fullscreen in order to call screen.orientation.lock()"_ — so the runtime lever now enters fullscreen first (its observed precondition) and then locks. Both need transient activation, so it can't run on load — it arms on the **first tap**. But fullscreen isn't a guaranteed fix (a WebAPK minted free-rotating can still refuse the lock; iOS has no `lock()`), so the **CSS overlay** is the backstop that can't silently fail: its visibility is keyed to the actual screen orientation, so when the lock holds the screen never reaches landscape and the overlay stays dormant; when the lock is refused, landscape shows a "rotate to portrait" screen. The served `orientation: portrait` manifest only re-freezes the Android WebAPK on Chrome's slow background update check — it cannot lock an already-running app — so it is the install-time default only (this is why a manifest-only feature left the phone rotating across ~1000 open/close cycles).

- **Runtime lever** — `applyPortraitLock` ([mobile-portrait-lock.ts](/src/renderer/src/lib/mobile-portrait-lock.ts)), driven by [useMobilePortraitLock](/src/renderer/src/hooks/useMobilePortraitLock.ts) at the app root. Gated on a **touch device** on the **web client** (never desktop Electron, never a hover-pointer desktop browser); touch, not viewport width, because width flips in landscape exactly when the gate must NOT drop. Order: lock supported? → `requestFullscreen()` (synchronously, inside the gesture) → `lock('portrait')`; if the lock is still refused after it entered fullscreen, it **exits fullscreen again** (never a chrome-less, still-rotating state). It **ships its outcome** + `displayMode` + `requestedFullscreen` to the PC log (`[ClientLog:orientation-lock]` via `shipClientLog`) so an on-device result is one-glance confirmable, never silent. The hook re-arms on `fullscreenchange` exit so the next tap re-locks after a swipe-out.
- **CSS overlay** — [PortraitLockOverlay](/src/renderer/src/components/PortraitLockOverlay.tsx) at the app root (gated render: setting on + touch + web client), shown purely by `.portrait-lock-overlay` + `@media (orientation: landscape) and (hover: none)` in [globals.css](/src/renderer/src/styles/globals.css). No JS coordinates it with the lock — the media query IS the "did the lock hold?" detector, so it never changes the experience of a working lock.
- **Manifest lever** — the on-disk [manifest.webmanifest](/src/renderer/public/manifest.webmanifest) stays `"orientation": "any"`; the web-access server rewrites it to `"portrait"` per request when the setting is on ([web-access-http-manifest.ts](/src/main/services/web/web-access-http-manifest.ts), wired pre-auth in [web-access-http-routes.ts](/src/main/services/web/web-access-http-routes.ts)), served `application/manifest+json` + `no-cache`, with a safe static fallback on any read error.

Platform notes: **Android** (installed Chrome/Edge PWA) — first tap → fullscreen + true portrait lock; if it's refused, the overlay keeps it upright. **iOS Safari** — no `lock()`, so the **overlay** is what keeps the app in portrait (the rotate-to-portrait screen). **Desktop / hover-pointer browser** — unaffected (gated out). Trade-off: fullscreen hides the Android status/address bar; swiping out of fullscreen releases the lock and the next tap re-locks.

Behaviour is unit-locked by [tests/unit/lib/mobile-portrait-lock.test.ts](/tests/unit/lib/mobile-portrait-lock.test.ts) (fullscreen-before-lock, exit-on-failure rollback, outcome beacon, disabled path stays silent), [tests/unit/hooks/useMobilePortraitLock.test.ts](/tests/unit/hooks/useMobilePortraitLock.test.ts) (arm-on-gesture + re-arm on fullscreen exit), [tests/unit/components/PortraitLockOverlay.test.tsx](/tests/unit/components/PortraitLockOverlay.test.tsx) (gated render), and [tests/unit/services/web/web-access-http-manifest.test.ts](/tests/unit/services/web/web-access-http-manifest.test.ts) (served `orientation` reflects the setting; safe static fallback). Full contract: [mobile-portrait-lock-contract.md](/.claude/memory/contracts/mobile-portrait-lock-contract.md).

## Related

- [Mobile Access part 2](mobile-remote-access-part-2.md) — staying connected: the offline and reconnecting indicators, reconnect speed, the watchdog, signing in on the phone, and the settings behind them.
- [Mobile Access part 3](mobile-remote-access-part-3.md) — what to do when something does not work.
- [mobile-device-tracking.md](mobile-device-tracking.md) — which device is connecting, and approving or blocking one.

### Related

- [cli-control.md](cli-control.md) — a _different_ localhost-only server for scripts and hotkeys
- [set-up-sms-integration.md](set-up-sms-integration.md) — SMS is an even lighter "reply from phone" path when you don't want a browser

