This post is about getting two AI coding agents to talk to each other across separate machines using herdr, and figuring out exactly what that setup takes. Short answer up front: it works, and it takes nothing beyond SSH you probably already have configured, no server, no API key, no new port to open. The rest of this walks through how I found that out, starting from the trivial same-machine case and working up to full duplex between two Macs on my network.
herdr itself is a terminal workspace manager built for AI coding agents. Point it at a pane, and it knows whether there’s a Claude, Codex, or whatever else sitting in there, what state it’s in, and it’ll let you drive it. The part that got me curious wasn’t managing one agent though, it was how easy it turned out to be to get two agents talking to each other, which is the actual subject here.
The name itself is a dropped-vowel spin on “herder,” and the project leans into it on purpose: the homepage calls a session full of agents “the herd,” with a mock terminal literally labeled herd-mock. Fitting, since the whole pitch is keeping track of a pile of terminals with semi-autonomous agents in them at once, same job a herder has, just with panes instead of livestock.
Two agents, one machine
Before crossing machines at all, it’s worth seeing how little there is to the same-machine case, because that’s the baseline everything else has to match. herdr already tracks every pane it manages under a workspace:pane address (w2:p1 is workspace 2, pane 1), and that addressing is exposed straight through the CLI, so one running agent can hand text to another with a single command, no setup beyond both of them already sitting in herdr-managed panes:
herdr agent prompt w2:p1 'do the thing'Bashagent prompt drops that text into the target pane as if you’d typed and hit enter. No server, no API key, nothing to configure. Under the hood it’s a Unix domain socket at ~/.config/herdr/herdr.sock, and the CLI is just a thin client speaking JSON over it. herdr agent read pulls the transcript back out the same way. Send and receive, both just local socket calls.
None of this needs a human typing herdr agent prompt by hand, either. It’s just a shell command, so if the agent you’re already talking to has terminal access, you can tell it “let the other agent know X” and it’ll run the herdr call itself, read the reply back, and hand you the result. The messaging in this whole post, the local case and the cross-machine parts later, was done exactly that way: I asked, the agent ran the commands.
herdr’s own docs back that up directly rather than leaving it as something you have to improvise: the automation guide describes exactly this, “one agent can create work for other agents, inspect their state, and collect their results,” and there’s a real installable skill for it, not just a paragraph you paste into a system prompt:
npx skills add herdrdev/herdr --skill herdr -gBashInstall that and any supported agent already knows how to split a pane, start a helper agent, prompt it, wait for it to settle, and read the result back, the exact primitives this whole post is built on.
That part I expected. What I actually wanted to know: does any of this survive once the two agents are on different machines?
Does herdr have its own network layer?
First thing I checked was whether herdr has some kind of built-in remote protocol, maybe an HTTP API I’d missed. It doesn’t. I dumped the bundled API schema (herdr api schema --json) and grepped for anything network-shaped. The only hits for “http” in the entire schema were $schema metadata pointers to json-schema.org. No REST endpoints, no websockets, no listening TCP port anywhere in the config.
The one cross-machine feature herdr does ship looks like this:
herdr --remote remote_machineBashand it turned out to just be SSH with extra steps. Run that and herdr shells out to ssh remote_machine, launches (or finds) the herdr server already running there, and attaches your local terminal to that remote session, same sidebar, same panes, same agents, just physically running somewhere else. It manages your ~/.ssh/config for keepalives so the attach survives a flaky connection, and there’s a config block ([remote] manage_ssh_config) that confirms it outright. herdr has zero network surface of its own. Every cross-machine byte rides on a transport you already had.
Reaching a real agent on another machine
Here’s the reasoning that made the rest of this click: herdr’s socket is just a file sitting on whichever machine the agent happens to be running on. --remote gets you there by attaching a whole terminal session over SSH, but that’s not the only way to use SSH. If I can already run arbitrary commands on a machine over SSH, and herdr’s own CLI is just one such command, then I don’t need herdr to know anything about the network at all, I just need to ask SSH to run herdr over there instead of running it myself. The remote part is entirely SSH’s problem. herdr just sees a normal local call, same as if someone had typed it at that machine’s own keyboard.
To try this for real, I set up a second Mac (I’ll call it wangxm-blog-remote) on the same network, already had SSH access to it, and had a Claude Code agent already running there in a folder called ~/Desktop/Agent_Test. First step, find that agent’s address, the same way I would if I were sitting at that machine:
ssh wangxm-blog-remote '~/.local/bin/herdr agent list'BashThat listed wangxm-blog-remote’s actual panes, including the one I was after: w4:p1, idle, cwd ~/Desktop/Agent_Test. Note the full path to the herdr binary, a plain ssh host 'command' doesn’t load the interactive shell’s PATH, so the short name alone won’t resolve. With the address in hand, sending it a message and reading the answer back is just two more of the same:
ssh wangxm-blog-remote '~/.local/bin/herdr agent prompt w4:p1 "reply with hostname and cwd" --wait --timeout 60000'
ssh wangxm-blog-remote '~/.local/bin/herdr agent read w4:p1 --source recent-unwrapped --lines 30'Bashagent prompt sends the text and, with --wait, blocks until the agent settles back down, so the call returning at all already tells you it landed. Then agent read pulls the actual transcript, and this is where it stopped being theoretical: the reply came back computed by a real agent process on a machine I wasn’t sitting at, not anything mirrored or faked locally.
wangxm-blog-local.local
~/Desktop/Agent_TestPlaintextThat’s the whole recipe, three SSH calls, no herdr-specific plumbing beyond addressing a pane correctly.
Making it two-way without opening a port
One SSH hop got me talking to wangxm-blog-remote. The next question was whether wangxm-blog-remote could talk back, given that my own machine doesn’t have Remote Login turned on. No SSH server, no way for anything to dial in.
Turns out that doesn’t matter. An SSH connection is a duplex pipe once it’s up, and it doesn’t care which side dialed. ssh -R asks the far end to open a listening socket that tunnels back through the same connection you initiated.
Most people only ever see -L/-R used for TCP: -L local_port:remote_addr:remote_port opens a port on your machine that forwards to some address reachable from the far end, -R remote_port:local_addr:local_port does the mirror image, opening a port on the far end that forwards back to something reachable from yours. Fewer people reach for the other form OpenSSH supports: give it a filesystem path instead of a port on either end, and it’ll forward a Unix domain socket instead. -L local_socket:remote_socket makes a socket file appear on your machine that connects straight through to a socket on the far end; -R remote_socket:local_socket does the mirror image, a socket file appears on the far end that connects back to one of yours. No TCP anywhere in that path, no port to pick or clash with, just two socket files bridged by the SSH connection between them. That’s the exact shape herdr’s socket needs.
So the plan: one ssh process from my machine, carrying both a -L forward (a local socket path that resolves to wangxm-blog-remote’s real herdr socket) and a -R forward (a socket path on wangxm-blog-remote that resolves back to mine), at the same time:
ssh -N -f \
-L /tmp/herdr-to-wangxm-blog-remote.sock:/Users/wangxm/.config/herdr/herdr.sock \
-R /tmp/herdr-to-local-from-wangxm-blog-remote.sock:/Users/wangxm-blog-local/.config/herdr/herdr.sock \
wangxm-blog-remoteBashBoth socket paths there are spelled out in full on purpose, not ~/.config/herdr/herdr.sock. I tried the tilde first and it quietly didn’t work: a ~ only expands when it’s the very first character of a shell word, not when it’s sitting after a colon in the middle of one, so the shell passes it through to ssh completely literally, tilde and all. Swapping in $HOME doesn’t save you either, since that gets expanded locally, on your machine, before the argument ever leaves for the remote side, so you’d end up asking the remote machine to open a path that only makes sense on yours. The actual absolute path on whichever machine owns that half of the forward is the only thing that reliably works.
wangxm-blog-remote there isn’t a hostname, it’s an alias, defined once in ~/.ssh/config so I don’t have to remember a user, an IP, and a key path every time:
Host wangxm-blog-remote
HostName wangxm-blog-remote.local
User wangxm
Port 22
LocalForward /tmp/herdr-to-wangxm-blog-remote.sock /Users/wangxm/.config/herdr/herdr.sock
RemoteForward /tmp/herdr-to-local-from-wangxm-blog-remote.sock /Users/wangxm-blog-local/.config/herdr/herdr.sockSSH ConfigLocalForward/RemoteForward are just -L/-R written as config instead of flags, same socket-path arguments, space-separated instead of colon-joined. With those baked in, the whole tunnel collapses to ssh -N -f wangxm-blog-remote, no -L/-R on the command line at all. Either way you invoke it, explicit flags or config, you end up with the same two socket files, which is what the rest of this relies on.
For this to work at all, herdr’s CLI needs to accept a socket path other than its default. Poking through the binary with strings, I found it reads HERDR_SOCKET_PATH as an environment override, and it turns out that’s real, documented behavior: herdr resolves which socket to use in order of --session <name>, then HERDR_SOCKET_PATH, then HERDR_SESSION=<name>, then the plain default. The docs are upfront that it’s meant as a low-level escape hatch rather than something to build on, which tracks with what I was doing to it:
# assumes the ssh -N -f tunnel above is already up
HERDR_SOCKET_PATH=/tmp/herdr-to-wangxm-blog-remote.sock herdr agent listBashran with no SSH wrapper at all and returned wangxm-blog-remote’s panes, straight through the forwarded socket. And the reverse:
ssh wangxm-blog-remote 'HERDR_SOCKET_PATH=/tmp/herdr-to-local-from-wangxm-blog-remote.sock herdr agent list'Bashrun from inside an SSH session on wangxm-blog-remote, came back with my machine’s own agent list. Same tunnel, opposite direction, no inbound SSH anywhere.
The real test was sending an actual prompt that way, from wangxm-blog-remote into an agent running on my machine:
ssh wangxm-blog-remote 'HERDR_SOCKET_PATH=/tmp/herdr-to-local-from-wangxm-blog-remote.sock herdr agent prompt w7:p1 "..." --wait --timeout 60000'BashThat message landed directly in the agent session on my machine, mid-conversation, exactly like it had arrived over a normal SSH login I never opened. I ran it a second time to make sure it wasn’t a fluke; both directions replied cleanly again.
One quirk worth flagging: if the target agent is busy when a message arrives, the sender’s --wait can report a timeout even though delivery actually succeeded. Not a lost message, just herdr’s lifecycle tracking not catching a state change in time. Easy to misread as a failure if you’re not watching the receiving side too.
Surviving a reboot
The whole point of this was reaching agents on machines I’m not sitting at, so the obvious next question is what happens when one of those machines actually reboots. herdr already has an answer, in two layers.
Layout comes back on its own. A machine reboot forces a full herdr server restart, and after that herdr restores the saved shape, workspaces, tabs, panes, working directories, focus, from a snapshot, even though the original processes are long gone. Separately, “native agent session restore” tries to bring the actual conversation back too, not just an empty shell sitting in the same directory. For agent kinds with an official herdr integration that reports a session reference, Claude Code, Codex, Cursor, Copilot, and a dozen others, herdr relaunches that agent using its own native resume flag (claude --resume <id>, codex resume <id>, and so on), so the conversation picks back up rather than starting over. This is on by default; [session] resume_agents_on_restore = false turns it off.
What doesn’t come back on its own is the piece I actually built: the SSH tunnel. That’s a plain background process outside herdr’s bookkeeping entirely, so a reboot on either end kills it, and nothing restores it automatically. herdr getting its own house back in order doesn’t extend to a process it never knew existed, you’d need your own launchd/systemd unit or similar to bring that part back.
Where this actually stands
The single-hop, SSH-to-a-remote-socket part is solid and something I’d reach for again. The bidirectional tunnel is more of a hand-built rig than a herdr feature: it leans on a low-level override (HERDR_SOCKET_PATH) that herdr’s own docs say is there for edge cases, not for wiring two machines together, it’s held up by one background ssh process that takes both directions down if it dies, and there’s no authentication layer beyond whatever trust the SSH connection already implies. Anyone who can reach the socket path can drive the agent, full stop, no scoping to “just messages.”
Still, the core claim holds. herdr doesn’t build a network of its own, it just happens to expose agents through a plain Unix socket, and a plain Unix socket composes with whatever transport you already trust. Point two of them at each other over one SSH connection and you get real duplex agent-to-agent messaging, no server to run, no port to expose, nothing new to secure.
Leave a Reply