The phone app
The phone app is LatchAI on a phone: the same engine, the same event stream, the same reducer the desktop workbench runs. It is not a second product with a synced copy of your work, and there is no account. Everything it shows comes from your daemon, on your machine.
It exists for the moment the desktop cannot answer. An agent blocked on run_shell at 3am.
An overnight pipeline that failed. A conversation you want to continue from a train.
What it is, and is not
Section titled “What it is, and is not”It is a progressive web app. You open it in a browser and add it to your Home Screen — there is no App Store or Play Store listing, and there is nothing to install on the phone beyond the page itself.
| Where | https://latchai.app/app |
| iPhone / iPad | Safari → Share ▸ Add to Home Screen |
| Android | Chrome → Add to Home screen / Install app |
| Also served by your own daemon | http://<mac>:7777/m/ — the same app, built for a local base |
Adding it to the Home Screen is not cosmetic. On iOS, web push only exists for an installed web app, and an installed PWA has its own storage walled off from Safari — so a pairing made in a Safari tab is invisible to the installed app. Install first, then pair.
The app is built for a thumb and asks the phone for portrait; there is no separate tablet layout.
Pair it in one scan
Section titled “Pair it in one scan”Pairing happens on the Mac, in Settings ▸ Remote Control.
- Turn Remote access on. The toggle starts an outbound connection to the relay immediately — no restart, no port to open, no firewall exception.
- The panel shows two QR codes. The first is
latchai.app/app: scan it with the phone and install the app as above. - Open the app on the phone, tap Scan QR, and point it at the second code.
That is the whole flow. The phone generates its own keypair, proves it holds the one-time secret in the code, and the daemon pins its public key. The durable half of that — relay URL, mailbox, both public keys, the phone’s own keypair — is saved on the phone, so a relaunch reconnects silently and you never scan again.
Two details worth knowing:
- Scan from inside the app, not with the system Camera. The Camera app opens the pairing link in Safari, which is a different storage origin from the installed app, and the pairing lands somewhere the app cannot read it. If the camera is unavailable, the panel also prints the pairing code as text and the app has a paste field.
- The code expires after 10 minutes. The Settings panel re-mints it every five, so the QR on screen is always live — but a screenshot of one taken yesterday is not.
Unpair from either end:
- On the phone — More ▸ Settings ▸ Danger zone ▸ Unpair this phone. It forgets the Mac and wipes everything cached locally.
- On the Mac — Settings ▸ Remote Control ▸ Paired devices ▸ Unpair. The phone is told, in those words, the next time it connects; its key stops opening frames immediately, and its push subscription is dropped with it.
The five tabs
Section titled “The five tabs”Navigation is a hash route with a real back stack, so every screen is addressable and a notification can send you straight to one. The app never switches tabs on its own.
The same wire protocol as the desktop, on a thumb. A turn is POST /api/chat, answered
202, and everything after it arrives as events which the phone reduces with the
workbench’s own reducer:
- Streaming replies, token by token, rendered as markdown with a caret.
- A collapsible Thinking row for a reasoning model’s thought stream.
- Tool and shell rows that expand to their full arguments and result.
- Permission cards inline in the transcript — the same card as the banner and the Inbox, all answering through the same endpoint, so whichever one you reach first retires the other two.
- A queued follow-up: the composer stays live during a turn, and a second message is enqueued rather than lost. Tap a queued chip to edit or withdraw it.
- Photos from the camera or library, resized on the phone before they are sent.
- A searchable conversations sheet, long-press to copy or send a message again.
- A model / agent picker using the same selection format as the desktop.
Turns from the phone always run in ask permission mode. That is deliberate and not
configurable here — “allow every tool for this turn” is not a switch to expose one-handed.
Workflows
Section titled “Workflows”Every workflow on the Mac, in the same folders as the desktop, with the same collapse state. The row leads with status, because the question you open this tab to answer is is the machine working? — a running workflow pulses and takes an accent edge rather than jumping to the top of the list.
A workflow opens as an ordered list of steps rather than a canvas: each step wears the status the executor reported, live or replayed from a finished run’s log through the same code path. A skipped step says “skipped (branch not taken)”. You also get its triggers and its run history.
▶ Run is always shown — see starting work below for when it is refused.
Dashboards
Section titled “Dashboards”Every dashboard you built on the Mac, drawn in one column, filling in as an agent collects. The list row leads with when it was generated, says “building…” while a run is in flight, and names how many widgets are broken before you open it. Charts reuse the workbench’s Chart.js renderer, loaded lazily so a phone opening the app to answer an approval never downloads it.
Read-only. Authoring writes a definition to disk and is deliberately unreachable from a phone.
The notification inbox, grouped by calendar day with Today and Yesterday named — because the question is usually “did something die overnight?” and named days answer it without arithmetic.
Pinned at the top is Needs you: every pending permission prompt and
review gate, merged into one list, with Allow / Deny
as two large buttons. The same thing appears as a banner in the app’s chrome from any
screen, and as a deep link (#/approvals/:id) that a push notification lands on.
Swipe a row to dismiss it, with five seconds of undo. A run failure opens to the full error tail read from the run’s own log, not the clipped text the notification carried.
- Runs — what is executing, with Stop. That is the only verb: resuming an interrupted run replays a plan and can rewrite files, which is not a one-handed decision. Rows show the workflow’s real name rather than its file id.
- Usage — today, 7 days or 30: runs, chats, tokens in and out, spend, tokens-by-day bars, and the top workflows, models and agents. Cost appears only when the daemon reports one, since an instance running local models spent hardware.
- Settings — which Mac and which relay you are paired to, the transport, connection status and last sync, theme (system / dark / light), alerts, the app version, and Unpair. No key material is shown; the mailbox is a public identifier for the pairing, the phone’s secret key is not.
Two ways to reach your Mac
Section titled “Two ways to reach your Mac”Every screen is transport-blind. The app picks one at pairing time and nothing above that layer knows which.
| Relay | Direct | |
|---|---|---|
| Where it works | anywhere | on the Mac’s own network (or its Tailscale address) |
| How | end-to-end sealed frames through a blind relay | fetch("/api/…") plus a WebSocket on /events |
| Credential | this phone’s own pinned X25519 key | one shared bearer token per home |
| Revocable per device | yes | no |
| Set up by | scanning the pairing QR | opening the /m/?token=… URL the daemon prints at boot |
Relay is the real one, and what “Add to Home Screen and scan” gives you. The daemon dials out to the relay, so there is no listening port, no NAT hole and no firewall exception. Both ends seal every payload to each other; the relay holds no keys.
Direct is a prototype path and says so: one secret for the whole fleet, no expiry, no
revocation, no record of which phone acted. It exists because it works on a LAN today. The
engine binds 127.0.0.1 by default, so reaching it from a phone means starting it with
LATCHAI_BIND=0.0.0.0 — behind Tailscale, never an open port.
Both transports stream. The phone subscribes to the daemon’s event feed once and renews the subscription; what is left of the request path is a catch-up on wake, on reconnect, and a slow heartbeat as a safety net.
The default relay, and running your own
Section titled “The default relay, and running your own”A fresh install points at wss://relay.latchai.app/relay the first time you turn Remote
Control on, and generates a random mailbox id for your daemon.
You can run your own instead. The relay is a single service in the LatchAI repo
(apps/relay) with a Dockerfile; the runtime image is node:22-alpine plus one bundled
.mjs, runs as a non-root user, and needs no writable filesystem. Its entire HTTP surface
is GET /healthz. Point a daemon at it by editing the relay block in
<latchHome>/latchai.config.json:
{ "relay": { "url": "wss://relay.example.com/relay", "mailbox": "a7f3c91e2b8d4506", "enabled": true, "remoteStart": false }}Put it behind a TLS terminator so phones dial wss://. Editing this file by hand needs a
daemon restart — the connector starts once at boot. Flipping the same switches from
Settings ▸ Remote Control does not.
mailbox is any opaque [A-Za-z0-9._-]{8,128} both ends agree on. Generate a random one
(openssl rand -hex 8): on a shared relay it is the only thing separating your mailbox from
anyone else’s. A malformed block is treated as off rather than as an error — a typo must
not be what decides whether your daemon opens a socket to the internet.
Alerts that reach a closed app
Section titled “Alerts that reach a closed app”The relay reaches a phone whose app is open. Web push is the other case: the app is not running, the phone is face-down, and an agent has just blocked.
Turning it on: install the app to the Home Screen, open it from there, pair it, then More ▸ Settings ▸ Notifications ▸ Turn alerts on and accept the system prompt. In a Safari tab the card says “Add LatchAI to your Home Screen to enable alerts” rather than offering a switch that cannot work.
What wakes you: a notification whose source is a gate (a permission prompt or a review gate), or whose severity is warn or error. A run that finished cleanly does not buzz anyone, and a work item changing lane is worth reading in the morning.
What is sent: a title, a kind, a deep link and an id — and nothing else. Never a notification body, never tool arguments, never a file path, never run output. The message is encrypted for your phone (RFC 8291), so the push service cannot read it either, but a lock screen shows whatever arrives; the interesting half stays one tap behind your device’s own unlock. The body line you see (“Waiting on your approval”, “A run failed”) is written by the app on the phone, not sent over the wire.
The id becomes the notification’s tag, so a re-sent alert for the same thing replaces the one on your lock screen instead of stacking. An approval is held for 10 minutes and sent at high urgency so it reaches a phone in low-power state; a failure is held for an hour at normal urgency.
Whose subscription it is: a phone’s, not the home’s. Un-pairing one device takes its alerts with it. Over the direct token path there is no per-device identity — one shared secret for the fleet — and the app records that honestly rather than inventing one.
The push sender identity (VAPID) is minted lazily, on the first phone that asks. A daemon nobody has enabled alerts on never writes one.
What a phone may ask your Mac to do
Section titled “What a phone may ask your Mac to do”Opening a payload is not the same as executing it. A decrypted request is bridged to exactly one loopback call on the daemon, and only if it is on an allowlist. Everything else is refused before a socket is opened.
| Allowed | |
|---|---|
| Chat | list, read, send, stop, permission mode, edit/withdraw the queued message |
| Inbox | read, mark read, dismiss one row |
| Gates and permissions | list and resolve |
| Runs | list, read one run’s events, stop |
| Approvals digest | GET /api/mobile/approvals — a merge of three lists already here |
| Web push | fetch the public VAPID key, subscribe, unsubscribe — this phone’s own alerting only |
| Workflows | the list only: names and groups for the run picker |
| Dashboards | list, one board’s definition, its rendered document — output, never source |
| Usage | the spend rollup and the per-run rows behind it |
| Pickers | the bare model and agent lists, read-only |
| Liveness | GET /api/status |
Refused, always: files, secrets, settings, the LatchAI home, MCP, the database area, webhooks, workflow authoring, resuming a half-finished run, dashboard authoring, and reading a dashboard’s source by reference. Remote Control itself is refused twice over — it is not on the allowlist, and the route additionally rejects any non-desktop caller, so you can only arm remote access from the machine itself.
/term is never on that list, under any circumstance. It is your own login shell with
your full environment. It is also unreachable by construction — it is a WebSocket whose
socket peer must be loopback — but the prohibition is written down in the source because the
temptation is obvious and the consequence is total.
Starting work is a second switch
Section titled “Starting work is a second switch”Two entries execute, with the whole tool registry behind them, so they are their own opt-in — off by default:
| Gated | |
|---|---|
POST /api/runs/:id |
run a workflow from the phone |
POST /api/dashboards/:id/run |
rebuild a dashboard from the phone |
That is the “Let paired phones start work (run a workflow, refresh a dashboard)” checkbox in Settings ▸ Remote Control. It is read live on every request, so turning it off takes effect on the next frame with no restart.
The asymmetry with Stop is deliberate and stays: stopping is a safety control and belongs to anyone who can reach the daemon; starting is an execution primitive.
When the switch is off the phone still shows the ▶ Run button — and shows you the refusal, which names the exact switch on the exact machine. A refusal you cannot act on is just an error message.
What the relay can see
Section titled “What the relay can see”Assume the relay is fully compromised and walk through what an attacker holds:
| They want | They get |
|---|---|
| Message contents | Ciphertext. Every payload is sealed with ChaCha20-Poly1305 under a key from an X25519 exchange between your phone and your daemon; the relay was never a party to it |
| To forge a message | Nothing either end will open — a tampered byte fails the tag, a frame lifted from another mailbox fails the AAD |
| To impersonate your Mac while pairing | Nothing — the daemon’s public key travels out of band, off your own screen |
| To reach your daemon’s API | No route. The daemon dials out; nothing listens. And it executes only what opens under a pinned key and matches the allowlist |
| Stored history | Nothing. No database, no queue, no spool. A frame for a peer that is not attached is dropped, not saved |
| An admin console | There isn’t one. The HTTP surface is GET /healthz |
| To break the link | Yes — it can drop, delay or refuse frames. That is the one attack the design survives rather than prevents: the daemon reconnects with jittered backoff, and the desktop never needed the relay at all |
| Traffic analysis | Yes — it sees which addresses attached to which mailbox, when, and how many bytes flowed |
What is deliberately not claimed: no forward secrecy, no padding or cover traffic, and no protection against a phone that is itself compromised.
Three files hold the device facts, under <latchHome>/relay/, mode 0600 inside a 0700
directory: keys.json (the daemon’s keypair and its pinned phones), vapid.json (the push
sender identity) and push.json (one record per subscribed phone). Losing them means
re-pairing and re-enabling alerts; nothing there is irreplaceable, and nothing there is safe
to hand out.
Offline, and cold opens
Section titled “Offline, and cold opens”The app used to open on a blocking “Reconnecting…” screen. It does not now: the last lists it saw are kept in IndexedDB and rendered instantly, and the pill in the top bar says whether you are looking at live data or how old the cached rows are.
- The cache is keyed to the pairing, so re-pairing to a different Mac can never show the previous one’s rows.
- Cached approvals are visible but inert. Their buttons stay disabled until a live sync confirms the question is still open, because answering a question that was already resolved is the one mistake this screen must never make.
- Unpairing wipes it.
- The service worker caches the app shell and its content-hashed assets so the app opens
out of signal with an honest disconnected state instead of a browser error page. Every
/apiand/eventsrequest goes straight to the network, always.
Since 0.9.3, a cold open shows a loading state rather than asserting “No chats” and “No runs” while the first round trips are still in flight.
Known limitations
Section titled “Known limitations”- No workflow editing, and none planned. A canvas needs pan, zoom and precise dragging; the phone shows an ordered step list with live per-node status instead.
- Dashboards are read-only. The phone reads boards and rebuilds them; it does not write them.
- Mermaid diagrams in dashboards render as source, with a note. Drawing them would mean shipping a library several times the size of the rest of the bundle into an app whose reason for existing is answering an approval on a cellular link.
- Work items have no phone screen. A work-item notification names the key and the project as text.
- No voice. The composer reserves the mic slot, disabled and labelled, so the geometry does not move when speech-to-text lands.
- Photos are re-encoded on the phone down a quality ladder to fit the request body cap, so a large photograph ships smaller than it was taken.
- The direct-mode token is a stub, not a security design: one bearer secret per home, no expiry, no revocation, no per-device audit.
- Pairing is alpha (“R1-lite”) — the one-time secret travels inside a copyable string, so it is only as strong as the channel you copy it over. Scan it off your own screen. Paired phones do not expire yet; unpair one to revoke it. The phone’s own secret key sits in browser storage in the clear.
Troubleshooting
Section titled “Troubleshooting”“Not available from the phone.” That is the allowlist, not a broken connection. The screen asked for something a phone may not ask for. Retrying will not change it.
“Your Mac is busy” / “Your Mac couldn’t complete this.” Both are retryable — the first is too many requests in flight over the bridge, the second is a loopback call that failed on the Mac.
A relay blip does not un-pair you. Only the daemon saying it does not know this phone clears the session — the app shows “This phone was un-paired from your Mac” when that happens. Anything else is a connection to wait out, and the app keeps its cached rows while it does.
“Mac not reachable” with full bars. The app tracks its own socket and your daemon’s presence as two separate facts, because the common failure is exactly this one: the phone is fine and the laptop is asleep. Wake the Mac.
A 401 in direct mode means the token is wrong, or the daemon’s home moved. Re-open the
/m/?token=… URL from the daemon’s boot log.
Alerts do not appear on iPhone. Almost always one of: the app is being opened from a Safari tab instead of the Home Screen; the alerts switch was never turned on; or the system prompt was declined. The Notifications card in Settings names which of those it is.
A stale screen right after an update is usually the old service worker serving its cached shell until it revalidates. Close the app and reopen it.
The phone shows nothing at all after enabling Remote Control. Check the state row in Settings ▸ Remote Control: On · Connecting… means the daemon has not reached the relay yet. Off means it is not trying.