Skip to content
Hoody.com

Hooks let you run your own JavaScript on inbound traffic to any of your container services, before it reaches the service. Use them for logging, auth gates, header transforms, payload scans, or “inspect-and-forward” patterns. A hook script has the same cost, deploy surface, and SDK as any other hoody-exec script.

Hooks are a tenant-owned MITM layer. Your hook script runs inside your own hoody-exec (not the proxy), sees the real client IP, and can do anything a regular hoody-exec script can.


  • Identity gate: require a valid Hoody login (a signed identity claim) before your app is reachable. See the full recipe below.
  • Audit log: record every login attempt with user-agent and outcome, without modifying the login service.
  • Rate limit: reject requests above a threshold before they hit an expensive backend.
  • Header transform: strip sensitive headers from upstream responses, or add CSP headers uniformly.
  • Short-circuit: return a cached or synthetic response without touching the real service.
  • Body scan: reject uploads that fail antivirus before they land on disk.
  • Traffic mirroring: fan out a copy of the request to a secondary analytics backend.

Client ─► Hoody Proxy ──(match?)──► YES ──► your hoody-exec ──► your-hook-script.js
├─ inspect / transform / short-circuit
└─ optional: forward to real upstream
(container-ip:service-port)
  1. You create a hook through the dedicated /api/v1/containers/{id}/proxy/hooks/{service} endpoint; the server assigns its ID and stores it in the container’s proxy-permissions document.
  2. The entry says: “for service X, when the request matches these predicates, route it through this script in my hoody-exec.”
  3. On every matching request, the proxy dispatches to your hoody-exec with the original URL preserved. Your script runs, sees the request, and decides.
  4. If you want to pass through to the real upstream, your script makes an HTTP call to metadata.hook.upstream.host:port (the authoritative address, pinned by the proxy).

TPROXY preserves the client IP end to end; your hook script can read req.socket.remoteAddress like any other script.


You create hooks one at a time on the dedicated hooks endpoint, which assigns each rule a stable server-side id:

POST /api/v1/containers/<id>/proxy/hooks/terminal body
{
"match": { "method": ["POST"], "path": "/api/login*" },
"script": { "path": "/login-audit" },
"timeout": 500
}
login-audit.ts
module.exports = async function (req, res, metadata, shared) {
// Regular requests (no hook) have metadata.hook === undefined.
if (!metadata.hook) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ mode: 'regular', url: req.url }));
return;
}
const { auditId, origMethod, origPath } = metadata.hook;
const clientIp = req.headers['x-real-ip'] ?? req.socket.remoteAddress;
// Log the attempt.
console.log(JSON.stringify({
auditId, clientIp, method: origMethod, path: origPath,
ua: req.headers['user-agent'] ?? null,
}));
// Forward to the real login backend. `metadata.hook.forward()` handles
// URL assembly, RFC 7230 hop-by-hop stripping, multi-value Set-Cookie,
// byte-transparent compression passthrough, client-abort propagation,
// and sanitized 502 on upstream unavailability.
await metadata.hook.forward(req, res);
};

When a hook dispatch is active, metadata.hook carries three non-enumerable helper methods, forward(), fetchUpstream(), and pipeResponse(), which handle the fetch-and-pipe plumbing you would otherwise write by hand.

forward(req, res, overrides?) → Promise<void>

Section titled “forward(req, res, overrides?) → Promise<void>”

forward() is a one-shot passthrough: it reads req, sends to the authoritative upstream, and streams the response back into res. It handles:

  • URL assembly from metadata.hook.upstream.{host,port} + origPath + client query string. Byte-preserving (no new URL() canonicalization).
  • Request-side hop-by-hop stripping (RFC 7230 §6.1 base set + Connection: <token> extension). The client’s Host header is preserved by default (override via overrides.headers.host).
  • Body source: buffered req.rawBody when present, else Readable.toWeb(req) when // @rawBody is set. content-length rules enforced.
  • Response-side hop-by-hop stripping and multi-value Set-Cookie preservation via getSetCookie().
  • Compression: always Bun.fetch({ decompress: false }); upstream bytes forward verbatim with content-encoding intact.
  • Client-abort propagation: guarded wiring on req.close / req.aborted / res.close so SSE/long-poll disconnects abort the upstream fetch.
  • Error translation: network/abort/timeout become a sanitized 502 with an x-hoody-hook-audit header. Programming errors (invalid-override/no-body/body-consumed/duplex-unsupported/bytes-already-sent) rethrow so the script author sees them as real bugs.
// Transparent passthrough with logging
module.exports = async function (req, res, metadata) {
if (!metadata.hook) { res.writeHead(404); res.end(); return; }
console.log('hook', metadata.hook.auditId, metadata.hook.origMethod, metadata.hook.origPath);
await metadata.hook.forward(req, res);
};

fetchUpstream(req, overrides?) → Promise<Response>

Section titled “fetchUpstream(req, overrides?) → Promise<Response>”

fetchUpstream() is a non-consuming fetch for inspect-then-forward: it returns a standard Fetch API Response for the caller to inspect, mutate, or pipe. It does not write to res.

// Auth gate: inspect upstream, rewrite response on reject
module.exports = async function (req, res, metadata) {
if (!metadata.hook) { res.writeHead(404); res.end(); return; }
try {
const up = await metadata.hook.fetchUpstream(req);
if (up.status === 401) {
res.writeHead(401, { 'content-type': 'text/plain' });
res.end('upstream rejected token'); return;
}
res.setHeader('x-hoody-hook-audit', metadata.hook.auditId);
await metadata.hook.pipeResponse(up, res, { method: req.method });
} catch (e) {
if (e instanceof metadata.hook.HookUpstreamError) {
res.writeHead(502); res.end('upstream unavailable');
} else throw e;
}
};

pipeResponse(upstream, res, { method? }?) → Promise<void>

Section titled “pipeResponse(upstream, res, { method? }?) → Promise<void>”

pipeResponse() is the piping half of forward(), exposed for inspect-then-forward. It handles status, response-side hop-by-hop stripping, multi-value Set-Cookie, transfer-encoding/content-length reconciliation (RFC 7230 §3.3.3), and HEAD/204/304 no-body handling (pass method: 'HEAD' for HEAD suppression). The helper treats a client close during streaming as silent cancellation. Mid-stream upstream errors throw HookUpstreamError('stream-aborted').

interface HookUpstreamOverrides {
method?: string; // RFC 7230 token; wire case preserved
pathAndQuery?: string; // byte-preserving; rejects # / .. / \ / whitespace / control / absolute-form
host?: string; // IPv4 / DNS label; leading-zero octets rejected (octal-parse SSRF guard)
port?: number; // 1..65535
headers?: Record<string, string | string[] | null>; // `null` deletes; validates name + value
body?: BodyInit | null; // `null` drops body
signal?: AbortSignal; // merged with dispatcher abort + timeoutMs
timeoutMs?: number; // 1..86_400_000 (24h)
onUpstreamError?: (err: HookUpstreamError) => { status; headers?; body?; }; // forward() only
}

User scripts run in the host realm via new Function(), so HookUpstreamError is not reachable as a free identifier. Use metadata.hook.HookUpstreamError for instanceof branching, or rely on the realm-independent surface: err.name === 'HookUpstreamError' plus err.kind.

kindWhen it fires
networkUpstream TCP error (ECONNREFUSED, reset, DNS)
abortClient-abort propagated via dispatcher signal or overrides.signal
timeoutoverrides.timeoutMs fired (Bun TimeoutError properly classified)
invalid-overrideBad host / port / pathAndQuery / method / headers / timeoutMs / signal / onUpstreamError
bytes-already-sentpipeResponse called with res.headersSent already true
stream-abortedUpstream read failed mid-stream (while client still connected)
no-bodyNon-@rawBody script consumed req without preserving rawBody, or pre.js drained the @rawBody stream
body-consumedfetchUpstream called twice on a streamed @rawBody without overrides.body
duplex-unsupportedRuntime rejected duplex: 'half' (Bun < 1.3)

Gate any service on “a valid Hoody user” with custom logic. Hoody mints identity claims: ED25519-signed credentials proving that Hoody authenticated the user. For the common case, prefer the native hoody-identity permission group, which takes one permission-file entry and no code, and injects verified X-Hoody-Identity-* headers for you. Reach for the hook recipe below when you need what the native gate doesn’t do: browser-facing apps (cookie transport, since browsers can’t attach custom headers on navigation), custom rejection responses, per-path logic, or claim verification combined with other checks.

The pattern works like this: the client sends its claim as <payload_b64>.<signature_hex> in an x-hoody-claim header. The header sits deliberately outside the reserved x-hoody-identity-* namespace, which the edge unconditionally strips before traffic reaches any upstream, including your hoody-exec hook; a claim sent in the reserved namespace would never arrive. The hook verifies the signature and payload, rejects with 401 on any failure, and forwards with trusted x-hoody-user-* headers your app can rely on.

The entry is a catch-all match on your app’s service:

POST /api/v1/containers/<id>/proxy/hooks/run body
{
"match": { "method": "*", "path": "*" },
"script": { "path": "/identity-gate" },
"timeout": 3000
}
identity-gate.js
const { createPublicKey, verify } = require('crypto');
// Pin Hoody's key at deploy time: GET https://api.hoody.com/api/v1/meta/public-key
// Re-pin when that endpoint's active_kid rotates; until you do, newly issued
// claims fail verification and this gate returns 401 for everyone (fail-closed).
const HOODY_PUBLIC_KEY_HEX = '8c8d683c125761bd9157e3a6f98c30d81cd7f2be4d16062a8342d1fcd2ca474a';
const HOODY_KID = 'v1';
const SPKI_PREFIX = '302a300506032b6570032100'; // DER envelope for a raw ed25519 key
const HOODY_KEY = createPublicKey({
key: Buffer.from(SPKI_PREFIX + HOODY_PUBLIC_KEY_HEX, 'hex'),
format: 'der',
type: 'spki',
});
function verifyClaim(headerValue) {
const [payload_b64, signature_hex] = String(headerValue ?? '').split('.');
if (!payload_b64 || !/^[0-9a-f]{128}$/.test(signature_hex ?? '')) return null;
if (!verify(null, Buffer.from(payload_b64, 'utf8'), HOODY_KEY, Buffer.from(signature_hex, 'hex'))) return null;
let p;
try { p = JSON.parse(Buffer.from(payload_b64, 'base64url').toString('utf8')); } catch { return null; }
const now = Math.floor(Date.now() / 1000);
const ok = p.claim_type === 'identity' && p.iss === 'hoody-api'
&& p.exp > now && p.exp > p.iat && p.iat <= now + 300 && p.kid === HOODY_KID;
return ok ? p : null;
}
module.exports = async function (req, res, metadata) {
if (!metadata.hook) { res.writeHead(404); res.end(); return; }
const user = verifyClaim(req.headers['x-hoody-claim']);
if (!user) {
res.writeHead(401, { 'content-type': 'application/json' });
res.end(JSON.stringify({
error: 'HOODY_IDENTITY_REQUIRED',
message: 'Send a valid identity claim as x-hoody-claim: <payload_b64>.<signature_hex>',
}));
return;
}
await metadata.hook.forward(req, res, {
headers: {
'x-hoody-claim': null, // don't leak the credential to the app
'x-hoody-user': user.username, // trusted: set by this hook from the verified payload,
'x-hoody-user-id': user.sub, // replacing anything the client sent
'x-hoody-user-type': user.type, // "user" | "admin"
},
});
};
  • WebSocket upgrades: they never hit hooks, so this recipe does not gate WebSocket endpoints. Keep WebSocket surfaces off the gated service, or verify the claim inside the app for those paths.
  • CORS preflights: method: "*" skips OPTIONS, so preflights reach your app ungated. Harmless if your app does no work on OPTIONS; add OPTIONS to match.method to gate preflights too.
  • Header trust scope: the identity headers are trustworthy only on hook-matched traffic. With the catch-all match above, every non-WebSocket, non-OPTIONS request is verified. If you narrow the match, requests outside it arrive with whatever headers the client sent.
  • Direct ingress: the gate covers only traffic that flows through the Hoody Proxy hook layer. Your app’s real container-ip:port exists independently of the proxied hostname; anything that reaches it directly (for example, from another process or container that can route to it) bypasses the hook entirely and can send arbitrary x-hoody-user-* headers. Restrict direct ingress with the Container Firewall, and never trust these headers on a listener that isn’t guaranteed to receive only hook-forwarded traffic.
  • Authorization: “valid Hoody user” means any Hoody user. The claim proves Hoody authenticated someone, not that they belong to your project. If you need “my users only”, authorize user.sub against your own allowlist after verification.
  • Expiry: claims expire (30-day default). A 401 from this gate means the client should log in to Hoody again to obtain a fresh claim.

Each service key holds an ordered array of hook rules: at most 8 per service and 32 per file.

{
"hooks": {
"terminal": [ { ... }, { ... } ],
"files": [ { ... } ],
"exec": [ { ... } ]
}
}

Allowed services are the tenant-reachable ones your hoody-kit exposes via SNI. In current builds that’s terminal, files, notes, run, curl, watch, cron, pipe, sqlite, browser, notifications, tunnel, daemon, code, display, exec, agent, desktop, and d-tcp. That list is descriptive rather than normative: the API validates the service name by shape and then applies the reject list below, so a newly added service becomes hookable without a doc change. Custom hostname aliases resolve to one of these services at the proxy edge; they are not a separate dispatchable surface. Reject-listed: logs, proxy, workspaces, cdp (internal infrastructure; the API refuses to persist hooks for them, and the proxy additionally refuses to dispatch them even if an on-disk matrix file is tampered to include them).

match is a predicate; its fields are AND-joined.

{
"match": {
"method": ["POST", "PUT"], // or "*" for any-except-OPTIONS; single string also OK
"path": "/api/*", // glob: `*` matches one segment; trailing `*` matches any suffix
"headers": { "X-Tenant": "alice" } // optional: all keys must match exactly (keys case-insensitive)
}
}
  • method defaults to "*", which does not match OPTIONS; CORS preflight is skipped by default. Include OPTIONS explicitly if you want to hook preflights.
  • path defaults to "*" (any).
  • Requests with Upgrade: websocket are never hook-routed; WebSocket and hooks are mutually exclusive.
  • Rules evaluate in order; the first match wins.

script targets one of your hoody-exec scripts.

{
"script": {
"subdomain": "myapp", // optional, lowercase alphanum 1-64 chars or "default": /^(default|[a-z0-9]{1,64})$/
"execId": "obs", // optional, lowercase alphanum 1-64 chars: /^[a-z0-9]{1,64}$/
"path": "/login-audit" // required, exact path matching /^\/[A-Za-z0-9._\-\/]{0,256}$/; uppercase allowed; no `..` or `.` dot-segments, no `//`, no NUL, no wildcards, no percent-encoding
}
}

Coordinates match your hoody-exec script addressing. A hoody-exec script’s public URL has hostname [<subdomain>.]<projectId>-<containerId>[-exec-<execId>].<node>.<domain> with the script path served at <path>; both the <subdomain>. prefix and the -exec-<execId> segment are optional. The three script fields map to the same coordinates: subdomain (optional), execId (optional), path (required). Specifically:

  • subdomain and execId are lowercase alphanum only (to match the public SNI parser, which lowercases the hostname before matching).
  • script.path allows uppercase ASCII letters but rejects *, %, spaces, :, non-ASCII, and path-traversal segments.
  • match.path is a glob (allows *) and has its own charset (see below).

timeout is a soft, client-visible deadline enforced inside hoody-exec.

{ "timeout": 500 }

Milliseconds, clamped to [1, 30000]; the default is 500 ms. The deadline sits well inside nginx’s much longer upstream read timeout (proxy_read_timeout 86400s in the current edge config), so the hook deadline always fires first. The main reason to keep it tight is tenant UX: a 30s timeout makes a slow hook feel like a broken service.

What the client sees when the deadline fires depends on whether your script has already started writing:

  • If headers haven’t been sent, the client gets 504 hook timeout (JSON body).
  • If headers or body streaming has started, hoody-exec destroys the response socket. The client sees a truncated response or a connection reset. There is no trailing 504; the TCP-level abort is the signal.

The deadline does not cancel JavaScript execution. Your script keeps running until it naturally returns. When it later tries to res.write/res.end on the destroyed socket, those calls error out (swallowed by hoody-exec so the worker stays healthy). The practical effect: long-running I/O you kicked off (for example an in-flight await fetch(...)) proceeds to completion and its response is discarded. Don’t rely on cooperative cancellation at the deadline; design your hook to fit well inside the timeout.


When invoked as a hook, metadata.hook is populated:

metadata.hook = {
auditId: "550e8400-e29b-41d4-a716-446655440000", // UUID, or "none" if audit rate-limited, audit-gate blocked, or DB write failed
origMethod: "POST", // client's original method
origPath: "/api/login", // client's original path (query stripped)
service: "terminal", // the service the client targeted
upstream: {
host: "192.168.1.42", // container IP; the authoritative host for forwarding
port: 76 // real service port (not hoody-exec's)
}
};

upstream.host:port is the host and port of the real service. It does not carry the full routing envelope that non-hook requests would resolve to: service-specific query arg injection, path rewrites, and explicit https protocol are not propagated into metadata.hook.upstream. Use it for the common case of “forward the request as-is to the real service”; if your hook needs to replicate the full non-hook routing, fetch the service’s oracle response yourself or forward via an internal endpoint your tenant owns.

For a non-hook invocation of the same script, metadata.hook is undefined; one script can serve both regular traffic and hook dispatches.

req.url is the client’s original URL (path + query). req.headers is the client’s headers with X-Hoody-Hook-* stripped. Note that the edge proxy also injects or rewrites the standard forwarding headers (Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto) the same way it does for non-hook traffic. Use X-Real-IP for the real client IP.


  • Tenant isolation: hook scripts run in your own hoody-exec and see only your tenant’s traffic. The proxy cannot dispatch another tenant’s request to your hook.
  • Tenant privileges: your script runs with tenant privileges. A hook can’t do anything a hoody-exec script couldn’t already do; there’s no sandbox escape risk beyond what already exists.
  • Metadata forgery: clients cannot forge X-Hoody-Hook-* headers. The proxy sweeps them before the request reaches hoody-exec, even on direct traffic to the exec service.
  • Fail-closed by default: a hook error (exception, timeout, 404 on the target script) returns an error to the client; there is no fail-open fallback. If you want optional hooks, catch errors inside your script and return success.
  • Best-effort audit trail: the proxy tries to write one audit row per matched hook dispatch with op: hook-dispatch, carrying projectId, containerId, groupName, service, scriptRef, origMethod, origPath. The hook still dispatches (with metadata.hook.auditId === 'none') if the audit subsystem can’t write the row: specifically on (a) the per-scope SNI rate-limit bucket being exhausted, (b) the audit-gate being in a blocked state, or (c) a DB write exception. Audit sits off the hot path and never gates routing. Don’t rely on the audit log as a source of truth for every hook invocation.

  • Container level only. The API rejects project-level hooks.
  • No WebSocket hooks. Upgrades skip the hook layer; use a REST endpoint if you need hooking.
  • Bounded streaming. Long-lived SSE and streaming responses are bounded by the hook timeout (max 30s); streams that outlive the timeout get truncated.
  • Hard timeout, no JS cancellation. At the deadline the client sees a 504 (if headers haven’t been sent yet) or a truncated or reset response (if headers are already in flight). Your script keeps running until it naturally completes; its response is discarded. Cooperate with the deadline by yielding.
  • Soft caps. At most 8 hooks per service and 32 per file. Design for first-match-wins; don’t rely on iterating many fine-grained rules.

Your hook forwards via metadata.hook.upstream.host:port, the container IP, directly. Never fetch the public SNI (https://<projectId>-<containerId>-terminal-1.<domain>/...) from inside a hook: that routes back through the proxy and can re-trigger the hook, potentially infinitely.

The proxy enforces a timeout budget on the outermost client request, so unbounded recursion is self-limiting, but it still wastes container resources. Use metadata.hook.upstream as the only upstream address.


Hooks are first-class resources with their own CRUD endpoints. Although hooks are stored in the permissions document, manage them only through the dedicated hooks endpoints; bulk permissions writes cannot carry hooks today.

VerbPathWhat it does
GET/api/v1/containers/{id}/proxy/hooksList all hooks grouped by service
GET/api/v1/containers/{id}/proxy/hooks/{service}List hooks for one service
POST/api/v1/containers/{id}/proxy/hooks/{service}Append or insert a hook
DELETE/api/v1/containers/{id}/proxy/hooks/{service}Clear all hooks for a service
GET/api/v1/containers/{id}/proxy/hooks/{service}/{hookId}Get a single hook
PUT/api/v1/containers/{id}/proxy/hooks/{service}/{hookId}Replace a hook in place
DELETE/api/v1/containers/{id}/proxy/hooks/{service}/{hookId}Remove a hook
PATCH/api/v1/containers/{id}/proxy/hooks/{service}/{hookId}/positionMove a hook to a new position

All mutating endpoints require If-Match: file:v<N> (ETag from the last read) for optimistic concurrency.

Read-only calls don’t need an ETag. Mutating calls (create, update, delete, clear-service, move) require --if-match file:v<N>; fetch the current version with list or get first.

Add a single hook:

Terminal window
hoody containers proxy hooks create <container-id> terminal \
--match-method POST \
--match-path '/api/login*' \
--script-path /login-audit \
--timeout 500 \
--if-match file:v1

List, get, update, move, delete:

Terminal window
hoody containers proxy hooks list <container-id>
hoody containers proxy hooks list-service <container-id> terminal
hoody containers proxy hooks get <container-id> terminal <hook-id>
hoody containers proxy hooks update <container-id> terminal <hook-id> \
--match-method POST,PUT \
--match-path '/api/login*' \
--script-path /login-audit \
--timeout 1000 \
--if-match file:v2
hoody containers proxy hooks move <container-id> terminal <hook-id> --position 0 --if-match file:v3
hoody containers proxy hooks delete <container-id> terminal <hook-id> --if-match file:v4 -y
hoody containers proxy hooks clear-service <container-id> terminal --if-match file:v5

hooks delete is the only hook command that prompts for confirmation: without the global -y/--yes it blocks on a y/N question, so scripts and agents must pass it.

Targeted hook CRUD (methods live under client.api.proxyHooks):

// Add a hook
await client.api.proxyHooks.addContainerProxyHook(containerId, 'terminal', {
match: { method: ["POST"], path: "/api/login*" },
script: { path: "/login-audit" },
timeout: 500,
}, { ifMatch: 'file:v1' });
// List hooks for a service
const { data } = await client.api.proxyHooks.listContainerProxyServiceHooks(containerId, 'terminal');
// Move a hook to the front
await client.api.proxyHooks.moveContainerProxyHook(containerId, 'terminal', hookId, { position: 0 }, { ifMatch: 'file:v2' });
// Remove a hook
await client.api.proxyHooks.removeContainerProxyHook(containerId, 'terminal', hookId, { ifMatch: 'file:v3' });

client.api.proxyPermissionsContainer.replace() cannot carry hooks: the permissions validator requires each rule’s server-assigned id, and the endpoint’s body schema rejects id as an unknown property. Route every hook write through client.api.proxyHooks.