One principle, three processes.
The agent connects out. Everything else in the architecture follows from that one decision, including the parts that look unusual.
Where each connection goes.
Read the arrows. Every one that touches a developer machine points away from it.
The web app
:50051UI and REST API. Holds sessions, database writes and authorization. It never talks to an agent directly — every daemon call goes through one module, over localhost, behind an internal token.
The WebSocket server
:50052The only process that holds agent sockets. 1753 lines in one file, bound to 127.0.0.1 by default because its relay endpoints are remote command execution if they are reachable.
The agent
your machineA CLI daemon with 19 commands, installed as a launchd or systemd service with a cron watchdog. It dials out and holds the socket open.
Why nothing dials in.
Every remote-management tool has to solve the same problem: your server needs to reach a machine that is behind NAT, on a corporate network, or on hotel Wi-Fi. Most solve it by asking you to open something — a forwarded port, a VPN, a firewall rule, an inbound tunnel.
Dialout inverts it. The agent opens the connection and keeps it, so the machine needs no inbound reachability at all. A laptop that moves between four networks a day stays online the whole time, and the security surface on it is a single outbound socket rather than a listening port.
The cost is that the server has to hold every socket, which is why that is one dedicated process rather than something the web app does on the side.
Detach, don’t kill.
Every terminal is a tmux session, so the shell outlives the browser tab. When a browser socket drops, the server keeps the PTY for ten minutes so a reconnect resumes exactly where you were. Past that it detaches the tmux client and leaves the session running — only closing the terminal deliberately ends it.
Session names are deterministic, so reopening a tab attaches to the session that is already there instead of starting a second one. A tab’s startup command is deliberately not replayed on that path: it would type a command into the session you just rejoined.
setup-cowork writes a guarded block into your shell rc so a terminal you open normally joins the same tmux. Everything interpolated into that block is filtered first — it is a file the tool writes into your shell startup, and it is treated that way.
Read the transcript, not the screen.
Claude Code, Codex and Grok each already write a structured JSONL transcript of the session. The agent finds the right file, tails it, and normalises all three into one event type — so the chat surface does not know or care which CLI produced a message.
The alternative would be scraping the terminal UI, which breaks on every upstream release. Finding the file is the hard part instead: each vendor escapes the working directory differently, and a session’s directory can change mid-session, so a transcript is matched against any directory its header mentions rather than just the first.
A proxy is not enough.
Serving a local app under a path prefix breaks it. The app emits absolute paths — /_next/…, /api/… — that resolve to the wrong place, so the page loads its shell and then stalls.
So the tunnel rewrites the response. Absolute paths are rewritten in HTML, CSS and JavaScript, and an injected script patches fetch, XMLHttpRequest, history.pushState, anchor clicks and the Navigation API at runtime. Redirect Location headers are rewritten too, and content-encoding is stripped because the body was decoded to rewrite it.
Bodies are capped at 10 MB. Machine-offline and server-not-running both return a styled page rather than a proxy error, because the reader is usually a client who does not know what either of those means.