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 { tunnel } from '@hoody-ai/hoody-tunnel-sdk';
// Your laptop is now live on the internetconst app = await tunnel.expose({ url, token, containerPort: 3000, to: { host: '127.0.0.1', port: 3000 } });
// Your container's Postgres is now on 127.0.0.1:5432 inside the containerconst db = await tunnel.pull({ url, token, containerPort: 5432, to: { 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://myapp-3000.hoody.icu 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 { tunnel } from '@hoody-ai/hoody-tunnel-sdk';
await using app = await tunnel.expose({ url: 'wss://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.icu/api/v1/tunnel/connect', token: process.env.HOODY_TUNNEL_TOKEN!, containerPort: 3000, to: { host: '127.0.0.1', port: 3000 },});
console.log(app.publicUrl); // https://myapp-3000.hoody.icu// 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.icu/api/v1/tunnel/bindings"2. Pull a Remote Service to Localhost
Section titled “2. Pull a Remote Service to Localhost”import { tunnel } from '@hoody-ai/hoody-tunnel-sdk';
await using db = await tunnel.pull({ url: 'wss://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.icu/api/v1/tunnel/connect', token: process.env.HOODY_TUNNEL_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.icu/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 { tunnel } from '@hoody-ai/hoody-tunnel-sdk';
await using server = await tunnel.serve({ url: 'wss://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.icu/api/v1/tunnel/connect', token: process.env.HOODY_TUNNEL_TOKEN!, containerPort: 3000, fetch(req) { return new Response('Hello from my laptop!'); },});
console.log(server.url); // public URL serving your handlerInternally, tunnel.serve() 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. Drop down to the low-level TunnelSession API when you want full control:
import { TunnelSession } from '@hoody-ai/hoody-tunnel-sdk';
const session = new TunnelSession({ url, token });await session.connect();
// Expose two HTTP servicesconst web = await session.bind({ kind: 'http', mode: 'expose', containerPort: 3000 });const api = await session.bind({ kind: 'http', mode: 'expose', containerPort: 5000 });
// Pull a databaseconst pg = await session.bind({ kind: 'tcp', mode: 'pull', containerPort: 5432 });
console.log(web.publicUrl); // https://myapp-3000.hoody.icuconsole.log(api.publicUrl); // https://myapp-5000.hoody.icu// Container code reaches your Postgres at 127.0.0.1:5432
// ... run until done ...await session.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 tunnel.pull({ url, 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 reflects that port.
Multi-WebSocket (v2 Protocol)
Section titled “Multi-WebSocket (v2 Protocol)”For high-throughput workloads, opt into hoody-tunnel.v2 and the SDK spreads streams across multiple parallel WebSockets:
const session = new TunnelSession({ url, token, maxConnections: 4, // negotiate v2; open up to 4 parallel WebSockets});await session.connect();
console.log(session.hello?.connectionsGranted); // kit may clamp lowerFrames 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 when maxConnections is omitted.
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:
const session = new TunnelSession({ url, token });await session.connect();
// Later, after a disconnect...const resumed = new TunnelSession({ url, token, resumeSessionId: session.id,});await resumed.connect();// All bindings restored — no visitor downtimeBind 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 session.bind({ kind: 'http', mode: 'expose', containerPort: 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
# Prometheus-format metricshoody tunnel metrics
# Unified view: sessions + bindings + stream counts + FD budgethoody tunnel list
# Just the sessionshoody tunnel sessions list
# Just the bindings (EXPOSE + PULL)hoody tunnel bindings list
# Kill a session (grace_ms: 0-5000, default 50)hoody tunnel sessions kill sess_abc123 --grace-ms 100import { HoodyClient } from '@hoody-ai/hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.icu', 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 optionawait containerClient.tunnel.killSession('sess_abc123', { grace_ms: 100,});BASE="https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.icu/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": "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://myapp-3000.hoody.icu ▼ ┌───────────────────────────┐ │ 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 20+ │ └────────────────┬───────────┘ ▼ [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.