Module 4: Middleware and Customization

Introduction: Customizing Agents Without Rewriting Them

Overview

In Module 3 you built complete agents with create_agent. One line of code and you had an autonomous agent: it reasons, calls tools, observes results, and repeats until it has the answer. You added system prompts, custom state, streaming, structured output. Everything worked.

Until you needed something create_agent doesn't solve with a parameter: you want to know how long each model call takes. Or you need it to use a cheap model for simple questions and a powerful one for complex questions. Or to filter the available tools based on the user's permissions. Or to retry automatically when a tool fails.

You could rewrite the agent from scratch for each case. But that defeats the whole point of create_agent: a high-level abstraction you shouldn't have to open up in order to modify.

The answer is middleware — functions that intercept what happens between the agent and the model (or between the agent and the tools) without touching the agent's internal logic. Think of them as filters you can snap on or off without ever opening the agent's code.


Where are we in the guide?

This is Module 4 of the guide LangChain & LangGraph: From Chains to Agents. It's the last module of Block 1 (LangChain Core) before we move on to Block 2 (LangGraph Fundamentals).

Block 1: LangChain Core (Modules 1-4)      ← YOU ARE HERE (Module 4)
Block 2: LangGraph Fundamentals (Modules 5-7)
Block 3: Advanced LangGraph (Modules 8-10)
Block 4: Production (Modules 11-12)
Your progress through Block 1:

Module 1: Models and Providers            ✅ Done
    │
    ▼
Module 2: Tools and Tool Calling          ✅ Done
    │
    ▼
Module 3: Agents (create_agent)           ✅ Done
    │
    ▼
Module 4: Middleware and Customization    ← YOU ARE HERE

In Module 1 you learned to connect to models. In Module 2 you gave them tools. In Module 3 you automated the whole loop with create_agent. Now you're going to customize that agent without rewriting it: intercept its calls, change its behavior, and add cross-cutting capabilities.


The bridge: from building agents to customizing them

What you already know

In Module 3 you got here:

from dotenv import load_dotenv
load_dotenv()

from langchain.agents import create_agent
from langchain_core.tools import tool

@tool
def search(query: str) -> str:
    """Search the web for information."""
    return f"Results for '{query}': LangChain is a framework for LLMs."

agent = create_agent(
    "openai:gpt-4.1-mini",
    tools=[search],
    system_prompt="You are a research assistant."
)

result = agent.invoke({"messages": [("user", "What is LangChain?")]})
print(result["messages"][-1].content)
# Expected output: LangChain is a framework for building applications with LLMs.

Works perfectly. But now ask yourself these questions:

  • How long did the model call take? You don't know.
  • How many tokens did it burn? You don't know.
  • Can you switch to a more powerful model when the question is complex? Not without rewriting.
  • Can you filter the tools based on who's asking? Not without rewriting.
  • Can you retry if the model fails? Not without rewriting.

What you'll learn here

With middleware, you add every one of those capabilities without touching the agent:

from dotenv import load_dotenv
load_dotenv()

from langchain.agents import create_agent
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
import time

@tool
def search(query: str) -> str:
    """Search the web for information."""
    return f"Results for '{query}': LangChain is a framework for LLMs."

model = init_chat_model("openai:gpt-4.1-mini")

def before_model(messages):
    print(f"[LOG] Sending {len(messages)} messages to the model")

def after_model(response):
    print(f"[LOG] Model replied with {len(response.content)} characters")
    if response.tool_calls:
        print(f"[LOG] Tool calls: {[tc['name'] for tc in response.tool_calls]}")

agent = create_agent(
    model,
    tools=[search],
    before_model=before_model,
    after_model=after_model,
    system_prompt="You are a research assistant."
)

result = agent.invoke({"messages": [("user", "What is LangChain?")]})
print(result["messages"][-1].content)
# Expected output:
# [LOG] Sending 2 messages to the model
# [LOG] Model replied with 0 characters
# [LOG] Tool calls: ['search']
# [LOG] Sending 4 messages to the model
# [LOG] Model replied with 62 characters
# LangChain is a framework for building applications with LLMs.

The agent behaves exactly the same. But now you can see what it does. And this is only the beginning — the more advanced middleware doesn't just observe, it changes the behavior.


The problem: customization without rewriting

As your agents go from prototypes to production, you need capabilities that cut across multiple agents:

NeedWithout middlewareWith middleware
Loggingprint() scattered through the codeOne centralized hook that intercepts everything
Latency monitoringTiming each call by hand@wrap_model_call with automatic time.time()
Token countingChecking response.usage_metadata manuallyAn automatic accumulator in after_model
Model routingif/else before creating the agentMiddleware that decides per request
Tool filteringTools hardcoded into create_agentDynamic tools per user or context
Retry on toolstry/except inside every tool@wrap_tool_call with reusable retry logic
Custom error handlingError logic in every agentOne middleware that applies to all of them

Without middleware, each of these needs means modifying the agent directly — or worse, copy-pasting logic between agents. With middleware, you write the logic once and apply it wherever you need it.


An analogy: airport security checkpoints

Picture an airport:

Passenger ──▶ [Check-in] ──▶ [Security] ──▶ [Immigration] ──▶ Plane
                   │              │                │
              Verifies        Inspects         Validates
              identity        luggage          permissions
  • The passenger is the message headed to the model (or the tool call).
  • The plane is the model (or the tool).
  • The checkpoints are middleware.

Each checkpoint can:

  • Inspect what passes through (logging — "this passenger has a carry-on")
  • Modify what passes through (change the gate — "your flight moved to gate 12")
  • Block what passes through (security — "this item can't go through")
  • Redirect what passes through (immigration — "no visa, go to another line")

And the key part: the airline doesn't change. The plane takes off the same way. The checkpoints operate independently — you can add a new one (a health screening) or remove one (drop the liquids check) and the plane never finds out.

That's middleware: interceptors that sit between your code and the services it calls, without either side having to change.


What middleware can do

create_agent middleware operates at two interception points:

1. Intercepting model calls

Every time the agent is about to call the LLM, middleware can step in:

Messages ──▶ [before_model] ──▶ LLM ──▶ [after_model] ──▶ Response
                                 │
                          [wrap_model_call]
                        (wraps the whole cycle)
  • before_model — Runs before the messages go to the model. It can inspect them, log them, or modify them.
  • after_model — Runs after the model's response comes back. It can inspect the response, log metrics, or modify it.
  • @wrap_model_call — Wraps the entire cycle. It receives the request, can modify it, calls the handler (which runs the model), and can modify the response. It's the most powerful of the three.

2. Intercepting tool calls

Every time the agent is about to run a tool, middleware can step in:

Tool call ──▶ [wrap_tool_call] ──▶ Tool ──▶ Result
  • @wrap_tool_call — Wraps every tool execution. It can inspect the arguments, modify them, retry on failure, log results, or replace the tool entirely.

The hooks at a glance

HookWhen it runsWhat it receivesWhat it's for
before_modelBefore each LLM callThe message listLogging, validation, modifying messages
after_modelAfter each LLM responseThe model's responseLogging, metrics, modifying the response
@wrap_model_callWraps the whole model cycleRequest + handlerModel routing, retry, full transformation
@wrap_tool_callWraps each tool executionTool call + handlerRetry, error handling, tool-level logging

Module map

CapsuleTopicWhat you'll learn
02Your first middleware: logging and monitoringbefore_model, after_model, timing, token counting, callbacks
03@wrap_model_call: intercepting model callsModelRequest, the handler pattern, modifying requests, reading state
04@wrap_tool_call: customizing tool executionToolCallRequest, custom error handling, retry logic, tool-level logging
05Dynamic models: smart selectionRouting by complexity, by cost, by latency requirements
06Dynamic tools and dynamic promptsFiltering tools by permissions, registering tools at runtime, advanced dynamic prompts
07AgentMiddleware class: composed middlewareCombining state_schema + tools + hooks, middleware as reusable modules
08Project: Agent with dynamic routingAgent with cheap/powerful models, logging middleware, dynamic tools

How the learning flows: You start by observing what the agent does (logging in capsule 02). Then you learn to modify the model calls (03) and the tool calls (04). With that foundation, you build the advanced capabilities: dynamic model selection (05) and dynamic tools (06). Then you learn to package it all into reusable modules (07). Finally, you integrate everything into the project (08).


Connection to the project

This module's mini-project: Agent with Dynamic Model Routing

In Capsule 08 you'll build an agent that:

  1. Uses @wrap_model_call to automatically pick between a cheap model (gpt-4.1-mini) and a powerful one (gpt-4.1) based on the complexity of the question
  2. Includes logging middleware that records every model call with a timestamp, duration, and tokens consumed
  3. Implements dynamic tools — the available tools change based on the user's role
  4. Has retry middleware on tools that automatically retries when an external API fails

Every concept from capsules 02-07 comes together in this project.

Connection to the guide as a whole

Middleware closes out Block 1. Once you've got it, you'll understand how to customize agents at the LangChain level — the high-level layer. What comes next operates lower down:

  • Module 5 (LangGraph Fundamentals): After mastering the high-level abstractions (create_agent + middleware), you'll drop down to LangGraph to build custom workflows. If middleware lets you intercept, LangGraph lets you redesign the whole flow.
  • Modules 6-7: Conditionals, loops, and subgraphs. These are the building blocks create_agent uses internally — now you'll build them yourself.
  • Modules 8-10: Persistent memory, human-in-the-loop, and multi-agent. Middleware complements all of these — for instance, you can use middleware to log the decisions of a multi-agent system.
  • Modules 11-12: Production and deployment. The logging and monitoring middleware you build here is the foundation of observability in production.

Boundaries: what this module does NOT cover

  • Custom LangGraph nodes — Covered in Modules 5-7. Here you customize agents with middleware; you don't build graphs from scratch.
  • Multi-agent systems — Covered in Module 10. Here you work with a single agent and its interceptors.
  • Production deployment — Covered in Modules 11-12. Here you build middleware for development; the production infrastructure comes later.
  • LangSmith / LangFuse — External observability tools. The logging middleware you build here is the manual version; observability platforms are complementary.
  • Advanced guardrails — Content validation with dedicated frameworks. Middleware can do basic validation, but full guardrails are their own topic.

Technical setup

Prerequisites

Before you go on, make sure you have:

  • Module 3 finished — you know how to create agents with create_agent, system prompts, state, streaming, and structured output
  • Comfort with Python decorators — you understand @decorator and functions that take and return functions
  • Python 3.11+ installed
  • ✅ At least one API key from a provider that supports tool calling (OpenAI or Anthropic recommended)

Installation

You use the same packages as in Module 3. Nothing new to install:

pip install langchain langgraph langchain-openai python-dotenv

If you already have everything installed from Module 3, check that your langchain version is v1.2+ (that's the release where the middleware system landed):

import langchain
print(langchain.__version__)
# You need >= 1.2.0

Check that everything works

from dotenv import load_dotenv
load_dotenv()

from langchain.agents import create_agent
from langchain_core.tools import tool

@tool
def ping(message: str) -> str:
    """Reply with a pong."""
    return f"pong: {message}"

def before_model(messages):
    print(f"[TEST] Middleware active — {len(messages)} messages")

agent = create_agent(
    "openai:gpt-4.1-mini",
    tools=[ping],
    before_model=before_model
)

result = agent.invoke({"messages": [("user", "Ping with 'hello'")]})
print(result["messages"][-1].content)
# Expected output:
# [TEST] Middleware active — 2 messages
# [TEST] Middleware active — 4 messages
# pong: hello (or similar)

If you see the [TEST] messages followed by the agent's answer, your setup is ready for middleware.

If something breaks:

ErrorCauseFix
ImportError: cannot import name 'create_agent'Old langchain versionpip install --upgrade langchain (you need v1.2+)
TypeError: create_agent() got an unexpected keyword argument 'before_model'Your langchain version doesn't support middlewarepip install --upgrade langchain (you need v1.2+)
ModuleNotFoundError: No module named 'langgraph'langgraph isn't installedpip install langgraph

Signs it worked

By the end of this module, you'll know you got it if:

  • ✅ You can add logging to an existing agent without modifying its code
  • ✅ You understand the difference between before_model, after_model, @wrap_model_call, and @wrap_tool_call
  • ✅ Your agent automatically picks the right model based on the complexity of the question
  • ✅ You can filter the available tools based on the user's permissions or context
  • ✅ You know how to compose several middleware into one reusable AgentMiddleware
  • ✅ Your project shows dynamic routing, logging, and dynamic tools all working together

A look ahead: from middleware to LangGraph

In this module you customize agents with middleware — interceptors that change behavior without changing structure. But the agent is still a linear ReAct loop: model → tools → model → tools → answer.

What happens when you need a different flow? For example:

  • An agent that plans first and executes second (two sequential phases)
  • A workflow that branches based on the type of input (classify → route A or route B)
  • A process that requires human approval before a critical action runs
  • A system with several agents collaborating

For that you need LangGraph — the low-level orchestration framework. In Module 5, you'll leave the high-level abstractions (create_agent + middleware) behind and build graphs from scratch: nodes, edges, conditionals, and custom state management.

# Module 4: Middleware (customize the ReAct loop)
agent = create_agent(
    model, tools,
    middleware=[logging_middleware, routing_middleware]
)

# Module 5: LangGraph (design your own flow)
from langgraph.graph import StateGraph

graph = StateGraph(MyState)
graph.add_node("classify", classify_input)
graph.add_node("simple_agent", handle_simple)
graph.add_node("complex_agent", handle_complex)
graph.add_conditional_edges("classify", route_by_complexity)

Middleware gives you control over the agent. LangGraph gives you control of the entire flow.


Summary

  • In Module 3 you learned to create autonomous agents with create_agent — but customizing them meant modifying the agent's code directly
  • LangChain v1.2+'s middleware system lets you intercept and modify the agent's behavior without rewriting it
  • Middleware works like airport security checkpoints: it inspects, modifies, or blocks what passes between the agent and the services it calls
  • There are two interception points: model calls (before_model, after_model, @wrap_model_call) and tool calls (@wrap_tool_call)
  • The simple hooks (before_model, after_model) are for observing; the wrappers (@wrap_model_call, @wrap_tool_call) are for modifying
  • Middleware solves cross-cutting needs: logging, monitoring, model routing, dynamic tools, retry, error handling — all without copying logic between agents
  • This is the last module of Block 1 (LangChain Core). Next comes LangGraph for low-level custom workflows
  • The capstone project is an agent with dynamic model routing — a cheap model for simple questions, a powerful one for complex ones

Further reading

  1. create_agent API Reference — Full parameter reference, including the middleware hooks
  2. LangChain Agents Overview — The official conceptual guide to agents and customization
  3. Middleware Pattern — Wikipedia — The general concept of middleware in software engineering
  4. LangGraph Agents — How create_agent builds graphs internally (useful context for understanding where middleware gets injected)
  5. Python Decorators — Real Python — A refresher on Python decorators (prerequisite for @wrap_model_call and @wrap_tool_call)
  6. What's New in LangChain v1.2 — The release notes that introduced the middleware system

Module 4 — LangChain & LangGraph: From Chains to Agents

Next capsule: Your First Middleware: Logging and Monitoring — you'll learn to observe what your agent does with before_model and after_model.