Middleware & Guardrails
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.
| Hook | Example use |
|---|---|
@before_model | trim history, add context, block “ignore previous instructions” |
@wrap_model_call | change model, retry |
@after_model | block unsafe answers |
@wrap_tool_call | log 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:
The tool messages are exactly what LangChain produced in the hands-on run below. On reject, the money tool never runs.
03Built-in middleware
| Middleware | Use | Banking example |
|---|---|---|
PIIMiddleware("credit_card", strategy="mask") | hide email, card, IP… before the LLM sees it | 4111 1111 1111 1111 → **** **** **** 1111 |
HumanInTheLoopMiddleware | approval before risky tools | transfers, emails, refunds |
SummarizationMiddleware | summarise old messages in long chats | a 2-hour support chat |
ModelCallLimitMiddleware / ToolCallLimitMiddleware | stop loops, control cost | max 1 transfer per run |
ModelFallbackMiddleware | backup model if the primary fails | gpt-oss-120b down → gpt-oss-20b |
ModelRetryMiddleware / ToolRetryMiddleware | auto retry | flaky core-banking API |
TodoListMiddleware | agent plans its tasks | multi-step account opening |
LLMToolSelectorMiddleware | pick relevant tools when you have many | 50 banking tools |
| PII strategy | Result for ajeet@example.com |
|---|---|
redact | [REDACTED_EMAIL] |
mask | partly hidden, e.g. last 4 digits of a card |
hash | a stable hash: same value → same token |
block | raise 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.
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)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
groq_key with your free key from console.groq.com, switch on Notebook access, then run the cells from the top.06Practice tasks
- Add human approval to a
send_emailtool with approve / edit / reject. - Write
@before_modelmiddleware that blocks the text “ignore previous instructions”. - 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
- Middleware runs around the agent loop: before/after model, around tools.
PIIMiddlewarehides personal data before the LLM sees it.HumanInTheLoopMiddleware+ checkpointer pauses risky tools for approval; resume withCommand(resume=…).- Limits and fallbacks protect cost and uptime.
- Write your own with
@before_model,@after_model,@wrap_tool_call,@wrap_model_call.
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
- LangChain: Middleware
- LangChain: Human-in-the-loop
- OWASP Top 10 for LLM Apps (EasyLearnSpace)
- Ajeetkumar’s notebook: LC_14_Middleware_Guardrails.ipynb (download)