Skip to main content

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
The dividing line is simple: YAML declares the structure of the system. Hooks are code. The YAML only registers a hook and says where it runs; the behavior lives in Python.

What hooks are not

Hooks are not a place for business logic the model should decide to run, and not a place for secrets in YAML.
  • 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 are deterministic runtime code controlled by the framework lifecycle — they run when the engine reaches a lifecycle point, every time, regardless of what the model does.

Hooks vs. tools

ToolHook
Called byThe model, when it decides toThe engine, automatically
Visible to the modelYesNever
PurposeDo work the model asked forAuth, validation, audit, policy
Declared in YAML asBusiness capabilityA lifecycle registration only

The lifecycle

Hooks run at fixed points as a request flows through the engine:
Engine builds / starts
  └─ on_engine_start        validate env, initialize clients

For each request (run)
  ├─ on_run_start           enrich the run context (may modify)

  ├─ Agent calls a tool
  │    ├─ before_tool_call        observe the pending call
  │    ├─ (MCP tool only)
  │    │    ├─ before_mcp_request   inject auth headers (may modify)
  │    │    ├─ … HTTP request …
  │    │    └─ after_mcp_response   observe status / latency
  │    ├─ … tool executes …
  │    ├─ transform_tool_result    reshape the result (may modify)   ┐ on success
  │    ├─ after_tool_call          audit the completed call          ┘
  │    └─ on_tool_error            observe a failed tool call          on failure

  ├─ on_run_end             observe a successful run
  └─ on_run_error           record a failed run (original error preserved)

Engine shuts down
  └─ on_engine_stop         cleanup, flush (best-effort)
Every point below is a moment the engine will call your hook, if you have registered one for it. Unregistered points cost nothing.

Engine lifecycle

Fires once per engine process, not per request.
Hook pointFiresCan modifyReceivesTypical use
on_engine_startOnce, before any request is servedNo — observe onlyEngineContext (system name, metadata)Validate required env vars exist; initialize clients
on_engine_stopOnce, during shutdown (best-effort)No — observe onlyEngineContextCleanup, flush buffers

Run lifecycle

Fires once per request.
Hook pointFiresCan modifyReceivesTypical use
on_run_startBefore the graph executesYes — may return an updated RunContextRunContext (run/user/org ids, auth context)Attach identity/metadata to the run
on_run_endAfter a successful response is readyNo — observe onlyRunEndContext (safe summary; no answer text)Log run outcome
on_run_errorWhen a request fails, for any reasonNo — never masks the original errorthe raised exceptionAlert, record failures

Tool lifecycle

Fires around every local or MCP tool call.
Hook pointFiresCan modifyReceivesTypical use
before_tool_callRight before any tool runsNo — observe onlyToolRequestContext (no arguments)Audit, policy checks
after_tool_callAfter a tool call succeedsNo — observe onlyToolCallContext (safe metadata)Audit logging
transform_tool_resultAfter a tool succeeds, before its result reaches the modelYes — must return a ToolResultContextToolResultContext (includes result text)Truncate, redact, or normalize a result
on_tool_errorWhen a tool call raises an errorNo — observe onlyToolCallContext (sanitized error)Alert, log failures

MCP lifecycle

Fires around every outgoing MCP HTTP request.
Hook pointFiresCan modifyReceivesTypical use
before_mcp_requestRight before an MCP HTTP request goes out — every operation: connect, list tools, call toolYes — may return the request with updated headersMcpRequestContext (server id, url, empty headers to fill)Inject auth headers (most common use)
after_mcp_responseAfter every MCP HTTP response, success or error statusNo — observe onlyMcpResponseContext (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:
hooks:
  on_engine_start:
    - plugin: research_hooks
      method: validate_environment

  before_mcp_request:
    - plugin: research_hooks
      method: inject_context7_auth

  after_tool_call:
    - plugin: research_hooks
      method: audit_tool_call
      failure_policy: warn
plugins/plugins.toml — map the plugin id to its Python class. This is the only place the import path lives:
[hooks.plugins]
research_hooks = "plugins.hooks.research_hooks:ResearchHooksHook"
The YAML never contains an import path, a config block, or a secret — it only says which hook runs where. agentctl generate creates and maintains the plugins.toml entry for you.

Field reference

plugin
string
Logical plugin id, resolved to a class through plugins/plugins.toml’s [hooks.plugins] table. Used together with method. Mutually exclusive with ref.
method
string
The method name to call on the resolved plugin class. Required when using plugin.
ref
string
A direct module.path:function import to a hook function — an advanced alternative to plugin + method for manual wiring. Mutually exclusive with plugin + method.
failure_policy
string
default:"fail"
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 single HookInvocation event and reads its typed payload with payload_as:
from agent_engine.runtime.hooks import HookInvocation, McpRequestContext

class ResearchHooksHook:
    async def inject_context7_auth(self, event: HookInvocation) -> McpRequestContext:
        request = event.payload_as(McpRequestContext)
        ...
        return request
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.
import os
from agent_engine.runtime.hooks import HookInvocation, McpRequestContext

class McpAuthHook:
    async def inject_auth(self, event: HookInvocation) -> McpRequestContext:
        request = event.payload_as(McpRequestContext)
        if request.server_id != "internal_api":
            return request  # only authenticate the private server
        token = os.environ["INTERNAL_MCP_TOKEN"]
        return request.with_headers({"Authorization": f"Bearer {token}"})
The token is read from the environment at request time. It never appears in YAML, prompts, or logs. Gate on request.server_id so public servers stay unauthenticated.

Token exchange

To exchange the caller’s inbound token for an MCP-scoped one:
async def inject_auth(self, event: HookInvocation) -> McpRequestContext:
    request = event.payload_as(McpRequestContext)
    inbound_token = event.run_context.auth_context.inbound_access_token
    mcp_token = await exchange_token(inbound_token, audience="mcp-service")
    return request.with_headers({"Authorization": f"Bearer {mcp_token}"})

HMAC signing

import hashlib, hmac, os, time
from agent_engine.runtime.hooks import HookInvocation, McpRequestContext

async def sign(self, event: HookInvocation) -> McpRequestContext:
    request = event.payload_as(McpRequestContext)
    secret = os.environ["HMAC_SECRET"].encode()
    ts = str(int(time.time()))
    sig = hmac.new(secret, f"{ts}:{request.url}".encode(), hashlib.sha256).hexdigest()
    return request.with_headers({"X-Timestamp": ts, "X-Signature": sig})

Auditing tool calls

import logging
from agent_engine.runtime.hooks import HookInvocation, ToolCallContext

logger = logging.getLogger("audit")

class AuditHook:
    async def record_tool_call(self, event: HookInvocation) -> None:
        call = event.payload_as(ToolCallContext)
        logger.info(
            "tool_call run_id=%s tool=%s provider=%s status=%s elapsed_ms=%s",
            event.run_context.run_id if event.run_context else None,
            call.tool_name,
            call.provider,
            call.status,
            call.latency_ms,
        )
Register audit hooks with 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

Use transform_tool_result to cap or redact a result before it reaches the model. It must return a ToolResultContext:
from agent_engine.runtime.hooks import HookInvocation, ToolResultContext

async def trim_result(self, event: HookInvocation) -> ToolResultContext:
    result = event.payload_as(ToolResultContext)
    if len(result.result) > 8000:
        return result.with_result(result.result[:8000] + "\n… [truncated]")
    return result

Passing identity into hooks

The embedding application passes per-request identity through the run context:
from agent_engine.runtime.hooks import AuthContext, RunContext

ctx = RunContext(
    user_id="user-123",
    organization_id="org-7",
    auth_context=AuthContext(
        inbound_access_token=request.headers["Authorization"],
        scopes=("support:read",),
    ),
)
result = await engine.run(message, context=ctx)
Inside a hook, read it from the event:
user_id = event.run_context.user_id
token = event.run_context.auth_context.inbound_access_token

Worked example: the Enterprise Knowledge Assistant

The enterprise-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 if ANTHROPIC_API_KEY or CONTEXT7_API_KEY is missing.
  • Authenticated MCP requests (before_mcp_request) — injects the Context7 key into context7 requests 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.
The code reads secrets from the environment and registers through plugins.toml — no secrets in YAML.

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: fail so they never fail open.