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).

The client IP is preserved 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 + the query string of req.url (the client’s query with the proxy’s additions described under What your hook script sees). Byte-preserving (no new URL() canonicalization).
  • Client path sent as the client sent it: @ (as in /@babel/core) and a backslash in the query go through unchanged. Characters a URL parser would rewrite are percent-encoded so they arrive intact: a backslash in the path as %5C, # as %23, ? as %3F, and control characters, space and DEL as %XX. Non-ASCII paths arrive as the client’s UTF-8 bytes. A path that cannot be forwarded faithfully (a . or .. segment, including %2e forms, or a leading //) is refused with the error kind unforwardable-path, and nothing reaches the upstream.
  • 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: the buffered request body when present, else the request body streamed as it arrives when // @rawBody is set. content-length rules enforced.
  • Response-side hop-by-hop stripping, with each Set-Cookie header kept separate.
  • Compression: the upstream response is never decompressed; its bytes forward verbatim with content-encoding intact.
  • Client-abort propagation: when the client disconnects (for example from an SSE stream or a long poll), the upstream fetch is cancelled.
  • Error translation: network/abort/timeout become a sanitized 502 with an x-hoody-hook-audit header. unforwardable-path becomes 400 bad request path with Cache-Control: no-store. 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. The body is the upstream’s bytes as sent (not decompressed), so you can also return it directly from your script: return await metadata.hook.fetchUpstream(req) answers with the upstream’s status, headers and bytes, Content-Encoding included.

It forwards the client path by the same rule as forward(). If it refuses the path (unforwardable-path) and your script then ends with an uncaught error, the client gets 400 bad request path rather than a 500. That holds even when the script caught the refusal and failed later for another reason: the path could not be forwarded. A script that sends its own answer is never overridden. An unreachable upstream reached through an uncaught fetchUpstream() stays a 500; only forward() turns it into a 502.

// 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').

Content-Encoding and Content-Length of a Response

Section titled “Content-Encoding and Content-Length of a Response”

Content-Encoding follows the same rule whether you pass a Response to pipeResponse() or return it from your script. Content-Length differs: when you return a Response, its length is always computed from the bytes actually sent, so the column below applies to pipeResponse() only.

Where the Response came fromContent-EncodingContent-Length (pipeResponse())
fetchUpstream(), or your script’s fetch() / Bun.fetch() that left the body alone (decompress off, no coding, or a coding it does not decode such as compress)kept (the bytes are as sent)kept (never on a 204)
your script’s fetch() / Bun.fetch() that decoded the body: once empty and identity entries are set aside, exactly one gzip, x-gzip, deflate, br or zstd coding remains (such as gzip or gzip, identity)dropped (the bytes are decoded)dropped; the length is recomputed
a fetch made some other way (a helper module you require() or import(), a library using the runtime’s fetch) with one of those codingsdropped (treated as decoded, since a fetch decodes by default)dropped; the length is recomputed
anything else: new Response(...), new Response(r.body, r)kept as you set itdropped whenever a body is sent (kept on HEAD and 304; a 204 never carries one); the length is recomputed

decompress is read where fetch() reads it: from the options (an object or a function) when they give a boolean or a number, otherwise from the input (fetch({ url, decompress: false }), or a Request given a decompress property; never a URL or a string). false, NaN and any number strictly between -1 and 1 (such as 0) mean “do not decompress”; any other number means decompress, and other types say nothing. It is read at the moment fetch() itself reads it, during the call; changing the options or the input afterwards changes nothing.

A Response’s own .clone() keeps its origin. If you rewrap a decoded body (new Response(r.body, r)), delete Content-Encoding yourself: the rewrapped Response no longer knows its body was decoded.

Two cases are taken for decoded although their bytes are still encoded, so they go out without Content-Encoding:

  • Response.prototype.clone.call(r) of a Response whose body was left alone. Use r.clone() instead.
  • A fetch that did not decompress, made outside your script’s own fetch() / Bun.fetch() (a helper module, import(), (await import('bun')).fetch). Fetch raw bytes with your script’s own fetch() or Bun.fetch().
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
}

Each member is read once, when the call starts, and only from the object you pass itself: a plain property, or a getter (which runs then). Members the object inherits, whether from a class or through Object.create(defaults), are ignored, so put every override directly on the object. The same applies to pipeResponse()’s { method } and to the { status, headers, body } that onUpstreamError returns.

HookUpstreamError is available to your script as a global and as metadata.hook.HookUpstreamError, so err instanceof HookUpstreamError works. You can also branch on 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
invalid-overrideBad host / port / pathAndQuery / method / headers / timeoutMs / signal / onUpstreamError
unforwardable-pathThe client’s own path has a dot segment or a leading // and cannot be forwarded faithfully (forward() answers 400)
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. A cookie your hook reads should be named with the __Host- prefix and set by the address it protects, without a Domain attribute (cookie credentials).

The pattern works like this: the claim Hoody mints is three-part, <kid>.<payload_b64>.<signature_hex>. This recipe pins a single kid at deploy time, so the client sends just the last two parts, <payload_b64>.<signature_hex>, in an x-hoody-claim header. The header sits deliberately outside the reserved x-hoody-identity-* namespace, which Hoody’s proxy always 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, // identity type, e.g. "user"
},
});
};
  • WebSocket upgrades: a WebSocket upgrade on a path this hook matches is refused with 403 (HOOK_WEBSOCKET_UNSUPPORTED), so with the catch-all match above the gated service accepts no WebSocket connections. If the app needs WebSockets, narrow the match so the WebSocket paths fall outside it, and verify the claim inside the app on 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-OPTIONS request is verified or refused. 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": [ { ... } ]
}
}

A service key must be the canonical name of a hookable service: terminal, files, notes, run, curl, watch, cron, pipe, sqlite, browser, notifications, tunnel, daemon, code, display, exec, agent, desktop, d-tcp, and bot where the image ships it. The proxy looks a hook up by the canonical name of the service a request resolves to, so a key it could never select is refused with 422:

  • Short names such as t, tty, f or note: use the canonical name (terminal, files, notes). The error message names it.
  • Protocols (http, https, ssh) and names that are not a service: refused. A port-addressed hostname (...-http-8080) resolves to the service that owns the port, if any, and uses that service’s hooks.

Reject-listed: logs, egress, workspaces, cdp. These are platform services: hooks can’t be saved for them and are never run for them.

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

{
"match": {
"method": ["POST", "PUT"], // or a single method "POST", or "*" for any-except-OPTIONS
"path": "/api/*", // glob: `*` matches one segment; trailing `*` matches any suffix
"headers": { "X-Tenant": "alice" } // optional: all keys must match exactly (keys case-insensitive)
}
}
  • method is one of GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, or "*", as a single string or an array of 1-7 distinct methods. Case doesn’t matter on input; methods are stored uppercase. "*" is only valid on its own: ["*"], ["*", "OPTIONS"] and [] are refused with 422.
  • method defaults to "*", which does not match OPTIONS; CORS preflight is skipped by default. Include OPTIONS explicitly if you want to hook preflights.
  • A rule that lists GET also matches HEAD requests, because most servers answer HEAD with their GET handler. metadata.hook.origMethod tells your script which one arrived. A rule that lists only HEAD does not match GET.
  • Header names in headers must be valid HTTP field names, and no two may differ only in case. They are stored lowercase.
  • path defaults to "*" (any).
  • A request with Upgrade: websocket on a path a hook matches is refused with 403 (HOOK_WEBSOCKET_UNSUPPORTED), or with 503 if the matching rule is itself invalid (as for any request it matches). WebSocket upgrades on paths no hook matches route normally.
  • Rules evaluate in order; the first match wins.

script targets one of your hoody-exec scripts.

{
"script": {
"subdomain": "myapp", // optional, lowercase alphanum 1-63 chars (a hostname label): /^[a-z0-9]{1,63}$/
"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 (hostnames are matched in lowercase).
  • 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 in the range [1, 30000]; values outside the range are rejected. The default is 500 ms. The platform’s own upstream read timeout is far longer, 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.

applies_to narrows a hook to callers admitted through particular permission groups: groups the caller belongs to whose permission for this service grants the request. Leave it out and the hook runs for every request its match selects.

{ "applies_to": { "groups": ["staff", "admins"] } }

groups is a non-empty array of group names from the container’s proxy permissions. The hook fires when the caller’s matched groups (the groups that granted this request, not every group the caller belongs to) intersect that list, so several names read as “any of these”. An empty array, or a group name the permissions don’t define, is rejected on write.

The group filter is applied after method, path, and headers, as part of the same first-match-wins walk. A request that satisfies the predicate but was admitted through none of the listed groups is not stopped (belonging to a listed group is not enough if that group’s permission did not grant the request): it falls through to the next rule for that service, and to the service itself if no rule takes it.


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

metadata.hook = {
auditId: "550e8400-e29b-41d4-a716-446655440000", // UUID, or "none" when no audit ID is available
origMethod: "POST", // client's original method
origPath: "/api/login", // client's original path (query stripped); non-ASCII: UTF-8 text, or %XX for bytes that are not valid UTF-8
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 (apart from the instance selector, which arrives in the query), 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, build that request 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), with two additions the proxy makes to the query. The instance selector the permission check ran on (id, terminal_id, display, browser_port) is set to the authorized instance and replaces a client value for the same key, so forward() reaches the instance the request was allowed for, never one the client named. On a request through an alias that lands on its target_path, the parameters written in target_path are added too; under allow_path_override: false they replace a client value for the same key. req.headers is the client’s headers with X-Hoody-Hook-* stripped. Note that Hoody’s 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 hoody-exec cannot use the hook target it was handed (for example a subdomain longer than 63 characters from a rule saved before that limit), it answers 503 hook target unavailable and runs no other script in its place. If you want optional hooks, catch errors inside your script and return success.
  • Invalid hook configuration fails closed: a stored rule that no longer passes the rules above (for example one saved before a limit was tightened) is never skipped. Requests that match it get 503 (HOOK_UNAVAILABLE); other requests to the service are unaffected. Update or delete the rule to clear it. A service whose stored hook data can’t be read at all answers every request with 503. A request whose path is longer than 4096 bytes on a path a hook matches gets 414 (HOOK_PATH_TOO_LONG). These answers come from the proxy itself; the request never reaches your hook or the service.
  • Best-effort audit trail: the proxy tries to record every matched hook dispatch in its audit log. If the record can’t be written (for example under a burst of hook traffic), the hook still dispatches, with metadata.hook.auditId === 'none'. Auditing never delays or blocks 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 with 422.
  • No WebSocket hooks. A WebSocket upgrade on a hooked path is refused, not hooked. Keep WebSocket paths outside your hook matches, or 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.
  • 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 (hoody containers proxy hooks create|set|delete|move and hoody containers proxy services hooks clear) 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 -c <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 -c <container-id>
hoody containers proxy services hooks list -c <container-id> terminal
hoody containers proxy hooks get -c <container-id> terminal <hook-id>
hoody containers proxy hooks set -c <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 -c <container-id> terminal <hook-id> --position 0 --if-match file:v3
hoody containers proxy hooks delete -c <container-id> terminal <hook-id> --if-match file:v4 -y
hoody containers proxy services hooks clear -c <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.proxy.hooks):

// Add a hook
await client.api.proxy.hooks.create(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.proxy.hooks.listByService(containerId, 'terminal');
// Move a hook to the front
await client.api.proxy.hooks.move(containerId, 'terminal', hookId, { position: 0 }, { ifMatch: 'file:v2' });
// Remove a hook
await client.api.proxy.hooks.delete(containerId, 'terminal', hookId, { ifMatch: 'file:v3' });

client.api.proxy.containerPermissions.set() cannot carry hooks: it accepts only an empty hooks: {}. Route every hook write through client.api.proxy.hooks.