Tunnel
Section titled “Tunnel”Your laptop is already a server. You just haven’t plugged it in yet.
hoody-tunnel pushes a local HTTP service out through your container’s public domain — or pulls a container-side TCP service down onto your laptop’s loopback. Both directions. One multiplexed WebSocket. Zero certificate dance, zero DNS edits, zero signup flow.
Why This Matters
Section titled “Why This Matters”Exposing localhost:3000 to the internet has always been a side quest. Install ngrok. Sign up for an account. Configure a tunnel. Bump into the rate limit. Pay for a custom domain. Then, when you need the reverse — pulling your container’s Postgres down to a local GUI — that’s a completely different tool.
We rebuilt both directions as one thing.
import { tunnelExpose, tunnelPull } from '@hoody-ai/hoody-sdk';
// Your laptop is now live on the internetconst app = await tunnelExpose({ container, token, containerPort: 3000, to: { kind: 'http', 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({ container, token, containerPort: 5432, to: { kind: 'tcp', host: '127.0.0.1', port: 5432 } });One session carries both. TLS, ACME, and custom domains are already handled by Hoody Proxy upstream — the tunnel never touches a certificate. Your laptop never opens a port to the outside world. The WebSocket does all the work.
Two Modes, One Session
Section titled “Two Modes, One Session”EXPOSE — Local to Public
Section titled “EXPOSE — Local to Public”Push a local HTTP or WebSocket server out through your container to the public internet:
[Your laptop :3000] → WebSocket → [Container :3000] → Hoody Proxy → [Public internet]Visitors hit https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.com and land on your laptop’s port 3000. HTTP/1.1 requests and WebSocket upgrades flow transparently. Your laptop can be behind NAT, a corporate firewall, or a hotel Wi-Fi captive portal — it’s the client, not the server.
PULL — Remote to Local
Section titled “PULL — Remote to Local”Pull a TCP service from your laptop into the container’s loopback:
[Your laptop :5432] ← WebSocket ← [Container 127.0.0.1:5432]Container-side code connects to 127.0.0.1:5432 and hits your laptop’s Postgres. Raw TCP — the tunnel doesn’t inspect or parse anything. Databases, Redis, gRPC, SSH — whatever speaks TCP works.
Quick Start
Section titled “Quick Start”1. Expose a Local HTTP Server
Section titled “1. Expose a Local HTTP Server”import { tunnelExpose } from '@hoody-ai/hoody-sdk';
await using app = await tunnelExpose({ container: CONTAINER_ID, token: process.env.HOODY_TOKEN!, containerPort: 3000, to: { kind: 'http', 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 —# 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"2. Pull a Remote Service to Localhost
Section titled “2. Pull a Remote Service to Localhost”import { tunnelPull } from '@hoody-ai/hoody-sdk';
await using db = await tunnelPull({ container: CONTAINER_ID, token: process.env.HOODY_TOKEN!, containerPort: 5432, to: { kind: 'tcp', 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"3. Run an Inline HTTP Handler
Section titled “3. Run an Inline HTTP Handler”Skip starting a local server entirely — pass a fetch handler and the SDK wraps it:
import { tunnelServe } from '@hoody-ai/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 handlerInternally, tunnelServe() boots a Bun.serve on a random local port and exposes it — the kit never runs user code. You write a standard Request/Response handler. The internet gets a URL.
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-ai/hoody-sdk';
// Expose two HTTP servicesconst web = await tunnelExpose({ container, token, containerPort: 3000, to: { kind: 'http', host: '127.0.0.1', port: 3000 } });const api = await tunnelExpose({ container, token, containerPort: 5000, to: { kind: 'http', host: '127.0.0.1', port: 5000 } });
// Pull a databaseconst pg = await tunnelPull({ container, token, containerPort: 5432, to: { kind: 'tcp', 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: { kind: 'tcp', 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 — all bindings restored, 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: { kind: 'http', 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. Same endpoints, same auth model — because everything in Hoody is HTTP.
# 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-ai/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 integerawait containerClient.tunnel.killSession('sess_abc123', 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"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 & review — Show a client your work-in-progress without pushing to staging
- Hybrid workflows — Run heavy GPU workloads locally, expose them through your container’s public domain
- Webhook development — Receive GitHub, Stripe, or GitLab webhooks on your laptop during development
What’s Next
Section titled “What’s Next”Your laptop just became a server. Expose local services. Pull remote databases. One WebSocket. Zero configuration. ngrok is a product. Tunneling is a feature.