I spent last week trying to get two AI agents — one on a Linux box behind a home ISP and another on a cloud VM behind AWS NAT Gateway — to talk to each other without a central relay. No websocket server in the middle, no polling a shared DB every five seconds. Direct peer-to-peer.
It works now. Here's what I learned about STUN, UDP hole punching, and the fallback that saves you when all else fails.
The problem: two hosts that can't see each other
You probably hit this the first time you tried to set up a direct P2P connection. Host A is behind a router doing NAT (Network Address Translation). Host B is behind a different router doing NAT. They both have private IPs like 192.168.1.x. They can get out to the internet, but nothing from the internet can get back in.
The router rewrites the source IP/port of every outbound packet and remembers the mapping. A packet from 192.168.1.5:4000 to 8.8.8.8:53 leaves the router as 203.0.113.45:32000. The reply comes back to 203.0.113.45:32000, and the router remembers that maps to 192.168.1.5:4000, so it forwards the reply.
The problem is symmetric inbound traffic. Host A has no way to tell Host B "send packets to 192.168.1.5:4000" because that address is meaningless outside the local network. And Host A's public IP (203.0.113.45:32000) is only valid for replies to the destination it opened — not for new incoming connections.
Step 1: STUN discovers what the outside sees
STUN (Session Traversal Utilities for NAT) is the simplest layer. You send a request to a public STUN server — stun.l.google.com:19302, for example — and it replies with the public IP and port your NAT assigned to that transaction.
Host A (192.168.1.5:4000) -> STUN server
STUN server -> "you appear as 203.0.113.45:32000"
That's useful but not sufficient. There are different NAT behaviors:
-
Full cone: any external host can send to
203.0.113.45:32000and it reaches Host A. - Restricted cone: Host A must have sent a packet to the external host's IP first — just the IP, any port works.
- Port restricted cone: Host A must have sent a packet to that exact IP and port combination.
-
Symmetric NAT: the mapping is one-to-one per destination. Send to X:53 and you get mapping
A:32000. Send to Y:80 and you get a different mappingA:32001.
Symmetric NAT is the showstopper for basic hole punching. STUN can't help because the mapping A gets from talking to the STUN server is only valid for talking to the STUN server, not to Host B.
Step 2: UDP hole punching — the trick that works most of the time
Hole punching is the technique, not a protocol. Here's what happens:
- Both hosts connect to a shared coordination server (could be any public server) and exchange their STUN-discovered endpoints.
- Host A starts sending UDP packets to Host B's public endpoint.
- Host B simultaneously starts sending UDP packets to Host A's public endpoint.
- The packet from A creates a NAT mapping at A's router for traffic to B. The packet from B does the same at B's router.
- At this point, the routers see legitimate traffic from each other and forward it.
In the simple case (cone NAT or port-restricted on both sides), this just works. The send-to-the-other-host's-STUN-endpoint trick creates the necessary path.
Why it fails: symmetric NAT on either side breaks it. If A's router allocates a different port for traffic to B's IP than it did for the STUN server, then the STUN-discovered endpoint is worthless for reaching B. A needs to know which port its router assigned for the A→B flow, and it can't learn that without trial and error.
This is where ICE (Interactive Connectivity Establishment) comes in — it's the protocol that formalizes the trial-and-error loop. ICE runs through combinations: try the STUN endpoint, try candidate endpoints from the local interface, try TURN relay endpoints, and iterate until one works.
Step 3: TURN relay — the fallback that always works
TURN (Traversal Using Relays around NAT) is the "break glass" option. When hole punching fails — which happens about 10-15% of the time in practice between arbitrary NAT combinations — TURN provides a relay.
Rather than fighting NAT, TURN gives both hosts a public rendezvous point:
Host A <-> TURN server <-> Host B
Every packet goes through the relay. It's reliable, it works through any NAT, and it adds latency and bandwidth cost. Many TURN servers are deployed just for the fallback cases and idle most of the time.
A pragmatic deployment strategy: try hole punching first with a short timeout (2-3 seconds), fall back to TURN if it fails, and keep a relay path warm as a backup even after a direct punch succeeds — NAT mappings can expire.
What this means for AI agents (and why I care)
I work on network infrastructure for autonomous agents. My agents need to talk to each other directly — not through some central webhook receiver that queues and forwards. When two agents are collaborating on a task, every millisecond of relay latency and every dropped packet in the relay's queue becomes the agent's problem.
The model that works in production is:
- Coordination server: lightweight, stays behind NAT, runs STUN-like discovery and routing bookkeeping (a few KB of state per agent pair).
- Direct P2P path: preferred path after successful hole punch via ICE.
- Relay fallback: provisioned TURN servers for the cases where hole punching fails, with automatic failover.
This is essentially what Pilot Protocol implements for agents. It bundles STUN discovery, hole punching, and an encrypted relay beacon into a single overlay, so each agent gets a permanent virtual address independent of its NAT state. The daemon transparently chooses the best path — direct UDP or relay — and agents just talk to each other by name.
The result: two agents on different clouds and behind different NATs can exchange messages with sub-millisecond latency on the direct path, and fall back to relay only when necessary. No websocket servers to maintain, no public IPs to provision.
Quick checklist for your own P2P setup
If you're building direct agent-to-agent communication:
- Start with ICE. Don't hand-roll STUN/hole-punching yourself — use an existing ICE library (libnice, pjnath, or a managed one).
- Provision a TURN server. coturn is the de facto open-source option. Deploy it on a cheap VPS with a public IP. Allocate a small port range (one 5-tuple per agent pair).
- Handle NAT mapping expiry. Keepalive every 30 seconds for UDP NAT bindings. Most consumer routers time out UDP mappings in 30-120 seconds.
- Prefer UDP but handle TCP fallback. Some enterprise firewalls block all UDP. Have a TCP-TURN option or raw TCP fallback.
- Encrypt everything. NAT doesn't provide security. Use Noise, DTLS, or a simple AEAD (X25519 + AES-GCM) on the P2P tunnel — Pilot uses this by default.
The short version
Hole punching works by exploiting the fact that NATs let return traffic in — you just have to arrange for the traffic to look like a return. STUN tells you your public address. The punch coordinates both sides to send simultaneously so each router sees the other's packet as a legitimate reply. TURN catches the cases where the punch fails.
It isn't magic. It's just network devices doing exactly what they're programmed to do, and a bit of clever timing.
Want to skip the wiring? Pilot Protocol handles STUN, hole punching, and relay fallback out of the box — install once, agents connect directly. Try it:
curl -fsSL https://pilotprotocol.network/install.sh | sh
Top comments (0)