AiTechWorlds
AiTechWorlds
Full autonomy isn't always the goal. The best production agents know when to proceed independently and when to pause, ask for clarification, or request approval. This design pattern human in the loop is what makes agents safe and trustworthy in high stakes workflows.
Fully autonomous agents fail in predictable ways:
Human in the loop gives agents a safety valve: when uncertainty is high or stakes are high, pause and verify.
LangGraph has a built in interrupt function that pauses execution and waits for human input:
from langgraph.types import interrupt
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph, END
from typing import TypedDict
class AgentState(TypedDict):
task: str
plan: str
approved: bool
result: str
def plan_node(state: AgentState) -> AgentState:
"""Generate a plan for the task."""
plan = generate_plan(state["task"])
return {**state, "plan": plan}
def approval_node(state: AgentState) -> AgentState:
"""Pause and wait for human approval of the plan."""
# interrupt() pauses the graph and returns control to the caller
# The caller can inspect state, then resume with a value
human_decision = interrupt({
"question": "Do you approve this plan?",
"plan": state["plan"],
"options": ["approve", "reject", "modify"]
})
return {**state, "approved": human_decision == "approve"}
def execute_node(state: AgentState) -> AgentState:
"""Execute only if approved."""
if not state["approved"]:
return {**state, "result": "Plan rejected by user."}
result = execute_plan(state["plan"])
return {**state, "result": result}
def should_execute(state: AgentState) -> str:
return "execute" if state["approved"] else END
# Build graph with checkpointing (required for interrupts)
checkpointer = MemorySaver()
graph = StateGraph(AgentState)
graph.add_node("plan", plan_node)
graph.add_node("approval", approval_node)
graph.add_node("execute", execute_node)
graph.set_entry_point("plan")
graph.add_edge("plan", "approval")
graph.add_conditional_edges("approval", should_execute, {"execute": "execute", END: END})
graph.add_edge("execute", END)
app = graph.compile(
checkpointer=checkpointer,
interrupt_before=["approval"] # OR use interrupt() inside the node
)
# Running the agent
config = {"configurable": {"thread_id": "task_001"}}
# Step 1: Run until the interrupt
result = app.invoke(
{"task": "Delete all log files older than 30 days from the server", "plan": "", "approved": False, "result": ""},
config
)
# Result shows the pending interrupt:
print("Agent paused. Plan:", result["plan"])
# > "Plan: 1. SSH to server 2. Find files: find /var/logs -mtime +30 3. Delete: rm -rf"
# Step 2: Human reviews and responds
user_says = "approve" # or "reject"
# Resume execution with the human's decision
final_result = app.invoke(
Command(resume=user_says), # Resume with human input
config
)
print("Result:", final_result["result"])For actions with irreversible consequences:
from langgraph.types import interrupt, Command
class EmailState(TypedDict):
recipient: str
subject: str
body: str
sent: bool
def compose_email(state: EmailState) -> EmailState:
"""Generate the email."""
body = llm.invoke(f"Write an email to {state['recipient']} about {state['subject']}").content
return {**state, "body": body}
def review_email(state: EmailState) -> EmailState:
"""Show draft to human before sending."""
response = interrupt({
"message": "Review this email before sending:",
"to": state["recipient"],
"subject": state["subject"],
"body": state["body"],
"instructions": "Reply with 'send', 'cancel', or provide new instructions"
})
if response == "send":
return {**state, "approved": True}
elif response == "cancel":
return {**state, "approved": False}
else:
# User gave new instructions — go back and revise
revised_body = llm.invoke(f"Revise the email with these changes: {response}\n\nOriginal:\n{state['body']}").content
return {**state, "body": revised_body, "needs_review": True}
def send_email_node(state: EmailState) -> EmailState:
"""Actually send the email."""
email_client.send(to=state["recipient"], subject=state["subject"], body=state["body"])
return {**state, "sent": True}Agents can ask for clarification before beginning work:
def clarification_node(state: AgentState) -> AgentState:
"""Ask clarifying questions if the task is ambiguous."""
# Check if the task is ambiguous
analysis = llm.invoke(
f"Is this task clear enough to execute? Task: {state['task']}\n"
"If yes, respond with 'CLEAR'. If not, list the 2-3 most important clarifying questions."
).content
if analysis.strip() == "CLEAR":
return {**state, "needs_clarification": False}
# Pause and ask the human
questions = analysis
human_answers = interrupt({
"type": "clarification_needed",
"original_task": state["task"],
"questions": questions
})
# Update the task with the answers
enriched_task = f"{state['task']}\n\nAdditional context:\n{human_answers}"
return {**state, "task": enriched_task, "needs_clarification": False}For batch operations, interrupt every N items to let humans verify quality:
def process_items_with_sampling(state: AgentState) -> AgentState:
processed = []
for i, item in enumerate(state["items"]):
result = process_one(item)
processed.append(result)
# Sample every 10th item for human review
if (i + 1) % 10 == 0:
human_feedback = interrupt({
"type": "quality_check",
"message": f"Sample check after {i+1} items",
"sample_input": item,
"sample_output": result,
"question": "Does this look correct? Type 'continue' or describe a problem."
})
if human_feedback != "continue":
# Human found an issue — pause and fix before continuing
return {**state, "error": human_feedback, "processed": processed}
return {**state, "processed": processed}In web applications, the "human" is a user in a browser:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
agent_sessions = {} # In production, use Redis
@app.post("/agent/start")
async def start_agent(request: dict):
thread_id = generate_unique_id()
# Start agent asynchronously
config = {"configurable": {"thread_id": thread_id}}
state = agent_app.invoke(request, config)
# Check if paused at interrupt
if "__interrupt__" in state:
interrupt_data = state["__interrupt__"][0].value
return {
"thread_id": thread_id,
"status": "awaiting_human",
"question": interrupt_data
}
return {"thread_id": thread_id, "status": "complete", "result": state}
@app.post("/agent/resume/{thread_id}")
async def resume_agent(thread_id: str, human_response: dict):
config = {"configurable": {"thread_id": thread_id}}
# Resume with human's response
result = agent_app.invoke(
Command(resume=human_response["response"]),
config
)
if "__interrupt__" in result:
return {"status": "awaiting_human", "question": result["__interrupt__"][0].value}
return {"status": "complete", "result": result}Interrupt for irreversible actions: Sending emails, deleting data, making purchases, publishing content.
Don't interrupt for reads: Searching, reading files, retrieving data these are safe and don't need approval.
Show the human enough context: The interrupt payload should give the human exactly what they need to make a decision not raw agent state.
Give clear options: "Approve or reject" is better than open ended "what would you like to do?"
Timeout and default behavior: In async systems, decide what happens if the human doesn't respond in 24 hours cancel, proceed with a default, or escalate.
Next lesson: Agent evaluation and testing measuring whether your agents actually work.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises