# Middleware & hooks

Transform requests and results, observe execution, and choose between middleware, hooks, and extensions.

Middleware transforms data at defined points in the agent's execution pipeline.
Hooks observe lifecycle events. An extension packages middleware, hooks, tools,
and other capabilities with installation, saved state, and cleanup.

## Which one should I use?

| Need | Choose | Why |
| --- | --- | --- |
| Normalize tool arguments | Middleware | The returned call becomes the call the engine validates and authorizes |
| Transform a model response or tool result | Middleware | The returned value continues through execution |
| Log a tool completion or collect diagnostic metrics | Hook | Observe an event without changing the result |
| Reuse a feature across agents with one constructor option | Extension | Bundle installation and configuration |
| Combine a tool, middleware, hooks, and saved state | Extension containing middleware and hooks | Give the whole feature a lifecycle |
| Approve or deny tool execution | Permission manager and execution checks | Hooks are best-effort observers, not an enforcement boundary |

Start with standalone middleware for a focused transformation. Package it in an
extension when callers should configure one reusable feature, or when the feature
needs resources or persistence. **An extension can install middleware** with
`ctx.add_middleware(...)`; these are complementary interfaces.

## The four middleware stages

Subclass `Middleware` and override only the stages you need. The base methods
return their inputs unchanged. Every override is async and must return the expected
value, including when it chooses not to change anything.

| Method | Receives | Must return | Runs before |
| --- | --- | --- | --- |
| `before_llm_call(messages, tools)` | Conversation messages and canonical tool schemas | `(messages, tools)` | The provider request |
| `after_llm_call(response)` | Completed provider response | A valid response, preferably `ProviderResponse` | Response enters memory and is used for further decisions |
| `before_tool_execution(tool_call)` | `ToolCall` | `ToolCall` with the same `id` | Schema validation, permission resolution, and dispatch |
| `after_tool_execution(result)` | `ToolResult` | `ToolResult` with the same `tool_call_id` | Processed tool result enters the conversation |

`before_llm_call` does not receive the system prompt, model, or provider settings.
Use `AgentConfig` for static configuration or an extension prompt provider for
dynamic system context. Changing the tool schemas sent to the model does not
register handlers or replace the permission policy in the executor.

## A complete standalone middleware example

This middleware strips whitespace from a search query while preserving call identity.

```python
from dataclasses import replace
from harnessx import Agent, Middleware, ToolCall

class NormalizeSearch(Middleware):
    async def before_tool_execution(self, call: ToolCall) -> ToolCall:
        query = call.input.get("query")
        if call.name == "search" and isinstance(query, str):
            return replace(call, input={**call.input, "query": query.strip()})
        return call

def search(query: str) -> str:
    """Return a search result for the requested query."""
    return f"Results for {query}"

agent = Agent(tools=[search])
registration = agent.middleware.add(NormalizeSearch())
# Run the agent inside `async with agent:` when your provider is configured.
# To remove this middleware during setup: registration.close()
```

The same transformation applies to `run`, `run_stream`, and durable engine runs.
The engine validates and checks permissions against the transformed call. Preserve
`call.id` even if you intentionally change its name or arguments. Rewriting a tool
name still requires that the target tool be registered and permitted.

## Install a pipeline through the constructor

```python
from harnessx import Agent, Middleware, MiddlewarePipeline, ToolCall

class RequireQuery(Middleware):
    async def before_tool_execution(self, call: ToolCall) -> ToolCall:
        if call.name == "search" and not str(call.input.get("query", "")).strip():
            raise ValueError("Search query must not be blank")
        return call

pipeline = MiddlewarePipeline()
pipeline.add(RequireQuery())
agent = Agent(middleware=pipeline)
```

Throwing an exception is an execution failure, not a request for human approval. Use
ASK and a permission manager for approvals.

`pipeline.add(...)` returns an idempotent registration handle. Call `close()` to
remove it. Configure or remove middleware between runs, not during an active run.
Standalone middleware has no automatic async teardown contract; own resources in
application code or put the middleware inside an extension.

## Ordering and execution flow

If you add middleware A, then B, **each stage runs A, then B**. Response stages do
not unwind in reverse order. B receives A's returned value.

The principal sequence is:

1. Compose the system prompt and trim conversation context.
2. Run `before_llm_call`, then call the provider.
3. Run `after_llm_call`, then store the processed response.
4. For requested tools, run `before_tool_execution`, validate the result, and resolve permissions.
5. Immediately before dispatch, check current execution policy again.
6. Execute the handler, retaining the raw outcome for durable recovery when applicable.
7. Run `after_tool_execution`, emit the tool-end hook, and collect processed results into memory.
8. Continue the model loop or finish the run.

## Transforming results safely

```python
from dataclasses import replace
from harnessx import Agent, Middleware, ToolResult

class HideFixtureToken(Middleware):
    async def after_tool_execution(self, result: ToolResult) -> ToolResult:
        return replace(
            result,
            content=result.content.replace("fixture-token", "[redacted]"),
        )

agent = Agent()
agent.middleware.add(HideFixtureToken())
```

Preserve `is_error` and result identity. The result stage receives the result, not
the corresponding call arguments.

Result middleware changes what continues into the conversation. **It does not
prove the original content was never stored.** Durable execution commits raw tool
results before postprocessing, and a flight recorder can capture raw model
responses before response middleware. Redact at the data source when original
content must never enter storage or recording.

## Streaming, errors, and retries

Overriding `after_llm_call` makes the current engine buffer that model response's
text and thinking deltas until response processing completes. It then emits the
processed text. This trades immediate token delivery for consistent transformed
output. An override that only observes a response still triggers buffering, so use
hooks when observation is enough.

Middleware exceptions are not swallowed like hook exceptions. They can fail the
run. Handler exceptions usually become error tool results, which can then pass
through result middleware. Inspect `RunResult.status`, `error`, and `pending`.

Durable recovery can rerun processing. In particular, a committed raw tool result
can be postprocessed again without rerunning the handler. Middleware must tolerate
repeated execution; a database write or notification inside it does not acquire
exactly-once semantics. Prefer pure transformations and use registered tools with
appropriate recovery policies for external actions.

### Provider-owned content

Some response content belongs to the provider. Native Anthropic blocks reach
`after_llm_call` as ordered canonical blocks, or as opaque
`{"type": "provider", "provider": "anthropic", "data": {...}}` blocks. Middleware
must leave signed thinking, opaque blocks, and a pending provider continuation
as they are. Changing them raises `ValueError` and fails the run. Other
providers' adapters leave out opaque blocks that belong to another vendor.

## Hooks for observation

```python
from typing import cast
from harnessx import Agent, HookContext, HookEvent
from harnessx.hooks import HOOK_PAYLOADS, ToolCallEndData

agent = Agent()

async def log_result(ctx: HookContext) -> None:
    data = cast(ToolCallEndData, ctx.data)  # typed view of the same dict
    result = data["result"]
    print("Tool result:", result.tool_call_id, "error:", result.is_error)

registration = agent.hooks.on(HookEvent.TOOL_CALL_END, log_result)
assert HOOK_PAYLOADS[HookEvent.TOOL_CALL_END] is ToolCallEndData
# registration.close() removes the observer.
```

Hooks may be synchronous or async. `hooks.on(...)`, `before_tool(...)`,
`after_tool(...)`, and `on_error(...)` all return a `Registration` handle whose
`close()` removes the hook. The three shortcuts still work as decorators, but the
decorated name is then the registration rather than the callback:

```python
@agent.hooks.after_tool
async def audit(ctx: HookContext) -> None:
    print(ctx.data["tool_call"].name)

audit.close()  # audit is the Registration
```

`HookContext` supplies the event, agent, event-specific `data`, and a timestamp.
`data` stays a `dict`, but the keys every built-in event carries are declared in
`harnessx.hooks` as TypedDicts (`AgentStartData`, `AgentEndData`,
`LoopIterationData`, `LLMRequestData`, `LLMResponseData`, `ToolCallStartData`,
`ToolCallEndData`, `RetryData`, `ContextCondensedData`, `RepetitionData`,
`ErrorData`, `SkillInvokedData`, `KnowledgeAccessedData`, `SandboxExecData`,
`CheckpointEventData`, `RecoveryData`, `TodosUpdatedData`). `HOOK_PAYLOADS` maps each `HookEvent` to its shape; cast
`ctx.data` to it for typed access instead of assuming every event contains a tool
result.

Common engine events include `AGENT_START`, `AGENT_END`, `LOOP_ITERATION_START`,
`LOOP_ITERATION_END`, `LLM_REQUEST`, `LLM_RESPONSE`, `TOOL_CALL_START`,
`TOOL_CALL_END`, `RETRY`, `CONTEXT_CONDENSED`, `REPETITION`, and `ERROR`.
`CONTEXT_CONDENSED` carries `messages_dropped`, `summary_chars` and `summarized`;
`REPETITION` carries `period` (how many calls the repeating pattern has),
`laps` (how many times it repeated, which is the guard's `threshold`) and `tool`
(the first tool the model called in that turn). See [Long-run reliability](../loop-reliability/). `AGENT_END` contains `data["result"]`, a final
`RunResult` for completion, failure, or cancellation. An approval wait or pause is not
a terminal completion.

`LLM_REQUEST` carries the request as sent:

| Key in `ctx.data` | Value |
| --- | --- |
| `system` | The rendered system prompt |
| `model` | The model identifier |
| `max_tokens` | The reply budget for this call |
| `temperature` | The sampling temperature |
| `stream` | Whether the request streams |
| `message_count` | Number of conversation messages sent |
| `tool_count` | Number of tool schemas sent |
| `prefix_key` | The [prompt-cache](../prompt-caching/) key, or `None` when caching is disabled |

Consecutive iterations of one run share a `prefix_key` unless the system prompt or
tool list changed.

Four events come from features rather than the core loop. `SKILL_INVOKED` carries
`skill`, `found`, `source_path`, and `body_chars`. `KNOWLEDGE_ACCESSED` is emitted by
the [knowledge](../knowledge/) tools with `tool`, `bundle`, `found`, and either
`query` and `hits` or `path` and `body_chars`. `SANDBOX_EXEC` is emitted for every
sandbox execution; see [sandbox](../sandbox/) for its payload and setup. `CHECKPOINT`
is emitted by `AgentRuntime` at every persisted phase boundary with `session_id`,
`run_id`, `status`, and `phase`.

## Recovery events

`HookEvent.RECOVERY` fires when the loop recovers from a reply or request that
hit a limit, instead of stopping. Its payload is `RecoveryData`:

| Key in `ctx.data` | Value |
| --- | --- |
| `reason` | What happened; see below |
| `attempt` | How many recoveries of this kind the run has made, this one included |
| `reply_budget` | The reply budget the next call asks for, or `None` |

| `reason` | Meaning |
| --- | --- |
| `truncated_tool_call` | A tool call was cut off at the reply budget. It was refused and the model asked again with a larger budget |
| `truncated_text` | The reply was cut off. The model was asked to continue |
| `empty_reply` | The model said nothing. It was asked to go on |
| `reply_limit` | The provider refused the reply budget as too large. The call was repeated within the limit it named |
| `context_overflow` | The request did not fit the context window. The history was condensed and the call repeated |
| `context_overflow_spill` | The request did not fit and the history was too short to condense. Large tool results went to disk and the call was repeated |
| `spurious_overflow` | A request far below the window was refused as too long. It was tried once more |
| `malformed_tool_input` | A streamed tool call could not be read or identified. The reply was asked for again, once |
| `paused_turn` | The server paused a long turn. It was sent back to resume |
| `iteration_limit` | The run reached `max_iterations`. The model was asked for a final answer without tools |
| `unfinished_plan` | The model stopped with no answer and its task list still open. It was asked once to finish or say what is left |

```python
from harnessx import Agent, HookEvent

agent = Agent()
agent.hooks.on(HookEvent.RECOVERY, lambda ctx: print(
    "recovered:", ctx.data["reason"], "budget now", ctx.data["reply_budget"]))
```

See [Long-run reliability](../loop-reliability/#recovering-from-a-limit) for the
limits behind each reason. The payload type is `RecoveryData` in
`harnessx.hooks`.

## Task list events

`HookEvent.TODOS_UPDATED` fires when the agent changes its task list with
`write_todos`. Its payload is `TodosUpdatedData`, with the whole list every
time:

| Key in `ctx.data` | Value |
| --- | --- |
| `todos` | Every item, as `{"content": ..., "status": ...}`; status is `pending`, `in_progress` or `completed` |
| `completed` | How many items are completed |
| `total` | How many items there are |
| `in_progress` | The item being worked on, or `None` |

```python
from harnessx import Agent, HookEvent

agent = Agent()
agent.hooks.on(HookEvent.TODOS_UPDATED, lambda ctx: print(
    f"{ctx.data['completed']}/{ctx.data['total']} done"))
```

The same payload reaches the run stream as `RunEventType.TODOS_UPDATED`; see
[Streaming](../streaming/#task-list-updates).

## Observe retries and serving providers

`HookEvent.RETRY` fires when the engine schedules another model or tool attempt, and
`LLM_RESPONSE` carries `provider`, the provider that answered. A long wait
before a model retry, like a server's `Retry-After`, also shows up on the run
stream as `RunEventType.WAITING` with `{"on": "retry", "seconds": n}`, where
`seconds` is how long it has waited so far. See
[Retries & fallback models](../retries-and-fallbacks/) for both payloads, an observer
example, and configuration.

Observer exceptions are suppressed. Hooks must not be the sole authorization,
required audit, or verification step. Return values do not transform the pipeline;
use middleware for that. Avoid mutating engine data through `ctx.agent`.

Continue with the [extensions guide](../extensions/) for a feature that installs
middleware, a tool, a hook, and prompt context together.
