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. The fixture search handler makes the example independent of a search service; replace the handler with your integration when needed.

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 fixture 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)

This shows pipeline injection; register the application tools separately. 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.

A permission-denied or invalid call does not execute a handler and does not take the normal successful-handler postprocessing path. Middleware transformations are not guaranteed to receive every synthetic error result.

Middleware added directly before extension installation stays earlier in the pipeline. Extension middleware is added in extension installation order; later manual additions run afterward. Extension priority orders installation, not an independent priority for individual middleware stages.

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())

This exact-string example illustrates the return-value contract; it is not a complete secret-detection system. Preserve is_error and result identity. The result-stage API 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/thinking deltas until response processing completes. It then emits processed text; it does not expose the original incremental text while awaiting a final transformation. This trades immediate token delivery for consistent transformed output. An override that only observes a response still triggers buffering; use hooks when observation is sufficient.

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.

Direct Python function calls and ToolRegistry.execute() do not run the agent's middleware pipeline. Use the agent engine for these execution guarantees.

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, ErrorData, SkillInvokedData, KnowledgeAccessedData, SandboxExecData, CheckpointEventData). 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, and ERROR. 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 contains the request as sent: data["system"], the rendered system prompt, data["model"], data["max_tokens"], data["temperature"], data["stream"], data["message_count"], data["tool_count"], and data["prefix_key"], the prompt-cache key of that request, or None when caching is disabled. Consecutive iterations of one run share a 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 by Sandbox(config, hooks=...) for every execute() and execute_command() with kind, tier, exit_code, timed_out, and execution_time_ms; Agent(sandbox=...) supplies the agent's own hooks when the sandbox has none, so the bash built-in's commands reach agent.hooks. CHECKPOINT is emitted by AgentRuntime at every persisted phase boundary with session_id, run_id, status, and phase.

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 complete feature that installs middleware, a tool, a hook, and saved state.