# Hooks

Hooks let you intercept and modify agent behavior at every stage of a run -- model requests, tool calls, streaming events -- using simple decorators or constructor arguments. No subclassing needed.

The [`Hooks`](/docs/ai/api/pydantic-ai/capabilities/#pydantic_ai.capabilities.Hooks) capability is the recommended way to add [lifecycle hooks](/docs/ai/core-concepts/capabilities#hooking-into-the-lifecycle) for application-level concerns like logging, metrics, and lightweight validation. For reusable capabilities that combine hooks with tools, instructions, or model settings, subclass [`AbstractCapability`](/docs/ai/api/pydantic-ai/capabilities/#pydantic_ai.capabilities.AbstractCapability) instead -- see [Building custom capabilities](/docs/ai/core-concepts/capabilities#building-custom-capabilities).

## Quick start

Create a [`Hooks`](/docs/ai/api/pydantic-ai/capabilities/#pydantic_ai.capabilities.Hooks) instance, register hooks via `@hooks.on.*` decorators, and pass it to your agent:

hooks\_decorator.py

```python
from pydantic_ai import Agent, ModelRequestContext, RunContext
from pydantic_ai.capabilities import Hooks

hooks = Hooks()


@hooks.on.before_model_request
async def log_request(ctx: RunContext[None], request_context: ModelRequestContext) -> ModelRequestContext:
    print(f'Sending {len(request_context.messages)} messages to the model')
    #> Sending 1 messages to the model
    return request_context


agent = Agent('test', capabilities=[hooks])
result = agent.run_sync('Hello!')
print(result.output)
#> success (no tool calls)
```

## Registering hooks

### Decorator registration

The `hooks.on` namespace provides decorator methods for every lifecycle hook. Use them as bare decorators or with parameters:

```python
# Bare decorator
@hooks.on.before_model_request
async def my_hook(ctx, request_context):
    return request_context

# With parameters (timeout, tool filter)
@hooks.on.before_model_request(timeout=5.0)
async def my_timed_hook(ctx, request_context):
    return request_context
```

Multiple hooks can be registered for the same event -- they fire in registration order.

### Constructor kwargs

You can also pass hook functions directly to the [`Hooks`](/docs/ai/api/pydantic-ai/capabilities/#pydantic_ai.capabilities.Hooks) constructor:

hooks\_constructor.py

```python
from pydantic_ai import Agent, ModelRequestContext, RunContext
from pydantic_ai.capabilities import Hooks


async def log_request(ctx: RunContext[None], request_context: ModelRequestContext) -> ModelRequestContext:
    print(f'Sending {len(request_context.messages)} messages to the model')
    #> Sending 1 messages to the model
    return request_context


agent = Agent('test', capabilities=[Hooks(before_model_request=log_request)])
result = agent.run_sync('Hello!')
print(result.output)
#> success (no tool calls)
```

Both sync and async hook functions are accepted. Sync functions are automatically wrapped for async execution.

### On-demand hooks

[`Hooks`](/docs/ai/api/pydantic-ai/capabilities/#pydantic_ai.capabilities.Hooks) is a capability, so it can be loaded on demand just like any other capability:

deferred\_hooks\_capability.py

```python
from pydantic_ai import Agent, RunContext, ToolDefinition
from pydantic_ai.capabilities import Hooks, ValidatedToolArgs
from pydantic_ai.messages import ToolCallPart

approval_hooks = Hooks(
    id='approval-hooks',
    description='Use when a workflow needs approval before destructive actions.',
    defer_loading=True,
)


@approval_hooks.on.before_tool_execute
async def require_approval(
    ctx: RunContext[None],
    *,
    call: ToolCallPart,
    tool_def: ToolDefinition,
    args: ValidatedToolArgs,
) -> ValidatedToolArgs:
    # Runs only after the model loads `approval-hooks`.
    return args


agent = Agent('openai-responses:gpt-5.4', capabilities=[approval_hooks])
```

You do not need to guard hooks owned by a deferred `Hooks` instance with `ctx.capability_loaded`; Pydantic AI skips those hooks until the model calls the `load_capability` tool for that capability. Once the hook runs, `ctx.capability_loaded` is true for that hook's owning capability. To check a different capability, inspect `ctx.loaded_capability_ids` or `ctx.available_capability_ids`.

If a hook must enforce a rule before a workflow is loaded, keep that hook in an always-available capability and inspect `ctx.loaded_capability_ids`; an on-demand hook cannot run before the model loads its own capability.

The run-scoped hooks -- `before_run` and `wrap_run` -- are bound at the start of the run, so a capability the model loads mid-run won't get them for that run; they only fire when the capability is already loaded at the start (for example after resuming from message history). The capability's per-step hooks (node, model-request, tool, output) fire from the next step onwards once it has loaded, and `after_run` fires at the end of the run if it was loaded at any point during it.

## Hook types

### Run hooks

`hooks.on.`

Constructor kwarg

`AbstractCapability` method

`before_run`

`before_run=`

`before_run`

`after_run`

`after_run=`

`after_run`

`run`

`run=`

`wrap_run`

`run_error`

`run_error=`

`on_run_error`

Run hooks fire once per agent run. `wrap_run` (registered via `hooks.on.run`) wraps the entire run and supports error recovery.

### Node hooks

`hooks.on.`

Constructor kwarg

`AbstractCapability` method

`before_node_run`

`before_node_run=`

`before_node_run`

`after_node_run`

`after_node_run=`

`after_node_run`

`node_run`

`node_run=`

`wrap_node_run`

`node_run_error`

`node_run_error=`

`on_node_run_error`

Node hooks fire for each graph step (`UserPromptNode`, `ModelRequestNode`, `CallToolsNode`).

Note

`wrap_node_run` hooks are called automatically by [`agent.run()`](/docs/ai/api/pydantic-ai/agent/#pydantic_ai.agent.AbstractAgent.run), [`agent.run_stream()`](/docs/ai/api/pydantic-ai/agent/#pydantic_ai.agent.AbstractAgent.run_stream), and [`agent_run.next()`](/docs/ai/api/pydantic-ai/run/#pydantic_ai.run.AgentRun.next), but **not** when iterating with bare `async for node in agent_run:`.

### Model request hooks

`hooks.on.`

Constructor kwarg

`AbstractCapability` method

`before_model_request`

`before_model_request=`

`before_model_request`

`after_model_request`

`after_model_request=`

`after_model_request`

`model_request`

`model_request=`

`wrap_model_request`

`model_request_error`

`model_request_error=`

`on_model_request_error`

Model request hooks fire around each LLM call. `ModelRequestContext` bundles `model`, `messages`, `model_settings`, and `model_request_parameters`. To swap the model for a given request, set `request_context.model` to a different [`Model`](/docs/ai/api/models/base/#pydantic_ai.models.Model) instance.

To skip the model call entirely, raise [`SkipModelRequest(response)`](/docs/ai/api/pydantic-ai/exceptions/#pydantic_ai.exceptions.SkipModelRequest) from `before_model_request` or `model_request` (wrap).

### Tool validation hooks

`hooks.on.`

Constructor kwarg

`AbstractCapability` method

`before_tool_validate`

`before_tool_validate=`

`before_tool_validate`

`after_tool_validate`

`after_tool_validate=`

`after_tool_validate`

`tool_validate`

`tool_validate=`

`wrap_tool_validate`

`tool_validate_error`

`tool_validate_error=`

`on_tool_validate_error`

Validation hooks fire when the model's JSON arguments are parsed and validated. All tool hooks receive `call` ([`ToolCallPart`](/docs/ai/api/pydantic-ai/messages/#pydantic_ai.messages.ToolCallPart)) and `tool_def` ([`ToolDefinition`](/docs/ai/api/pydantic-ai/tools/#pydantic_ai.tools.ToolDefinition)) parameters.

Note

Tool validation and execution hooks only fire for function tools. Internal output tools (used to deliver structured output) are not user-facing and are skipped.

To skip validation, raise [`SkipToolValidation(args)`](/docs/ai/api/pydantic-ai/exceptions/#pydantic_ai.exceptions.SkipToolValidation) from `before_tool_validate` or `tool_validate` (wrap).

### Tool execution hooks

`hooks.on.`

Constructor kwarg

`AbstractCapability` method

`before_tool_execute`

`before_tool_execute=`

`before_tool_execute`

`after_tool_execute`

`after_tool_execute=`

`after_tool_execute`

`tool_execute`

`tool_execute=`

`wrap_tool_execute`

`tool_execute_error`

`tool_execute_error=`

`on_tool_execute_error`

Execution hooks fire when the tool function runs. `args` is always the validated `dict[str, Any]`.

To skip execution, raise [`SkipToolExecution(result)`](/docs/ai/api/pydantic-ai/exceptions/#pydantic_ai.exceptions.SkipToolExecution) from `before_tool_execute` or `tool_execute` (wrap).

### Output validation hooks

`hooks.on.`

Constructor kwarg

`AbstractCapability` method

`before_output_validate`

`before_output_validate=`

`before_output_validate`

`after_output_validate`

`after_output_validate=`

`after_output_validate`

`output_validate`

`output_validate=`

`wrap_output_validate`

`output_validate_error`

`output_validate_error=`

`on_output_validate_error`

Output validation hooks fire when structured output is parsed against the output schema. They do **not** fire for plain text or image output. All output hooks receive an `output_context` ([`OutputContext`](/docs/ai/api/pydantic-ai/capabilities/#pydantic_ai.capabilities.OutputContext)) parameter.

Note

During streaming, output **validation** hooks fire on every partial validation attempt as well as the final result. Output **processing** hooks fire only when partial validation succeeds, and on the final result. Check `ctx.partial_output` in your hooks to distinguish partial from final results and avoid expensive work on partials.

### Output processing hooks

`hooks.on.`

Constructor kwarg

`AbstractCapability` method

`before_output_process`

`before_output_process=`

`before_output_process`

`after_output_process`

`after_output_process=`

`after_output_process`

`output_process`

`output_process=`

`wrap_output_process`

`output_process_error`

`output_process_error=`

`on_output_process_error`

Output processing hooks fire when the output is processed -- extracting values, calling output functions, and running output validators.

See [Output hooks](/docs/ai/core-concepts/capabilities#output-hooks) for the full lifecycle, signatures, and details on how output validators interact with processing hooks.

### Tool preparation

`hooks.on.`

Constructor kwarg

`AbstractCapability` method

`prepare_tools`

`prepare_tools=`

`prepare_tools`

`prepare_output_tools`

`prepare_output_tools=`

`prepare_output_tools`

Filters or modifies tool definitions the model sees on each step.

`prepare_tools` handles **function** tools; `prepare_output_tools` handles [output tools](/docs/ai/api/pydantic-ai/output/#pydantic_ai.output.ToolOutput) separately, with `ctx.max_retries` reflecting the **output** retry budget. Both run as `PreparedToolset` wrappers -- the result flows into the model's request _and_ `ToolManager.tools`, so filtering also blocks tool execution.

### Deferred tool call hook

`hooks.on.`

Constructor kwarg

`AbstractCapability` method

`deferred_tool_calls`

`deferred_tool_calls=`

`handle_deferred_tool_calls`

Resolves [deferred tool calls](/docs/ai/tools-toolsets/deferred-tools) (approval-required or externally-executed) inline during a run. The hook receives a [`DeferredToolRequests`](/docs/ai/api/pydantic-ai/tools/#pydantic_ai.tools.DeferredToolRequests) and returns a [`DeferredToolResults`](/docs/ai/api/pydantic-ai/tools/#pydantic_ai.tools.DeferredToolResults) (or `None` to decline). Multiple registered hooks accumulate: each receives the still-unresolved requests and can resolve some or all of them.

hooks\_deferred\_tool\_calls.py

```python
from pydantic_ai import Agent, DeferredToolRequests, DeferredToolResults, RunContext
from pydantic_ai.capabilities import Hooks

hooks = Hooks()


@hooks.on.deferred_tool_calls
async def auto_approve(
    ctx: RunContext[None], *, requests: DeferredToolRequests
) -> DeferredToolResults:
    return requests.build_results(approve_all=True)


agent = Agent('test', capabilities=[hooks])


@agent.tool_plain(requires_approval=True)
def delete_file(path: str) -> str:
    return f'File {path!r} deleted'
```

For pure application-level handler registration without other hooks, the dedicated [`HandleDeferredToolCalls`](/docs/ai/api/pydantic-ai/capabilities/#pydantic_ai.capabilities.HandleDeferredToolCalls) capability is more concise -- see [Resolving deferred calls with a handler](/docs/ai/tools-toolsets/deferred-tools#resolving-deferred-calls-with-a-handler).

### Event stream hooks

`hooks.on.`

Constructor kwarg

`AbstractCapability` method

`run_event_stream`

`run_event_stream=`

`wrap_run_event_stream`

`event`

`event=`

_(per-event convenience)_

`run_event_stream` wraps the full event stream as an async generator. `event` is a convenience -- it fires for each individual event during a streamed run:

hooks\_event.py

```python
from pydantic_ai import Agent, AgentStreamEvent, RunContext
from pydantic_ai.capabilities import Hooks

hooks = Hooks()
event_count = 0


@hooks.on.event
async def count_events(ctx: RunContext[None], event: AgentStreamEvent) -> AgentStreamEvent:
    global event_count
    event_count += 1
    return event


agent = Agent('test', capabilities=[hooks])
```

## Tool hook filtering

Tool hooks (validation and execution) support a `tools` parameter to target specific tools by name:

hooks\_tool\_filter.py

```python
from pydantic_ai import Agent, RunContext, ToolDefinition
from pydantic_ai.capabilities import Hooks, ValidatedToolArgs
from pydantic_ai.messages import ToolCallPart

hooks = Hooks()
call_log: list[str] = []


@hooks.on.before_tool_execute(tools=['send_email'])
async def audit_dangerous_tools(
    ctx: RunContext[None],
    *,
    call: ToolCallPart,
    tool_def: ToolDefinition,
    args: ValidatedToolArgs,
) -> ValidatedToolArgs:
    call_log.append(f'audit: {call.tool_name}')
    return args


agent = Agent('test', capabilities=[hooks])


@agent.tool_plain
def send_email(to: str) -> str:
    return f'sent to {to}'


result = agent.run_sync('Send an email to test@example.com')
print(call_log)
#> ['audit: send_email']
```

The `tools` parameter accepts a sequence of tool names. The hook only fires for matching tools -- other tool calls pass through unaffected.

## Timeouts

Each hook supports an optional `timeout` in seconds. If the hook exceeds the timeout, a [`HookTimeoutError`](/docs/ai/api/pydantic-ai/capabilities/#pydantic_ai.capabilities.HookTimeoutError) is raised:

hooks\_timeout.py

```python
import asyncio

from pydantic_ai import Agent, ModelRequestContext, RunContext
from pydantic_ai.capabilities import Hooks, HookTimeoutError

hooks = Hooks()


@hooks.on.before_model_request(timeout=0.01)
async def slow_hook(
    ctx: RunContext[None], request_context: ModelRequestContext
) -> ModelRequestContext:
    await asyncio.sleep(10)  # Will be interrupted by timeout
    return request_context  # pragma: no cover


agent = Agent('test', capabilities=[hooks])
try:
    agent.run_sync('Hello')
except HookTimeoutError as e:
    print(f'Hook timed out: {e.hook_name} after {e.timeout}s')
    #> Hook timed out: before_model_request after 0.01s
```

Timeouts are set via the decorator parameter (`@hooks.on.before_model_request(timeout=5.0)`) or via the constructor when using kwargs.

## Wrap hooks

Wrap hooks let you surround an operation with setup/teardown logic. In the `hooks.on` namespace, wrap hooks drop the `wrap_` prefix -- `hooks.on.model_request` corresponds to `wrap_model_request`:

hooks\_wrap.py

```python
from pydantic_ai import Agent, ModelRequestContext, RunContext
from pydantic_ai.capabilities import Hooks, WrapModelRequestHandler
from pydantic_ai.messages import ModelResponse

hooks = Hooks()
wrap_log: list[str] = []


@hooks.on.model_request
async def log_request(
    ctx: RunContext[None], *, request_context: ModelRequestContext, handler: WrapModelRequestHandler
) -> ModelResponse:
    wrap_log.append('before')
    response = await handler(request_context)
    wrap_log.append('after')
    return response


agent = Agent('test', capabilities=[hooks])
result = agent.run_sync('Hello!')
print(wrap_log)
#> ['before', 'after']
```

## Hook ordering

When multiple hooks are registered for the same event (either on the same `Hooks` instance or across multiple capabilities):

-   **`before_*`** hooks fire in registration/capability order
-   **`after_*`** hooks fire in reverse order
-   **`wrap_*`** hooks nest as middleware -- the first registered hook is the outermost layer

Hook timing also affects what is populated on [`RunContext`](/docs/ai/api/pydantic-ai/tools/#pydantic_ai.tools.RunContext). Early run and node hooks can fire before the current step's tool manager and model request parameters have been assembled. At that point `ctx.available_tool_names` can still include tool-search discoveries reconstructed from history, but `ctx.tools` and current request parameters may be empty or reflect the previous step. `before_model_request` and later model-request hooks see the request about to be sent, including the current function tools, native tools, and model settings. Tool and output hooks see the state for the call or output currently being processed.

For on-demand capabilities, `ctx.loaded_capability_ids` updates as soon as the `load_capability` tool runs. Function tools, native tools, and model settings from the loaded capability appear on the next model request, while hooks owned by that capability can only run for hook points reached after the capability has loaded.

See [Composition and middleware semantics](/docs/ai/core-concepts/capabilities#composition-and-middleware-semantics) for details on how hooks from multiple capabilities interact.

## Error hooks

Error hooks (`*_error` in the `hooks.on` namespace, `on_*_error` on `AbstractCapability`) use **raise-to-propagate, return-to-recover** semantics:

-   **Raise the original error** -- propagates unchanged _(default)_
-   **Raise a different exception** -- transforms the error
-   **Return a result** -- suppresses the error

See [Error hooks](/docs/ai/core-concepts/capabilities#error-hooks) for the full pattern and recovery types.

## Triggering retries with `ModelRetry`

Hooks can raise [`ModelRetry`](/docs/ai/api/pydantic-ai/exceptions/#pydantic_ai.exceptions.ModelRetry) to ask the model to try again with a custom message -- the same exception used in [tool functions](/docs/ai/tools-toolsets/tools-advanced#tool-retries) and output validators.

**Model request hooks** (`after_model_request`, `wrap_model_request`, `on_model_request_error`):

-   The retry message is sent back to the model as a [`RetryPromptPart`](/docs/ai/api/pydantic-ai/messages/#pydantic_ai.messages.RetryPromptPart)
-   `after_model_request`: the original response is preserved in message history so the model can see what it said
-   `wrap_model_request`: the response is preserved only if the handler was called
-   Retries count against the output side of the agent's retry budget

**Tool hooks** (`before/after_tool_validate`, `before/after_tool_execute`, `wrap_tool_execute`, `on_tool_execute_error`):

-   Converted to tool retry prompts, same as when a tool function raises `ModelRetry`
-   Retries count against the tool's `max_retries` limit

**Output hooks** (`before/after_output_validate`, `before/after_output_process`, `wrap_output_process`, `on_output_process_error`):

-   Converted to retry prompts, same as when an output function raises `ModelRetry`
-   For tool output, retries count against the tool's `max_retries` limit
-   For text output, retries count against the output side of the agent's retry budget

`ModelRetry` from `wrap_model_request`, `wrap_tool_execute`, and `wrap_output_process` is treated as control flow -- it bypasses the corresponding `on_*_error` hook.

hooks\_model\_retry.py

```python
from pydantic_ai import Agent, RunContext
from pydantic_ai.capabilities import Hooks
from pydantic_ai.exceptions import ModelRetry
from pydantic_ai.messages import ModelResponse
from pydantic_ai.models import ModelRequestContext

hooks = Hooks()


@hooks.on.after_model_request
async def check_response(
    ctx: RunContext[None],
    *,
    request_context: ModelRequestContext,
    response: ModelResponse,
) -> ModelResponse:
    if 'PLACEHOLDER' in str(response.parts):
        raise ModelRetry('Response contains placeholder text. Please provide real data.')
    return response


agent = Agent('test', capabilities=[hooks])
result = agent.run_sync('Hello')
print(result.output)
#> success (no tool calls)
```

## When to use `Hooks` vs `AbstractCapability`

Use [`Hooks`](/docs/ai/api/pydantic-ai/capabilities/#pydantic_ai.capabilities.Hooks)

Use [`AbstractCapability`](/docs/ai/api/pydantic-ai/capabilities/#pydantic_ai.capabilities.AbstractCapability)

Application-level hooks (logging, metrics)

Reusable, packaged capabilities

Quick one-off interceptors

Combined tools + hooks + instructions + settings

No configuration state needed

Complex per-run state management

Single-file scripts

Multi-agent shared behavior