LangChain Hands-on · Module 3 · Lab 14

Middleware & Guardrails

⏱ 20 min + hands-on🎯 Intermediate🖼 1 diagram🧪 2 live demos🐍 Python code

An agent that can move money needs rules around it. Middleware is code that runs around the agent loop, before and after every model and tool call, without changing the agent itself. In this lab you hide card numbers before they reach the LLM, pause for a manager’s approval before a transfer, stop runaway loops, and block “guaranteed returns” sales talk.

In this session

  • What middleware is and where its hooks run
  • PIIMiddleware: redact, mask, hash, block
  • Human-in-the-loop approval with interrupt and resume
  • Call limits and model fallback
  • SummarizationMiddleware for long chats
  • Custom middleware: logging, compliance guard, dynamic model

01The big idea: airport security for your agent

🛂

Think of airport security. Passengers (messages) walk the same path, but checks happen at fixed points: ID check before boarding, bag scan, customs after landing. The plane (agent) does not change. Middleware adds checks at fixed points of the agent loop.

before_agentonce at startbefore_modeltrim, add context, block bad inputMODELwrap_model_call: swap model, retryafter_modelblock unsafe answersTOOLSwrap_tool_call: log, approve, limitafter_agentonce at endloop while tools are called
Middleware hooks run around the agent loop, like security checks at an airport: the agent code itself does not change.
HookExample use
@before_modeltrim history, add context, block “ignore previous instructions”
@wrap_model_callchange model, retry
@after_modelblock unsafe answers
@wrap_tool_calllog tools, check permissions

02Human-in-the-loop: you approve the transfer

HumanInTheLoopMiddleware(interrupt_on={"transfer_money": True}) pauses the agent before the tool runs. The agent needs a checkpointer so it can stop, wait for you, and resume with Command(resume=…). Try both decisions:

Demo · Approve or reject a Rs 5,000 transfer (real outputs)

The tool messages are exactly what LangChain produced in the hands-on run below. On reject, the money tool never runs.

03Built-in middleware

MiddlewareUseBanking example
PIIMiddleware("credit_card", strategy="mask")hide email, card, IP… before the LLM sees it4111 1111 1111 1111 → **** **** **** 1111
HumanInTheLoopMiddlewareapproval before risky toolstransfers, emails, refunds
SummarizationMiddlewaresummarise old messages in long chatsa 2-hour support chat
ModelCallLimitMiddleware / ToolCallLimitMiddlewarestop loops, control costmax 1 transfer per run
ModelFallbackMiddlewarebackup model if the primary failsgpt-oss-120b down → gpt-oss-20b
ModelRetryMiddleware / ToolRetryMiddlewareauto retryflaky core-banking API
TodoListMiddlewareagent plans its tasksmulti-step account opening
LLMToolSelectorMiddlewarepick relevant tools when you have many50 banking tools
PII strategyResult for ajeet@example.com
redact[REDACTED_EMAIL]
maskpartly hidden, e.g. last 4 digits of a card
hasha stable hash: same value → same token
blockraise an error, the message is not processed

04Hands-on: run it here

PII redaction, both approval paths, a tool-call limit, logging middleware and the compliance guard from the notebook. Look at the first line: the LLM only ever saw [REDACTED_EMAIL] and **** **** **** 1111.

lab14_middleware.py
from standins import ScriptedModel, RuleModel, call
from langchain.agents import create_agent
from langchain.tools import tool
from langchain.messages import AIMessage
from langchain.agents.middleware import (PIIMiddleware, HumanInTheLoopMiddleware, ToolCallLimitMiddleware,
                                         before_model, after_model, wrap_tool_call, AgentState)
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from langgraph.runtime import Runtime

@tool
def get_balance(account_id: str) -> str:
    """Get account balance."""
    return f"Account {account_id} balance: Rs 1,24,500"

@tool
def transfer_money(to_account: str, amount: float) -> str:
    """Transfer money to another account."""
    return f"Rs {amount} transferred to {to_account}."
bank_tools = [get_balance, transfer_money]

print("--- 1. PII middleware ---")
class Echo(RuleModel):
    def _pick(self, messages): return "You shared: " + messages[-1].content
pii_agent = create_agent(Echo(), tools=[], middleware=[
    PIIMiddleware("email", strategy="redact", apply_to_input=True),
    PIIMiddleware("credit_card", strategy="mask", apply_to_input=True)])
r = pii_agent.invoke({"messages": [{"role": "user", "content": "My email is ajeet@example.com and card 4111 1111 1111 1111."}]})
print("what the LLM saw:", r["messages"][0].content)

print("\n--- 2. Human-in-the-loop ---")
for decision in ["approve", "reject"]:
    llm = ScriptedModel([call("transfer_money", {"to_account": "ACC999", "amount": 5000}),
                         "Done: Rs 5000 sent to ACC999." if decision == "approve" else "The transfer was not done: the manager rejected it."])
    hitl_agent = create_agent(llm, bank_tools, checkpointer=InMemorySaver(),
                              middleware=[HumanInTheLoopMiddleware(interrupt_on={"transfer_money": True, "get_balance": False})])
    config = {"configurable": {"thread_id": "hitl-" + decision}}
    r = hitl_agent.invoke({"messages": [{"role": "user", "content": "Transfer Rs 5000 to ACC999"}]}, config)
    req = r["__interrupt__"][0].value["action_requests"][0]
    print(f"⏸ paused, waiting for approval: {req['name']}({req['args']})")
    resume = {"decisions": [{"type": "approve"}]} if decision == "approve" else {"decisions": [{"type": "reject", "message": "Manager rejected this transfer."}]}
    r = hitl_agent.invoke(Command(resume=resume), config)
    print(f"  {decision:7s} -> tool said: {r['messages'][-2].content!r}\n            agent said: {r['messages'][-1].content}")

print("\n--- 3. Tool call limit ---")
greedy = ScriptedModel([call("transfer_money", {"to_account": "A1", "amount": 100}, "t1"), call("transfer_money", {"to_account": "A2", "amount": 100}, "t2"), "All done."])
r = create_agent(greedy, bank_tools, middleware=[ToolCallLimitMiddleware(tool_name="transfer_money", run_limit=1)]).invoke(
    {"messages": [{"role": "user", "content": "Send 100 to A1 and 100 to A2"}]})
print([m.content[:60] for m in r["messages"] if m.type == "tool"])

print("\n--- 4. Custom logging + compliance guard ---")
@before_model
def log_before(state: AgentState, runtime: Runtime):
    print(f"➡️  calling LLM with {len(state['messages'])} messages")

@wrap_tool_call
def log_tools(request, handler):
    print(f"🔧 tool: {request.tool_call['name']} {request.tool_call['args']}")
    return handler(request)

log_llm = ScriptedModel([call("get_balance", {"account_id": "ACC777"}), "Balance of ACC777 is Rs 1,24,500."])
print(create_agent(log_llm, bank_tools, middleware=[log_before, log_tools]).invoke({"messages": [{"role": "user", "content": "Balance of ACC777?"}]})["messages"][-1].content)

BANNED = ["guaranteed returns", "100% profit"]
@after_model(can_jump_to=["end"])
def compliance_guard(state: AgentState, runtime: Runtime):
    text = (state["messages"][-1].content or "").lower()
    if any(word in text for word in BANNED):
        return {"messages": [AIMessage("⚠️ This answer was blocked by compliance policy.")], "jump_to": "end"}
pushy = ScriptedModel(["Invest now! Our fund gives guaranteed returns of 30%!"])
print(create_agent(pushy, [], middleware=[compliance_guard]).invoke({"messages": [{"role": "user", "content": "Should I invest in your mutual fund?"}]})["messages"][-1].content)
--- 1. PII middleware --- what the LLM saw: My email is [REDACTED_EMAIL] and card **** **** **** 1111. --- 2. Human-in-the-loop --- ⏸ paused, waiting for approval: transfer_money({'to_account': 'ACC999', 'amount': 5000}) approve -> tool said: 'Rs 5000.0 transferred to ACC999.' agent said: Done: Rs 5000 sent to ACC999. ⏸ paused, waiting for approval: transfer_money({'to_account': 'ACC999', 'amount': 5000}) reject -> tool said: 'User rejected the tool call for `transfer_money` with reason: Manager rejected this transfer.' agent said: The transfer was not done: the manager rejected it. --- 3. Tool call limit --- ['Rs 100.0 transferred to A1.', "Tool call limit exceeded. Do not call 'transfer_money' again"] --- 4. Custom logging + compliance guard --- ➡️ calling LLM with 1 messages 🔧 tool: get_balance {'account_id': 'ACC777'} ➡️ calling LLM with 3 messages Balance of ACC777 is Rs 1,24,500. ⚠️ This answer was blocked by compliance policy.
Runs here without any API key. A small stand-in plays the LLM (RuleModel or ScriptedModel from standins.py), so every output below is from a real run of this code. In Colab you swap one line: llm = ChatGroq(model="openai/gpt-oss-20b").

05Hands-on: Ajeetkumar’s Colab notebook

Colab setup (same in every notebook): click 🔑 Secrets in the Colab sidebar, add groq_key with your free key from console.groq.com, switch on Notebook access, then run the cells from the top.
Demo · Notebook player · Lab 14

06Practice tasks

  1. Add human approval to a send_email tool with approve / edit / reject.
  2. Write @before_model middleware that blocks the text “ignore previous instructions”.
  3. Limit an agent to a maximum of 3 tool calls.

Try these in the same Colab notebook after you finish the lab. Change one thing at a time and look at the output again.

07Summary

08Quick quiz

1. What does HumanInTheLoopMiddleware need to pause and resume?

2. How do you continue after approval?

3. Which hook can block an unsafe answer?

4. Stop an agent from transferring twice in one run?

5. PIIMiddleware("credit_card", strategy="mask") turns 4111 1111 1111 1111 into…

09References

Created by Ajeetkumar