Skip to content

Tunnel Tools

Tunnels let the agent expose a local port on the runner machine through the PizzaPi relay server, making it accessible as a public URL in the web UI. This is useful when the agent starts a dev server, build preview, or any local service and you want to interact with it from your browser — even if the runner is on a different machine or behind NAT.


When the agent creates a tunnel, the following happens:

  1. The agent calls create_tunnel with a local port number.
  2. A service_message is sent over the relay’s Socket.IO connection to the runner daemon’s TunnelService.
  3. The daemon registers the port and confirms the tunnel.
  4. The relay server begins proxying HTTP requests (and WebSocket upgrades) from the public URL to 127.0.0.1:<port> on the runner.

All traffic flows through the relay server — the runner’s local port is never directly exposed to the internet. The relay handles:

  • HTTP proxyingGET, POST, PUT, DELETE, PATCH, HEAD, and OPTIONS requests are forwarded to the local service and streamed back.
  • WebSocket proxying — upgrade requests are tunneled through a second WebSocket channel, enabling live-reload, HMR, and real-time protocols.
  • HTML/JS/CSS rewriting — the proxy rewrites absolute paths in HTML responses, ES module imports, and CSS url() references so they route through the tunnel prefix. An injected interceptor script patches fetch, XMLHttpRequest, EventSource, WebSocket, history.pushState, history.replaceState, location.assign, location.replace, navigator.sendBeacon, window.open, Element.prototype.setAttribute, and dynamic resource loading at runtime.

Tunnels support two URL schemes:

SchemeURL patternStability
Runner-based (preferred)/api/tunnel/runner/<runnerId>/<port>/Stable across session switches
Session-based (legacy)/api/tunnel/<sessionId>/<port>/Breaks when session changes

The agent automatically prefers runner-based URLs when a runner ID is available, falling back to session-based URLs otherwise.

Both patterns are gated on an interactive relay session — they only load in a browser already logged into the relay, and return 401 anywhere else. So create_tunnel and list_tunnels do not hand those URLs out directly: they mint a self-authenticating URL instead, preferring the dedicated tunnel origin below and otherwise returning a signed /api/tunnel/auth/<token>/… path. Those work in any browser, on any device, with no cookie — the URL itself is the credential, so treat it as a secret and expect it to expire.

Section titled “Dedicated tunnel origin (recommended for SPAs)”

The path-prefix schemes above share the relay’s origin, so a tunnelled app sees /api/tunnel/… in location.pathname. Client-side routers (React Router, Next.js, SvelteKit) read that path and render their 404 page. The proxy’s HTML/JS rewriting compensates for asset loading, but window.location cannot be forged — SPA routing under a path prefix is fundamentally lossy.

Set PIZZAPI_TUNNEL_DOMAIN on the relay to give each tunnel its own origin instead:

Terminal window
PIZZAPI_TUNNEL_DOMAIN=t.localhost:7492 # value: [scheme://]host[:port]

Each tunnel gets an opaque random subdomain — https://<label>.<domain>/ — and the relay proxies the path verbatim: no rewriting, no injected interceptor, no prefix. SPA routing, location.href, forms, and WebSockets all work unmodified. Transport is unchanged: every request still flows through the relay and the runner’s encrypted tunnel channel; the label is Redis-backed (1 hour sliding TTL), unguessable (128-bit), and ownership is re-verified per request.

DNS options:

DeploymentSettingNotes
Same machinet.localhost:<port>Browsers resolve *.localhost to loopback — zero DNS, zero certs
LAN / tailnet<ip-with-dashes>.sslip.ioe.g. 100-64-1-2.sslip.io — public wildcard DNS to any IP, no setup. Still needs TLS: the UI iframes the tunnel origin from an HTTPS page, so a plain-http domain is mixed-content blocked
Real domainhttps://t.example.comWildcard DNS record *.t.example.com + wildcard TLS (Caddy below)

With pizza web, pass the variable once — it persists in the web config:

Terminal window
PIZZAPI_TUNNEL_DOMAIN=t.localhost:7492 pizza web

A wildcard origin needs a wildcard certificate, which neither tailscale serve nor a plain reverse proxy can issue. pizza web can deploy one for you — set PIZZAPI_CADDY=1 and it adds a Caddy service to the generated compose file, writes ~/.pizzapi/web/Caddyfile, and points *.<tunnel domain> at the relay:

Terminal window
PIZZAPI_TUNNEL_DOMAIN=https://t.example.com:8444 PIZZAPI_CADDY=1 pizza web

The listen port comes from PIZZAPI_TUNNEL_DOMAIN (default 443), and the upstream is the relay container — the relay only reads the Host header, so nothing else changes. Two certificate modes:

ModeWhenTrade-off
Internal CA (default)No DNS credentials givenZero setup, works on a private LAN/tailnet with no inbound reachability — but every device must trust Caddy’s root CA once, or tunnels break in the web UI (see below)
DNS-01 wildcardPIZZAPI_CADDY_DNS_PROVIDER + PIZZAPI_CADDY_DNS_TOKENPublicly trusted Let’s Encrypt cert, still no inbound :80 needed — requires a real domain and a DNS API token

DNS-01 is the one to want: the certificate is publicly trusted, so no device ever has to install a root CA, and the challenge is solved over the DNS API — the relay itself never has to be reachable from the internet. A tailnet-only relay can hold a real Let’s Encrypt wildcard.

Either way you supply the wildcard DNS record yourself: *.t.example.com → the relay host. <ip-with-dashes>.sslip.io covers that for free on a LAN or tailnet with no domain at all.

Hand-rolling the proxy instead is still fine — drop a compose.override.yml next to the generated compose file and leave PIZZAPI_CADDY unset. When PIZZAPI_TUNNEL_DOMAIN is unset, tunnels fall back to the path-prefix schemes above.


Expose a local port through the relay and get a public URL.

Parameters:

NameTypeRequiredDescription
portnumberYesLocal port to expose (1–65535)
namestringNoHuman-readable label (e.g. "dev-server", "storybook")

Returns: A text result containing the port, optional name, and the public URL. The structured details object includes:

{
"port": 3000,
"name": "dev-server",
"url": "/tunnel/3000",
"publicUrl": "https://your-relay.example.com/api/tunnel/runner/abc123/3000/"
}

url is the internal relay path fragment for that port; publicUrl is the real externally accessible URL.

Example agent output:

Tunnel created successfully.
Port: 3000
Name: dev-server
Public URL: https://your-relay.example.com/api/tunnel/runner/abc123/3000/

List all currently active tunnels for this session.

Parameters: None.

Returns: A text summary of active tunnels with their ports, names, pinned status, and public URLs. The structured details object includes:

{
"tunnels": [
{
"port": 3000,
"name": "dev-server",
"url": "http://127.0.0.1:3000",
"publicUrl": "https://your-relay.example.com/api/tunnel/runner/abc123/3000/",
"pinned": false
}
]
}

Example agent output:

1 active tunnel(s):
:3000 (dev-server) → https://your-relay.example.com/api/tunnel/runner/abc123/3000/
:4173 (preview) [pinned] → https://your-relay.example.com/api/tunnel/runner/abc123/4173/

Close an active tunnel and stop proxying traffic to the local port.

Parameters:

NameTypeRequiredDescription
portnumberYesPort of the tunnel to close (1–65535)

Returns: Confirmation that the tunnel was closed.

{
"closed": true,
"port": 3000
}

The relay enforces several security measures:

  • Auth-only access — every tunnel request requires a valid session cookie or API key, or a short-lived signed path token minted by the runner/session owner for mobile/cross-origin iframe access.
  • Dedicated signing secret — tunnel tokens are signed with PIZZAPI_TUNNEL_TOKEN_SECRET (distinct from the Better Auth session secret). When unset, the relay falls back to BETTER_AUTH_SECRET so unconfigured deployments keep working. Set it to allow independent rotation: rotating the tunnel secret does not invalidate existing auth sessions.
    • Key rotation — set PIZZAPI_TUNNEL_TOKEN_SECRET_PREVIOUS to the old secret; tokens signed by it are still accepted until their natural 1-hour TTL expires, then the env var can be removed.
  • Token claims — every token carries aud (pizzapi:tunnel), iat (issued-at), kid (key identifier derived from the active secret), and exp. On verify: aud, kid, and exp are all enforced. A token whose kid does not match the active key (or the previous key, if configured) is rejected outright.
  • Owner verification — the caller’s user ID must match the session/runner owner.
  • Header strippingCookie, Authorization, X-API-Key, and Referer headers are never forwarded to the local service, preventing auth-leakage to tunneled apps.
  • Query param sanitizationapiKey and tunnelToken query parameters are stripped before forwarding.
  • Hop-by-hop header removal — standard proxy headers (Connection, Transfer-Encoding, etc.) and Accept-Encoding are stripped so responses arrive uncompressed for rewriting.

The most common use case — an agent starts a development server and creates a tunnel so you can preview it in your browser:

Agent: I'll start the Vite dev server and create a tunnel so you can preview it.
> bun run dev
→ Local: http://localhost:5173/
> create_tunnel(port: 5173, name: "vite-dev")
→ Public URL: https://relay.example.com/api/tunnel/runner/abc123/5173/

The tunnel’s runtime interceptor ensures Vite’s HMR WebSocket connection, dynamic chunk loading, and SPA router navigation all work through the tunnel.

When the agent sets up a local API server, database UI, or documentation preview, tunnels let you interact with it without SSH or port forwarding:

> create_tunnel(port: 8080, name: "api-server")
> create_tunnel(port: 6006, name: "storybook")

Use list_tunnels to see all active tunnels at a glance.

Tunnels support Server-Sent Events (SSE) and chunked streaming responses. Content types that need no rewriting — such as SSE, JSON, images, and binary — are streamed directly with minimal latency. Only HTML, JavaScript/TypeScript modules, and CSS are buffered (up to a generous limit) for rewriting.


A typical agent interaction using tunnels:

User: Set up the Next.js project and let me preview it.
Agent:
1. npm install
2. npm run dev → starts on port 3000
3. create_tunnel(3000, "next-dev")
→ https://relay.example.com/api/tunnel/runner/r1/3000/
4. "Here's your preview URL: [link]. The dev server is running
with hot reload — changes will appear automatically."
... later ...
5. close_tunnel(3000) → tunnel closed after work is done

  • Relay required — the runner must be connected to the relay server. If the relay connection drops, tunnel URLs return 503 Runner not available.
  • No offline support — tunnels are inherently a network feature. Without a relay, use direct localhost access on the runner machine.
  • Request timeout — service message round-trips time out after 10 seconds. If the runner daemon is unresponsive, tunnel creation fails with a timeout error.
  • Single-user access — only the session/runner owner can access tunnel URLs. There is no sharing with other authenticated users.
  • Content rewriting limits (path-prefix mode only) — while the proxy rewrites HTML, JS modules, and CSS, some edge cases (e.g. paths constructed entirely at runtime from string concatenation, SPA routers reading location.pathname) may not be intercepted. Configure PIZZAPI_TUNNEL_DOMAIN to bypass rewriting entirely — apps on a dedicated tunnel origin run unmodified.
  • Local HTTPS targets — the runner probes each exposed port and speaks TLS to local HTTPS services automatically (self-signed certs accepted, loopback only).