Human-in-the-Loop in DeepAgents: Approve Before It Acts
Why some tool calls need a person, and how to gate them with interruptOn, decisions, and a checkpointer
Full autonomy is a great demo and a bad default. The first time an agent deletes the wrong file, or sends an email to "everyone@company.com" because that is what was in scope, you stop wanting it to run every action unsupervised. Human-in-the-loop is the answer: the agent does the thinking and proposes the action, but a person approves the few calls that are actually dangerous.
This is the last post in my series on building with DeepAgents. I have a more general piece on human-in-the-loop with deep agents and one on guardrails and safety for agents in production; this one is the focused, code-first walkthrough of the DeepAgents mechanism in TypeScript.
The core idea#
DeepAgents builds human-in-the-loop on LangGraph's interrupt capability. You declare which tools require approval with interruptOn. When the agent tries to call one of those tools, execution pauses and hands control back to you. You review the proposed call, decide what to do, and resume.
Two requirements make the whole thing work, and skipping either is the most common mistake:
- You must pass a checkpointer. The agent has to persist its state between the pause and the resume.
- You must resume on the same thread ID. Otherwise there is no paused state to come back to.
Basic configuration#
interruptOn maps tool names to interrupt configs. Each tool can be true (approve, edit, reject, respond all allowed), false (no interrupt), or an object with specific allowedDecisions.
import { tool } from "langchain";
import { createDeepAgent } from "deepagents";
import { MemorySaver } from "@langchain/langgraph";
import { z } from "zod";
const deleteFile = tool(
async ({ path }: { path: string }) => `Deleted ${path}`,
{ name: "delete_file", description: "Delete a file.", schema: z.object({ path: z.string() }) },
);
const readFile = tool(
async ({ path }: { path: string }) => `Contents of ${path}`,
{ name: "read_file", description: "Read a file.", schema: z.object({ path: z.string() }) },
);
const sendEmail = tool(
async ({ to, subject, body }: { to: string; subject: string; body: string }) => `Sent email to ${to}`,
{
name: "send_email",
description: "Send an email.",
schema: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
},
);
// A checkpointer is REQUIRED for human-in-the-loop
const checkpointer = new MemorySaver();
const agent = createDeepAgent({
model: "google_genai:gemini-3.1-pro-preview",
tools: [deleteFile, readFile, sendEmail],
interruptOn: {
delete_file: true, // approve, edit, reject, respond
read_file: false, // no interrupt needed
send_email: { allowedDecisions: ["approve", "reject"] }, // no editing
},
checkpointer,
});Notice the per-tool tailoring. Reading a file is harmless, so it runs freely. Deleting a file gets the full set of options. Sending an email can be approved or rejected but not edited, depending on how much you trust the agent's drafting.
The four decision types#
When a human reviews a paused tool call, allowedDecisions controls what they can do:
approve: run the tool with the arguments the agent proposed.edit: change the arguments before running.reject: skip this tool call entirely.respond: return the human's message directly as the tool result, without running the tool. This is the "ask the user" pattern.
Match the decisions to the risk:
const interruptOn = {
// Sensitive: allow everything
delete_file: { allowedDecisions: ["approve", "edit", "reject"] },
// Moderate: approve or reject only
write_file: { allowedDecisions: ["approve", "reject"] },
// Critical: must be approved, cannot be silently skipped
critical_operation: { allowedDecisions: ["approve"] },
};Handling an interrupt#
When an interrupt fires, the result carries an __interrupt__ field. You inspect the pending actions, collect decisions, and resume with a Command.
import { v7 as uuid7 } from "uuid";
import { Command } from "@langchain/langgraph";
const config = { configurable: { thread_id: uuid7() } };
let result = await agent.invoke(
{ messages: [{ role: "user", content: "Delete the file temp.txt" }] },
config,
);
if (result.__interrupt__) {
const interrupts = result.__interrupt__[0].value;
const actionRequests = interrupts.actionRequests;
const reviewConfigs = interrupts.reviewConfigs;
const configMap = Object.fromEntries(
reviewConfigs.map((cfg) => [cfg.actionName, cfg]),
);
for (const action of actionRequests) {
const reviewConfig = configMap[action.name];
console.log(`Tool: ${action.name}`);
console.log(`Arguments: ${JSON.stringify(action.args)}`);
console.log(`Allowed decisions: ${reviewConfig.allowedDecisions}`);
}
// One decision per actionRequest, in order
const decisions = [{ type: "approve" }];
result = await agent.invoke(
new Command({ resume: { decisions } }),
config, // must reuse the same config
);
}
console.log(result.messages[result.messages.length - 1].content);The shape is always the same: check __interrupt__, read the proposed calls, gather decisions, resume on the same config.
Multiple tool calls in one interrupt#
When the agent proposes several approval-gated calls at once, they are batched into a single interrupt. You provide one decision per call, in the same order as actionRequests.
let result = await agent.invoke(
{ messages: [{ role: "user", content: "Delete temp.txt and email admin@example.com" }] },
config,
);
if (result.__interrupt__) {
const actionRequests = result.__interrupt__[0].value.actionRequests;
console.assert(actionRequests.length === 2);
// Order matches actionRequests
const decisions = [
{ type: "approve" }, // delete_file
{ type: "reject" }, // send_email
];
result = await agent.invoke(new Command({ resume: { decisions } }), config);
}The decisions list must line up with the order of actionRequests. Get the order wrong and you approve the action you meant to reject. If you are building a real review UI, render the actions in order and bind each decision to its index, not to your assumptions about what the agent should have done.
Editing arguments before they run#
When edit is allowed, you can fix the arguments instead of rejecting outright. This is the difference between "no, do not email anyone" and "yes, but send it to the team list, not the whole company."
if (result.__interrupt__) {
const actionRequest = result.__interrupt__[0].value.actionRequests[0];
console.log(actionRequest.args); // { to: "everyone@company.com", ... }
const decisions = [{
type: "edit",
editedAction: {
name: actionRequest.name, // include the tool name
args: { to: "team@company.com", subject: "...", body: "..." },
},
}];
result = await agent.invoke(new Command({ resume: { decisions } }), config);
}Interrupts and subagents#
Subagents can have their own interruptOn that overrides the main agent's. A subagent can be stricter than its parent, for example requiring approval even for reads:
const agent = createDeepAgent({
tools: [deleteFile, readFile],
interruptOn: {
delete_file: true,
read_file: false,
},
subagents: [{
name: "file-manager",
description: "Manages file operations",
systemPrompt: "You are a file management assistant.",
tools: [deleteFile, readFile],
interruptOn: {
delete_file: true,
read_file: true, // stricter than the main agent
},
}],
checkpointer,
});Handling is identical: check for the interrupt on the result and resume with Command. If you want the deeper picture on delegation, see the subagents post.
There is also a lower-level option: a tool inside a subagent can call interrupt() directly to pause and wait for approval mid-execution. That is useful when the approval point is buried inside a tool's logic rather than at the tool-call boundary. You resume the same way, with Command({ resume: { approved: true } }), and the tool picks up where it left off.
The practices that keep this sane#
The docs land on four best practices, and they all come from real failure modes:
- Always use a checkpointer. Without persisted state there is nothing to resume.
- Always resume on the same
thread_id. A new thread has no paused state. - Match decision order to actions. Batched interrupts are positional.
- Tailor configs by risk. Free reads, gated writes, locked-down deletes and deploys.
My own rule on top of those: gate by blast radius, not by how often a tool is called. A delete that runs once a day still deserves approval; a read that runs constantly does not need it. The point of human-in-the-loop is not to slow the agent down everywhere. It is to put a person exactly where a mistake would be expensive and nowhere else.
That wraps the series. We went from how DeepAgents manages context, through subagents, memory, and skills, to putting a human in front of the actions that matter. Put together, that is most of what separates an agent that demos well from one you would actually let touch production.

Folarin Akinloye is an AI Engineer based in London, UK. He builds production-ready agentic AI systems, multi-agent architectures, and sophisticated RAG implementations, and writes about the engineering decisions behind them.