I've been building agents that need to receive callbacks from webhook providers — Stripe, GitHub, Slack, any service that POSTs to a URL when something happens. And every time, the same wall: my agent doesn't have a public IP. It's running on a laptop, a dev server, or inside a corporate network. There's no ingress.
You can tunnel. You can poll. You can run a relay server. But each of those adds a different kind of overhead — and most of them still require you to maintain infrastructure you'd rather not touch.
Here's the approach that finally made this click for me: instead of working around the missing public URL, remove the requirement entirely. Give the agent a permanent network address that doesn't depend on where it's running. Then the agent can tell the webhook provider where to find it, even though no one can route to its IP directly.
What Makes a Webhook Receiver Hard to Host on an Agent
A webhook works like this: a service sends an HTTP POST to a URL you give it. That URL must be reachable from the internet. If your agent is behind NAT, a CGNAT, a corporate firewall, or just a home router, there's no way to hand that URL out.
The typical workarounds are well-known:
- ngrok / localtunnel — one command, one temporary URL. Great for dev, but the URL changes on restart. Not something you embed in agent configuration and walk away from.
- Polling — your agent checks an API every N seconds instead of receiving a push. Works, but you burn request budget, add latency, and still need to hit a public endpoint for the polling.
- A relay server — a public VM that forwards webhooks to your agent over a persistent connection. This is the most reliable pattern, but now you're managing infrastructure.
All of them solve the symptom — the agent needs a public-facing endpoint — without addressing the root cause: your agent has no addressable presence on the network.
The Persistent Tunnel Approach Removes the Requirement
A persistent tunnel is a long-lived connection from your agent to a known point on the network. The agent initiates the connection (so outbound-only NAT is fine), and the tunnel stays open. Any webhook sent to the known point gets forwarded over the tunnel to the agent.
The key difference from the relay-server pattern: the tunnel substrate handles addressing and forwarding as a first-class feature. Your agent gets a permanent virtual address that survives restarts, network changes, and cloud migrations. You don't manage a relay — the network handles it.
On Pilot Protocol, this looks like:
# Install the daemon
curl -fsSL https://pilotprotocol.network/install.sh | sh
# Start it — your agent gets a permanent address
pilotctl daemon start
# Other agents and services can now reach you
pilotctl info
The daemon registers a stable address the first time it starts. From then on, that address belongs to that agent. Restart your laptop, move to a different cloud, reconnect from a hotel Wi-Fi — the address stays the same.
Receiving Webhooks Through the Tunnel
Once the daemon is running, anything that can message your agent can deliver a webhook payload. The pattern is:
- Your agent runs the daemon and gets a permanent address.
- A webhook sender (or another agent acting as a relay endpoint) sends the payload to that address.
- The tunnel delivers it over the encrypted UDP connection — no public URL needed.
You can wire this directly into a webhook receiver handler:
from pilotprotocol import PilotNode
node = PilotNode()
@node.on_message
async def handle_webhook(sender, payload):
event = payload.get("event")
data = payload.get("data")
print(f"Received {event} from {sender}: {data}")
# Process it like any webhook
node.connect() # Permanent address, persistent tunnel
The agent never needs to expose a port, open a firewall rule, or maintain a public DNS record. The tunnel is the ingress.
What You Keep and What You Lose
You keep: real-time delivery (push, not poll), no open firewall ports, no public DNS management, the agent can roam across networks without reconfiguring the webhook provider.
You lose: the convenience of hitting a raw HTTP endpoint with curl for debugging (though you can add a local HTTP listener that feeds into the same handler). And you need the daemon running — but you need the daemon running to be on the network at all, so that's not really incremental.
How This Compares to the Workarounds
| Approach | Public URL needed? | Survives restart? | Infrastructure to maintain |
|---|---|---|---|
| ngrok / localtunnel | Temporary one | No | None (service is ephemeral) |
| Polling | Only for the API | Yes | None (but burns requests) |
| Relay server (DIY) | Yes (the relay) | Yes | A VM, a reverse proxy, maybe a database |
| Persistent tunnel | No | Yes | A daemon (one install) |
The persistent tunnel is the only option that doesn't require a public URL at any layer. The agent connects out, the tunnel stays up, webhooks arrive. Same reliability as a relay server, less operational surface area.
When You'd Still Want a Traditional Setup
If you're already running a public-facing server and the agent is colocated with it, you don't need this — just expose a route on the existing server. The tunnel pattern pays off when the agent lives somewhere without ingress: a developer laptop, a CI runner, an edge device, a container without a public load balancer, or a corporate network that bans inbound connections outright.
It also helps when you want one agent to receive webhooks from multiple services without configuring each one with a unique tunnel URL. One address, many senders.
The One-Install Path
For an agent that needs to receive webhooks and has no public URL — the usual case for any agent running outside a cloud data center — a persistent tunnel removes the problem instead of papering over it.
curl -fsSL https://pilotprotocol.network/install.sh | sh
pilotctl daemon start
One install. One address. Webhooks arrive, no public IP required.
Top comments (0)