Skip to content

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. Same cost, same deploy surface, same SDK as any other hoody-exec script.

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


  • Audit log — record every login attempt with user-agent + 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 add an entry to your container’s proxy permissions file.
  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).

The client IP is preserved end-to-end via TPROXY — your hook script can see req.socket.remoteAddress just like any other script.


PATCH /api/v1/containers/<id>/proxy/permissions body
{
"project": "<project-id>",
"container": "<container-id>",
"groups": {},
"permissions": {},
"default": "allow",
"hooks": {
"terminal": [
{
"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);
};

Helper methods — metadata.hook.forward() / .fetchUpstream() / .pipeResponse()

Section titled “Helper methods — metadata.hook.forward() / .fetchUpstream() / .pipeResponse()”

When a hook dispatch is active, metadata.hook carries three non-enumerable helper methods that eliminate ~25 lines of hand-rolled fetch-and-pipe boilerplate.

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

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

One-shot passthrough. Reads req, sends to the authoritative upstream, streams the response back into res. 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). 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 → sanitized 502 with 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>”

Non-consuming fetch for inspect-then-forward. Returns a standard Fetch API Response; caller inspects/mutates/pipes. 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>”

Piping half of forward() exposed for inspect-then-forward. Handles status, response-side hop-by-hop, multi-value Set-Cookie, transfer-encoding/content-length reconciliation (RFC 7230 §3.3.3), HEAD/204/304 no-body (pass method: 'HEAD' for HEAD suppression). Client-close during streaming is handled 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' + 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)

hooks.<service> — per-service array (max 8 per service, 32 per file)

Section titled “hooks.<service> — per-service array (max 8 per service, 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. 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 (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": {
"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 "*". "*" 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. First match wins.

script — target (one of your hoody-exec scripts)

Section titled “script — target (one of your hoody-exec scripts)”
{
"script": {
"subdomain": "myapp", // optional, LOWERCASE alphanum 1-64 chars: /^[a-z0-9]{1,64}$/ — or "default"
"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": 500 }

Milliseconds, clamped to [1, 30000]. Default 500 ms. This is a soft, client-visible deadline enforced inside hoody-exec — it 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 → client gets 504 hook timeout (JSON body).
  • If headers/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.

What happens to your script when the deadline fires:

JavaScript execution is NOT cancelled. 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 (e.g. 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 OR 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 — authoritative HOST for forwarding
port: 76 // REAL service port (not hoody-exec's)
}
};

upstream.host:port gives you the host and port of the real service. It does NOT carry the full routing envelope that non-hook requests would resolve to — things like service-specific query arg injection, path rewrites, or 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 — but note the edge proxy also injects/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.


  • Hook scripts run in your own hoody-exec. They see only your tenant’s traffic. The proxy cannot dispatch another tenant’s request to your hook.
  • 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.
  • The proxy prevents metadata forgery. Clients cannot forge X-Hoody-Hook-* headers — they are swept by the proxy 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.
  • Audit trail is best-effort. 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 is not on the hot-path gating routing. Don’t rely on the audit log as a source of truth for every hook invocation.

  • Container-level only. Project-level hooks is rejected by the API.
  • WebSocket upgrades skip hooks. Use a REST endpoint if you need hooking.
  • Long-lived SSE/streaming — 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/reset response (if headers are already flying). Your script itself keeps running until it naturally completes — its response just gets discarded. Cooperate with the deadline by yielding.
  • Soft cap of 8 hooks per service, 32 per file. Design for first-match-wins; don’t rely on iterating many fine-grained rules.

Recursion — don’t fetch the public domain

Section titled “Recursion — don’t fetch the public domain”

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’s still a waste of container resources. Use metadata.hook.upstream as the only upstream address.


Hooks are first-class resources with their own CRUD endpoints. You can also manage them in bulk by writing the permissions document, which embeds hooks as a field.

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
PATCH/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"],"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"],"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
hoody containers proxy hooks clear-service <container-id> terminal --if-match file:v5

Bulk-write the whole permissions document (hooks inline):

Terminal window
hoody containers proxy permissions replace <container-id> \
--default allow \
--hooks '{"terminal":[{"match":{"method":["POST"]},"script":{"path":"/login-audit"}}]}' \
--if-match file:v5

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' });

Bulk replace via the permissions document:

await client.api.proxyPermissionsContainer.replace(containerId, {
project: projectId,
container: containerId,
groups: {},
permissions: {},
default: "allow",
hooks: {
terminal: [
{
match: { method: ["POST"], path: "/api/login*" },
script: { path: "/login-audit" },
timeout: 500,
},
],
},
}, { ifMatch: 'file:v5' });