Middleware

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against langchain 1.4.3, langchain-core 1.6.5 and langgraph 1.2.12 on Python 3.12, as verified on 2026-09-29. The running example is DocsAssistant, which answers questions over this site’s documentation with Ollama’s llama3.1:8b chat model and the nomic-embed-text embedding model over the pgvector store from Integrating with LangChain. Ollama was not available while the snippets were checked, so every output shown here was produced by running the code against the real packages with a scripted fake chat model (a GenericFakeChatModel subclass that replays prepared messages and ignores bind_tools); no output comes from a live model.

An agent is a loop: call the model, run the tools it asked for, call the model again, stop when it asks for no more tools. Middleware is how LangChain 1.x lets you reach into that loop without rewriting it. You pass a list of middleware to create_agent (see Agents with create_agent), and each one can observe, change or short-circuit the agent before and after every step. Logging, retries, summarization, approval gates, PII redaction, rate limits and prompt changes are all middleware; none of them needs a custom graph. The framework-neutral reasons to shape context this way are covered in Context Engineering for Agents; this page is the LangChain mechanics.

The hook lifecycle

Middleware is not a separate runtime. create_agent returns a compiled LangGraph graph, and the hooks run inside it, so a middleware-equipped agent can still be dropped into a larger graph as a node or subgraph and every hook keeps running (see LangGraph Fundamentals). There are six hooks, in two styles:

  • Node-style hooks run sequentially at fixed points and return either nothing or a dict of state updates: before_agent, before_model, after_model and after_agent.

  • Wrap-style hooks surround a call and decide whether, and how often, to run it: wrap_model_call and wrap_tool_call.

flowchart TD S([start]) --> BA["before_agent
once per invocation"] BA --> BM["before_model
each model call"] BM --> WM["wrap_model_call
around the model call"] WM --> AM["after_model
each model response"] AM -->|tool calls requested| WT["wrap_tool_call
around each tool call"] WT --> BM AM -->|no tool calls| AA["after_agent
once per invocation"] AA --> E([end]) BM -.->|"jump_to: end"| AA AM -.->|"jump_to: end"| AA
Figure 1. The create_agent loop with the six middleware hooks (dotted arrows are early exits with `jump_to`)
Table 1. When each hook runs
Hook Runs Typical use

before_agent

Once per invocation, before the loop starts

Load user data, validate input, start a timer

before_model

Before every model call

Trim or summarize history, enforce budgets, stop early

wrap_model_call

Around every model call

Retries, fallbacks, caching, changing the prompt, tools or model of one request

wrap_tool_call

Around every tool call

Auditing, retries, error handling, blocking or rewriting a call

after_model

After every model response

Validate output, count usage, gate tool calls (approval)

after_agent

Once per invocation, after the loop ends

Clean up, persist, notify

The next snippet defines one middleware class that prints when each hook fires, and installs two instances of it around a search_docs tool. Every middleware in an agent needs a unique name (it defaults to the class name), so the class derives the name from a label:

A tracing middleware that implements all six hooks
from typing import Any, Callable

from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse, ToolCallRequest
from langchain.messages import AIMessage, ToolMessage
from langchain.tools import tool
from langgraph.runtime import Runtime
from langgraph.types import Command


@tool
def search_docs(query: str) -> str:
    """Search the documentation."""
    return f"3 sections match {query!r}"


class TraceMiddleware(AgentMiddleware):
    def __init__(self, label: str):
        super().__init__()
        self.label = label

    @property
    def name(self) -> str:          # every middleware instance needs a unique name
        return f"Trace{self.label}"

    def before_agent(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
        print(f"{self.label}.before_agent")

    def before_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
        print(f"{self.label}.before_model ({len(state['messages'])} messages)")

    def wrap_model_call(self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]) -> ModelResponse:
        print(f"{self.label}.wrap_model_call -> in")
        response = handler(request)
        print(f"{self.label}.wrap_model_call <- out")
        return response

    def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
        print(f"{self.label}.after_model")

    def wrap_tool_call(self, request: ToolCallRequest, handler: Callable[[ToolCallRequest], ToolMessage | Command]) -> ToolMessage | Command:
        print(f"{self.label}.wrap_tool_call -> {request.tool_call['name']}")
        return handler(request)

    def after_agent(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
        print(f"{self.label}.after_agent")


agent = create_agent(
    model,                       # scripted fake model in the run below: one search_docs call, then an answer
    tools=[search_docs],
    middleware=[TraceMiddleware("A"), TraceMiddleware("B")],
)
out = agent.invoke({"messages": [{"role": "user", "content": "What is HNSW?"}]})
print(out["messages"][-1].content)

With a model scripted to call search_docs once and then answer, this printed:

Output
A.before_agent
B.before_agent
A.before_model (1 messages)
B.before_model (1 messages)
A.wrap_model_call -> in
B.wrap_model_call -> in
B.wrap_model_call <- out
A.wrap_model_call <- out
B.after_model
A.after_model
A.wrap_tool_call -> search_docs
B.wrap_tool_call -> search_docs
A.before_model (3 messages)
B.before_model (3 messages)
A.wrap_model_call -> in
B.wrap_model_call -> in
B.wrap_model_call <- out
A.wrap_model_call <- out
B.after_model
A.after_model
B.after_agent
A.after_agent
HNSW is a graph index.

The output shows the ordering rules. before_* hooks run in list order (A then B). wrap_* hooks nest like function calls, so the first middleware in the list is the outermost layer: A sees the request first and the response last. after_* hooks run in reverse list order (B then A). The loop runs before_model and after_model once per model call (twice here), while before_agent and after_agent run once for the whole invocation. Wrap-style hooks are layers around a call, not graph nodes, whereas each node-style hook that a middleware implements becomes a node in the compiled graph; agent.get_graph().nodes lists names such as PIIMiddleware[email].before_model and HumanInTheLoopMiddleware.after_model.

See LangChain — Middleware overview and LangChain — Custom middleware for the hook reference and the execution-order rules.

Writing hooks

Hooks can be written as decorated functions or as an AgentMiddleware subclass. Decorators (@before_agent, @before_model, @after_model, @after_agent, @wrap_model_call, @wrap_tool_call, and the convenience @dynamic_prompt) suit a single hook with no configuration. A class suits several hooks that belong together, init-time configuration (thresholds, models), or a hook that needs both a sync and an async version (abefore_model, aafter_model and so on).

State updates and early exit

A node-style hook returns a dict, which is merged into the agent state through the graph’s reducers (messages is additive), or None for no change. A hook that wants to stop the run adds "jump_to": "end" and must declare can_jump_to=["end"] on its decorator. Extra state fields come from a state_schema that extends AgentState:

A model-call budget kept in agent state, with an early exit
from typing import Any

from langchain.agents.middleware import AgentState, after_model, before_model
from langchain.messages import AIMessage
from langgraph.runtime import Runtime
from typing_extensions import NotRequired


class CounterState(AgentState):
    model_calls: NotRequired[int]


@before_model(state_schema=CounterState, can_jump_to=["end"])
def stop_after_two(state: CounterState, runtime: Runtime) -> dict[str, Any] | None:
    if state.get("model_calls", 0) >= 2:
        return {"messages": [AIMessage("Stopping: model-call budget used.")], "jump_to": "end"}
    return None


@after_model(state_schema=CounterState)
def count_calls(state: CounterState, runtime: Runtime) -> dict[str, Any] | None:
    return {"model_calls": state.get("model_calls", 0) + 1}

Run in the agent assembled in the next section, the counter reached 2 and the third model call never happened: the last message in the result was Stopping: model-call budget used. (the complete run, with its output, is under Custom middleware for DocsAssistant). See LangChain — Custom middleware for the state schema and jump rules.

Wrapping a model or tool call

A wrap-style hook receives the request and a handler. It may call the handler once (normal flow), several times (retry), or not at all (short-circuit, for example a cache hit). To change the request, do not assign to its attributes; request.override(…​) returns a modified copy, and the handler is called with that copy. A wrap hook can also add state updates by returning an ExtendedModelResponse that carries a Command:

Returning a state update from wrap_model_call
from typing import Callable

from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import ExtendedModelResponse, ModelRequest, ModelResponse, wrap_model_call
from langgraph.types import Command
from typing_extensions import NotRequired


class UsageState(AgentState):
    last_answer_chars: NotRequired[int]


@wrap_model_call(state_schema=UsageState)
def track_answer_size(request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]) -> ExtendedModelResponse:
    response = handler(request)
    size = len(response.result[-1].content)
    return ExtendedModelResponse(model_response=response, command=Command(update={"last_answer_chars": size}))


agent = create_agent(model, tools=[], middleware=[track_answer_size])
out = agent.invoke({"messages": [{"role": "user", "content": "What is HNSW?"}]})
print(out["last_answer_chars"])

With a fake model that answered HNSW is a graph index. this printed 22. The Command flows through the same reducers as any other state update. See LangChain — Custom middleware.

Built-in middleware

langchain.agents.middleware ships ready-made middleware for the recurring needs of production agents. The ones this page covers in detail are marked; the rest are listed so you know they exist and can read their pages.

Table 2. Built-in middleware in langchain 1.4.3
Middleware What it does Covered

SummarizationMiddleware

Compresses old messages into a summary when history nears a limit

Below

HumanInTheLoopMiddleware

Pauses before selected tool calls for approve / edit / reject

Below

PIIMiddleware

Detects and blocks, redacts, masks or hashes PII

Below

ModelRetryMiddleware, ToolRetryMiddleware

Retries failed model or tool calls with exponential backoff

Below

ModelCallLimitMiddleware, ToolCallLimitMiddleware

Caps model or tool calls per run and per thread

Below

ToolErrorMiddleware

Turns the tool exceptions your on_error handler chooses into error ToolMessage results the model can react to; all others propagate

Mentioned

ModelFallbackMiddleware

Falls back to alternative models when the primary fails

Listed

ContextEditingMiddleware

Clears old tool results from the context

Listed

TodoListMiddleware

Adds a write_todos planning tool

Listed

LLMToolSelectorMiddleware

Uses a model to pick relevant tools before the main call

Listed

ShellToolMiddleware, FilesystemFileSearchMiddleware, LLMToolEmulator

Shell session, file search tools, and model-emulated tools for tests

Listed

See LangChain — Built-in middleware for the complete list and each option.

Summarization

SummarizationMiddleware keeps a long conversation inside the context window. When the trigger is reached, it asks a summarizer model to condense the older messages and replaces them with one summary message, keeping the most recent ones as configured by keep. Both accept a token count, a message count or a fraction of the model’s context window. Fractions rely on the model’s profile data, which is not available for every model, so use tokens or messages when in doubt. This is text compression only; images and other media in messages that are summarized are represented by the text summary alone.

Summarizing after four messages, keeping the last two
from langchain.agents import create_agent
from langchain.agents.middleware import SummarizationMiddleware
from langgraph.checkpoint.memory import InMemorySaver

agent = create_agent(
    answerer,                                  # scripted fake model: "answer 1", "answer 2", ...
    tools=[],
    checkpointer=InMemorySaver(),
    middleware=[SummarizationMiddleware(model=summarizer, trigger=("messages", 4), keep=("messages", 2))],
)
cfg = {"configurable": {"thread_id": "t1"}}
for q in ["What is HNSW?", "And ef_search?", "And M?"]:
    out = agent.invoke({"messages": [{"role": "user", "content": q}]}, cfg)
    print(len(out["messages"]), [f"{type(m).__name__}:{str(m.content)[:40]}" for m in out["messages"]])

In production summarizer would be a cheap model such as ChatOllama(model="llama3.1:8b") or the string "claude-haiku-4-5"; here it was a fake that returned a fixed summary:

Output
2 ['HumanMessage:What is HNSW?', 'AIMessage:answer 1']
4 ['HumanMessage:What is HNSW?', 'AIMessage:answer 1', 'HumanMessage:And ef_search?', 'AIMessage:answer 2']
4 ['HumanMessage:Here is a summary of the conversation to', 'AIMessage:answer 2', 'HumanMessage:And M?', 'AIMessage:answer 3']

After the third question the first exchange was replaced by a single summary message, and the model was called with three messages instead of five. The stored thread state itself was rewritten, so the saved history stays small. The persistence side (checkpointers, trimming and filtering alternatives) is in Short-Term Memory & Checkpointers. See LangChain — Built-in middleware.

Human-in-the-loop

HumanInTheLoopMiddleware checks every tool call the model proposes against an interrupt_on policy. A call that matches pauses the graph with an interrupt, waits for a human decision, and only then runs, edits or skips the tool. The state is saved by a checkpointer, so the pause can outlive the process; a checkpointer is therefore required. Allowed decisions are approve, edit, reject and, for "ask the user" tools, respond. This is the approval gate DocsAssistant needs before it opens a GitHub issue:

Approval gate on open_github_issue
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langchain.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command

opened = []


@tool
def open_github_issue(title: str, body: str) -> str:
    """Open a GitHub issue in the docs repository."""
    opened.append(title)
    return f"Opened issue: {title}"


agent = create_agent(
    model,                       # scripted fake model: one open_github_issue call, then "Issue opened."
    tools=[open_github_issue],
    checkpointer=InMemorySaver(),
    middleware=[HumanInTheLoopMiddleware(
        interrupt_on={"open_github_issue": {"allowed_decisions": ["approve", "edit", "reject"]}},
        description_prefix="DocsAssistant wants to open an issue",
    )],
)
cfg = {"configurable": {"thread_id": "t1"}}
result = agent.invoke({"messages": [{"role": "user", "content": "File a docs issue about ef_search"}]}, cfg, version="v2")
req = result.interrupts[0].value
print("action_requests:", req["action_requests"])
print("review_configs:", req["review_configs"])
print("issues opened before approval:", opened)
out = agent.invoke(Command(resume={"decisions": [{"type": "approve"}]}), cfg, version="v2")
print("issues opened after approval:", opened)
print(out.value["messages"][-1].content)
Output
action_requests: [{'name': 'open_github_issue', 'args': {'title': 'Doc gap: ef_search', 'body': 'Explain ef_search.'}, 'description': "DocsAssistant wants to open an issue\n\nTool: open_github_issue\nArgs: {'title': 'Doc gap: ef_search', 'body': 'Explain ef_search.'}"}]
review_configs: [{'action_name': 'open_github_issue', 'allowed_decisions': ['approve', 'edit', 'reject']}]
issues opened before approval: []
issues opened after approval: ['Doc gap: ef_search']
Issue opened.

Nothing was opened until the resume with Command(resume={"decisions": […​]}), and the resume used the same thread_id. With version="v2" the result carries the pending actions in .interrupts; the decisions list must have one entry per action, in the same order. Tools set to False in interrupt_on run without asking, so read-only tools such as search_docs never interrupt. The middleware is the high-level form of the interrupt() primitive; editing arguments, rejection feedback, time travel and the UI side are in Human-in-the-Loop & Time Travel, and the design of approval flows in general is in Human-in-the-Loop. See LangChain — Human-in-the-loop.

PII

PIIMiddleware scans messages for one type of personally identifiable information and applies a strategy. Built-in types are email, credit_card, ip, mac_address and url; any other type name needs a detector, either a regular expression or a function. Strategies are block (raise PIIDetectionError), redact (replace with [REDACTED_<TYPE>]), mask (keep the last digits) and hash (a deterministic hash). By default only user input is checked (apply_to_input=True); apply_to_output and apply_to_tool_results extend it to model output and tool results. Add one middleware per PII type:

Redacting emails, masking cards and blocking a custom key pattern
from langchain.agents import create_agent
from langchain.agents.middleware import PIIDetectionError, PIIMiddleware

agent = create_agent(model, tools=[], middleware=[
    PIIMiddleware("email", strategy="redact"),
    PIIMiddleware("credit_card", strategy="mask"),
])
agent.invoke({"messages": [{"role": "user", "content": "Mail ana@example.com, card 4111 1111 1111 1111"}]})
print("model saw:", model.seen[0][-1].content)      # `model` here records what it was sent

strict = create_agent(model2, tools=[], middleware=[
    PIIMiddleware("api_key", detector=r"sk-[a-zA-Z0-9]{32}", strategy="block"),
])
try:
    strict.invoke({"messages": [{"role": "user", "content": user_text_with_a_key}]})
except PIIDetectionError as e:
    print("blocked:", type(e).__name__, "-", e)
Output
model saw: Mail [REDACTED_EMAIL], card **** **** **** 1111
blocked: PIIDetectionError - Detected 1 instance(s) of api_key in text content

The model never saw the address or the card number, and the request carrying a key-shaped string was refused before any model call. PII policy as a whole (guardrail middleware, tool-boundary defences, permission scoping) continues in Guardrails & Security. See LangChain — Built-in middleware.

Model and tool retry

ModelRetryMiddleware and ToolRetryMiddleware retry a failed call with exponential backoff: the delay before retry number n (counting from 0) is initial_delay * backoff_factor ** n, capped by max_delay, with optional jitter. max_retries counts attempts after the first, retry_on narrows which exceptions are retried, and on_failure decides what happens when retries run out: "continue" (the default) turns the failure into a message the agent can react to, "error" re-raises. ToolRetryMiddleware also accepts a tools list to limit which tools it protects.

Retrying a flaky model and a flaky tool
from langchain.agents import create_agent
from langchain.agents.middleware import ModelRetryMiddleware, ToolRetryMiddleware

agent = create_agent(
    flaky_model,                 # fake model: raises ConnectionError twice, then works
    tools=[search_docs],         # raises TimeoutError twice, then returns
    middleware=[
        ModelRetryMiddleware(max_retries=3, initial_delay=0.0, jitter=False),   # delays disabled for the demo
        ToolRetryMiddleware(max_retries=3, initial_delay=0.0, jitter=False),
    ],
)
out = agent.invoke({"messages": [{"role": "user", "content": "What is HNSW?"}]})
print("model failures left:", flaky_model.failures, "| tool attempts:", attempts["n"], "|", out["messages"][-1].content)

# retries exhausted: on_failure="continue" (default) turns the failure into an AIMessage
agent = create_agent(always_failing_model, tools=[], middleware=[ModelRetryMiddleware(max_retries=1, initial_delay=0.0, jitter=False)])
out = agent.invoke({"messages": [{"role": "user", "content": "hi"}]})
print("exhausted:", out["messages"][-1].content)
Output
model failures left: 0 | tool attempts: 3 | Done.
exhausted: Model call failed after 2 attempts with ConnectionError: ollama: connection reset

Both transient failures were absorbed without the agent noticing, and when a model stayed down the run ended with a readable message instead of a stack trace. Retries are for transient errors only; a tool that fails because its arguments are wrong should return an actionable error to the model instead (see Tools), which is what ToolErrorMiddleware is for. Place it before ToolRetryMiddleware (so retry is the inner layer) and give the retry middleware on_failure="error" so exceptions reach it after the retries are spent. See LangChain — Built-in middleware.

Call limits

Runaway loops and runaway bills are the two failures a limit prevents. ModelCallLimitMiddleware caps model calls per invocation (run_limit) and across a whole thread (thread_limit, which needs a checkpointer), and ends gracefully (exit_behavior="end", the default) or raises ("error"). ToolCallLimitMiddleware does the same for tool calls, optionally for one tool_name, and its default exit_behavior="continue" blocks only the excess calls with an error message so the model can still answer:

Capping a looping agent
from langchain.agents import create_agent
from langchain.agents.middleware import ModelCallLimitMiddleware, ToolCallLimitMiddleware
from langgraph.checkpoint.memory import InMemorySaver

agent = create_agent(
    looping_model,               # fake model that requests get_page forever
    tools=[get_page], checkpointer=InMemorySaver(),
    middleware=[ModelCallLimitMiddleware(run_limit=3, exit_behavior="end")],
)
out = agent.invoke({"messages": [{"role": "user", "content": "loop"}]}, {"configurable": {"thread_id": "a"}})
print(out["messages"][-1].content)

agent = create_agent(
    three_calls_model,           # fake model: three get_page calls, then "Enough."
    tools=[get_page],
    middleware=[ToolCallLimitMiddleware(tool_name="get_page", run_limit=2)],
)
out = agent.invoke({"messages": [{"role": "user", "content": "loop"}]})
for m in out["messages"]:
    if m.type == "tool":
        print(m.status, "|", m.content)
Output
Model call limits exceeded: run limit (3/3)
success | <p0>
success | <p1>
error | Tool call limit exceeded. Do not call 'get_page' again.

The first agent made three model calls and then ended with the limit message. In the second, the third get_page call was answered with an error ToolMessage instead of being executed, and the model then produced its final answer. See LangChain — Built-in middleware.

Custom middleware for DocsAssistant

The built-ins cover generic needs; the hooks above cover the rest. Two customisations come up in nearly every real agent: making the system prompt depend on the request, and controlling exactly what the model sees on each call, which is context engineering. This section shows both on DocsAssistant, using a runtime context object for per-request facts (see Tools for how tools read the same context).

Dynamic prompt

@dynamic_prompt is a shortcut for a wrap_model_call that replaces the system message. The function receives the ModelRequest, whose runtime.context carries the per-invocation context and whose state carries the messages, and returns the prompt string. DocsAssistant uses it to scope answers to one documentation area and adapt to the reader:

A system prompt built from runtime context
from dataclasses import dataclass

from langchain.agents.middleware import ModelRequest, dynamic_prompt


@dataclass
class DocsContext:
    component: str
    expertise: str


@dynamic_prompt
def docs_prompt(request: ModelRequest) -> str:
    ctx = request.runtime.context
    base = f"You are DocsAssistant. Answer only from the {ctx.component} documentation and cite page paths."
    if ctx.expertise == "beginner":
        base += " Explain terms briefly."
    return base

Context engineering with wrap_model_call

Because a wrap hook sees the whole request, it can change any part of it for one call, without touching the saved state: the messages, the tools, the model or the system message. Two small examples follow. The first keeps only the last six messages in the request; the stored thread keeps everything, which is the same idea as trimming in Short-Term Memory & Checkpointers but done per call:

Sending the model a window of the history
from typing import Callable

from langchain.agents.middleware import ModelRequest, ModelResponse, wrap_model_call

MAX_MESSAGES = 6


@wrap_model_call
def trim_history(request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]) -> ModelResponse:
    """Send the model only the most recent messages; the saved state keeps everything."""
    if len(request.messages) > MAX_MESSAGES:
        request = request.override(messages=request.messages[-MAX_MESSAGES:])
    return handler(request)

Run with eleven prior messages plus a new question, the state kept all twelve messages after the answer, while the model received ['a2', 'q3', 'a3', 'q4', 'a4', 'q5']. The second hides the heavy get_page tool until the agent has searched at least once, so a cheap first step cannot start by loading a whole page. Together with docs_prompt, the budget middleware from earlier and an audit hook on tool calls, the complete DocsAssistant stack is:

Prompt, tool visibility, budget and audit in one agent
from typing import Callable

from langchain.agents import create_agent
from langchain.agents.middleware import ModelRequest, ModelResponse, ToolCallRequest, wrap_model_call, wrap_tool_call
from langchain.messages import ToolMessage
from langgraph.types import Command


@wrap_model_call
def hide_get_page_until_search(request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]) -> ModelResponse:
    searched = any(isinstance(m, ToolMessage) and m.name == "search_docs" for m in request.messages)
    if not searched:
        request = request.override(tools=[t for t in request.tools if t.name != "get_page"])
    return handler(request)


@wrap_tool_call
def audit_tool_calls(request: ToolCallRequest, handler: Callable[[ToolCallRequest], ToolMessage | Command]) -> ToolMessage | Command:
    print("tool call:", request.tool_call["name"], request.tool_call["args"])
    result = handler(request)
    print("tool result:", getattr(result, "content", result))
    return result


agent = create_agent(
    model,                       # scripted fake: search_docs, then get_page, then a third call that is cut off
    tools=[search_docs, get_page],
    context_schema=DocsContext,
    middleware=[docs_prompt, stop_after_two, count_calls, hide_get_page_until_search, audit_tool_calls],
)
out = agent.invoke(
    {"messages": [{"role": "user", "content": "What is ef_search?"}]},
    context=DocsContext(component="vector-rag", expertise="beginner"),
)
print("system prompt:", model.seen[0][0].content)
print("tools bound per model call:", model.tools_seen)
print("model_calls:", out["model_calls"])
Output
tool call: search_docs {'query': 'HNSW'}
tool result: 3 sections match 'HNSW'
tool call: get_page {'path': 'modules/ROOT/pages/database/vector-rag/ann-indexes.adoc'}
tool result: <full text of modules/ROOT/pages/database/vector-rag/ann-indexes.adoc>
system prompt: You are DocsAssistant. Answer only from the vector-rag documentation and cite page paths. Explain terms briefly.
tools bound per model call: [['search_docs'], ['search_docs', 'get_page']]
model_calls: 2

Each middleware did its part: the prompt was built from the context object, only search_docs was bound to the first model call and both tools to the second, both tool calls were logged, and the stop_after_two budget ended the run after two model calls with the message shown earlier. What to put in the context in the first place, how to keep it small over a long run, and how to evaluate the choices is the subject of Context Engineering for Agents; the tool-side rules that make this kind of hiding safe are in Tool Design. See LangChain — Context engineering.

Choosing the order

Order matters because wrap hooks nest and node hooks run in list order, as the trace above shows. Two rules follow directly from it:

  • before_* hooks run first-to-last and after_* hooks last-to-first, so a middleware placed earlier prepares the input that later ones see, and a middleware placed later sees the model’s output first.

  • The first wrap_* middleware in the list is the outermost layer: it sees the original request and the final response, and it is the one whose retry logic re-runs everything inside it.

The agent loop with a checkpointer, streaming and the graph-level view of these same hooks continue in LangGraph Fundamentals and Streaming.

Summary

Table 3. Middleware cheat sheet
Need Use

Observe every step

before_model / after_model node hooks, or a class with all six hooks

Change one model request

wrap_model_call with request.override(…​), or @dynamic_prompt for the system prompt

Stop the run early

A hook that declares can_jump_to=["end"] and returns "jump_to": "end"

Keep history short

SummarizationMiddleware, or a trimming wrap_model_call

Ask a human first

HumanInTheLoopMiddleware with a checkpointer and a stable thread_id

Remove PII

PIIMiddleware, one instance per type, with block, redact, mask or hash

Survive transient failures

ModelRetryMiddleware, ToolRetryMiddleware

Cap cost and loops

ModelCallLimitMiddleware, ToolCallLimitMiddleware