> ## 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 from Python with rules in gateway-hooks.toml - deny, rewrite, add context or record a call from a command, an HTTP endpoint or a callable - and put a persona in front of every private individual with the built-in alias door.

`gatewaysdk.hooks` runs rules around an agent's tool calls, the way a Claude Code hook runs around one tool use. The rules live in `gateway-hooks.toml` next to the agent (JSON is accepted); the file, the events and the handler contract are the same as the [TypeScript SDK's](/sdk-ts/hooks), which documents them in full. This page covers the Python surface; [Hooks for any agent](/hooks) is the overview, with the seam in each agent SDK and the coding-agent command.

```python theme={null}
from gatewaysdk.hooks import HookRun

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

tools = run.wrap({"websearch": websearch, "spycloud_lookup": lookup}, vendor="spycloud")
run_my_agent(tools)                                    # every call goes through the rules

verdict = run.end(report, artifacts={"graph.json": graph_text})
print(verdict.passed, run.summary())
```

## The run

`HookRun(config=None, *, run_key=None, run_id=None, slugs=None, session=None, source=None, cwd=None)` takes a path, a parsed mapping in the file's shape, a `HookConfig`, or nothing. `HookRun.from_config(path, **options)` reads the file once; the run keeps that snapshot. A handler that cannot be built (a `callable` whose `module:function` does not import) fails here, not on the first matching call.

| Method | What it does |
| - | - |
| `seed(entities)` | mints aliases for `[{type, value, role?, identity?}]` before the agent starts |
| `wrap(tools, *, vendor=None)` | a `name -> callable` map (a `WorldToolkit`'s `impls`, the tools the agent calls against a real vendor) with `pre_tool_call` and `post_tool_call` around every call. Sync and async callables both; the keyword arguments are `tool_input`, positional ones land under `_args`. The drop-in shape of `capture_tool_calls` |
| `session(session, *, vendor=None)` | a `WorldSession` behind the rules: `call()` and `toolkit()` fire the tool-call events; `request(method, path, query=None, body=None)` makes one HTTP request to the session's api surface with `pre_request` before and `post_response` after, returning `{status, body, headers}` (a 4xx or 5xx is returned, not raised) |
| `on(event, *handlers, matcher=None, vendor=None, arg=None, name=None)` | adds Python callables as one rule. Each takes a `HookEvent` and returns a `HookResult`, a dict of the same shape, or `None` (allow). Several callables in one call are the handlers of one matcher |
| `end(report, artifacts=None)` | `run_end`; returns a `RunVerdict(passed, reason, findings, context)` after waiting for the `async` handlers |
| `legend()` | `{run_id, run_key, aliases}`: the map, with the run key, for the grader. Never store it with world data |
| `summary()` | the meters: calls, denials, hook errors, findings, and the alias door's substitutions and leak counts |

A deny raises `HookDenied` (`.reason`, `.handler`, `.findings`) to the caller; a rule that cannot be applied raises `HookError`. Both are recorded on `run.denials` and `run.errors` first. A tool's own exception goes through `post_tool_call` as `error` text and is re-raised with the same type and the rewritten message.

## Handlers from Python

A command handler's contract is the one on the TypeScript page: the event as JSON on stdin, the answer as JSON (or plain-text context) on stdout, exit 0 to continue, exit 2 to deny with stderr as the reason, any other exit a recorded hook error. In the file, `callable` names a Python function as `target = "my_agent.guards:check"`.

From code, a handler is any callable:

```python theme={null}
from gatewaysdk.hooks import HookEvent, HookResult

def cap_rows(event: HookEvent) -> HookResult | None:
    if event.event == "post_tool_call" and isinstance(event.tool_result, dict):
        rows = event.tool_result.get("rows", [])
        if len(rows) > 50:
            return HookResult(updated_result={**event.tool_result, "rows": rows[:50]},
                              additional_context=f"showing 50 of {len(rows)} rows")
    return None

run.on("post_tool_call", cap_rows, matcher="spycloud_.*")
```

`HookResult(decision="allow"|"deny"|"rewrite", reason=None, updated_input=UNSET, updated_result=UNSET, updated_error=UNSET, updated_request=UNSET, updated_response=UNSET, additional_context=None, findings=[])`. `UNSET` is distinct from `None`, so a handler can rewrite a value to `None`.

`additional_context` reaches the agent labelled `[hook <name>]`: appended to a string result, under `hook_context` in a dict result, or wrapping any other result as `{"result": ..., "hook_context": [...]}` (`gatewaysdk.hooks.with_context`).

## The alias door

`AliasMap(run_key, slugs=(), public_layer_wins=True, public_mailbox_domains=None)` is the derivation behind the `alias` handler, usable on its own. Nothing about it is a fixed rule: `public_layer_wins` (`[alias] public_layer_wins`) keeps a `role: public` seed real even where a private email's derived variants reach its domain — off, the variants apply as derived — and `public_mailbox_domains` (`[alias] public_mailbox_domains`) is the list of mail domains an email persona keeps real: omitted, the built-in free-mail list; `[]`, none; any other list replaces it. Methods: `mint(kind, value, identity_key=..., role=...)`, `seed(entities)`, `to_agent(text, where, meter=True)`, `to_world(text, where) -> (text, problems)`, `walk(node, "in" | "out", where)`, `mint_digest(digest)`, `scan_outgoing(text, where)`, `rewrite_instruction(text)`, `legend()`, `summary()`. `new_run_key()` mints a key; `canonical(kind, value)` is the folding one entity is keyed by.

The derivation is the same as the TypeScript SDK's; `gatewaysdk/fixtures/hooks/alias-vectors.json` pins the two, and the Python suite writes it (`GATEWAY_WRITE_GOLDENS=1 pytest tests/hooks/test_golden.py`).

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

## Declared live tools

The same file declares the live tools the agent calls in a `[tools.<name>]` table — HTTP requests
(`parse = json | regex | text`), Python callables (`callable = "pkg.mod:fn"`) and connector
operations (`connector` + `operation`) — with a `[live]` block (`record`, `replay`, `allowed_env`,
`timeout`) that applies to all of them. The grammar is on the [TypeScript page](/sdk-ts/hooks#declared-live-tools);
the Python surface is:

```python theme={null}
run = HookRun.from_config("gateway-hooks.toml")
tools = run.tools()                     # {"web_search": fn, "web_fetch": fn, ...}: wrapped by the rules, recorded
definitions = run.tool_definitions()    # [{"type": "function", "function": {name, description, parameters}}]
tools["web_search"](query="Keith Wexler", max_results=5)
run.live_log[-1]                        # {tool, input_asked, input_sent, result_raw, result_shown, elapsed, replayed}
```

Every call is one line in `[live].record` (`input_sent` holds the real values with defaults filled;
`result_shown` is what the agent saw); with `replay` set the tools answer from that file and an
unrecorded call raises `LiveToolError` naming the tool and its input. Building a run builds every
tool, so a callable that does not import, a missing `connector.toml` or an absent replay file fails
at `HookRun(...)`, not on the first call. `{run_id}` and `{run_dir}` are filled in per run
(`HookRun(run_dir=...)`, default `<cwd>/runs/<run id>`).

The example files `gatewaysdk/examples/hooks/duckduckgo.toml` and `exa.toml` load unchanged in
Python; `tests/hooks/test_live.py` runs them against fixture pages, and the Python suite writes the
expected parses the TypeScript suite asserts too.

`run.tool_definitions(shape="anthropic")` returns `{name, description, input_schema}`;
`LiveTools.from_config(path, run, run_dir=None)` builds the table for an existing run from a
hooks file alone; a `vendor = "..."` on a `[tools.*]` entry is the vendor its events carry; and a
refused call is a `denied: true` line whether the exception is a `HookDenied`, carries a `reason`,
or is named `*Denied` / `*Refusal`.

## From code

`run.on(event, *handlers, matcher=None, vendor=None, arg=None, name=None, before=False)` takes
callables or built-in instances (`DenyHandler("closed")`, `AliasHandler(run.alias)`); `before=True`
puts the rule ahead of the file's. On `post_tool_call` the `alias` handler rewrites the recorded
arguments into alias space too (`{ type = "alias", args = "keep" }` opts out), and the leak meter and
the `run_end` scan are case-folded with whitespace collapsed. `run.session(session, vendor=...,
request_fn=fn)` takes a transport of your own — `fn({"method", "path", "query", "body"}) -> {"status", "body", "headers"}` — so a client with its own auth still gets `pre_request` and
`post_response`.

## Order with security

`gatewaysdk.security` authorizes a call against platform policy; hooks run after it: `run.wrap(security.secure_tools(tools))`.


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