Not every agent action should run unsupervised. When an agent is about to send
an email, delete a record, execute a financial transaction, or perform any
irreversible operation, you need a human to review and approve the action first.
The Human-in-the-Loop (HITL) pattern lets your agent pause execution, present
the pending action to the user, and resume only after explicit approval.
How interrupts work
LangGraph agents support interrupts, explicit pause points where the agent
yields control back to the client. When the agent hits an interrupt:
- The agent stops executing and emits an interrupt payload
- The
useStream hook surfaces the interrupt via stream.interrupt
- Your UI renders a review card with approve/reject/edit options
- The user makes a decision
- Your code calls
stream.submit() with a resume command
- The agent picks up where it left off
Setting up useStream for HITL
Define a TypeScript interface matching your agent’s state schema and pass it as a type parameter to useStream for type-safe access to state values. In the examples below, replace typeof myAgent with your interface name:
import type { BaseMessage } from "@langchain/core/messages";
interface AgentState {
messages: BaseMessage[];
}
import { useStream } from "@langchain/react";
const AGENT_URL = "http://localhost:2024";
export function Chat() {
const stream = useStream<typeof myAgent>({
apiUrl: AGENT_URL,
assistantId: "human_in_the_loop",
});
const interrupt = stream.interrupt;
return (
<div>
{stream.messages.map((msg) => (
<Message key={msg.id} message={msg} />
))}
{interrupt && (
<ApprovalCard
interrupt={interrupt}
onRespond={(response) =>
stream.submit(null, { command: { resume: response } })
}
/>
)}
</div>
);
}
The interrupt payload
When the agent pauses, stream.interrupt contains a HITLRequest with the
following structure:
interface HITLRequest {
actionRequests: ActionRequest[];
reviewConfigs: ReviewConfig[];
}
interface ActionRequest {
action: string;
args: Record<string, unknown>;
description?: string;
}
interface ReviewConfig {
allowedDecisions: ("approve" | "reject" | "edit")[];
}
| Property | Description |
|---|
actionRequests | Array of pending actions the agent wants to perform |
actionRequests[].action | The action name (e.g. "send_email", "delete_record") |
actionRequests[].args | Structured arguments for the action |
actionRequests[].description | Optional human-readable description of what the action does |
reviewConfigs | Per-action configuration controlling which decisions are allowed |
reviewConfigs[].allowedDecisions | Which buttons to show: "approve", "reject", "edit" |
Decision types
The HITL pattern supports three decision types:
Approve
The user confirms the action should proceed as-is:
const response: HITLResponse = {
decision: "approve",
};
stream.submit(null, { command: { resume: response } });
Reject
The user denies the action with an optional reason:
const response: HITLResponse = {
decision: "reject",
reason: "The email tone is too aggressive. Please revise.",
};
stream.submit(null, { command: { resume: response } });
When an action is rejected, the agent receives the rejection reason and can
decide how to proceed—it may rephrase, ask clarifying questions, or abandon
the action entirely.
Edit
The user modifies the action’s arguments before approving:
const response: HITLResponse = {
decision: "edit",
args: {
...originalArgs,
subject: "Updated subject line",
body: "Revised email body with softer language.",
},
};
stream.submit(null, { command: { resume: response } });
Building the ApprovalCard
Here is a full approval card component that handles all three decision types:
function ApprovalCard({
interrupt,
onRespond,
}: {
interrupt: { value: HITLRequest };
onRespond: (response: HITLResponse) => void;
}) {
const request = interrupt.value;
const [editedArgs, setEditedArgs] = useState(
request.actionRequests[0]?.args ?? {}
);
const [rejectReason, setRejectReason] = useState("");
const [mode, setMode] = useState<"review" | "edit" | "reject">("review");
const action = request.actionRequests[0];
const config = request.reviewConfigs[0];
if (!action || !config) return null;
return (
<div className="rounded-lg border-2 border-amber-300 bg-amber-50 p-4">
<h3 className="font-semibold text-amber-800">Action Review Required</h3>
<p className="mt-1 text-sm text-amber-700">
{action.description ?? `The agent wants to perform: ${action.action}`}
</p>
<div className="mt-3 rounded bg-white p-3 font-mono text-sm">
<pre>{JSON.stringify(action.args, null, 2)}</pre>
</div>
{mode === "review" && (
<div className="mt-4 flex gap-2">
{config.allowedDecisions.includes("approve") && (
<button
className="rounded bg-green-600 px-4 py-2 text-white"
onClick={() => onRespond({ decision: "approve" })}
>
Approve
</button>
)}
{config.allowedDecisions.includes("reject") && (
<button
className="rounded bg-red-600 px-4 py-2 text-white"
onClick={() => setMode("reject")}
>
Reject
</button>
)}
{config.allowedDecisions.includes("edit") && (
<button
className="rounded bg-blue-600 px-4 py-2 text-white"
onClick={() => setMode("edit")}
>
Edit
</button>
)}
</div>
)}
{mode === "reject" && (
<div className="mt-4 space-y-2">
<textarea
className="w-full rounded border p-2"
placeholder="Reason for rejection..."
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
/>
<button
className="rounded bg-red-600 px-4 py-2 text-white"
onClick={() =>
onRespond({ decision: "reject", reason: rejectReason })
}
>
Confirm Rejection
</button>
</div>
)}
{mode === "edit" && (
<div className="mt-4 space-y-2">
<textarea
className="w-full rounded border p-2 font-mono text-sm"
value={JSON.stringify(editedArgs, null, 2)}
onChange={(e) => {
try {
setEditedArgs(JSON.parse(e.target.value));
} catch {
// allow invalid JSON while editing
}
}}
/>
<button
className="rounded bg-blue-600 px-4 py-2 text-white"
onClick={() =>
onRespond({ decision: "edit", args: editedArgs })
}
>
Submit Edits
</button>
</div>
)}
</div>
);
}
The resume flow
After the user makes a decision, the full cycle looks like this:
- Call
stream.submit(null, { command: { resume: hitlResponse } })
- The
useStream hook sends the resume command to the LangGraph backend
- The agent receives the
HITLResponse and continues execution
- If approved, the tool runs with the original (or edited) arguments
- If rejected, the agent receives the reason and decides its next step
- The
interrupt property resets to null as the agent resumes streaming
You can chain multiple HITL checkpoints in a single agent run. For example, an
agent might ask for approval to search, then ask again before sending an email
with the results. Each interrupt is handled independently.
Common use cases
| Use Case | Action | Review Config |
|---|
| Email sending | send_email | ["approve", "reject", "edit"] |
| Database writes | update_record | ["approve", "reject"] |
| Financial transactions | transfer_funds | ["approve", "reject"] |
| File deletion | delete_files | ["approve", "reject"] |
| API calls to external services | call_api | ["approve", "reject", "edit"] |
Handling multiple pending actions
An interrupt can contain multiple actionRequests when the agent wants to
perform several actions at once. Render a card for each and collect all
decisions before resuming:
function MultiActionReview({
interrupt,
onRespond,
}: {
interrupt: { value: HITLRequest };
onRespond: (responses: HITLResponse[]) => void;
}) {
const [decisions, setDecisions] = useState<Record<number, HITLResponse>>({});
const request = interrupt.value;
const allDecided =
Object.keys(decisions).length === request.actionRequests.length;
return (
<div className="space-y-4">
{request.actionRequests.map((action, i) => (
<SingleActionCard
key={i}
action={action}
config={request.reviewConfigs[i]}
onDecide={(response) =>
setDecisions((prev) => ({ ...prev, [i]: response }))
}
/>
))}
{allDecided && (
<button
className="rounded bg-green-600 px-4 py-2 text-white"
onClick={() =>
onRespond(
request.actionRequests.map((_, i) => decisions[i])
)
}
>
Submit All Decisions
</button>
)}
</div>
);
}
Best practices
Keep these guidelines in mind when implementing HITL workflows:
- Show clear context—always display what the agent wants to do and
why. Include the action description and the full arguments.
- Make approve the easiest path—if the action looks correct, approving
should be a single click. Reserve multi-step flows for reject/edit.
- Validate edited args—when users edit action arguments, validate the
JSON structure before sending. Show inline errors for malformed input.
- Persist the interrupt state—if the user refreshes the page, the
interrupt should still be visible.
useStream handles this via the thread’s
checkpoint.
- Log all decisions—for audit trails, log every approve/reject/edit
decision with timestamps and the user who made the decision.
- Set timeouts thoughtfully—long-running agents should not block
indefinitely on human review. Consider showing how long the agent has been
waiting.