I run coding agents on machines I own — a laptop, a $5 VPS, a box in the office. None of them have a public IP, two are behind NAT I don't control, and one is behind a corporate firewall that will never open an inbound port for me. I still want to open a browser tab anywhere and see what all of them are doing.
This is the architecture I ended up with, including the parts that were wrong the first time.
The constraint that decides everything
There are three usual ways to reach a machine you don't control the network of:
- Mesh VPN (Tailscale et al.) — excellent, but it's another daemon on every box, another identity system, and it doesn't help the teammate who just wants to click a link on their phone.
- Inbound tunnel (SSH -R, ngrok) — works, but the hostname churns, and now the security question is "what else on that box is reachable?"
- Move the work to a cloud VM you do control — which is what most agent products do, and it means your code and secrets live on someone else's hardware.
I wanted a fourth: the machine dials out, and everything rides that one connection. Outbound HTTPS is the one thing that works from every network on earth without asking anyone's permission.
So: cordane worker opens GET /api/v1/workers/connect with a bearer token, upgrades to a WebSocket, and that's the entire network surface. No listening socket, no port forward, no VPN, no SSH config. The control plane never dials the worker — it can't, and that's the point.
One connection, many streams
A single WebSocket gives you one bidirectional byte stream. I need N interactive terminals, M proxied HTTP connections, and a control channel, all at once, and I don't want N+M TCP connections punching through NAT with N+M keepalive timers.
So the WebSocket is adapted to a net.Conn and a yamux session runs over it. Multiplexing solved, ~200 lines of adapter.
The first stream — opened by the worker — is the control channel and carries no header. Every subsequent stream is opened by the server and begins with a one-line JSON header declaring what it is:
{"kind":"pty","session_id":"…"} // raw bidirectional PTY bytes
{"kind":"proxy","app_id":"…"} // raw TCP pipe to a worker-local port
After that header the stream is dumb bytes in both directions. A terminal is io.Copy in each direction between an xterm.js WebSocket and a yamux stream. A proxied dev server is io.Copy between an inbound HTTP connection and a yamux stream that lands on 127.0.0.1:$PORT on the worker. WebSockets and SSE inside proxied apps work because nothing in the middle tries to understand HTTP.
The control channel is newline-delimited JSON: requests carry an id, responses echo it, events have none. Boring on purpose — it's the part I'd have to debug at 2am from a log file.
State lives on the worker, not the control plane
The most consequential decision: PTYs, worktrees, and port reservations live on the worker. The control plane is a relay and a coordinator, not a terminal emulator.
The payoff is that I can deploy the control plane in the middle of the workday and nobody's terminal dies. The worker's tunnel drops, it reconnects, sessions are still there. On attach, the worker replays a scrollback ring — 2 MiB per session, in memory, dropping the older half on overflow to amortize copies — then switches to live output.
The honest flip side, since somebody will ask: restarting the worker does kill its live PTYs. Layout, working directories and commands are saved as a "space" definition and respawn (tmux-resurrect semantics — process state doesn't come back, and anyone who claims otherwise is lying to you). Scrollback is memory only; it's gone.
Two peers, two owners, one protocol
Here's a problem you don't have when you control both ends: I ship the control plane, but the worker binary runs on someone else's machine and gets upgraded when they feel like it. Version skew isn't an edge case, it's the steady state.
The handshake handles it. The worker's first control message is a hello with an integer protocol version, a build string, and a capability list. The hub answers with a server.hello event — deliberately carrying no id, so a worker too old to know about it ignores it as an unknown event instead of choking — containing its own protocol version, the oldest worker protocol it will serve, the latest worker build it recommends, and its own capabilities.
Two rules fell out of this and both earned their keep:
- Refuse loudly below the floor. The old behavior was tolerate-and-log,
which turned into mysterious half-broken sessions. Now the hub sends a reason and drops the tunnel.
- Gate optional features on the intersection of both capability sets. New
functionality ships without a protocol bump, and old workers keep working because the hub simply never sends them the new message shape.
Multi-process apps, multi-repo workspaces and branch rebasing all shipped this way. Where a new field would change the meaning of an old one, the legacy field is sent deliberately empty so an old worker fails loudly instead of silently doing half the job.
What actually breaks when you run ten agents on one box
The transport was the interesting engineering. The bugs were all resource collisions, because the whole point is parallelism: ten tickets, ten git worktrees, ten dev servers, on one machine.
Ports. Each ticket gets its own allocated ports and its own preview URL. Allocation lives in a worker-side file keyed opaquely (worktree path for a ticket, space id otherwise), so it survives worker restarts — and a stopped ticket keeps its reservation until teardown. That last bit fixes the failure mode that scales with exactly the parallelism you bought the thing for: stop ticket A, start ticket B on the freed port, resume A, watch both fight over
- The control plane also sends an exclusion list from its own view as
belt-and-braces, but the worker's file is the authority — it's the only thing that knows what's actually running.
Worktrees. Serialized per repo behind a mutex, and — this was a real bug — handled in goroutines rather than inline on the control read loop. Doing a git fetch on the read loop stalls every terminal on that worker for the duration. Obvious in hindsight; invisible until someone with a slow network opened a second tab.
Caches. One cache directory per project per worker, stable across worktrees and surviving cleanup, exported into hooks. Otherwise ten worktrees means ten npm ci cold downloads of the same tree, which is both slow and rude.
Two war stories
The agents that ran fine by hand and hung forever headless. Runs launched from the worker were stopping dead. ps showed state T — stopped, not blocked. The worker had been started from an interactive shell, so it inherited that terminal as its controlling terminal; a background-process-group child writing to it takes SIGTTOU and gets suspended by the kernel. Nothing in the logs, because the process never got to log anything. The fix is one line — Setsid on the child so it's not in that terminal's session at all — and the lesson is that "works when I run it by hand" and "works under a service manager" are different claims.
The dialog that ate the prompt. Coding-agent CLIs ask for confirmation before operating in a folder they haven't seen. Every ticket gets a fresh worktree, so every ticket is an unseen folder — the agent came up sitting on a trust prompt, the prompt got pasted into that dialog, and the run hung with no error. Notably, --dangerously-skip-permissions does not skip it. The worker now pre-marks exactly the directories it launches an agent in, in the owner's own agent config — never $HOME, never a shell pane, and it won't fabricate a config file for a tool that has never run on that box.
The security posture, stated plainly
A worker grants its owner a shell on that machine, so a PTY is remote code execution by design. Pretending otherwise would be the dishonest framing. What follows from that:
- Worker tokens are high-entropy, stored hashed server-side,
0600on the
worker; deleting a worker drops its tunnel immediately.
- Proxied apps get their own origin (
{app}--{worker}.{domain}) with a
host-scoped cookie minted by an HMAC handshake, so app content can't touch the control-plane origin. Path-based proxying shares that origin and is therefore documented as trusted-apps-only.
- Public share links are signed tokens, not stored secrets — re-derivable for
the copy button, revoked by bumping a generation counter, and re-checked on every proxied request so revoking closes tabs that are already open rather than just blocking new ones.
- Environment variables are encrypted at rest with a key you back up separately
from the database.
The threat I actually lose sleep over isn't the tunnel — it's a phished control- plane account, because that account reaches every worker its owner has. That one is a work in progress.
What it costs
Keystrokes take two network legs (browser → hub → worker) and there's no mosh-style predictive echo, so on a bad link you feel it. One connection per worker means a slow read loop is a shared resource, which is why the goroutine discipline above is a rule and not a preference. And an outbound-only design means the control plane can never reach in to fix a wedged worker — it can only wait for it to come back.
I'd make all the same calls again.
This is the architecture behind Cordane — a self-hostable hub for your team’s coding agents, free forever on the Community tier. Questions about any of it: GitHub Discussions or contact@cordane.ai. See it running: the terminal that survives a hub restart, a worker joining in one command.