> ## Documentation Index
> Fetch the complete documentation index at: https://docs.surfacearea.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Hooks

> Intercept an agent's tool calls with rules in gateway-hooks.toml - deny, rewrite, add context or record a call from a command, an HTTP endpoint or a function - and put a persona in front of every private individual with the built-in alias door.

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](/hooks/agent-sdks) and [inside Claude Code and coding agents](/hooks/coding-agents) through `gateway hooks run`; this page is the reference.

```typescript theme={null}
import { HookRun } from "@withgateway/sdk/hooks";

const run = await HookRun.fromConfig("gateway-hooks.toml");
run.seed([
  { type: "person_name", value: "Matthew Nowell", identity: "subject" },
]);

const tools = run.wrapTools(
  { websearch, spycloud_lookup },
  { vendor: "spycloud" },
);
await runMyAgent(tools); // every call goes through the rules

const verdict = await run.end(report, { "graph.json": graphText });
console.log(verdict.passed, run.summary());
```

## Events

Five events, in the order they fire around one call.

| Event | When | A handler can |
| - | - | - |
| `pre_tool_call` | before the tool runs | allow, deny with a reason, rewrite `tool_input`, add context |
| `post_tool_call` | after the tool returns (or throws) | rewrite `tool_result` or the error text, add findings and context |
| `pre_request` | before one HTTP request a session client makes | rewrite `path`, `query`, `body`; deny |
| `post_response` | after that response | rewrite the response `body` |
| `run_end` | once, with the final report and artifacts | fail the run with findings |

Claude Code's names are accepted as aliases: `PreToolUse`, `PostToolUse`, `Stop` and `SessionEnd` (the last two mean `run_end`).

## The file

```toml theme={null}
[alias]
slugs = ["acme"]                        # worlds that store fields as sha256:<slug>:<value>
public_layer_wins = true                # a `role: public` seed is never rewritten, even where a private email's derived variants reach its domain
public_mailbox_domains = ["gmail.com"]  # kept real inside an email persona; omit for the built-in free-mail list, [] to keep none

[vendors]
spycloud = "spycloud_.*"                # tool-name patterns -> vendor, for `vendor = ...` matchers

[[hooks.pre_tool_call]]
matcher = "spycloud_.* | websearch"     # `A | B` alternation of unanchored regexes; omitted, "" or "*" is every tool
vendor = "spycloud"                     # optional
arg = "email"                           # optional: the call must carry this argument (dotted paths work)
handlers = [
  { type = "alias" },
  { type = "command", command = "python guard.py", timeout = 10, name = "guard" },
]

[[hooks.post_tool_call]]
handlers = [{ type = "alias" }]

[[hooks.post_tool_call]]
handlers = [{ type = "record", sink = "runs/transcript.jsonl", async = true }]

[[hooks.run_end]]
handlers = [{ type = "alias", check = "outgoing" }]
```

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

| Type | Fields | What it does |
| - | - | - |
| `command` | `command`, optional `args`, `cwd` | Runs a program with the event as JSON on stdin and reads its answer on stdout. `command` alone runs through the shell; with `args` it is spawned directly |
| `http` | `url`, `headers`, `allowed_env` | POSTs the same JSON; the response body is the same answer shape. `$VAR` in a header value comes from the environment, and only for names in `allowed_env` |
| `callable` | `target` | `./guards.mjs#check`: an exported function, imported when the run starts |
| `alias` | `direction`, `check` | The persona two-way door, below. The direction follows the event |
| `record` | `sink`, `session`, `source` | Writes the call as the `captureToolCalls` record, one JSON line per call. `post_tool_call` and `post_response` only |
| `deny` | `reason` | Refuses every matching call. `pre_tool_call`, `pre_request` or `run_end` |

## The handler contract

A command reads this on stdin (an HTTP handler is POSTed it):

```json theme={null}
{
  "hook_event_name": "pre_tool_call",
  "run_id": "run-3f9a1c",
  "tool_name": "spycloud_lookup",
  "tool_input": { "email": "keith.wexler@tidewater-wexler.alias" },
  "matcher": "spycloud_.* | websearch",
  "cwd": "/home/agent",
  "vendor": "spycloud"
}
```

`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:

```json theme={null}
{
  "decision": "rewrite",
  "updated_input": {
    "email": "keith.wexler@tidewater-wexler.alias",
    "limit": 50
  },
  "additional_context": "capped at 50 rows",
  "findings": [{ "check": "limit_applied" }]
}
```

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

| Exit code | Meaning |
| - | - |
| 0 | continue; an empty stdout is `allow` |
| 2 | deny; stderr is the reason |
| anything else | a hook error: recorded on the run, never silent. It denies a `pre_*` call, fails `run_end`, and leaves a `post_*` result as it was |

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.

| Direction | Event | What happens |
| - | - | - |
| out | `pre_tool_call`, `pre_request` | every alias variant in the arguments, path, query or body becomes the real value. A `sha256:` digest used as a selector, or an alias that does not resolve, denies the call |
| in | `post_tool_call`, `post_response` | every real value and every variant of it (bare surname, `Surname, Given`, initials, handle and slug spellings, obfuscated emails, phone formattings, a world's stored `sha256:<slug>:<value>` form) becomes the matching alias variant, over the whole JSON tree, error text included |
| check | `run_end` | the report and the artifacts are scanned for any real value; a hit fails the run |

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

<Info>
  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.
</Info>

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

```toml theme={null}
[live]                                  # applies to every declared tool
record = "runs/live.jsonl"              # one line per call: input as SENT, result as ANSWERED and as SHOWN, elapsed
replay = ""                             # answer every tool from a recording instead of the network
allowed_env = ["EXA_API_KEY"]           # the only env names a tool may read; values never appear here
timeout = 45                            # seconds per request

[tools.web_search]                      # an HTTP-backed tool, no code
description = "Open web search."
input = { query = "string: The query.", max_results = "integer?: At most this many." }
defaults = { max_results = 10 }
method = "GET"                          # or POST with `body` as a JSON (or string) template over {input fields}
url = "https://html.duckduckgo.com/html/?q={query}"
headers = { User-Agent = "..." }        # $NAME interpolation only from [live].allowed_env
parse = "regex"                         # json | regex | text
pattern = '<a[^>]+class="result__a"[^>]+href="(?<url>[^"]+)"[^>]*>(?<title>.*?)</a>'
fields = { title = "title", url = { from = "url", query_param = "uddg" }, snippet = "snippet" }
limit = "{max_results}"

[tools.web_fetch]
input = { url = "string" }
url = "{url}"
parse = "text"                          # { url, status, title, text, truncated }; scripts and styles stripped
max_bytes = 200000

[tools.lookup]                          # a function joins the same table
callable = "./guards.mjs#lookup"
input = { id = "string" }

[tools.breach_by_email]                 # a connector operation, reusing the worlds grammar
connector = "./spycloud/connector.toml"
operation = "get_records_by_email"
```

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

```typescript theme={null}
const run = await HookRun.fromConfig("gateway-hooks.toml");
const tools = await run.tools(); // { web_search, web_fetch, ... } — wrapped by the rules, recorded
const definitions = await run.toolDefinitions(); // [{ type: "function", function: { name, description, parameters } }]
await tools.web_search!({ query: "Keith Wexler" });
console.log(run.liveLog.at(-1)); // { tool, input_asked, input_sent, result_raw, result_shown, elapsed, replayed }
```

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

```bash theme={null}
gateway hooks check gateway-hooks.toml
gateway hooks check gateway-hooks.toml --event post_tool_call --tool spycloud_lookup --input '{"email":"a@b.test"}'
echo '{"hook_event_name":"PreToolUse","session_id":"s1","cwd":".","tool_name":"Bash","tool_input":{"command":"ls"}}' | gateway hooks run
```

`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](/hooks/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](/sdk/hooks) in the Python SDK.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.