One Desktop, Two Brains: Unifying Milo and Echo in a Single Hermes Window
The wrong mental model (that we almost built)
The obvious-sounding plan was "install Hermes Desktop on forge too." That's backwards. Hermes Desktop is an Electron client; the thing worth connecting to is the dashboard server (hermes dashboard, port 9119) that every Hermes runtime can serve. Echo already is a full Hermes install on the MS-01 — Telegram gateway, API server, sessions, memory. It just wasn't serving a dashboard listener yet. Installing a GUI on a headless Linux box to look at a backend running on itself buys nothing.
The second wrong model was "just point Desktop's Remote URL at forge" — which works, but moves the entire app to the remote backend. Milo would go dark while Echo was up. Fine for a laptop that only ever remotes into one machine; wrong for a desktop that lives on the machine one of the agents calls home.
The right one: per-profile remote connections
Reading the Desktop source settled it. Connection resolution in apps/desktop/electron/main.ts has a documented precedence: per-profile override → environment → global remote. The connection.json that Desktop keeps has a profiles map, and each entry can independently be local, remote, SSH, or cloud. Better still, the main process keeps a pool of concurrent per-profile backends — background profiles keep streaming while another profile's socket is in the foreground. This isn't a hack; it's the designed shape.
So the recipe is:
# 1. A local placeholder profile — just a routing label, stays empty
hermes profile create echo --no-alias --no-skills
# 2. Per-profile override in Desktop's connection.json
"profiles": {
"echo": {
"mode": "remote",
"url": "http://100.71.206.45:9119",
"authMode": "oauth"
}
}
Notes that saved time:
authMode: "oauth"is correct even for a basic-auth dashboard. Desktop uses that mode for cookie auth plus WebSocket tickets; the bundled username/password provider rides the same path. You sign in once, the cookie persists.- The local profile name is just a label. A per-profile remote routes to whatever profile the remote dashboard serves. Identity is decided by forge's
HERMES_HOME, which is Echo's. Nothing about Echo lives on the M4. - Sessions and memory stay remote. The echo profile's session list reads from forge's
state.dbover the wire. Milo's stores never see Echo's data. The identity boundary is structural, not disciplinary. - Use the Tailscale IP in the URL, not the LAN IP. Same URL works at home (direct path, ~1ms) and away, and it sidesteps macOS Local Network permission entirely — LAN addresses from an Electron app need that grant, tailnet addresses don't.
Forge's side: fifteen minutes
Echo's runtime needed exactly two things: a dashboard.basic_auth block (since mid-2026, Hermes refuses to bind a dashboard on a non-loopback address without a real auth provider — the old --insecure escape hatch is gone, correctly), and a persistent hermes dashboard --host 0.0.0.0 --port 9119 service. Credentials went straight into Vaultwarden. After that, /api/status answered with overall: ok, gateway running, both platform adapters connected.
The gotcha: ping lies, ACLs don't
First connection attempt from the M4 over Tailscale: dead. But tailscale ping to forge answered in 1ms. This is the trap we've now hit twice on this tailnet, so it goes in writing: tailscale ping succeeding proves the tunnel, not the policy. Disco pings ride outside the packet filter; TCP doesn't.
The evidence pattern, for future-us:
| Check | Result | Meaning |
|---|---|---|
tailscale ping forge | pong, 1ms, direct | tunnel healthy |
nc -vz <ts-ip> 9119 | timeout | TCP dropped somewhere |
| UFW on forge | 9119 allowed from 100.64.0.0/10 | host firewall innocent |
tailscale debug netmap on forge | PacketFilter rules: 0 | tailnet ACL is the wall |
Our tailnet policy is default-deny with explicit per-node accepts, and forge simply had no rule. One ACL entry later — admin devices → forge's dashboard, API, and SSH ports, pushed via the Tailscale API — all three ports opened instantly. The host firewall had been ready the whole time; the packets just never arrived.
What it feels like now
One window. The profile picker lists Milo and Echo side by side. Flip to Echo and the session list, memory, and personality are forge's; flip back and Milo is exactly where he was — a live profile swap is a re-home, not a reboot, and background profiles keep streaming. Two agents, two machines, two memory stores, zero blending, one pane of glass.
state.db, whose HERMES_HOME), not by remembering which app to open.Cheat sheet
| Piece | Where | Detail |
|---|---|---|
| Hermes Desktop | M4 Max | one Electron app, multiple profiles |
| Milo runtime | M4 Max (local) | default + milo profiles, local backend |
| Echo runtime | MS-01 (forge) | full Hermes install; serves dashboard :9119 |
| Connection | connection.json profiles.echo | mode: remote, Tailscale URL, authMode: oauth |
| Auth | dashboard basic auth | creds in Vaultwarden; cookie persists in Desktop |
| Network | Tailscale | default-deny ACL + explicit accept for forge's ports |
| Rollback | two commands | delete the profiles.echo block; delete the placeholder profile |