Lab 09 · Guarded refunds
Let an agent issue refunds safely: an off-topic filter, PII redaction, human approval above $100 and a hard $500 cap that even a reviewer cannot override.
What you will build
A Northwind support agent that can issue real refunds, wrapped in four layers of middleware. Finance's rule is: anything over $100 gets a human, and nothing over $500 ever goes out, no matter who approved it. You build that rule and watch each layer fire.
What you will learn
- How
@before_agentcan end a run before the model is called - How
PIIMiddlewareredacts or masks personal data before the model sees it - How
HumanInTheLoopMiddlewarepauses a tool call for approve, edit or reject - How
@wrap_tool_callenforces a policy on the final arguments, whoever chose them - Why an interrupt needs a checkpointer and a
thread_id
Check your prerequisites
You need three things on your machine and three values from your instructor.
- Python 3.11, 3.12 or 3.13. Check with the command below. On Windows, install it from python.org and tick Add python.exe to PATH.
- A code editor. VS Code with the Python extension works well.
- A terminal. PowerShell on Windows, Terminal on macOS or Linux.
python --versionpython3 --versionFrom your instructor you get an Azure OpenAI endpoint, an API key and a deployment name. Keep the key private: never paste it into code or share it in chat.
Create the project folder and virtual environment
Create a new folder for this lab, open it in a terminal, and create a virtual environment. A virtual environment is a private copy of Python for this project, so the libraries you install here never clash with other projects.
mkdir C:\labs\lab-09-guarded-refunds
cd C:\labs\lab-09-guarded-refunds
python -m venv .venv
.venv\Scripts\Activate.ps1mkdir -p ~/labs/lab-09-guarded-refunds
cd ~/labs/lab-09-guarded-refunds
python3 -m venv .venv
source .venv/bin/activateKeep lab folders on a short path such as C:\labs. Deep folders, for example inside OneDrive or Desktop, can exceed the Windows path limit. Some libraries then fail to load with The filename or extension is too long. OneDrive syncing can also lock files the labs write.
When activation works, your prompt starts with (.venv). Activate it again every time you open a new terminal for this lab. Open the folder in VS Code with code . if you like.
PowerShell says running scripts is disabled on this system? Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, answer Y, and activate again.
This is what your folder will look like at the end of the lab:
Install the libraries
Create a file named requirements.txt in your lab folder with the content below. The versions are pinned to the ones this lab was tested with.
langchain==1.4.2
langchain-core==1.6.4
langchain-openai==1.6.2
python-dotenv==1.2.3
langgraph==1.2.11Then install everything into your virtual environment. It takes a minute or two.
python -m pip install --upgrade pip
pip install -r requirements.txtpython -m pip install --upgrade pip
pip install -r requirements.txtAdd your Azure OpenAI settings
Create a file named .env (the name starts with a dot) in your lab folder. Paste the template and replace the three placeholders with the values from your instructor.
# Azure OpenAI settings. Your instructor gives you these three values.
AZURE_OPENAI_ENDPOINT=https://<your-resource>.openai.azure.com/openai/v1
AZURE_OPENAI_API_KEY=<paste-your-key-here>
AZURE_OPENAI_DEPLOYMENT=<your-chat-deployment-name>
# Only used when your endpoint does NOT end in /openai/v1.
OPENAI_API_VERSION=2025-04-01-preview- AZURE_OPENAI_ENDPOINT is your resource URL. If it ends in
/openai/v1, keep it that way. If it is justhttps://<resource>.openai.azure.com/, paste that; the helper in the next step handles both. - AZURE_OPENAI_API_KEY is your secret key.
- AZURE_OPENAI_DEPLOYMENT is the deployment name, for example
gpt-5-mini. On Azure you call a deployment, not a model name.
Never commit .env to Git or share it. If you use Git, add a .gitignore file containing .env and .venv/.
Create the Azure OpenAI helper
Every lab talks to Azure OpenAI through one small helper file. Create model_setup.py and add the blocks below one after another. You write it once per lab folder; the lab code then only needs from model_setup import get_model.
Block 1Imports, .env loading and a console fix
load_dotenv() reads your .env file and puts every line into environment variables, so your key never appears in code.
The loop below it switches the console to UTF-8. Windows terminals default to an older encoding and crash when a model prints curly quotes or dashes.
"""Azure OpenAI model setup shared by every lab.
Import it in lab code as: from model_setup import get_model, get_embeddings
"""
import asyncio
import os
import sys
from dotenv import load_dotenv
from langchain_core.embeddings import Embeddings
from langchain_core.language_models import BaseChatModel
# Read AZURE_OPENAI_* values from the .env file next to your lab code.
load_dotenv()
# Windows consoles default to cp1252 and crash on the curly quotes models love to print.
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
_stream.reconfigure(encoding="utf-8", errors="replace")Block 2Read settings safely
_setting() fetches a value from the environment and stops with a friendly message if it is missing, instead of a long stack trace.
_uses_v1_api() checks the shape of your endpoint. Azure offers two:
https://<resource>.openai.azure.com/openai/v1is the newer OpenAI-compatible API.https://<resource>.openai.azure.com/is the classic API that needs anapi_version.
def _setting(name: str) -> str:
"""Return a required setting from .env, or stop with a readable message."""
value = os.getenv(name, "").strip()
if not value:
sys.exit(f"Missing {name} in your .env file. Open .env and fill it in.")
return value
def _uses_v1_api(endpoint: str) -> bool:
"""True for the OpenAI-compatible Azure endpoint that ends in /openai/v1."""
return endpoint.rstrip("/").endswith("/openai/v1")Block 3Build the chat model
get_model() returns a LangChain chat model connected to your deployment.
- With a
/openai/v1endpoint it usesChatOpenAIwithbase_url. On Azure the deployment name goes inmodel. - With a classic endpoint it uses
AzureChatOpenAIwithazure_deploymentandapi_version.
Either way the lab code just calls get_model() and never cares which one it got.
def get_model(deployment: str | None = None, **kwargs) -> BaseChatModel:
"""Return a chat model for your Azure OpenAI deployment."""
endpoint = _setting("AZURE_OPENAI_ENDPOINT")
api_key = _setting("AZURE_OPENAI_API_KEY")
name = deployment or _setting("AZURE_OPENAI_DEPLOYMENT")
if _uses_v1_api(endpoint):
from langchain_openai import ChatOpenAI
return ChatOpenAI(model=name, base_url=endpoint, api_key=api_key, **kwargs)
from langchain_openai import AzureChatOpenAI
return AzureChatOpenAI(
azure_deployment=name,
azure_endpoint=endpoint,
api_key=api_key,
api_version=os.getenv("OPENAI_API_VERSION", "2025-04-01-preview"),
**kwargs,
)Block 4Build the embeddings model
get_embeddings() does the same for embeddings, which turn text into vectors for search. Only the retrieval labs use it, but keeping one helper for every lab is simpler.
def get_embeddings(deployment: str | None = None) -> Embeddings:
"""Return an embeddings model for your Azure OpenAI embeddings deployment."""
endpoint = _setting("AZURE_OPENAI_ENDPOINT")
api_key = _setting("AZURE_OPENAI_API_KEY")
name = deployment or _setting("AZURE_OPENAI_EMBEDDING_DEPLOYMENT")
if _uses_v1_api(endpoint):
from langchain_openai import OpenAIEmbeddings
return OpenAIEmbeddings(model=name, base_url=endpoint, api_key=api_key)
from langchain_openai import AzureOpenAIEmbeddings
return AzureOpenAIEmbeddings(
azure_deployment=name,
azure_endpoint=endpoint,
api_key=api_key,
api_version=os.getenv("OPENAI_API_VERSION", "2025-04-01-preview"),
)Block 5A Windows fix for async code
use_stable_event_loop() matters only for the async MCP lab. The default Windows event loop can crash on exit after async network sessions close. On macOS and Linux it does nothing.
def use_stable_event_loop() -> None:
"""On Windows, use the Selector event loop so async MCP code exits cleanly."""
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())Check that the helper can reach your deployment with a one-line test:
python -c "from model_setup import get_model; print(get_model().invoke('Say hello in 5 words').text)"python -c "from model_setup import get_model; print(get_model().invoke('Say hello in 5 words').text)"You should see a short greeting. If you see an error instead, check the troubleshooting step at the end.
Write the guarded refund agent
Middleware are hooks that run around the agent loop: before the agent starts, before each model call, after each model call, and around each tool call. You stack them in a list, and each one has one job. The four layers here are:
block_off_topicrefuses off-topic input, so the model never runs.PIIMiddlewareredacts the email and masks card numbers.HumanInTheLoopMiddlewarepauses refunds over $100 for a reviewer.refund_policyrefuses anything over $500, right next to the side effect.
Layers 1 to 3 can all be talked around: a filter misses a phrasing, PII detection misses a format, a reviewer approves on autopilot. Layer 4 checks the arguments that are about to execute, so it holds even when a human typed them.
Create an empty file named guarded_refunds.py in your lab folder. Then copy each block below into it, in order, one after another.
Block 1Imports
- The middleware classes and decorators come from
langchain.agents.middleware. InMemorySaveris a checkpointer that keeps paused runs in memory.Commandis how you resume a paused run with the reviewer's decision.
"""Lab 09: the Northwind refund agent behind four layers of defence.
An off-topic filter, PII redaction, an approval gate over 100 USD, and a hard cap in
`wrap_tool_call`. The last layer trusts neither the model nor the human reviewer.
Run: python guarded_refunds.py
"""
from typing import Any, Callable
from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import (
HumanInTheLoopMiddleware,
PIIMiddleware,
ToolCallLimitMiddleware,
ToolCallRequest,
before_agent,
wrap_tool_call,
)
from langchain.messages import AIMessage, ToolMessage
from langchain.tools import tool
from langchain_core.language_models import BaseChatModel
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.runtime import Runtime
from langgraph.types import Command
from model_setup import get_modelBlock 2Policy values and pretend data
The two limits from Finance, the blocked topics, three orders, and REFUND_LEDGER, a list that stands in for the payments API. At the end you print the ledger to prove what money actually moved.
APPROVAL_THRESHOLD_USD = 100.0 # above this, a human decides
HARD_REFUND_CAP_USD = 500.0 # nobody exceeds this, not even an approving human
BLOCKED_TOPICS = {"medical advice", "legal advice", "stock tips", "crypto"}
REFUSAL = "I can only help with Northwind orders, billing and refunds."
ORDERS = {"A-100": 249.00, "A-101": 1299.50, "A-102": 42.00}
REFUND_LEDGER: list[dict[str, Any]] = [] # the side effect we protect; in production, a payments APIBlock 3The tools
get_order is a safe read. issue_refund is the dangerous one: it appends to the ledger. Its docstring says destructive, irreversible, real money, which also nudges the model to be careful.
@tool
def get_order(order_id: str) -> str:
"""Look up an order's total. Use before discussing or refunding an order."""
total = ORDERS.get(order_id.upper())
if total is None:
return f"No order found with id {order_id}."
return f"Order {order_id.upper()} total: {total:.2f} USD."
@tool
def issue_refund(order_id: str, amount: float) -> str:
"""Refund money to the customer for an order. Destructive, irreversible, real money."""
REFUND_LEDGER.append({"order_id": order_id.upper(), "amount": amount})
return f"Refunded {amount:.2f} USD on order {order_id.upper()}."Block 4Layer 1: off-topic filter
@before_agent runs once, before the first model call. If the last message mentions a blocked topic, it adds a refusal and returns "jump_to": "end".
can_jump_to=["end"] is required: it creates the shortcut edge. Without it, your jump_to key is silently ignored and the model runs anyway.
@before_agent(can_jump_to=["end"])
def block_off_topic(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
"""Layer 1: refuse out-of-scope requests before a single token is spent."""
if any(topic in state["messages"][-1].text.lower() for topic in BLOCKED_TOPICS):
return {"messages": [AIMessage(REFUSAL)], "jump_to": "end"}
return NoneBlock 5Layer 3: when to ask a human
needs_approval receives the pending tool call and returns True when the amount is above $100. Small refunds go through without bothering a reviewer, so reviewers stay attentive for the ones that matter.
def needs_approval(request: Any) -> bool:
"""Layer 3: interrupt only above the auto-approve limit, so reviewers stay awake."""
return request.tool_call["args"]["amount"] > APPROVAL_THRESHOLD_USDBlock 6Layer 4: the hard cap
@wrap_tool_call wraps every tool execution. handler(request) runs the real tool; not calling it is what keeps the money in the account.
- Other tools pass straight through.
- Refunds up to $500 run normally.
- Above $500 it returns a
ToolMessagewithstatus="error"instead. The model reads it and apologises to the customer instead of crashing.
Because it runs after the approval step, it sees the final arguments, including any amount a reviewer typed in.
@wrap_tool_call
def refund_policy(request: ToolCallRequest, handler: Callable[[ToolCallRequest], Any]) -> Any:
"""Layer 4: the hard floor. It validates the arguments that execute, whoever chose them."""
if request.tool_call["name"] != "issue_refund":
return handler(request)
amount = request.tool_call["args"]["amount"]
if amount <= HARD_REFUND_CAP_USD:
return handler(request)
return ToolMessage(
content=f"Refused by refund policy: {amount:.2f} USD is above the "
f"{HARD_REFUND_CAP_USD:.0f} USD hard cap. Tell the customer a specialist will review it.",
tool_call_id=request.tool_call["id"],
status="error", # an error result lets the model apologise instead of crashing
)Block 7The system prompt
Short and specific: look the order up first, and refund only what was paid. The prompt is guidance, not a guarantee. The guarantees are layers 1 to 4.
SYSTEM_PROMPT = (
"You are the Northwind support assistant. Look an order up with get_order before you "
"refund it, and refund only what the customer actually paid."
)Block 8Assemble the agent with its middleware
Order matters: cheapest and most certain first, the policy gate last, next to the side effect.
PIIMiddleware("email", strategy="redact")replaces emails with[REDACTED_EMAIL]before the model and the checkpoint see them.interrupt_onpausesissue_refundwhenneeds_approvalsays so, and offers approve, edit and reject.get_order: Falseis an explicit never ask.ToolCallLimitMiddlewareis a safety net: at most one refund per conversation.checkpointer=InMemorySaver()is mandatory. A pause has to be stored somewhere until the reviewer answers.
def build_agent(model: BaseChatModel | None = None):
"""Build the guarded agent. Pass a model to override the Azure OpenAI default."""
return create_agent(
model=model or get_model(),
tools=[get_order, issue_refund],
system_prompt=SYSTEM_PROMPT,
middleware=[
block_off_topic, # 1. cheapest and most certain first
PIIMiddleware("email", strategy="redact"), # 2. keep PII out of model and checkpoint
PIIMiddleware("credit_card", strategy="mask", apply_to_output=True),
HumanInTheLoopMiddleware( # 3. a human decides the expensive ones
interrupt_on={
"issue_refund": {
"allowed_decisions": ["approve", "edit", "reject"],
"when": needs_approval,
},
"get_order": False, # explicit: reads are auto-approved
},
description_prefix="Northwind refund approval",
),
refund_policy, # 4. policy code has the last word, over the model AND the human
# A safety net, not a guardrail: one refund per conversation, whatever happens.
ToolCallLimitMiddleware(tool_name="issue_refund", thread_limit=1),
],
checkpointer=InMemorySaver(), # an interrupt has nowhere to live without one
)Block 9Try every layer
The demo runs two conversations.
- Any crypto tips? hits layer 1: you see the refusal and no model message.
- A refund request with an email address. The model looks up A-100 ($249) and calls
issue_refund, which pauses because 249 > 100. The paused result has an__interrupt__key with the pending tool call.
The reviewer's decision is hard-coded, where a real approval UI would collect it: an edit to $5,000. Resuming with Command(resume={"decisions": [decision]}) continues the run, and layer 4 refuses the edited amount.
If the model answers without calling issue_refund, the demo prints its reply instead of failing on a missing __interrupt__.
def show(messages: list[Any]) -> None:
"""Print each message type with its text or its tool calls."""
for message in messages:
print(f" {type(message).__name__:14} {message.text or message.tool_calls}")
if __name__ == "__main__":
agent = build_agent()
print("== Layer 1: an off-topic request never reaches the model")
blocked = agent.invoke(
{"messages": [{"role": "user", "content": "Any crypto tips for me?"}]},
{"configurable": {"thread_id": "lab09-blocked"}},
)
show(blocked["messages"])
print("\n== Layers 2-4: a refund request with an email address in it")
config = {"configurable": {"thread_id": "lab09"}}
question = "Order A-100 arrived broken, refund it in full. I'm ada@northwind.example."
paused = agent.invoke({"messages": [{"role": "user", "content": question}]}, config)
interrupts = paused.get("__interrupt__")
if not interrupts:
print("The model did not call issue_refund, so there is nothing to approve. It said:")
show(paused["messages"])
else:
print("awaiting approval:", interrupts[0].value["action_requests"][0]["args"])
# A real approval UI collects this from a reviewer. This one edits the amount up to
# 5,000 USD, and layer 4 refuses it anyway, because approval is not authorisation.
decision = {
"type": "edit",
"edited_action": {"name": "issue_refund", "args": {"order_id": "A-100", "amount": 5000.0}},
}
result = agent.invoke(Command(resume={"decisions": [decision]}), config)
show(result["messages"])
print("ledger:", REFUND_LEDGER)Run the guarded agent
python guarded_refunds.pypython3 guarded_refunds.pyYour output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.
== Layer 1: an off-topic request never reaches the model
HumanMessage Any crypto tips for me?
AIMessage I can only help with Northwind orders, billing and refunds.
== Layers 2-4: a refund request with an email address in it
awaiting approval: {'order_id': 'A-100', 'amount': 249}
HumanMessage Order A-100 arrived broken, refund it in full. I'm [REDACTED_EMAIL].
AIMessage [{'name': 'get_order', 'args': {'order_id': 'A-100'}, 'id': 'call_1YmBklWZxEOGhbrZ909X3vlM', 'type': 'tool_call'}]
ToolMessage Order A-100 total: 249.00 USD.
AIMessage [{'name': 'issue_refund', 'args': {'order_id': 'A-100', 'amount': 249}, 'id': 'call_NhWiHKagUs7YzMe2sxZVnmzZ', 'type': 'tool_call'}]
ToolMessage Note: a human reviewer replaced this tool call before it ran. The call recorded in your message is the one you produced, not the one that executed. This was intentional and authorized. Do not re-issue your original call. Executed instead: issue_refund with arguments {"order_id": "A-100", "amount": 5000.0}.
Tool response:
Refused by refund policy: 5000.00 USD is above the 500 USD hard cap. Tell the customer a specialist will review it.
AIMessage [{'name': 'issue_refund', 'args': {'order_id': 'A-100', 'amount': 249}, 'id': 'call_4KzBNaHO7Ni8sHA0iub9GWlj', 'type': 'tool_call'}]
ToolMessage Tool call limit exceeded. Do not call 'issue_refund' again.
ledger: []Match each layer to the output:
- Layer 1: the crypto question gets the refusal straight away. There is no tool call and no model answer in that conversation.
- Layer 2: the stored
HumanMessagesays[REDACTED_EMAIL]. The model never saw the address. - Layer 3: the run paused at
awaiting approvalwithamount: 249.0. - Layer 4: the reviewer edited the amount to 5000, and the
ToolMessagesays Refused by refund policy. LangChain also tells the model that a reviewer replaced its call.
What happens next varies. Often the model tells the customer a specialist will review it. Sometimes, as above, it tries issue_refund again with the original 249; then the ToolCallLimitMiddleware safety net answers Tool call limit exceeded and the run stops.
The last line, ledger: [], is the proof: no money moved, although a human approved $5,000.
Check your work
You are done when
- The crypto question is refused without any model answer.
- The stored user message contains
[REDACTED_EMAIL], not the email address. - The refund pauses with
awaiting approvaland an amount of 249. - The $5,000 edit is refused by
refund_policyandledger: []is printed. - You changed the decision to approve (or reject) and saw the ledger change (or stay empty).
Troubleshooting
ValueError about a missing checkpointer, or the run never pauses
create_agent needs checkpointer=InMemorySaver(), and every invoke needs a config with a thread_id.
The off-topic question still reaches the model
The decorator must be @before_agent(can_jump_to=["end"]). Without can_jump_to, jump_to is ignored.
The demo prints 'The model did not call issue_refund'
The model asked a question or refused instead of refunding. Run again, or make the request more explicit, for example Refund order A-100 in full: 249 USD.
The resume fails with an error about decisions
The resume value must be {"decisions": [decision]}, with one decision per paused tool call, and the same thread_id as the paused run.
401 Unauthorized or 'Access denied due to invalid subscription key'
The key in .env is wrong or has spaces around it. Copy it again from your instructor, with no quotes.
404 'DeploymentNotFound' or 'The API deployment for this resource does not exist'
AZURE_OPENAI_DEPLOYMENT must be the deployment name exactly as your instructor gave it, and the endpoint must be the same resource.
429 Too Many Requests
The shared deployment is busy. Wait 30 seconds and run again.
ModuleNotFoundError: No module named 'model_setup' (or another package)
Run the command from inside your lab folder, with the virtual environment activated. Reinstall with pip install -r requirements.txt if a library is missing.
'python' is not recognized, or the wrong Python runs
Activate the virtual environment first. On Windows you can also try py -3.11 instead of python when creating the venv.
Go further
- Change
decisionto{"type": "approve"}and run again: the ledger now shows a 249 USD refund. - Change it to
{"type": "reject", "message": "Send photos of the damage first."}: the ledger stays empty and the model relays your message. - Edit the amount to
120.0instead and confirm the ledger records 120, not 249. - Refuse a refund larger than the order total as well as one above the cap, and test it with a $400 refund on the $42 order A-102.
- Add a second reviewer tier: refunds over $400 allow only approve and reject, so nobody can silently edit the biggest ones.
- Swap
InMemorySaverforSqliteSaver(see Lab 08) and approve from a second process.
Stuck?
Download the finished project and compare it with yours. It contains every file from this guide except .env and .venv, so you still add your own settings.