Skip to main content
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:
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)).