This post is about human-in-the-loop, or HITL.
An agent can take real actions. It can send an email, delete a file, spend money. Most of the time that’s fine. Sometimes it’s not. You want a person to check first.
Human-in-the-loop is simple to describe: the agent pauses before a risky action, a human decides, then it continues.
the agent wants to do something risky
│
▼
PAUSE ⏸ the run stops and waits
│
a human decides
│ │ │
approve edit reject
│ │ │
▼ ▼ ▼
do it fix it skip it
│
▼
the agent carries on
I’ll go through eight small lessons. First we pause and resume a graph by hand, to see the raw mechanism. Then we use the middleware that does it for you, with approve, edit and reject. Each lesson adds one idea.
These examples use LangChain and LangGraph 1.x, current in October 2026.
The one hard part: pausing
Everything here rests on one idea. The agent stops in the middle of a run and comes back to the same spot later, maybe minutes later, after a human replies.
To come back to the same spot, the run’s state has to be saved somewhere. That saver is called a checkpointer. HITL does not work without one.
interrupt()— pause the graph and wait.Command— resume it with the human’s answer.checkpointer— saves the paused run so it can continue.thread_id— the id of one run, so resume knows which pause to continue.
Setup
mkdir hitl-demo && cd hitl-demo
npm init -y
npm pkg set type=module
npm install langchain @langchain/langgraph @langchain/openrouter zod
You need Node.js 20.6 or newer and an OpenRouter API key from openrouter.ai.
These lines are shared by the lessons below.
import { createAgent, tool } from "langchain";
import { ChatOpenRouter } from "@langchain/openrouter";
import * as z from "zod";
// Paste your key here. Fine for a script on your own machine.
process.env.OPENROUTER_API_KEY = "sk-or-v1-paste-your-key-here";
const MODEL = new ChatOpenRouter({
model: "openai/gpt-4o-mini",
temperature: 0,
});
const ask = (agent, text, config) =>
agent.invoke({ messages: [{ role: "user", content: text }] }, config);
const lastText = (result) => result.messages.at(-1).content;
// A risky tool and a safe one, used throughout.
const sendEmail = tool(
async ({ to, subject }) => `Email sent to ${to}: "${subject}"`,
{
name: "send_email",
description: "Send an email to someone",
schema: z.object({ to: z.string(), subject: z.string() }),
},
);
const getWeather = tool(
async ({ city }) => `It's sunny in ${city}.`,
{
name: "get_weather",
description: "Get the weather for a city (safe, read-only)",
schema: z.object({ city: z.string() }),
},
);
Now the lessons.
Lesson 1: no check (the problem)
Here’s an agent that can send email. It just does it.
you: "Email bob@acme.com with subject 'Lunch?'"
│
▼
agent → send_email runs right away
│
▼
📧 sent. no one got to check it first
async function lesson1() {
const agent = createAgent({ model: MODEL, tools: [sendEmail] });
const result = await ask(agent, "Email bob@acme.com with subject 'Lunch?'");
console.log(lastText(result)); // the email is already gone
}
Fine for weather. Not fine for emails, payments, or deletes. We want a
pause before send_email runs.
Lesson 2: pause a graph by hand
Before the easy way, let’s see the raw mechanism. LangGraph has one
function for this: interrupt().
Call it inside a node and the graph stops there. Nothing after it runs. The
invoke call returns early, with the paused value on __interrupt__.
graph: START → ask → END
the "ask" node calls interrupt("Approve?")
│
▼
the graph STOPS here ⏸
│
▼
invoke() returns, with __interrupt__ set
(END has not been reached yet)
import {
Annotation,
StateGraph,
START,
END,
interrupt,
MemorySaver,
} from "@langchain/langgraph";
async function lesson2() {
const State = Annotation.Root({ decision: Annotation() });
const graph = new StateGraph(State)
.addNode("ask", () => {
const answer = interrupt("Do you approve? (yes/no)"); // pause here
return { decision: answer };
})
.addEdge(START, "ask")
.addEdge("ask", END)
.compile({ checkpointer: new MemorySaver() }); // required for pausing
const config = { configurable: { thread_id: "t1" } };
const result = await graph.invoke({}, config);
console.log(result.__interrupt__);
// [ { value: "Do you approve? (yes/no)" } ] → the graph is paused
}
If the graph parts feel new, I explain StateGraph, nodes and edges in
the LangGraph post. The only
new thing here is interrupt().
Notice the checkpointer in compile. Without it, interrupt() has nowhere
to save the pause, and it throws.
Lesson 3: resume with Command
The graph is paused. To continue, invoke it again — but this time pass a
Command with a resume value, and the same thread_id.
- The checkpointer loads the saved state for that thread.
- Your
resumevalue becomes the return value ofinterrupt(). - The node runs on from there, and the graph finishes.
run 1: invoke(input) → pauses, state saved under thread "t1"
run 2: invoke(Command{resume}) → loads "t1", interrupt() returns your
same thread "t1" value, the node finishes, END reached
import { Command } from "@langchain/langgraph";
async function lesson3() {
const State = Annotation.Root({ decision: Annotation() });
const graph = new StateGraph(State)
.addNode("ask", () => {
const answer = interrupt("Do you approve? (yes/no)");
return { decision: answer };
})
.addEdge(START, "ask")
.addEdge("ask", END)
.compile({ checkpointer: new MemorySaver() });
const config = { configurable: { thread_id: "t1" } };
await graph.invoke({}, config); // pauses
// Resume. "yes" is what interrupt() returns inside the node.
const result = await graph.invoke(new Command({ resume: "yes" }), config);
console.log(result.decision); // "yes"
}
That’s the whole mechanism: pause, save, resume. Everything below is a friendlier layer on top of these three lines.
Lesson 4: the easy way (approve)
You rarely write interrupt() yourself. For guarding tools, LangChain has
humanInTheLoopMiddleware. It pauses the agent before a chosen tool runs.
You pick which tools to guard with interruptOn:
true→ pause and ask.false→ safe, run free.
A checkpointer is still required, because the pause still has to be saved.
agent wants: send_email(to, subject)
│
▼
the middleware pauses it ⏸
│
▼
__interrupt__ shows what it wants to do
│
you approve → the tool runs → the agent continues
import { humanInTheLoopMiddleware } from "langchain";
import { INTERRUPT, isInterrupted } from "@langchain/langgraph";
function buildEmailAgent() {
return createAgent({
model: MODEL,
tools: [sendEmail, getWeather],
checkpointer: new MemorySaver(), // required
middleware: [
humanInTheLoopMiddleware({
interruptOn: {
send_email: true, // risky → ask
get_weather: false, // safe → run free
},
}),
],
});
}
async function lesson4() {
const agent = buildEmailAgent();
const config = { configurable: { thread_id: "hitl-1" } };
// The agent wants to send an email, so it pauses.
let result = await ask(agent, "Email bob@acme.com with subject 'Lunch?'", config);
if (isInterrupted(result)) {
const request = result[INTERRUPT][0].value.actionRequests[0];
console.log("Paused. The agent wants to call:", request.name);
console.log("With args:", request.arguments);
// A human approves. The run resumes and the tool runs.
result = await agent.invoke(
new Command({ resume: { decisions: [{ type: "approve" }] } }),
config, // same thread_id = resume the same paused run
);
console.log("After approval:", lastText(result));
}
}
isInterrupted(result) tells you the agent paused.
result[INTERRUPT][0].value.actionRequests[0] is what it wants to do: the
tool name and the arguments. You show that to a human and wait.
This is a middleware, the same idea as in the middleware post. It just happens to pause the run.
Lesson 5: edit the arguments
Approve runs the tool as-is. But what if the agent got it almost right?
A human can fix the arguments before the tool runs. That’s edit.
agent wants: send_email(subject: "Lunch?")
│
you edit → send_email(subject: "Lunch tomorrow at 1pm?")
│
▼
the fixed call runs
async function lesson5() {
const agent = buildEmailAgent();
const config = { configurable: { thread_id: "hitl-edit" } };
await ask(agent, "Email bob@acme.com with subject 'Lunch?'", config);
const result = await agent.invoke(
new Command({
resume: {
decisions: [
{
type: "edit",
editedAction: {
name: "send_email",
args: { to: "bob@acme.com", subject: "Lunch tomorrow at 1pm?" },
},
},
],
},
}),
config,
);
console.log("After edit:", lastText(result));
}
The tool runs with your arguments, not the model’s. Good for a quick fix when the idea is right but a detail is wrong.
Lesson 6: reject with feedback
Sometimes the answer is just no. reject stops the tool and sends a
message back to the agent, so it can change course instead of trying again.
agent wants: send_email("You're all fired")
│
you reject + a reason
│
▼
the tool never runs;
the agent reads your reason and does something else
async function lesson6() {
const agent = buildEmailAgent();
const config = { configurable: { thread_id: "hitl-reject" } };
await ask(agent, "Email the whole company: 'You are all fired'", config);
const result = await agent.invoke(
new Command({
resume: {
decisions: [
{
type: "reject",
message: "Never send that. Offer to draft a polite note instead.",
},
],
},
}),
config,
);
console.log("After reject:", lastText(result));
}
The message is the key part. The agent reads it like feedback and adapts.
An approve/edit/reject is the full set: do it, fix it, or stop it.
Lesson 7: choose what to guard
interruptOn is where you decide the policy. Each tool gets one of three
settings.
interruptOn:
send_email → true pause, any decision allowed
delete_file → { ... } pause, approve or reject only (no edit)
get_weather → false safe, runs free
humanInTheLoopMiddleware({
interruptOn: {
send_email: true,
delete_file: { allowedDecisions: ["approve", "reject"] }, // no editing
get_weather: false,
},
descriptionPrefix: "Please review this action",
});
truepauses the tool and allows approve, edit and reject.- An object lets you narrow it.
allowedDecisions: ["approve", "reject"]means a human can’t quietly rewrite a delete, only allow or block it. falsemeans the tool is safe, so it never pauses.
The rule of thumb: guard anything that changes the world or costs money. Let read-only tools run free.
Lesson 8: the whole thing, one file
Here’s a small agent you can actually talk to. Risky tools pause and ask
you in the terminal. Safe tools just run. Save it as hitl-agent.mjs.
you type a message
│
▼
did the agent pause? ── no ──► print the answer
│ yes
▼
show what it wants to do
│
you type approve / reject
│
▼
resume → print the answer
// hitl-agent.mjs
import { createAgent, tool, humanInTheLoopMiddleware } from "langchain";
import {
MemorySaver,
Command,
INTERRUPT,
isInterrupted,
} from "@langchain/langgraph";
import { ChatOpenRouter } from "@langchain/openrouter";
import readline from "node:readline/promises";
import * as z from "zod";
// Paste your key here. Fine for a script on your own machine.
process.env.OPENROUTER_API_KEY = "sk-or-v1-paste-your-key-here";
const MODEL = new ChatOpenRouter({
model: "openai/gpt-4o-mini",
temperature: 0,
});
const sendEmail = tool(
async ({ to, subject }) => `Email sent to ${to}: "${subject}"`,
{
name: "send_email",
description: "Send an email to someone",
schema: z.object({ to: z.string(), subject: z.string() }),
},
);
const getWeather = tool(
async ({ city }) => `It's sunny in ${city}.`,
{
name: "get_weather",
description: "Get the weather for a city (safe, read-only)",
schema: z.object({ city: z.string() }),
},
);
const agent = createAgent({
model: MODEL,
tools: [sendEmail, getWeather],
checkpointer: new MemorySaver(),
middleware: [
humanInTheLoopMiddleware({
interruptOn: {
send_email: true, // risky → ask a human
get_weather: false, // safe → run free
},
}),
],
});
const terminal = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const config = { configurable: { thread_id: `chat-${Date.now()}` } };
console.log("Ask me to send an email or check the weather. Ctrl+C to quit.\n");
while (true) {
const input = (await terminal.question("You: ")).trim();
if (!input) continue;
let result = await agent.invoke(
{ messages: [{ role: "user", content: input }] },
config,
);
// Keep asking the human while the agent is paused.
while (isInterrupted(result)) {
const request = result[INTERRUPT][0].value.actionRequests[0];
console.log(`\n⏸ The agent wants to run: ${request.name}`);
console.log(` args: ${JSON.stringify(request.arguments)}`);
const decision = (await terminal.question(" approve or reject? ")).trim();
const resume =
decision === "approve"
? { decisions: [{ type: "approve" }] }
: { decisions: [{ type: "reject", message: "The user said no." }] };
result = await agent.invoke(new Command({ resume }), config);
}
console.log("Bot:", result.messages.at(-1).content, "\n");
}
Running it
node hitl-agent.mjs
Watch the email get a checkpoint and the weather run straight through:
You: Email bob@acme.com with subject 'Lunch tomorrow?'
⏸ The agent wants to run: send_email
args: {"to":"bob@acme.com","subject":"Lunch tomorrow?"}
approve or reject? approve
Bot: I've sent the email to bob@acme.com.
You: What's the weather in Paris?
Bot: It's sunny in Paris.
The email paused and waited for you. The weather didn’t, because you marked it safe. That’s the whole point: a human on the risky steps, and nowhere else.
About that hardcoded key
A key pasted into a script on your own machine is fine. A key in a script that ends up in a git repo is not. Bots scan public GitHub for keys.
Before you commit, drop the line that sets the key, and run like this:
node --env-file=.env hitl-agent.mjs
Put OPENROUTER_API_KEY=sk-or-v1-... in a .env file, and add .env to
.gitignore.
Where to go from here
- Use a real checkpointer.
MemorySaverforgets on restart.SqliteSaverkeeps a paused run on disk, so a human can approve it tomorrow. - Guard saving, not just sending. Point the same middleware at a
remembertool and confirm before a fact is stored. That’s the end of the memory post. - Show the approval in a web app, not the terminal. The pattern is the
same: the agent returns an interrupt, your UI shows it, the user clicks,
you resume with a
Command. - Narrow the decisions per tool with
allowedDecisions, so a delete can be allowed or blocked but never quietly rewritten.
That’s the whole idea. The agent pauses, a human decides, the agent continues. Approve, edit, or reject — do it, fix it, or stop it.