Skip to main content
A hook is a rule that runs around one tool call, the way a Claude Code hook runs around one tool use. Rules live in a file next to the agent, 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

A rule is a matcher and a list of handlers. Every handler may set 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:
  • deny beats rewrite beats allow. Any deny denies, with its reason.
  • At most one handler per matcher may rewrite. A second rewriter is an error (HookError), never a race. Two alias handlers under one matcher are refused when the file loads.
  • additional_context lines reach the agent labelled [hook <name>]: appended to a string result, under hook_context in an object result, or wrapping any other result as { result, hook_context }.
  • Findings accumulate on the run (run.findings).
Rules of one event run in declaration order, and the next rule sees the previous rule’s rewrite. Put a 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

The alias 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.
The derivation is the same in the Python SDK; 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.
Every call is one line in [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

On post_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.