gateway-hooks.toml (JSON is accepted), and @withgateway/sdk/hooks attaches them to the tools the agent calls. The agent changes nothing. The same file runs with any agent SDK and inside Claude Code and coding agents through gateway hooks run; this page is the reference.
Events
Five events, in the order they fire around one call.
Claude Code’s names are accepted as aliases:
PreToolUse, PostToolUse, Stop and SessionEnd (the last two mean run_end).
The file
name (the label on the context it adds), timeout in seconds (command 60, http 30, function none; 120 on run_end) and async = true (run without blocking the call; the answer is logged, never applied).
The file is read once, when the run starts. Edit it, start a new run; gateway hooks check is the reload.
Handler types
The handler contract
A command reads this on stdin (an HTTP handler is POSTed it):post_tool_call adds tool_result (or error), the HTTP events add request and response, run_end adds report and artifacts. The run key is never in it.
It answers on stdout:
decision is allow (the default), deny (with reason) or rewrite (with updated_input, updated_result, updated_error, updated_request or updated_response). Plain text on stdout is additional_context. Claude Code’s hookSpecificOutput form (permissionDecision, updatedInput, additionalContext) is accepted too, so a hook written for Claude Code runs unchanged.
A malformed answer (
decision: "maybe", findings that are not a list) is a hook error, not an allow.
How a rule is applied
Every handler of one matcher sees the same event; there is no implicit chaining inside a rule. Their answers merge:denybeatsrewritebeatsallow. Any deny denies, with its reason.- At most one handler per matcher may rewrite. A second rewriter is an error (
HookError), never a race. Twoaliashandlers under one matcher are refused when the file loads. additional_contextlines reach the agent labelled[hook <name>]: appended to a string result, underhook_contextin an object result, or wrapping any other result as{ result, hook_context }.- Findings accumulate on the run (
run.findings).
record handler under its own [[hooks.post_tool_call]] after the alias rule to record the alias-space transcript, or beside the alias handler to record what the tool actually returned.
A deny throws HookDenied to the caller; post_tool_call still fires with error: "denied: <reason>" so a record handler logs it. A tool’s own exception goes through post_tool_call as error text and is re-thrown with the same class and the rewritten message.
The alias door
Thealias handler replaces every private individual with a plausible stand-in whose attributes are consistent: Matthew Nowell becomes Keith Wexler, his work email keith.wexler@tidewater-wexler.alias, his handle kwexler, his phone a number in the reserved +1 999 555 01xx range. Every attribute of one identity is derived from the same run key, so the links an investigator relies on survive.
run.seed(entities) mints the aliases before the agent starts. Each entity is { type, value, role?, identity? }; identity groups the attributes of one person, role: "public" marks the public layer (a subject company, an officer named in a filing), which is never replaced. Kinds with a dedicated derivation: person_name, email, username, phone, company, domain, ip, account, machine, log; any other kind gets a REF-nnnn stand-in.
What slips through the inbound rewrite is metered, not assumed: run.summary().leak_through_inbound counts every real value found in text about to reach the agent. run.legend() is the map, with the run key, for the grader. Keep it with the run, never with world data.
The alias door keeps real identities out of the model’s context, the
transcript and the stored run. It does not hide the subject from a live
provider: the real name is what reaches the search engine or the vendor,
exactly as in production. Do not read it as anonymous search.
gatewaysdk/fixtures/hooks/alias-vectors.json pins the two, so a run seeded in one language grades in the other.
Attach the rules
run.wrapTools(tools, { vendor? }) takes a name -> async function map, the shape a WorldToolkit’s impls, the Vercel AI SDK’s tools and captureToolCalls share, and returns the same map with the rules around every call.
run.session(session, { vendor? }) puts the rules on a world session: call() and toolkit() fire the tool-call events, and request(method, path, { query, body }) makes one HTTP request to the session’s api surface with pre_request before it and post_response after. A 4xx or 5xx is returned as { status, body }, not thrown, so the agent sees the vendor’s own error shape rewritten like any result.
run.on(event, fn, { matcher?, vendor?, arg?, name? }) adds a function as a rule from code. It takes the HookEvent and returns a result, a plain object of the same shape, or nothing.
Security first, hooks second: run.wrapTools(security.secureTools(tools)).
Declared live tools
The same file declares the live tools the agent calls, generically: a[tools.<name>] table
whose entries are HTTP requests, functions or connector operations, all producing one callable
shape. A web search is just one declared tool; the SDK knows nothing about “search”.
input is a shorthand for a JSON schema: type[?][: description] (? = optional), or a schema
table. {field} holes in url (URL-encoded), body and limit come from the input; a body string
that is exactly one hole becomes the field’s typed value, and an absent optional field drops its key.
parse = "json" takes results (a JSON path such as $.items[*]) and fields (result field = item
path, or { from, query_param } to unwrap a redirect); parse = "regex" takes a pattern with named
groups ((?<name>...), one object per match, HTML tags stripped and entities decoded) and the same
fields; parse = "text" returns the page’s readable text. A connector tool sends the operation
with the connection’s own auth, and every variable the connection reads must be in allowed_env.
[live].record: the input as the agent asked it, as it was sent (real
values, defaults filled), the raw answer, the answer as shown (alias space) and the elapsed time; a
denial or a failure is a line with error. Set replay to that file and every declared tool answers
from it without touching the network — an unrecorded call is refused by name, so a re-score is
deterministic. {run_id} and {run_dir} in record, replay and a record handler’s sink are
filled in per run (runDir defaults to <cwd>/runs/<run id>).
Two example files ship in gatewaysdk/examples/hooks/: duckduckgo.toml (keyless search and fetch,
regex-parsed) and exa.toml (a keyed JSON API). Both are tested against fixture pages, and
gateway hooks check <file> --probe web_search --input '{"query": "example"}' makes one real call.
Each [tools.*] entry may set vendor = "...", which its events carry (for vendor = matchers)
instead of the one run.tools({ vendor }) gives every tool. run.toolDefinitions("anthropic")
returns { name, description, input_schema } for a Messages loop; the default shape is OpenAI’s.
LiveTools.fromConfig(file, run, { runDir? }) builds the table for an existing run from a hooks
file alone. A recording line for a refused call carries denied: true whatever the exception’s
class, as long as it is a HookDenied, carries a string reason, or is named *Denied /
*Refusal / *Refused.
What the door does to recorded arguments
Onpost_tool_call the alias handler also rewrites tool_input into alias space (unmetered), so a
record handler placed after it stores alias-space arguments — the real selector never reaches the
transcript. { type = "alias", args = "keep" } opts out. The leak meter and the run_end scan are
case-folded with whitespace collapsed and check every variant of every alias, so NOWELL LTD on a
page is counted.
From code
run.on(event, handler | [handlers], { matcher, vendor, arg, name, before }) takes functions or
built-in instances (new DenyHandler("closed"), new AliasHandler(run.alias)); before: true puts
the rule ahead of the file’s. A matcher string splits on top-level | only, so ^(a|b)_tool$ stays
one regex. run.session(session, { vendor, requestFn }) takes a transport of your own —
requestFn({ method, path, query, body }) -> { status, body, headers? } — so a client with its own
auth (an x-api-key, a password grant, a key in the query) still gets pre_request and
post_response. Every response carries elapsed (seconds); a network failure comes back as
{ status: null, body: null, error: "<text>", elapsed } through post_response rather than as a
lost exception.
The name variants include the bare lower- and upper-case spellings (nowell, NOWELL,
matthew nowell), so a profile slug such as /in/nowell or a handle fragment is rewritten and
mapped back on the way out.
Check a file, or run it as a coding agent’s hook
gateway hooks run is the command a coding agent names in its hook settings: the agent’s event
on stdin, the agent’s answer on stdout, the run’s key and seeds kept under .gateway/hooks/<session id> between the processes the agent spawns. In Claude Code and coding agents
has the settings entry and the full contract.
The command validates the file, builds every handler and every declared tool, sends one sample event through the rules that match, and lists the [tools] table with each tool’s input. Command and http handlers really run. A handler that fails on the sample, or two handlers that both rewrite it, is reported as a problem and the command exits 1. --json prints the report as JSON; --seed seeds the alias door first; --probe <tool> makes one real call with --input (opt-in: it uses the network and any key the tool reads).
Python
The Python package has the same module,gatewaysdk.hooks, with the same file, events and handler contract; see Hooks in the Python SDK.