- 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-middleware-hitl3description: 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---5
6<overview>
7Middleware patterns for production LangChain agents:
8
9- **HumanInTheLoopMiddleware** / **humanInTheLoopMiddleware**: Pause before dangerous tool calls for human approval
10- **Custom middleware**: Intercept tool calls for error handling, logging, retry logic
11- **Command resume**: Continue execution after human decisions (approve, edit, reject)
12
13**Requirements:** Checkpointer + thread_id config for all HITL workflows.
14</overview>
15
16---
17
18## Human-in-the-Loop
19
20<ex-basic-hitl-setup>
21<python>
22Set up an agent with HITL middleware that pauses before sending emails for approval.
23```python
24from langchain.agents import create_agent
25from langchain.agents.middleware import HumanInTheLoopMiddleware
26from langgraph.checkpoint.memory import MemorySaver
27from langchain.tools import tool
28
29@tool
30def send_email(to: str, subject: str, body: str) -> str:
31 """Send an email."""
32 return f"Email sent to {to}"
33
34agent = create_agent(
35 model="gpt-4.1",
36 tools=[send_email],
37 checkpointer=MemorySaver(), # Required for HITL
38 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```typescript
51import { createAgent, humanInTheLoopMiddleware } from "langchain";
52import { MemorySaver } from "@langchain/langgraph";
53import { tool } from "@langchain/core/tools";
54import { z } from "zod";
55
56const 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);
64
65const 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>
78
79<ex-running-with-interrupts>
80<python>
81Run the agent, detect an interrupt, then resume execution after human approval.
82```python
83from langgraph.types import Command
84
85config = {"configurable": {"thread_id": "session-1"}}
86
87# Step 1: Agent runs until it needs to call tool
88result1 = agent.invoke({
89 "messages": [{"role": "user", "content": "Send email to john@example.com"}]
90}, config=config)
91
92# Check for interrupt
93if "__interrupt__" in result1:
94 print(f"Waiting for approval: {result1['__interrupt__']}")
95
96# Step 2: Human approves
97result2 = agent.invoke(
98 Command(resume={"decisions": [{"type": "approve"}]}),
99 config=config
100)
101```
102</python>
103<typescript>
104Run the agent, detect an interrupt, then resume execution after human approval.
105```typescript
106import { Command } from "@langchain/langgraph";
107
108const config = { configurable: { thread_id: "session-1" } };
109
110// Step 1: Agent runs until it needs to call tool
111const result1 = await agent.invoke({
112 messages: [{ role: "user", content: "Send email to john@example.com" }]
113}, config);
114
115// Check for interrupt
116if (result1.__interrupt__) {
117 console.log(`Waiting for approval: ${result1.__interrupt__}`);
118}
119
120// Step 2: Human approves
121const result2 = await agent.invoke(
122 new Command({ resume: { decisions: [{ type: "approve" }] } }),
123 config
124);
125```
126</typescript>
127</ex-running-with-interrupts>
128
129<ex-editing-tool-arguments>
130<python>
131Edit the tool arguments before approving when the original values need correction.
132```python
133# Human edits the arguments — edited_action must include name + args
134result2 = 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 email
142 "subject": "Project Meeting - Updated",
143 "body": "...",
144 },
145 },
146 }]
147 }),
148 config=config
149)
150```
151</python>
152<typescript>
153Edit the tool arguments before approving when the original values need correction.
154```typescript
155// Human edits the arguments — editedAction must include name + args
156const 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 email
165 subject: "Project Meeting - Updated",
166 body: "...",
167 },
168 },
169 }]
170 }
171 }),
172 config
173);
174```
175</typescript>
176</ex-editing-tool-arguments>
177
178<ex-rejecting-with-feedback>
179<python>
180Reject a tool call and provide feedback explaining why it was rejected.
181```python
182# Human rejects
183result2 = agent.invoke(
184 Command(resume={
185 "decisions": [{
186 "type": "reject",
187 "feedback": "Cannot delete customer data without manager approval",
188 }]
189 }),
190 config=config
191)
192```
193</python>
194</ex-rejecting-with-feedback>
195
196<ex-multiple-tools-different-policies>
197<python>
198Configure different HITL policies for each tool based on risk level.
199```python
200agent = 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 edit
209 "read_email": False, # No HITL for reading
210 }
211 )
212 ],
213)
214```
215</python>
216</ex-multiple-tools-different-policies>
217
218<boundaries>
219### What You CAN Configure
220
221- 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)
225
226### What You CANNOT Configure
227
228- Interrupt after tool execution (must be before)
229- Skip checkpointer requirement for HITL
230</boundaries>
231
232<fix-missing-checkpointer>
233<python>
234HITL middleware requires a checkpointer to persist state.
235```python
236# WRONG
237agent = create_agent(model="gpt-4.1", tools=[send_email], middleware=[HumanInTheLoopMiddleware({...})])
238
239# CORRECT
240agent = create_agent(
241 model="gpt-4.1", tools=[send_email],
242 checkpointer=MemorySaver(), # Required
243 middleware=[HumanInTheLoopMiddleware({...})]
244)
245```
246</python>
247<typescript>
248HITL requires a checkpointer to persist state.
249```typescript
250// WRONG: No checkpointer
251const agent = createAgent({
252 model: "anthropic:claude-sonnet-4-5", tools: [sendEmail],
253 middleware: [humanInTheLoopMiddleware({ interruptOn: { send_email: true } })],
254});
255
256// CORRECT: Add checkpointer
257const 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>
265
266<fix-no-thread-id>
267<python>
268Always provide thread_id when using HITL to track conversation state.
269```python
270# WRONG
271agent.invoke(input) # No config!
272
273# CORRECT
274agent.invoke(input, config={"configurable": {"thread_id": "user-123"}})
275```
276</python>
277</fix-no-thread-id>
278
279<fix-wrong-resume-syntax>
280<python>
281Use Command class to resume execution after an interrupt.
282```python
283# WRONG
284agent.invoke({"resume": {"decisions": [...]}})
285
286# CORRECT
287from langgraph.types import Command
288agent.invoke(Command(resume={"decisions": [{"type": "approve"}]}), config=config)
289```
290</python>
291<typescript>
292Use Command class to resume execution after an interrupt.
293```typescript
294// WRONG
295await agent.invoke({ resume: { decisions: [...] } });
296
297// CORRECT
298import { Command } from "@langchain/langgraph";
299await agent.invoke(new Command({ resume: { decisions: [{ type: "approve" }] } }), config);
300```
301</typescript>
302</fix-wrong-resume-syntax>