| name | langchain-human-in-the-loop |
| description | Add human oversight to LangChain agents using HITL middleware - includes interrupts, approval workflows, edit/reject decisions, and checkpoints |
| language | js |
langchain-human-in-the-loop (JavaScript/TypeScript)
Overview
Human-in-the-Loop (HITL) lets you add human oversight to agent tool calls. When agents propose sensitive actions (like database writes or sending emails), execution pauses for human approval, editing, or rejection.
Key Concepts:
- humanInTheLoopMiddleware: Pauses execution for human decisions
- Interrupts: Checkpoint where agent waits for human input
- Decisions: approve, edit, or reject tool calls
- Checkpointer: Required for persistence across interruptions
When to Use HITL
| Scenario | Use HITL? | Why |
|---|
| Database writes | ✅ Yes | Prevent data corruption |
| Sending emails/messages | ✅ Yes | Review before sending |
| Financial transactions | ✅ Yes | Confirm before executing |
| Deleting data | ✅ Yes | Prevent accidental loss |
| Read-only operations | ❌ No | Low risk |
| Internal calculations | ❌ No | No external impact |
Decision Tables
HITL Decision Types
| Decision | Effect | When to Use |
|---|
approve | Execute tool as-is | Tool call looks correct |
edit | Modify args then execute | Need to change parameters |
reject | Don't execute, provide feedback | Tool call is wrong |
Code Examples
Basic HITL Setup
import { createAgent, humanInTheLoopMiddleware } from "langchain";
import { MemorySaver } from "@langchain/langgraph";
import { tool } from "langchain";
import { z } from "zod";
const sendEmail = tool(
async ({ to, subject, body }) => {
return `Email sent to ${to}`;
},
{
name: "send_email",
description: "Send an email",
schema: z.object({
to: z.string().email(),
subject: z.string(),
body: z.string(),
}),
}
);
const agent = createAgent({
model: "gpt-4.1",
tools: [sendEmail],
checkpointer: new MemorySaver(),
middleware: [
humanInTheLoopMiddleware({
interruptOn: {
send_email: {
allowedDecisions: ["approve", "edit", "reject"],
},
},
}),
],
});
Running with Interrupts
import { Command } from "@langchain/langgraph";
const config = { configurable: { thread_id: "session-1" } };
const result1 = await agent.invoke({
messages: [{ role: "user", content: "Send email to john@example.com saying hello" }]
}, config);
if ("__interrupt__" in result1) {
const interrupt = result1.__interrupt__[0];
console.log("Waiting for approval:", interrupt.value);
}
const result2 = await agent.invoke(
new Command({
resume: {
decisions: [{ type: "approve" }],
},
}),
config
);
console.log(result2.messages[result2.messages.length - 1].content);
Editing Tool Arguments
const config = { configurable: { thread_id: "session-2" } };
const result1 = await agent.invoke({
messages: [{ role: "user", content: "Email alice about the meeting" }]
}, config);
const result2 = await agent.invoke(
new Command({
resume: {
decisions: [{
type: "edit",
args: {
to: "alice@company.com",
subject: "Project Meeting - Updated",
body: "...",
},
}],
},
}),
config
);
Rejecting with Feedback
const config = { configurable: { thread_id: "session-3" } };
const result1 = await agent.invoke({
messages: [{ role: "user", content: "Delete old customer data" }]
}, config);
const result2 = await agent.invoke(
new Command({
resume: {
decisions: [{
type: "reject",
feedback: "Cannot delete customer data without manager approval",
}],
},
}),
config
);
Multiple Tools with Different Policies
import { humanInTheLoopMiddleware } from "langchain";
const agent = createAgent({
model: "gpt-4.1",
tools: [sendEmail, readEmail, deleteEmail],
checkpointer: new MemorySaver(),
middleware: [
humanInTheLoopMiddleware({
interruptOn: {
send_email: {
allowedDecisions: ["approve", "edit", "reject"],
},
delete_email: {
allowedDecisions: ["approve", "reject"],
},
read_email: false,
},
}),
],
});
Streaming with HITL
const config = { configurable: { thread_id: "session-4" } };
for await (const [mode, chunk] of await agent.stream(
{ messages: [{ role: "user", content: "Send report to team" }] },
{ ...config, streamMode: ["updates", "messages"] }
)) {
if (mode === "messages") {
const [token, metadata] = chunk;
if (token.content) {
process.stdout.write(token.content);
}
} else if (mode === "updates") {
if ("__interrupt__" in chunk) {
console.log("\nWaiting for approval...");
break;
}
}
}
for await (const [mode, chunk] of await agent.stream(
new Command({ resume: { decisions: [{ type: "approve" }] } }),
{ ...config, streamMode: ["messages"] }
)) {
}
Custom Interrupt Logic
import { createMiddleware } from "langchain";
const customHITL = createMiddleware({
name: "CustomHITL",
wrapToolCall: async (toolCall, handler, runtime) => {
if (toolCall.name === "database_write") {
const value = toolCall.args.value;
if (value > 1000) {
const decision = await runtime.interrupt({
toolCall,
reason: "Large database write requires approval",
});
if (decision.type === "approve") {
return await handler(toolCall);
} else if (decision.type === "edit") {
return await handler({ ...toolCall, args: decision.args });
} else {
throw new Error(decision.feedback || "Rejected");
}
}
}
return await handler(toolCall);
},
});
Boundaries
What You CAN Configure
✅ Which tools require approval: Per-tool policies
✅ Allowed decision types: approve, edit, reject
✅ Custom interrupt logic: Conditional interrupts
✅ Feedback messages: Explain rejections
✅ Modified arguments: Edit tool parameters
What You CANNOT Configure
❌ Skip checkpointer: HITL requires persistence
❌ Interrupt after execution: Must interrupt before
❌ Force model to not call tool: HITL responds after model decides
❌ Modify model's decision-making: Only tool execution
Gotchas
1. Missing Checkpointer
const agent = createAgent({
model: "gpt-4.1",
tools: [sendEmail],
middleware: [humanInTheLoopMiddleware({...})],
});
import { MemorySaver } from "@langchain/langgraph";
const agent = createAgent({
model: "gpt-4.1",
tools: [sendEmail],
checkpointer: new MemorySaver(),
middleware: [humanInTheLoopMiddleware({...})],
});
2. No thread_id
await agent.invoke(input);
await agent.invoke(input, {
configurable: { thread_id: "user-123" }
});
3. Wrong Resume Syntax
await agent.invoke({
resume: { decisions: [...] }
});
import { Command } from "@langchain/langgraph";
await agent.invoke(
new Command({
resume: { decisions: [{ type: "approve" }] }
}),
config
);
4. Not Checking for Interrupts
const result = await agent.invoke(input, config);
console.log(result.messages);
if ("__interrupt__" in result) {
} else {
}
Links to Documentation