Human-in-the-loop & guardrails
Some actions you cannot let an agent take without a human looking first. This module covers the LangGraph primitives that make that easy — interrupts, approvals, edits, breakpoints, and time-travel — plus the input/output guardrails that keep agents on-rails when no human is watching.
1. Why "human-in-the-loop" matters
Even a 99%-accurate agent makes one wrong call in a hundred. If those wrong calls include "delete the prod database" or "send the resignation email", you need a human in the path. The categories of action that should almost always require approval:
- Irreversible: payments, emails to outsiders, terminating accounts, posting publicly.
- High-blast-radius: editing shared docs, granting permissions, deploying.
- Privacy-sensitive: pulling salary data, accessing health info.
- Anything novel: in the early life of the agent, gate everything until you trust it.
Don't ask "should we add a human gate?" Ask "which actions require one?" Then put the gate as close to that action as possible — at the tool call, not at the start of the conversation.
2. The mechanism: interrupt() and Command(resume=…)
LangGraph's interrupt() primitive pauses a graph mid-execution, surfaces a payload to your application, and waits for a Command(resume=…) to continue. The checkpointer makes this safe across processes, restarts, and days.
from langgraph.types import interrupt, Command
@tool
def send_email(to: str, subject: str, body: str) -> str:
"""Send an email to anyone."""
# Pause and ask a human to approve.
approval = interrupt({
"kind": "approve_email",
"to": to, "subject": subject, "body": body,
})
if not approval.get("approved"):
return f"User declined to send. Reason: {approval.get('reason','none')}"
# Optionally allow the approver to edit before sending:
final = approval.get("edited", {"to": to, "subject": subject, "body": body})
_mail_api.send(**final)
return f"Sent email to {final['to']}"
import { interrupt } from "@langchain/langgraph";
export const sendEmail = tool(
async ({ to, subject, body }) => {
const approval = interrupt({ kind: "approve_email", to, subject, body });
if (!approval?.approved) return `User declined: ${approval?.reason ?? "no reason"}`;
const final = approval?.edited ?? { to, subject, body };
await mailApi.send(final);
return `Sent email to ${final.to}`;
},
{ name: "send_email", description: "Send an email to anyone.",
schema: z.object({ to: z.string(), subject: z.string(), body: z.string() }) },
);
On the application side, when the run pauses, you receive a result containing the interrupt's payload. Your UI shows the user the proposed email, captures their decision, then resumes the run:
# 1. Run hits the interrupt and pauses:
result = await jarvis.ainvoke({"messages": [("user", "Email Priya the offsite plan.")]}, config=cfg)
# result["__interrupt__"] -> the payload {"kind": "approve_email", ...}
# 2. Your UI shows the email, captures approval/edits:
approval = {"approved": True, "edited": {"to": "priya@acme.com", "subject": "Offsite plan", "body": "..."}}
# 3. Resume the same thread with the approval:
result = await jarvis.ainvoke(Command(resume=approval), config=cfg)
import { Command } from "@langchain/langgraph";
let result = await jarvis.invoke({ messages: [{ role: "user", content: "Email Priya the offsite plan." }] }, cfg);
// result.__interrupt__ -> the payload
const approval = { approved: true, edited: { to: "priya@acme.com", subject: "Offsite plan", body: "..." } };
result = await jarvis.invoke(new Command({ resume: approval }), cfg);
The interrupted run is paused, not lost. The checkpointer holds its state under the thread_id. Resume must use the same thread_id. If your UI doesn't track thread_id carefully, the user's approval will get routed to a fresh, empty graph and silently do nothing.
3. Four patterns of human-in-the-loop
Approve / reject (the most common)
Above. The user sees the proposed action and OKs it or kills it.
Approve with edits
The user can also tweak the arguments before approval. Powerful for tool calls where the model gets the intent right but the details slightly off ("yes, send the email but change the subject line").
Provide input
The agent asks the user for missing info ("which printer model do you have?") and waits. Same mechanism — interrupt() with a question payload.
Review and continue
At a checkpoint, show the user the agent's plan so far and let them say "looks good, continue" before any tools fire. Useful for long, expensive workflows.
4. Breakpoints — interrupting at specific nodes
Beyond tool-level interrupts, you can declare node-level breakpoints at compile time. Use this when you want to pause for review before a whole specialist runs.
# Interrupt before any specialist runs:
jarvis = graph.compile(
checkpointer=checkpointer,
interrupt_before=["it", "hr", "calendar", "knowledge"],
)
# Interrupt after the supervisor decides, so the user can override:
# interrupt_after=["supervisor"]
const jarvis = graph.compile({
checkpointer,
interruptBefore: ["it", "hr", "calendar", "knowledge"],
});
5. Time-travel and editing state
Because every step is checkpointed, you can: (a) inspect the full history of a thread, (b) "rewind" to a past checkpoint, (c) edit the state at that point, (d) re-run from there. Invaluable for debugging and for "actually no, restart from before that bad tool call":
# Inspect history:
history = [snap async for snap in jarvis.aget_state_history(cfg)]
for snap in history:
print(snap.config["configurable"]["checkpoint_id"], snap.next, snap.values["messages"][-1].content[:80])
# Rewind: pick a checkpoint and resume from it.
target = history[3].config
edited = {"messages": [HumanMessage(content="Actually, change the subject to 'Offsite v2 plan'")]}
await jarvis.aupdate_state(target, edited)
await jarvis.ainvoke(None, config=target)
const history = [];
for await (const snap of jarvis.getStateHistory(cfg)) history.push(snap);
const target = history[3].config;
await jarvis.updateState(target, {
messages: [new HumanMessage("Actually, change the subject to 'Offsite v2 plan'")],
});
await jarvis.invoke(null, target);
This is also how LangSmith's "fork from here" / "replay" buttons work in the UI — under the hood it's just the same APIs.
6. Guardrails — what to do when no human is watching
Most turns won't be approved by a human. Guardrails are the cheap automated checks that catch the obvious failures.
Input guardrails (before the LLM sees the message)
- PII redaction — strip credit cards, SSNs.
- Topic gate — reject "tell me how to hack our CRM" before the LLM ever sees it.
- Prompt-injection detector — flag suspicious instructions from untrusted text.
Output guardrails (after the LLM, before the user / tool)
- Schema validation — refuse to call a tool with malformed args (LangChain does this automatically when you use Pydantic/Zod schemas).
- Policy check — does this email contain confidential terms?
- Hallucination check — does the answer cite a real document? If not, re-ask.
Tool guardrails (between LLM and tool call)
- Per-user rate limits ("max 3 emails per minute").
- Per-tool allowlists ("knowledge agent cannot call
send_email"). - Argument linting ("if
toisn't an @acme.com address, require approval").
The easiest place to add tool guardrails is inside the tool function — return an "ERROR: not allowed because X" string. The LLM sees that on the next turn and adapts. No framework needed.
7. Jarvis with safety
Jarvis now: pauses for human approval before sending emails or granting access; logs every approval to LangSmith; rejects messages that look like prompt-injection; and rate-limits any one user to 10 actions per minute. We're production-shaped. Next we put it behind a control plane and ship it.
Quick check
1. What primitive pauses a LangGraph run waiting for human input?
2. To resume a paused run, you must…
3. Where should you place a human approval gate?
4. What are guardrails for?