Wiring the AI Agents on My Scattered Machines Into a Mesh That Can Call Itself
Starting point: I have a pile of machines, but they can’t reach each other
I have a handful of machines lying around: a cloud server with a public IP, a phone that stays plugged in year-round, a Mac, and a few miscellaneous Linux boxes. Each one runs an agent, and each does its own thing.
The trouble is they don’t know one another exists. I wanted the agent on my Mac to offload a heavy chunk of work to the cloud server on the fly, or to have the cloud agent reach out and ask the phone for some context that only lives locally on it — and I couldn’t. Between them sat a layer I didn’t take seriously at first, and only later realized was the whole crux of the thing: network reachability.
First, separate the two planes: control plane and data plane
Before wiring anything up, I made myself get clear on which kind of “interconnect” I was actually after.
I already had a Git-based “mailbox” mechanism serving as the control plane: the agent on each machine leaves messages in a shared repo to coordinate and hand off tasks, and every action lands in the git history. The upside of this setup is that it’s durable, asynchronous, and auditable — who said what, when, and what decisions they made all leave a trail. The downside is just as obvious: it’s slow. It’s good for “leave a note,” not for “I want the result right now.”
What I needed to fill in was the other half — the data plane: one machine dispatches a task to another and gets the result back in seconds. Real-time calls between agents. I picked Google’s A2A (Agent-to-Agent) open protocol to standardize the call interface, because I didn’t want to invent yet another message format of my own.
One-line framing: the mailbox is the control plane (slow, reliable, leaves a trail), and A2A is the data plane (fast, real-time). The two planes complement each other; neither replaces the other.
The real roadblock: NAT
With the protocol settled, I figured the rest was just grunt work. Then I hit a wall the moment I started.
The cloud server has a public IP, so any machine can initiate a connection to it — no problem there. But the other direction? The cloud server can’t reach the machines hiding behind home broadband and mobile-carrier NAT. My phone and my Mac only have “outbound” capability; they have no “inbound” address.
Why is inbound unreachable? Because these devices sit behind NAT and simply don’t have an address the outside world can dial. Their internal IPs mean nothing to the public internet; the NAT box only learns where to send a returning packet after the device initiates a connection and leaves a record in the translation table. Without that record, a packet arriving from outside has no idea which machine on the internal network it belongs to, and gets dropped on the spot. So the direction “the hub actively connects out to a spoke” is physically a dead end — it’s not that a port isn’t open, it’s that there’s no number to dial in the first place.
That alone nailed down the topology. The only machine others can connect to is the cloud server with the public IP. So it naturally became the hub, and every other machine became a spoke orbiting around it. Hub-and-spoke, no choice in the matter.
[phone spoke]
|
[Mac spoke]--[cloud server hub]--[Linux spoke]
|
(only the hub is publicly reachable)
Two directions, wildly different difficulty
Connectivity, taken apart, is really two directions, and each has to be solved separately.
Direction one, spoke calls hub — this one’s easy. The spoke acts as the client and connects out to the hub. That’s an outbound connection, reachable by nature. NAT doesn’t block outbound. So I got this path working first with almost no effort: the agent on a spoke just fires an A2A request straight at the hub’s endpoint and gets the result back.
Direction two, hub calls spoke — this is the real problem. I wanted the agent on the hub to turn around and dispatch a task to some spoke sitting behind NAT. But as I said above, the hub can’t dial in.
The fix is to flip the direction around: worker-pull, long-polling.
Instead of having the hub push tasks out, I have the spoke reach outbound to the hub and “pull” tasks. A worker stays resident on the spoke, long-polling a /poll endpoint on the hub — the connection just hangs there, and if the hub has no work yet it holds the request open instead of answering. The moment a task lands that’s addressed to this spoke, it gets shoved back down this already-established outbound connection. The spoke runs its inference locally and then POSTs the result back to the hub.
Here’s the neat part: from NAT’s point of view, it’s the spoke going outbound the entire time. There’s always a record for it in the translation table, so the return packets always find their way home. I used “outbound long-polling” to completely sidestep “inbound unreachability.” It’s the classic NAT-traversal move — nothing fancy, but reliable.
A few real engineering details
Once it was working, a few things surfaced that only show up in actual operation.
Cost. The A2A server costs essentially nothing while idle — it just hangs there waiting. Only when a task actually arrives does it fire up the LLM for inference. No task, no bill. That’s what let me feel comfortable leaving it running all the time.
Staying alive. The worker has to stay alive; if it dies, the whole chain breaks. On the phone I backstop it with launch-on-boot plus a keepalive script; on the Mac I switched to a launchd daemon — letting the system babysit it is far less hassle than rolling my own keepalive, and it’ll bring the worker back up for me if it drops.
Security. Plaintext HTTP running naked on the public internet is unacceptable — task content and results would be floating around in the open. I got TLS on as fast as I could, and used a Bearer token for auth. This isn’t optional; it’s the minimum bar for exposing a public endpoint.
Closing thought: connectivity is the ticket in, not the moat
After I got this mesh wired up, I cooled off and actually thought about how much it was worth.
Connectivity itself — the A2A protocol, long-polling, hub-and-spoke — is being commoditized fast. It’s a borrowed open protocol that anyone can use, and the tricks I leaned on are tricks anyone can copy. It’s the ticket in, not the moat. Treating this part as a core advantage is missing the point.
The thing that’s genuinely hard to copy, the thing that actually bears load, is something else: the verification plane.
As the number of agents climbs and the duration of autonomous operation stretches out, a system with “no sense of pain” runs into serious trouble. It will quietly drift off course and amplify errors layer by layer — the agent does the wrong thing, the system itself has no idea, and it falls on me, the human “pain sensor,” to discover it after the fact. The more machines there are and the longer they run, the smaller the fraction I can personally keep an eye on, and the larger the surface area of things going out of control.
So my judgment now is clear:
Before adding more machines, give every cross-machine task a “machine-decidable acceptance contract,” and make the results reviewable. Install the pain sense first, then add the muscle.
Muscle is compute nodes, more spokes. Pain is the verification loop — for every task, define “what counts as success” before it goes out, and when it comes back the machine can adjudicate it automatically, raising an alarm and leaving a trail if it fails. Without this layer, the more muscle you add, the larger the surface area of the system spinning out of control, and in the end it’s a liability.
Wiring up the mesh only took a few days, but I’m going to spend the time ahead installing the pain sense first.