- HumanInTheLoopMiddleware / humanInTheLoopMiddleware: Pause before dangerous tool calls for human approval
- Custom middleware: Intercept tool calls for error handling, logging, retry logic
- Command resume: Continue execution after human decisions (approve, edit, reject)
Requirements: Checkpointer + thread_id config for all HITL workflows.
Human-in-the-Loop
@tool
def send_email(to: str, subject: str, body: str) -> str:
"""Send an email."""
return f"Email sent to {to}"
agent = create_agent(
model="gpt-4.1",
tools=[send_email],
checkpointer=MemorySaver(), # Required for HITL
middleware=[
HumanInTheLoopMiddleware(
interrupt_on={
"send_email": {"allowed_decisions": ["approve", "edit", "reject"]},
}
)
],
)
</python>
<typescript>
Set up an agent with HITL that pauses before sending emails for human approval.
```typescript
import { createAgent, humanInTheLoopMiddleware } from "langchain";
import { MemorySaver } from "@langchain/langgraph";
import { tool } from "@langchain/core/tools";
import { z } from "zod";
const sendEmail = tool(
async ({ to, subject, body }) => `Email sent to ${to}`,
{
name: "send_email",
description: "Send an email",
schema: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
}
);
const agent = createAgent({
model: "anthropic:claude-sonnet-4-5",
tools: [sendEmail],
checkpointer: new MemorySaver(),
middleware: [
humanInTheLoopMiddleware({
interruptOn: { send_email: { allowedDecisions: ["approve", "edit", "reject"] } },
}),
],
});
config = {"configurable": {"thread_id": "session-1"}}
Step 1: Agent runs until it needs to call tool
result1 = agent.invoke({
"messages": [{"role": "user", "content": "Send email to john@example.com"}]
}, config=config)
Check for interrupt
if "interrupt" in result1:
print(f"Waiting for approval: {result1['interrupt']}")
Step 2: Human approves
result2 = agent.invoke(
Command(resume={"decisions": [{"type": "approve"}]}),
config=config
)
</python>
<typescript>
Run the agent, detect an interrupt, then resume execution after human approval.
```typescript
import { Command } from "@langchain/langgraph";
const config = { configurable: { thread_id: "session-1" } };
// Step 1: Agent runs until it needs to call tool
const result1 = await agent.invoke({
messages: [{ role: "user", content: "Send email to john@example.com" }]
}, config);
// Check for interrupt
if (result1.__interrupt__) {
console.log(`Waiting for approval: ${result1.__interrupt__}`);
}
// Step 2: Human approves
const result2 = await agent.invoke(
new Command({ resume: { decisions: [{ type: "approve" }] } }),
config
);
- Which tools require approval (per-tool policies)
- Allowed decisions per tool (approve, edit, reject)
- Custom middleware hooks:
before_model, after_model, wrap_tool_call, before_agent, after_agent
- Tool-specific middleware (apply only to certain tools)
What You CANNOT Configure
- Interrupt after tool execution (must be before)
- Skip checkpointer requirement for HITL
CORRECT
agent = create_agent(
model="gpt-4.1", tools=[send_email],
checkpointer=MemorySaver(), # Required
middleware=[HumanInTheLoopMiddleware({...})]
)
</python>
<typescript>
HITL requires a checkpointer to persist state.
```typescript
// WRONG: No checkpointer
const agent = createAgent({
model: "anthropic:claude-sonnet-4-5", tools: [sendEmail],
middleware: [humanInTheLoopMiddleware({ interruptOn: { send_email: true } })],
});
// CORRECT: Add checkpointer
const agent = createAgent({
model: "anthropic:claude-sonnet-4-5", tools: [sendEmail],
checkpointer: new MemorySaver(),
middleware: [humanInTheLoopMiddleware({ interruptOn: { send_email: true } })],
});
CORRECT
agent.invoke(input, config={"configurable": {"thread_id": "user-123"}})
</python>
</fix-no-thread-id>
<fix-wrong-resume-syntax>
<python>
Use Command class to resume execution after an interrupt.
```python
# WRONG
agent.invoke({"resume": {"decisions": [...]}})
# CORRECT
from langgraph.types import Command
agent.invoke(Command(resume={"decisions": [{"type": "approve"}]}), config=config)
// CORRECT
import { Command } from "@langchain/langgraph";
await agent.invoke(new Command({ resume: { decisions: [{ type: "approve" }] } }), config);
</typescript>
</fix-wrong-resume-syntax>
1---2name: langchain-middleware3description: INVOKE THIS SKILL when you need human-in-the-loop approval, custom middleware, or structured output. Covers HumanInTheLoopMiddleware for human approval of dangerous tool calls, creating custom middleware with hooks, Command resume patterns, and structured output with Pydantic/Zod.4---56<overview>7Middleware patterns for production LangChain agents:89- **HumanInTheLoopMiddleware** / **humanInTheLoopMiddleware**: Pause before dangerous tool calls for human approval10- **Custom middleware**: Intercept tool calls for error handling, logging, retry logic11- **Command resume**: Continue execution after human decisions (approve, edit, reject)1213**Requirements:** Checkpointer + thread_id config for all HITL workflows.14</overview>1516---1718## Human-in-the-Loop1920<ex-basic-hitl-setup>21<python>22Set up an agent with HITL middleware that pauses before sending emails for approval.23```python24from langchain.agents import create_agent25from langchain.agents.middleware import HumanInTheLoopMiddleware26from langgraph.checkpoint.memory import MemorySaver27from langchain.tools import tool2829@tool30def send_email(to: str, subject: str, body: str) -> str:31 """Send an email."""32 return f"Email sent to {to}"3334agent = create_agent(35 model="gpt-4.1",36 tools=[send_email],37 checkpointer=MemorySaver(), # Required for HITL38 middleware=[39 HumanInTheLoopMiddleware(40 interrupt_on={41 "send_email": {"allowed_decisions": ["approve", "edit", "reject"]},42 }43 )44 ],45)46```47</python>48<typescript>49Set up an agent with HITL that pauses before sending emails for human approval.50```typescript51import { createAgent, humanInTheLoopMiddleware } from "langchain";52import { MemorySaver } from "@langchain/langgraph";53import { tool } from "@langchain/core/tools";54import { z } from "zod";5556const sendEmail = tool(57 async ({ to, subject, body }) => `Email sent to ${to}`,58 {59 name: "send_email",60 description: "Send an email",61 schema: z.object({ to: z.string(), subject: z.string(), body: z.string() }),62 }63);6465const agent = createAgent({66 model: "anthropic:claude-sonnet-4-5",67 tools: [sendEmail],68 checkpointer: new MemorySaver(),69 middleware: [70 humanInTheLoopMiddleware({71 interruptOn: { send_email: { allowedDecisions: ["approve", "edit", "reject"] } },72 }),73 ],74});75```76</typescript>77</ex-basic-hitl-setup>7879<ex-running-with-interrupts>80<python>81Run the agent, detect an interrupt, then resume execution after human approval.82```python83from langgraph.types import Command8485config = {"configurable": {"thread_id": "session-1"}}8687# Step 1: Agent runs until it needs to call tool88result1 = agent.invoke({89 "messages": [{"role": "user", "content": "Send email to john@example.com"}]90}, config=config)9192# Check for interrupt93if "__interrupt__" in result1:94 print(f"Waiting for approval: {result1['__interrupt__']}")9596# Step 2: Human approves97result2 = agent.invoke(98 Command(resume={"decisions": [{"type": "approve"}]}),99 config=config100)101```102</python>103<typescript>104Run the agent, detect an interrupt, then resume execution after human approval.105```typescript106import { Command } from "@langchain/langgraph";107108const config = { configurable: { thread_id: "session-1" } };109110// Step 1: Agent runs until it needs to call tool111const result1 = await agent.invoke({112 messages: [{ role: "user", content: "Send email to john@example.com" }]113}, config);114115// Check for interrupt116if (result1.__interrupt__) {117 console.log(`Waiting for approval: ${result1.__interrupt__}`);118}119120// Step 2: Human approves121const result2 = await agent.invoke(122 new Command({ resume: { decisions: [{ type: "approve" }] } }),123 config124);125```126</typescript>127</ex-running-with-interrupts>128129<ex-editing-tool-arguments>130<python>131Edit the tool arguments before approving when the original values need correction.132```python133# Human edits the arguments — edited_action must include name + args134result2 = agent.invoke(135 Command(resume={136 "decisions": [{137 "type": "edit",138 "edited_action": {139 "name": "send_email",140 "args": {141 "to": "alice@company.com", # Fixed email142 "subject": "Project Meeting - Updated",143 "body": "...",144 },145 },146 }]147 }),148 config=config149)150```151</python>152<typescript>153Edit the tool arguments before approving when the original values need correction.154```typescript155// Human edits the arguments — editedAction must include name + args156const result2 = await agent.invoke(157 new Command({158 resume: {159 decisions: [{160 type: "edit",161 editedAction: {162 name: "send_email",163 args: {164 to: "alice@company.com", // Fixed email165 subject: "Project Meeting - Updated",166 body: "...",167 },168 },169 }]170 }171 }),172 config173);174```175</typescript>176</ex-editing-tool-arguments>177178<ex-rejecting-with-feedback>179<python>180Reject a tool call and provide feedback explaining why it was rejected.181```python182# Human rejects183result2 = agent.invoke(184 Command(resume={185 "decisions": [{186 "type": "reject",187 "feedback": "Cannot delete customer data without manager approval",188 }]189 }),190 config=config191)192```193</python>194</ex-rejecting-with-feedback>195196<ex-multiple-tools-different-policies>197<python>198Configure different HITL policies for each tool based on risk level.199```python200agent = create_agent(201 model="gpt-4.1",202 tools=[send_email, read_email, delete_email],203 checkpointer=MemorySaver(),204 middleware=[205 HumanInTheLoopMiddleware(206 interrupt_on={207 "send_email": {"allowed_decisions": ["approve", "edit", "reject"]},208 "delete_email": {"allowed_decisions": ["approve", "reject"]}, # No edit209 "read_email": False, # No HITL for reading210 }211 )212 ],213)214```215</python>216</ex-multiple-tools-different-policies>217218<boundaries>219### What You CAN Configure220221- Which tools require approval (per-tool policies)222- Allowed decisions per tool (approve, edit, reject)223- Custom middleware hooks: `before_model`, `after_model`, `wrap_tool_call`, `before_agent`, `after_agent`224- Tool-specific middleware (apply only to certain tools)225226### What You CANNOT Configure227228- Interrupt after tool execution (must be before)229- Skip checkpointer requirement for HITL230</boundaries>231232<fix-missing-checkpointer>233<python>234HITL middleware requires a checkpointer to persist state.235```python236# WRONG237agent = create_agent(model="gpt-4.1", tools=[send_email], middleware=[HumanInTheLoopMiddleware({...})])238239# CORRECT240agent = create_agent(241 model="gpt-4.1", tools=[send_email],242 checkpointer=MemorySaver(), # Required243 middleware=[HumanInTheLoopMiddleware({...})]244)245```246</python>247<typescript>248HITL requires a checkpointer to persist state.249```typescript250// WRONG: No checkpointer251const agent = createAgent({252 model: "anthropic:claude-sonnet-4-5", tools: [sendEmail],253 middleware: [humanInTheLoopMiddleware({ interruptOn: { send_email: true } })],254});255256// CORRECT: Add checkpointer257const agent = createAgent({258 model: "anthropic:claude-sonnet-4-5", tools: [sendEmail],259 checkpointer: new MemorySaver(),260 middleware: [humanInTheLoopMiddleware({ interruptOn: { send_email: true } })],261});262```263</typescript>264</fix-missing-checkpointer>265266<fix-no-thread-id>267<python>268Always provide thread_id when using HITL to track conversation state.269```python270# WRONG271agent.invoke(input) # No config!272273# CORRECT274agent.invoke(input, config={"configurable": {"thread_id": "user-123"}})275```276</python>277</fix-no-thread-id>278279<fix-wrong-resume-syntax>280<python>281Use Command class to resume execution after an interrupt.282```python283# WRONG284agent.invoke({"resume": {"decisions": [...]}})285286# CORRECT287from langgraph.types import Command288agent.invoke(Command(resume={"decisions": [{"type": "approve"}]}), config=config)289```290</python>291<typescript>292Use Command class to resume execution after an interrupt.293```typescript294// WRONG295await agent.invoke({ resume: { decisions: [...] } });296297// CORRECT298import { Command } from "@langchain/langgraph";299await agent.invoke(new Command({ resume: { decisions: [{ type: "approve" }] } }), config);300```301</typescript>302</fix-wrong-resume-syntax>