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.
How It Works
Section titled “How It Works”When the agent creates a tunnel, the following happens:
- The agent calls
create_tunnelwith a local port number. - A
service_messageis sent over the relay’s Socket.IO connection to the runner daemon’s TunnelService. - The daemon registers the port and confirms the tunnel.
- 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 proxying —
GET,POST,PUT,DELETE,PATCH,HEAD, andOPTIONSrequests 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 patchesfetch,XMLHttpRequest,EventSource,WebSocket,history.pushState,history.replaceState,location.assign,location.replace,navigator.sendBeacon,window.open,Element.prototype.setAttribute, and dynamic resource loading at runtime.
URL Schemes
Section titled “URL Schemes”Tunnels support two URL schemes:
| Scheme | URL pattern | Stability |
|---|---|---|
| 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.
Dedicated tunnel origin (recommended for SPAs)
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:
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:
| Deployment | Setting | Notes |
|---|---|---|
| Same machine | t.localhost:<port> | Browsers resolve *.localhost to loopback — zero DNS, zero certs |
| LAN / tailnet | <ip-with-dashes>.sslip.io | e.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 domain | https://t.example.com | Wildcard DNS record *.t.example.com + wildcard TLS (Caddy below) |
With pizza web, pass the variable once — it persists in the web config:
PIZZAPI_TUNNEL_DOMAIN=t.localhost:7492 pizza webWildcard TLS with the bundled Caddy
Section titled “Wildcard TLS with the bundled Caddy”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:
PIZZAPI_TUNNEL_DOMAIN=https://t.example.com:8444 PIZZAPI_CADDY=1 pizza webThe 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:
| Mode | When | Trade-off |
|---|---|---|
| Internal CA (default) | No DNS credentials given | Zero 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 wildcard | PIZZAPI_CADDY_DNS_PROVIDER + PIZZAPI_CADDY_DNS_TOKEN | Publicly 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.
create_tunnel
Section titled “create_tunnel”Expose a local port through the relay and get a public URL.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
port | number | Yes | Local port to expose (1–65535) |
name | string | No | Human-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_tunnels
Section titled “list_tunnels”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_tunnel
Section titled “close_tunnel”Close an active tunnel and stop proxying traffic to the local port.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
port | number | Yes | Port of the tunnel to close (1–65535) |
Returns: Confirmation that the tunnel was closed.
{ "closed": true, "port": 3000}Authentication & Security
Section titled “Authentication & Security”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 toBETTER_AUTH_SECRETso 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_PREVIOUSto the old secret; tokens signed by it are still accepted until their natural 1-hour TTL expires, then the env var can be removed.
- Key rotation — set
- Token claims — every token carries
aud(pizzapi:tunnel),iat(issued-at),kid(key identifier derived from the active secret), andexp. On verify:aud,kid, andexpare all enforced. A token whosekiddoes 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 stripping —
Cookie,Authorization,X-API-Key, andRefererheaders are never forwarded to the local service, preventing auth-leakage to tunneled apps. - Query param sanitization —
apiKeyandtunnelTokenquery parameters are stripped before forwarding. - Hop-by-hop header removal — standard proxy headers (
Connection,Transfer-Encoding, etc.) andAccept-Encodingare stripped so responses arrive uncompressed for rewriting.
Use Cases
Section titled “Use Cases”Dev Server Preview
Section titled “Dev Server Preview”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.
Sharing a Local Service
Section titled “Sharing a Local Service”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.
SSE and Streaming Endpoints
Section titled “SSE and Streaming Endpoints”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.
Example Workflow
Section titled “Example Workflow”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 doneLimitations
Section titled “Limitations”- 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
localhostaccess 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. ConfigurePIZZAPI_TUNNEL_DOMAINto 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).