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.

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

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

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

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:

@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. 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 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 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 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
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 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
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.

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 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 for a feature that installs middleware, a tool, a hook, and prompt context together.