Ever get that tiny knot in your stomach when you think about letting an AI agent loose on a real, important task? You know, something more serious than writing a poem or summarizing an article. I’m talking about tasks with real consequences, like cleaning up a customer database, processing financial transactions, or managing inventory.
We’ve all seen the demos where AI agents seem like magic. But what happens when the magic goes wrong? What if it misunderstands a request and deletes the wrong data? Or reformats a crucial file into oblivion? There’s no "undo" button for a lot of these actions.
That’s the core problem we’re going to tackle today. We need to move beyond building AI agents that are just clever and fast, and start building ones that are safe, reliable, and accountable.
Here’s the secret: we stop treating the AI’s work as a single, all-or-nothing command. Instead, we treat it like a transaction at a bank. There are stages, checks, and balances. There’s a moment to review everything before you hit "confirm." We’re going to build an AI agent with a safety net, using an incredible tool called LangGraph. Let's get into it.
So, What's the Big Idea? A Safety Net for AI
Think about how you use a shopping cart online. You add items, you remove them, you change quantities. Nothing is final. The items are just sitting in a temporary "sandbox." Only when you review your cart, enter your details, and click that final "Place Order" button does the transaction actually happen.
We're going to build that exact same logic for an AI agent. Our agent will:
- Propose Changes: Look at some messy data and figure out what needs to be fixed.
- Stage Them: Apply those fixes in a temporary, "sandbox" area. The original data is untouched.
- Validate: Check its own work against a set of strict rules.
- Ask for Permission: Pause and present its plan to a human for a simple "approve" or "reject."
- Commit or Rollback: If approved, make the changes permanent. If rejected, discard the changes completely, leaving no trace.
This is called a two-phase commit, and it’s a cornerstone of reliable systems. LangGraph makes it surprisingly straightforward to build this kind of structured, safe workflow.
First, Let's Get Our Tools Ready
Before we can build, we need to set up our workshop. This is the quick-and-easy part. We're just installing the necessary libraries (like LangGraph) and telling our code how to talk to an OpenAI model like GPT-4o.
If you’re following along in a notebook like Google Colab, this is all you need to get started.
# Make sure we have the latest and greatest
pip -q install -U langgraph langchain-openai
# A bunch of standard Python imports
import os, json, uuid, copy, math, re, operator
from typing import Any, Dict, List, Optional
from typing_extensions import TypedDict, Annotated
# The brains of our operation
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, AnyMessage
# The magic of LangGraph
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt, Command
# Securely set up your OpenAI API key
def _set_env_openai():
if os.environ.get("OPENAI_API_KEY"):
return
try:
from google.colab import userdata
k = userdata.get("OPENAI_API_KEY")
if k:
os.environ["OPENAI_API_KEY"] = k
return
except Exception:
pass
import getpass
os.environ["OPENAI_API_KEY"] = getpass.getpass("Enter OPENAI_API_KEY: ")
_set_env_openai()
# We'll use a reliable model and set temperature to 0 for predictable results
MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o-mini")
llm = ChatOpenAI(model=MODEL, temperature=0)
We’re setting the temperature to 0 to make the AI’s responses as deterministic and predictable as possible. For a task like this, we want a reliable worker, not a creative genius.
Our Mission: Cleaning Up a Messy Ledger
Every real-world project starts with messy data. Here’s the patient we’ll be operating on today—a sample financial ledger.
Take a look. It’s a bit of a disaster, right?
SAMPLE_LEDGER = [
{"txn_id": "T001", "name": "Asha", "email": "ASHA@Example.com", "amount": "1,250.50", "date": "12/01/2025", "note": "Membership renewal"},
{"txn_id": "T002", "name": "Ravi", "email": "ravi@example.com", "amount": "-500", "date": "2025-12-02", "note": "Chargeback?"},
{"txn_id": "T003", "name": "Sara", "email": "sara@example.com", "amount": "700", "date": "02-12-2025", "note": "Late fee waived"},
{"txn_id": "T003", "name": "Sara", "email": "sara@example.com", "amount": "700", "date": "02-12-2025", "note": "Duplicate row"},
{"txn_id": "T004", "name": "Lee", "email": "lee@example.com", "amount": "NaN", "date": "2025/12/03", "note": "Bad amount"},
]
We’ve got it all:
- Duplicate transaction IDs (
T003). - Inconsistent date formats (
12/01/2025vs.2025-12-02). - Amounts that are strings with commas.
- Even a non-numeric amount (
NaN).
Our agent's job is to fix this. But crucially, we want it to propose a set of fixes, not just go wild on our data. To do that, we need to give it some safe tools to work with.
Building the "Safe Mode" Toolkit
We'll write a few simple Python functions that define the "rules of the road." These functions will help us profile, patch, and validate the data. Think of them as the guardrails that keep our agent on the right path.
# A simple function to turn messy amount strings into clean numbers
def _parse_amount(x):
if isinstance(x, (int, float)): return float(x)
if isinstance(x, str):
try: return float(x.replace(",", ""))
except: return None
return None
# A function to standardize all dates into YYYY-MM-DD format
def _iso_date(d):
if not isinstance(d, str): return None
d = d.replace("/", "-")
p = d.split("-")
if len(p) == 3 and len(p[0]) == 4: return d
if len(p) == 3 and len(p[2]) == 4: return f"{p[2]}-{p[1]}-{p[0]}"
return None
# This function scans the ledger for obvious problems
def profile_ledger(rows):
seen, anomalies = {}, []
for i, r in enumerate(rows):
if _parse_amount(r.get("amount")) is None: anomalies.append(i)
if r.get("txn_id") in seen: anomalies.append(i)
seen[r.get("txn_id")] = i
return {"rows": len(rows), "anomalies": anomalies}
# This lets us apply the AI's proposed changes to a copy of the data
def apply_patch(rows, patch):
out = copy.deepcopy(rows)
# Handle removals first to avoid index issues
for op in sorted([p for p in patch if p["op"] == "remove"], key=lambda x: x["idx"], reverse=True):
out.pop(op["idx"])
for op in patch:
if op["op"] in {"add", "replace"}:
out[op['idx']][op['field']] = op['value']
return out
# The final check: Does the cleaned data meet our standards?
def validate(rows):
issues = []
for i, r in enumerate(rows):
if _parse_amount(r.get("amount")) is None: issues.append(i)
if _iso_date(r.get("date")) is None: issues.append(i)
return {"ok": len(issues) == 0, "issues": issues}
The key here is apply_patch. It works on a copy of the data, which is the heart of our "sandbox" concept. The agent can experiment freely without any risk to the original ledger.
Defining the Agent's Step-by-Step Plan
Now we get to the LangGraph part. We're going to define our agent's workflow as a series of distinct steps, or "nodes" in a graph.
First, we define the "state"—this is the memory of our workflow. It holds the original data, the proposed changes, the sandbox version, and the final decision.
class TxnState(TypedDict):
messages: Annotated[List[AnyMessage], add_messages]
raw_rows: List[Dict[str, Any]] # The original, messy data
sandbox_rows: List[Dict[str, Any]] # The data with proposed changes
patch: List[Dict[str, Any]] # The list of proposed changes
validation: Dict[str, Any] # The result of our validation check
approved: Optional[bool] # The human's decision
Next, we define a function for each step in our process. Each function takes the current state and returns an update.
# Step 1: Analyze the raw data
def node_profile(state):
p = profile_ledger(state["raw_rows"])
return {"messages": [AIMessage(content=json.dumps(p))]}
# Step 2: Ask the LLM to generate a "patch" of fixes
def node_patch(state):
sys = SystemMessage(content="Return a JSON patch list fixing amounts, dates, emails, duplicates")
usr = HumanMessage(content=json.dumps(state["raw_rows"]))
r = llm.invoke([sys, usr])
patch = json.loads(re.search(r"\[.*\]", r.content, re.S).group())
return {"patch": patch, "messages": [AIMessage(content=json.dumps(patch))]}
# Step 3: Apply the patch to the sandbox
def node_apply(state):
return {"sandbox_rows": apply_patch(state["raw_rows"], state["patch"])}
# Step 4: Validate the sandbox data
def node_validate(state):
v = validate(state["sandbox_rows"])
return {"validation": v, "messages": [AIMessage(content=json.dumps(v))]}
# Step 5: PAUSE and wait for a human
def node_approve(state):
# This 'interrupt' is the magic that pauses the graph
decision = interrupt({"validation": state["validation"]})
return {"approved": decision == "approve"}
# Step 6a: If approved, commit
def node_commit(state):
# In a real app, you'd save the sandbox_rows to your database here
return {"messages": [AIMessage(content="COMMITTED")]}
# Step 6b: If not approved (or if validation failed), rollback
def node_rollback(state):
# Here, you just discard the changes. Easy!
return {"messages": [AIMessage(content="ROLLED BACK")]}
See how clean that is? Each function has one specific job. The node_approve function is the most important one—it uses interrupt to pause the entire process and wait for a human to chime in.
Choreographing the Workflow with a Graph
Now we connect these steps into a flowchart. This is where LangGraph shines. We’re not leaving it up to the AI to decide what to do next; we are explicitly defining the control flow.
builder = StateGraph(TxnState)
# Add all our steps as nodes in the graph
builder.add_node("profile", node_profile)
builder.add_node("patch", node_patch)
builder.add_node("apply", node_apply)
builder.add_node("validate", node_validate)
builder.add_node("approve", node_approve)
builder.add_node("commit", node_commit)
builder.add_node("rollback", node_rollback)
# Define the path the agent must follow
builder.add_edge(START, "profile")
builder.add_edge("profile", "patch")
builder.add_edge("patch", "apply")
builder.add_edge("apply", "validate")
# This is a critical decision point!
builder.add_conditional_edges(
"validate",
# If validation passes, go to the human approval step.
# If it fails, go straight to rollback.
lambda s: "approve" if s["validation"]["ok"] else "rollback",
{"approve": "approve", "rollback": "rollback"}
)
# Another decision point, this time based on human input
builder.add_conditional_edges(
"approve",
# If the human approved, commit. Otherwise, rollback.
lambda s: "commit" if s["approved"] else "rollback",
{"commit": "commit", "rollback": "rollback"}
)
# The end of the line
builder.add_edge("commit", END)
builder.add_edge("rollback", END)
# Compile the graph into a runnable application
app = builder.compile(checkpointer=InMemorySaver())
This code is like drawing a flowchart. We’ve created a process that the agent must follow. It can't skip the validation step. It can't commit changes without passing through the approval node. We’ve built the governance right into the system's DNA.
Showtime: Running Our Safe Agent
Let's see it in action. We'll kick off the process with our messy ledger and see where it pauses.
def run():
state = {
"messages": [],
"raw_rows": SAMPLE_LEDGER,
"sandbox_rows": [],
"patch": [],
"validation": {},
"approved": None,
}
cfg = {"configurable": {"thread_id": "txn-demo"}}
# Start the process
out = app.invoke(state, config=cfg)
# Check if the graph has paused for human input
if "__interrupt__" in out:
print("--- PAUSED FOR HUMAN APPROVAL ---")
print(json.dumps(out["__interrupt__"], indent=2))
# Get the human's decision
decision = input("approve / reject: ").strip()
# Resume the process with the decision
out = app.invoke(Command(resume=decision), config=cfg)
print("--- FINAL RESULT ---")
print(out["messages"][-1].content)
run()
When you run this, the agent will execute the first few steps and then pause. Your screen will show something like this:
--- PAUSED FOR HUMAN APPROVAL ---
{
"validation": {
"ok": true,
"issues": []
}
}
approve / reject:
The agent is telling you, "I've analyzed the data, created a patch, applied it to a sandbox, and confirmed the result is valid. Everything looks good on my end. What do you want to do?"
If you type approve and hit Enter, the graph will resume and move to the commit node. If you type reject (or anything else), it will go to the rollback node. You are in complete control.
Why This Changes Everything
This might seem like a lot of setup for a simple data cleaning task, but the pattern is incredibly powerful. We’ve moved from a world of "prompt-and-pray" to a world of structured, auditable, and human-supervised AI workflows.
This isn't just an AI agent; it's a transaction coordinator. It can stage its work, inspect it, and reverse it, all while keeping a perfect audit trail.
By building agents this way, we can start deploying them in scenarios that require trust, compliance, and recoverability. Think about it: automated financial reconciliation, regulated document processing, or even complex software deployment pipelines. These are places where a single mistake can be costly. But with a system that pauses for approval at critical junctures, you get the speed and power of AI without sacrificing safety and control.
This is how we build AI we can actually trust. Not by hoping it's perfect, but by designing systems that gracefully handle the fact that it isn't.




