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, which documents them in full. This page covers the Python surface; Hooks for any agent is the overview, with the seam in each agent SDK and the coding-agent command.
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.
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:
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).
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.
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;
the Python surface is:
[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)).