Tunnel
Section titled “Tunnel”hoody-tunnel carries traffic between your laptop and your container over one multiplexed WebSocket, in both directions. An EXPOSE binding publishes a local HTTP or WebSocket server at your container’s public URL. A PULL binding makes a TCP service on your laptop reachable on the container’s loopback.
Your laptop opens the WebSocket, so it never accepts an inbound connection. The container’s public hostname and its certificate already exist, so the tunnel adds no DNS, certificate, or signup step.
Compared with ngrok
Section titled “Compared with ngrok”Publishing localhost:3000 with a standalone tunnel client means installing it, creating an account, configuring a tunnel, working around its rate limits, and paying for a custom domain. The other direction, letting code on a remote machine reach a database running on your laptop, is normally a second tool with its own setup.
hoody-tunnel does both from one session. The kit runs in every container, and the public URL it serves is the container’s own domain, so no third-party account sits in the path.
import { tunnelExpose, tunnelPull } from 'hoody-sdk';
// Visitors reach your laptop's port 3000 at the container's public URLconst app = await tunnelExpose({ url: hoody.getKitUrl('tunnel', container).replace(/^https:/, 'wss:') + '/api/v1/tunnel/connect', token, containerPort: 3000, to: { host: '127.0.0.1', port: 3000 } });
// Your laptop's Postgres is now reachable at 127.0.0.1:5432 inside the containerconst db = await tunnelPull({ url: hoody.getKitUrl('tunnel', container).replace(/^https:/, 'wss:') + '/api/v1/tunnel/connect', token, containerPort: 5432, to: { host: '127.0.0.1', port: 5432 } });Both bindings ride the same session. Hoody Proxy terminates TLS, runs ACME, and serves custom domains upstream, so hoody-tunnel never handles a certificate. Your laptop never opens a listening port to the outside world; it is the WebSocket client in both directions.
EXPOSE and PULL
Section titled “EXPOSE and PULL”EXPOSE
Section titled “EXPOSE”Traffic arrives at the public URL, passes through Hoody Proxy into the container, and crosses the WebSocket to your laptop:
[Your laptop :3000] → WebSocket → [Container :3000] → Hoody Proxy → [Public internet]Visitors request https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com and reach your laptop’s port 3000. HTTP/1.1 requests and WebSocket upgrades both pass through. Because your laptop is the client rather than the server, this works from behind NAT, a corporate firewall, or a captive portal.
Container-side code connects to a loopback listener, and the tunnel carries that connection over the WebSocket to a TCP service on your laptop:
[Your laptop :5432] ← WebSocket ← [Container 127.0.0.1:5432]Code running in the container connects to 127.0.0.1:5432 and reaches Postgres on your laptop. The tunnel forwards raw TCP without inspecting or parsing it, so anything that speaks TCP works: databases, Redis, gRPC, SSH.
Quick start
Section titled “Quick start”Expose a local HTTP server
Section titled “Expose a local HTTP server”import { tunnelExpose } from 'hoody-sdk';
await using app = await tunnelExpose({ url: hoody.getKitUrl('tunnel', container).replace(/^https:/, 'wss:') + '/api/v1/tunnel/connect', token: process.env.HOODY_TOKEN!, containerPort: 3000, to: { host: '127.0.0.1', port: 3000 },});
console.log(app.publicUrl); // https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com// Visitors now reach your laptop's port 3000 via this URL# The tunnel data plane is a WebSocket with a binary framing protocol, so# use the SDK above. The REST endpoints are for inspection (see below).
# List active bindings to confirm the expose landedcurl "https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel/bindings"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Lists active bindings on the container’s tunnel service, so you can confirm the expose landed.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel/bindings&method=GET&response=transparent Pull a local service into the container
Section titled “Pull a local service into the container”import { tunnelPull } from 'hoody-sdk';
await using db = await tunnelPull({ url: hoody.getKitUrl('tunnel', container).replace(/^https:/, 'wss:') + '/api/v1/tunnel/connect', token: process.env.HOODY_TOKEN!, containerPort: 5432, to: { host: '127.0.0.1', port: 5432 }, // your local Postgres});
// Container-side code can now: psql -h 127.0.0.1 -p 5432# Inspect active pull bindings from the containercurl "https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel/bindings"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Lists active pull bindings from inside the container, so you can confirm the pull landed.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel/bindings&method=GET&response=transparent Serve an inline HTTP handler
Section titled “Serve an inline HTTP handler”Instead of starting a local server, pass a fetch handler and the SDK hosts it for you:
import { tunnelServe } from 'hoody-sdk';
await using server = await tunnelServe({ container: CONTAINER_ID, token: process.env.HOODY_TOKEN!, containerPort: 3000, fetch(req) { return new Response('Hello from my laptop!'); },});
console.log(server.publicUrl); // public URL serving your handlertunnelServe() boots a Bun.serve on a random local port and exposes that port, so your handler runs on your laptop and the kit never executes user code. The handler itself is a standard Request to Response function.
Multiple Bindings in One Session
Section titled “Multiple Bindings in One Session”A single session can hold many bindings simultaneously. The high-level helpers cover the common cases: expose two HTTP services and pull a database, each returning its own handle. For direct control over a session and its bindings, drop down to the low-level box.tunnel.* API:
import { tunnelExpose, tunnelPull } from 'hoody-sdk';
// Expose two HTTP servicesconst web = await tunnelExpose({ url: hoody.getKitUrl('tunnel', container).replace(/^https:/, 'wss:') + '/api/v1/tunnel/connect', token, containerPort: 3000, to: { host: '127.0.0.1', port: 3000 } });const api = await tunnelExpose({ url: hoody.getKitUrl('tunnel', container).replace(/^https:/, 'wss:') + '/api/v1/tunnel/connect', token, containerPort: 5000, to: { host: '127.0.0.1', port: 5000 } });
// Pull a databaseconst pg = await tunnelPull({ url: hoody.getKitUrl('tunnel', container).replace(/^https:/, 'wss:') + '/api/v1/tunnel/connect', token, containerPort: 5432, to: { host: '127.0.0.1', port: 5432 } });
console.log(web.publicUrl); // https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.comconsole.log(api.publicUrl); // https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com// Container code reaches your Postgres at 127.0.0.1:5432
// ... run until done ...await web.close();await api.close();await pg.close();Random port assignment
Section titled “Random port assignment”Pass containerPort: 0 (or omit it on the low-level bind) and the kit picks a free port for you, in both directions:
- PULL: the kit grabs a kernel-ephemeral loopback port.
- EXPOSE: the kit picks a random available port in the 20000-65534 range.
await using db = await tunnelPull({ container, token, containerPort: 0, // kit assigns a kernel-ephemeral loopback port to: { host: '127.0.0.1', port: 5432 },});
console.log(db.bind.containerPort); // e.g. 43217// Container code reaches your Postgres at 127.0.0.1:43217Either way the chosen port comes back in the BIND_OK response (bind.containerPort), and for EXPOSE the resulting publicUrl is the URL at which the binding is publicly reachable.
Multi-WebSocket (v2 Protocol)
Section titled “Multi-WebSocket (v2 Protocol)”For high-throughput workloads, the hoody-tunnel.v2 subprotocol spreads streams across multiple parallel WebSockets. Negotiate it through the low-level box.tunnel.* API; the number of connections the kit grants comes back as connectionsGranted (visible in both the /tunnels and /sessions responses, alongside the v2 indicator: protocol: "hoody-tunnel.v2" in /tunnels, isV2: true in /sessions).
Frames are pinned to the WebSocket that delivered each STREAM_OPEN, preserving per-stream ordering while letting unrelated streams parallelize across connections. v1 sessions keep working unchanged.
Session Resilience
Section titled “Session Resilience”Takeover grace
Section titled “Takeover grace”If your WebSocket drops (laptop sleeps, network blip, train tunnel) listeners stay up for 60 seconds by default. EXPOSE listeners return 503 Service Unavailable with Retry-After: 5 to visitors during the gap. Reconnect within the grace period and everything resumes exactly where it left off.
Session resume
Section titled “Session resume”Reconnect with the same session ID and the kit atomically rehydrates all bindings under the new WebSocket, so bindings are restored with no visitor downtime. Session resume lives on the low-level box.tunnel.* API: capture the session ID from the live session, then reconnect with it after a disconnect. (Admin killSession is the exception: it bypasses orphan parking, so a killed session cannot be resumed.)
Bind takeover
Section titled “Bind takeover”A new session can steal an EXPOSE binding from another session (or from an orphaned grace-period session) by setting takeover: true:
const binding = await tunnelExpose({ container, token, containerPort: 3000, to: { host: '127.0.0.1', port: 3000 }, takeover: true, // atomically claim port 3000 from any prior holder});The old session’s streams on that binding receive RESET(BIND_TAKEOVER) and the new owner starts serving immediately.
Inspection & Control
Section titled “Inspection & Control”Six HTTP endpoints let you observe and manage the tunnel from anywhere: the container, another container, your laptop, an AI agent with a fetch call, or the Hoody dashboard. The endpoints and auth model are the same in every case, because each is an ordinary HTTP request.
# Liveness + versionhoody tunnel health -c <container-id>
# Prometheus-format metricshoody tunnel metrics -c <container-id>
# Unified view: sessions + bindings (EXPOSE + PULL) + stream counts + FD budgethoody tunnel list -c <container-id>
# Kill a sessionhoody tunnel sessions kill sess_abc123 -c <container-id> --yesimport { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER,});
// Liveness + versionconst health = await containerClient.tunnel.health.check();
// Prometheus-format metricsconst metrics = await containerClient.tunnel.getMetrics();
// Unified view: sessions + bindings + stream counts + FD budgetconst tunnels = await containerClient.tunnel.listTunnels();
// Just the sessionsconst sessions = await containerClient.tunnel.listSessions();
// Just the bindings (EXPOSE + PULL)const bindings = await containerClient.tunnel.listBindings();
// Kill a session. session_id is positional; grace_ms is an optional query param inside the options objectawait containerClient.tunnel.killSession('sess_abc123', { grace_ms: 100 });BASE="https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel"
# Liveness + versioncurl "$BASE/health"
# Prometheus-format metricscurl "$BASE/metrics"
# Unified view: sessions + bindings + stream counts + FD budgetcurl "$BASE/tunnels"
# Just the sessionscurl "$BASE/sessions"
# Just the bindings (EXPOSE + PULL)curl "$BASE/bindings"
# Kill a session (grace_ms: 0-5000, default 50)curl -X DELETE "$BASE/sessions/sess_abc123?grace_ms=100"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Covers all six inspection endpoints, ending with killing a named session; swap in a real session ID from the sessions or tunnels response first.
# Health
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel/health&method=GET&response=transparent
# Metrics
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel/metrics&method=GET&response=transparent
# Tunnels (sessions + bindings)
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel/tunnels&method=GET&response=transparent
# Sessions
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel/sessions&method=GET&response=transparent
# Bindings
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel/bindings&method=GET&response=transparent
# Kill a session
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com/api/v1/tunnel/sessions/sess_abc123?grace_ms=100&method=DELETE&response=transparent A GET /tunnels response looks like this:
{ "sessions": [{ "sessionId": "a1b2c3", "peerAddr": "203.0.113.42:51234", "protocol": "hoody-tunnel.v1", "connectionsGranted": 1, "activeStreams": 3, "exposeBindings": [{ "bindId": 1, "containerPort": 3000 }], "pullBindings": [{ "bindId": 2, "containerPort": 5432 }] }], "orphanedSessions": 0, "totalStreams": 3, "totalBindings": 2, "fdPermitsAvailable": 4094}For the full request/response schema of every endpoint, jump to the API reference:
Architecture
Section titled “Architecture” [Visitor browser / curl / agent] │ https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com ▼ ┌───────────────────────────┐ │ Hoody Proxy (nginx) │ TLS termination, SNI routing └─────────────┬─────────────┘ │ HTTP/1.1 or WS upgrade ▼ ┌────────────────────────────────────────────────────┐ │ Container │ │ ┌─────────────────────────────────────────┐ │ │ │ hoody-tunnel (Rust / axum) │ │ │ │ │ │ │ │ Base listener :50 (control plane) │ │ │ │ └── /api/v1/tunnel/connect (WS) │ │ │ │ │ │ │ │ EXPOSE listeners (dynamic) │ │ │ │ :3000 → tunneled to laptop │ │ │ │ :5000 → tunneled to laptop │ │ │ │ │ │ │ │ PULL listeners (dynamic, loopback) │ │ │ │ 127.0.0.1:5432 → tunneled to laptop │ │ │ └────────────────┬────────────────────────┘ │ └────────────────────┼───────────────────────────────┘ │ multiplexed WebSocket ▼ ┌────────────────────────────┐ │ Tunnel SDK (your laptop) │ │ Bun 1.3+ / Node 22+ │ └────────────────┬───────────┘ ▼ [Local services: :3000, :5000, :5432]The tunnel is a stateless multiplexer. Flow control runs per-stream and per-session so a slow visitor never blocks the others. Certificates live upstream in Hoody Proxy; hoody-tunnel forwards bytes.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Max sessions | 8 (default; --max-sessions) |
| Max bindings per session | 8 (default; --max-bindings-per-session) |
| Max concurrent streams per session | 1024 (default; --max-streams-per-session) |
| Max frame payload | 65,536 bytes |
| Per-stream flow control window | 1 MiB (default; --stream-initial-window) |
| Per-session flow control window | 16 MiB (default; --session-initial-window) |
| Hello timeout | 5 seconds (default; --hello-timeout) |
| Ping interval | 30 seconds of receive inactivity |
| Pong timeout | 60 seconds (default; --pong-timeout) |
| Takeover grace period | 60 seconds (default; --takeover-grace) |
| Idle timeout | 300 seconds (default; --idle-timeout) |
killSession drain budget | 0-5000 ms (default 50) |
Use Cases
Section titled “Use Cases”- Local development: share your dev server with teammates or test webhooks without deploying anywhere
- Database access: pull your container’s production Postgres to a local GUI (DBeaver, pgAdmin, TablePlus)
- AI agent tooling: let an AI agent running in your container call services running on your laptop
- Demo and review: show a client your work in progress without pushing to staging
- Hybrid workflows: run heavy GPU workloads locally and expose them through your container’s public domain
- Webhook development: receive GitHub, Stripe, or GitLab webhooks on your laptop during development