What are hooks?
Hooks are one of Extra’s core extension mechanisms. They are Python code that the engine runs automatically at well-defined moments in the runtime lifecycle — when the engine starts, when a run begins or fails, and around every tool call and MCP request. They exist for the behavior that has to happen outside the LLM: deterministic integration and side-effect logic that the framework — not the model — controls. Typical uses:- Environment validation — fail fast at startup if a required credential or connection is missing
- MCP authentication — inject auth headers into outgoing MCP requests
- Auditing — record tool calls and run outcomes as safe metadata
- Policy — reshape or cap a tool result before it reaches the model
- Context enrichment — attach identity or metadata to a run
- Error handling — record run failures for alerting or observability
- Enterprise integration — bridge the engine to internal systems without changing the engine
What hooks are not
- Hooks are not prompts. They contain no natural-language instructions.
- Hooks are not tools. The model never sees a hook, cannot name it, and cannot call it. If you want the model to be able to invoke something, that is a tool, not a hook.
- Hooks are not agent instructions. They do not steer the model; they run around it.
- Hooks are not YAML business logic. YAML registers a hook — it does not configure its behavior. There is no per-hook
config:block. If a hook needs a value, it reads it in Python. - Hooks are not a home for secrets. Never put tokens, keys, or credentials in YAML. Secrets live in environment variables; the hook reads them directly at runtime.
Hooks vs. tools
| Tool | Hook | |
|---|---|---|
| Called by | The model, when it decides to | The engine, automatically |
| Visible to the model | Yes | Never |
| Purpose | Do work the model asked for | Auth, validation, audit, policy |
| Declared in YAML as | Business capability | A lifecycle registration only |
The lifecycle
Hooks run at fixed points as a request flows through the engine:Engine lifecycle
Fires once per engine process, not per request.| Hook point | Fires | Can modify | Receives | Typical use |
|---|---|---|---|---|
on_engine_start | Once, before any request is served | No — observe only | EngineContext (system name, metadata) | Validate required env vars exist; initialize clients |
on_engine_stop | Once, during shutdown (best-effort) | No — observe only | EngineContext | Cleanup, flush buffers |
Run lifecycle
Fires once per request.| Hook point | Fires | Can modify | Receives | Typical use |
|---|---|---|---|---|
on_run_start | Before the graph executes | Yes — may return an updated RunContext | RunContext (run/user/org ids, auth context) | Attach identity/metadata to the run |
on_run_end | After a successful response is ready | No — observe only | RunEndContext (safe summary; no answer text) | Log run outcome |
on_run_error | When a request fails, for any reason | No — never masks the original error | the raised exception | Alert, record failures |
Tool lifecycle
Fires around every local or MCP tool call.| Hook point | Fires | Can modify | Receives | Typical use |
|---|---|---|---|---|
before_tool_call | Right before any tool runs | No — observe only | ToolRequestContext (no arguments) | Audit, policy checks |
after_tool_call | After a tool call succeeds | No — observe only | ToolCallContext (safe metadata) | Audit logging |
transform_tool_result | After a tool succeeds, before its result reaches the model | Yes — must return a ToolResultContext | ToolResultContext (includes result text) | Truncate, redact, or normalize a result |
on_tool_error | When a tool call raises an error | No — observe only | ToolCallContext (sanitized error) | Alert, log failures |
MCP lifecycle
Fires around every outgoing MCP HTTP request.| Hook point | Fires | Can modify | Receives | Typical use |
|---|---|---|---|---|
before_mcp_request | Right before an MCP HTTP request goes out — every operation: connect, list tools, call tool | Yes — may return the request with updated headers | McpRequestContext (server id, url, empty headers to fill) | Inject auth headers (most common use) |
after_mcp_response | After every MCP HTTP response, success or error status | No — observe only | McpResponseContext (status code, latency — no body) | Log latency, status codes |
The context objects hooks receive carry safe metadata only. Tool arguments and full results are deliberately omitted from the observe-only points, because they can hold sensitive user data.
transform_tool_result is the one point that sees a tool’s result text — precisely so it can reshape it — and is treated as trusted code that must never log the result body.Declaring a hook
A hook has two parts: a registration in YAML, and the Python code it points to.agents.yaml — register the hook under its lifecycle point. Each point takes a list of entries, and each entry names a plugin and the method to call on it:
plugins/plugins.toml — map the plugin id to its Python class. This is the only place the import path lives:
agentctl generate creates and maintains the plugins.toml entry for you.
Field reference
Logical plugin id, resolved to a class through
plugins/plugins.toml’s [hooks.plugins] table. Used together with method. Mutually exclusive with ref.The method name to call on the resolved plugin class. Required when using
plugin.A direct
module.path:function import to a hook function — an advanced alternative to plugin + method for manual wiring. Mutually exclusive with plugin + method.What happens if the hook itself raises.
There is intentionally no
config: field. YAML registers a hook; it does not parameterize its behavior. A hook that needs values reads them from the environment (for secrets) or from the RunContext (for per-request identity) in Python.The hook method signature
A managed hook method receives a singleHookInvocation event and reads its typed payload with payload_as:
event.run_context gives per-request identity; event.payload_as(T) returns the point-specific context. Long-lived state (clients, caches) can live on the instance; per-request state must not — read it from the event each call.
MCP auth
The most common hook. YAML defines which MCP server exists; the hook injects authentication at runtime, so tokens never appear in YAML, prompts, or logs.- YAML defines the MCP server (see MCP & Tools).
- Hook code injects the auth header before each request.
- Secrets stay in environment variables; the hook reads them directly.
- This scales to one header or many, bearer tokens, tenant-specific auth, HMAC signing, and full enterprise auth flows.
request.server_id so public servers stay unauthenticated.
Token exchange
To exchange the caller’s inbound token for an MCP-scoped one:HMAC signing
Auditing tool calls
failure_policy: warn — a logging failure shouldn’t abort the user’s request. Log only safe metadata: identifiers, provider, status, timing — never prompts, arguments, results, or secrets.
Reshaping tool results
Usetransform_tool_result to cap or redact a result before it reaches the model. It must return a ToolResultContext:
Passing identity into hooks
The embedding application passes per-request identity through the run context:Worked example: the Enterprise Knowledge Assistant
Theenterprise-knowledge-assistant example ships a single hook plugin, ResearchHooksHook, that demonstrates the most useful lifecycle points end to end:
- Environment validation (
on_engine_start) — fails the build ifANTHROPIC_API_KEYorCONTEXT7_API_KEYis missing. - Authenticated MCP requests (
before_mcp_request) — injects the Context7 key intocontext7requests only; the public DeepWiki server stays unauthenticated. - Tool-call auditing (
after_tool_call,failure_policy: warn) — logs safe metadata, never prompts, secrets, or results. - Result reshaping (
transform_tool_result) — caps oversized DeepWiki responses before they reach the model. - Safe error handling (
on_run_error) — records the failure’s type, not its message.
plugins.toml — no secrets in YAML.
- Hook code:
plugins/hooks/research_hooks.py - Registration:
agents.yamlandplugins/plugins.toml
Security
Hooks are trusted in-process code. The engine never logs auth headers, tokens, HMAC signatures, or inbound access tokens. Your hook code must hold the same line:- Read secrets from environment variables, never from YAML.
- Never log secrets, prompts, tool arguments, or raw tool results — log safe metadata only.
- Prefer recording an error’s type in
on_run_error, not its message, which may contain user input. - Keep security-critical hooks (like auth) at the default
failure_policy: failso they never fail open.