The app
A single HTML page plus a service worker. Install it to the Home Screen. Pairing stores an HttpOnly cookie; after that the URL looks ordinary.
Remote Agents is a self-hosted window onto the Codex, Claude Code, and Grok sessions already on your machine. Phone, laptop, and native CLIs stay aligned because they are looking at the same files — not because a cloud copies the chat.
The agents you already use live on one host: a Mac that stays awake, with Codex, Claude, or Grok signed in. This app does not replace those CLIs. It sits next to them and lets any paired browser drive the same threads.
Three facts explain the whole design:
Sessions already live on disk. Each CLI writes threads in its own directory. Resume on the laptop works because those files are the conversation.
CLIs speak machine protocols, not HTTP. JSON-RPC over stdio, stream-json, ACP. A phone cannot talk to them directly.
The bridge is the translator. One Node process on the host turns taps into CLI calls, and CLI output into a live browser feed.
Every remote device goes through HTTPS to the bridge. Native apps on the host skip the bridge and read the session files themselves. That split is why continuing on the phone and then opening the same thread in Codex Desktop feels like one session.
Browsers talk to the bridge. Native tools talk to the files. The files are the conversation.
A single HTML page plus a service worker. Install it to the Home Screen. Pairing stores an HttpOnly cookie; after that the URL looks ordinary.
HTTP for actions you take (list, send, stop). Server-Sent Events for everything the agent does. One stream is broadcast to every open client.
Each CLI is different. Adapters implement one contract — list, read, send, interrupt, models — and emit one event shape, so the UI does not care which provider you picked.
A passkey-gated project terminal. Web Push when a bridge-driven turn finishes. Durable “continue after usage resets” if you turn that on.
The picture above is the shape. These are the invariants. A new provider, a second phone, or a crashed send all have to fit them.
The UI does not speak Codex, Claude, or Grok. It speaks a small shared model. Each adapter translates its CLI into that model and back.
| Adapters do | Adapters emit |
|---|---|
| list, read, send, interrupt, models — plus queue where the CLI has a native one. | turns and items — user/agent messages, reasoning, commands, file changes, approvals — as they happen. |
History and the live stream use the same item shapes. Adding a provider means filling that contract, not teaching the UI a new dialect.
A live turn has a single owner. The UI follows that owner; it does not invent a second one.
| Who holds the turn | What a paired browser may do |
|---|---|
| This bridge — it spawned the CLI process. | Steer or stop, using whatever that CLI actually supports. |
| Something else — Codex Desktop, a terminal, another CLI. | Queue or wait. Never kill that process to take the thread. |
Every send carries a client request id that the bridge records. A retry of the same id cannot post twice. If the bridge dies after the CLI may already have the prompt, that send is uncertain — shown to the owner, never auto-replayed. Double-sending is worse than asking.
List, send, and stop are ordinary requests. Tokens, commands, and approvals are one Server-Sent Event stream, copied to every open browser.
Not WebSocket. Two directions, two shapes; a socket would mix them and still need reconnect/auth.Turns this bridge did not start have no event stream. A watcher polls the session files of threads someone actually has open, then clients refetch.
Not tail-every-file. That would be expensive and still a guess compared to the bridge’s own stream.The pairing token is who may drive agents. Funnel, Serve, or Cloudflare Access is only how a browser reaches the host.
Not one login for everything. A shareable doc and a shell on your Mac are not the same permission.Passkey-gated, off by default. Interactive PTYs run in a worker so a stuck shell cannot take down the bridge or a live agent turn.
Not a sandbox. The project folder is a starting directory. The OS user is the real boundary.The phone never calls OpenAI, Anthropic, or xAI. The bridge spawns the same CLIs you already use, with the same login on the host. If a provider is missing or signed out, it simply does not appear in the picker.
Long-lived JSON-RPC over stdin/stdout — the same engine the VS Code extension uses. One holder process per thread; native steer during a live turn. Sessions in ~/.codex/sessions.
A process for the active turn. The adapter translates Claude’s envelope into the same items Codex already emits. Approvals come through a PreToolUse hook. Sessions in ~/.claude/projects.
A persistent Agent Client Protocol process per open thread. ACP has no live-injection method, so a follow-up during a run is interrupt, then send. Sessions in ~/.grok/sessions.
The UI shows each provider’s real surface. Codex can steer a turn the bridge owns. Claude can inject a follow-up into its live process. Grok cannot. A turn owned by Codex Desktop or another CLI is never force-taken; the phone queues or waits instead of killing that process.
Devices stay in lockstep because they share the host, not because chats are replicated. Open a thread on the phone, send, lock the phone, sit down at the laptop, resume the same thread in the native app: you are reading the file the CLI just wrote.
Codex Desktop, codex resume, Claude Code, Grok. They read and write session files directly. The bridge is not in the path.
History loads over HTTP. Live work arrives on one SSE stream that the bridge broadcasts to every connected client — phone and laptop at once.
While a turn is running, there are two live paths. Which one you get depends on who started the turn.
The watcher is cheap on purpose: it follows what is on screen, not every session on the machine.
Send from the PWA. The CLI writes the session. Later, the native app on the Mac opens the same thread. There was only ever one conversation.
SSE fan-out. List order, running badges, and the open transcript update on every connected browser at the same time.
You keep working in Codex Desktop. The phone, with that thread open, refetches as the file moves. It will not steal the writer.
Opening a thread re-reads the file. A send carries a client request id so a retry cannot post twice. If the bridge died mid-send, that request stays uncertain until you decide.
Chat pairing is enough to drive an agent. A project terminal is more: it is your user on that machine. So it is off by default, and the pairing cookie is not enough to open it.
On the host you run remote-agents terminal enable, scan a five-minute QR (or type the code), and create a passkey. Each browser enrolls separately. Opening a terminal later needs a fresh assertion. Administration stays on the machine — a loopback control endpoint, not the web app.
The shell starts in the thread’s project folder. That folder is a starting directory, not a sandbox. Interactive sessions run in a forked worker so a stuck PTY cannot take down the bridge or the agents. The page is xterm.js talking WebSocket to that worker.
A long random token in the pairing URL becomes an HttpOnly cookie. Every app and API response needs it. Unauthenticated replies are generic: no thread names, paths, or version. Funnel is internet-reachable, so treat the pairing link like a password. Serve keeps it on your tailnet; Cloudflare adds an identity policy on your own hostname.
server.mjsHTTP, auth, SSE fan-out, routingproviders/Codex, Claude, and Grok adapterssend-ledger.mjsNamed sends; uncertain vs retryablewatch.mjsFollow a thread someone else is drivingpublic/index.htmlThe PWApublic/terminal.*Passkey-gated project terminalbin/codex-phone.mjsSetup, service, Tailscale, pairing QR