Back to resources
// technical · agents

Human-in-the-loop, for real

A good agent doesn't ask everything, nor does it decide everything alone. It acts by default, and pauses to ask when it's genuinely blocking — then resumes where it left off, even two hours later.

TechnicalJul 9, 2026~7 min · intermediate
LangGraphinterruptcheckpointerPostgresact-first
01

Ask vs decide

The first version of our adjustment chat interrogated the user endlessly, one question after another. Unbearable. The right posture is act-first: act by default with explicit assumptions, and ask only on a genuine blocker.

always ask

an interrogation.

the user does the agent’s work
slow, frustrating
act-first

a good colleague.

acts with stated assumptions
asks only if it’s blocking
lets you correct afterward
02

Pausing cleanly: interrupt

LangGraph provides interrupt(): called inside a node, it suspends the run and surfaces a question to your app. The graph doesn’t “block” a server thread — it stops and hands control back.

clarify.tscopy
import { interrupt } from "@langchain/langgraph";
// dans un noeud : suspendre le run et demander a l'humain
function clarify(state) {
if (state.deadline) return state; // rien a demander -> on continue
const answer = interrupt({ // <- le graphe s'arrete ICI
question: "Quelle est l'echeance visee ?",
context: state.brief,
});
return { ...state, deadline: answer }; // reprise avec la reponse
}
// the graph (langgraph)
gather
context
clarify
ask ?
draft
decide
review
self-review
apply
apply
review ↺ draft — loop if not enoughclarify → human-in-the-loop
Postgres — durable stateLangfuse — traced
03

Resuming the run

On the app side, the first call stops at the interrupt and returns the question. You show it, the user answers, and you re-run the same run with their answer via Command({ resume }).

route.tscopy
import { Command } from "@langchain/langgraph";
// 1er appel : le graphe tourne jusqu'a l'interrupt, puis rend la main
const first = await graph.invoke({ brief }, config);
// -> first.__interrupt__ contient la question posee a l'utilisateur
// ... on affiche la question, l'utilisateur repond ...
// reprise : on relance le MEME run (meme thread_id) avec la reponse
await graph.invoke(new Command({ resume: "fin mars 2026" }), config);
04

Durable: surviving the pause

For the agent to resume exactly where it left off — even after a redeploy, or an answer two hours later — the state must be persisted. That’s the checkpointer’s job: it saves the graph state at each step, here in Postgres.

graph.tscopy
import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres";
// l'etat du run est persiste -> il survit a un reboot, une repons 2h plus tard
const checkpointer = PostgresSaver.fromConnString(process.env.DATABASE_URL);
const graph = builder.compile({ checkpointer });
// chaque run a un thread_id : c'est lui qui permet de reprendre au bon endroit
const config = { configurable: { thread_id: runId } };

With a checkpointer, a run becomes a resumable conversation, not a throwaway call. That’s what makes human-in-the-loop usable in real production.

!

Reserve the pause for real decision points (destructive actions, costly ambiguity). An interrupt at every step and you recreate the interrogation you meant to escape.

05

Approval gates: acting under condition

Act-first has one clean exception: irreversible actions. Deleting data, emailing a customer, confirming a payment, pushing to prod — there, the agent doesn't guess, it asks for approval. Anthropic puts it well in Building effective agents: you place checkpoints before high-stakes actions, not at every step. It's the same interrupt() as for a clarification, but what you surface is no longer an open question — it's a plan to approve.

Three possible answers, not two

A good approval gate isn't just "yes / no." You also want to let the human edit the action before it fires. The interrupt payload surfaces the proposed action, and the answer via Command({ resume }) carries the decision — which the node then executes.

acceptthe action fires as-is, the run continues.
editthe human edits the arguments (recipient, amount), then you run the corrected version.
rejectyou cancel the action and send the agent back to think, with the rejection reason.
approval.tscopy
import { interrupt, Command } from "@langchain/langgraph";
// Inside a node, before a destructive tool call
async function sendEmailNode(state: State) {
const draft = state.pendingEmail;
// Surface the proposed action, not an open question
const decision = interrupt({
kind: "approval",
action: "send_email",
to: draft.to,
subject: draft.subject,
body: draft.body,
}) as { type: "accept" | "edit" | "reject"; args?: EmailArgs; reason?: string };
if (decision.type === "reject") {
// Loop back with the reason, no side effect fired
return new Command({
goto: "plan",
update: { note: `Email rejected: ${decision.reason}` },
});
}
const finalDraft = decision.type === "edit" ? decision.args! : draft;
await mailer.send(finalDraft); // the only irreversible line, gated
return { sent: true };
}
!

Classic trap: on resume, LangGraph re-runs the node from the top, not from the interrupt line — see the Interrupts docs. So any side effect placed before interrupt() (log, write, API call) replays. Keep the node "pure" before the gate, and put the irreversible action only after the decision.

Timeout and escalation

An approval gate with no time limit becomes a zombie run: suspended forever because nobody answered. Since state is persisted by the checkpointer (thread_id + Postgres), the run costs nothing while it waits — but you still need a policy. In practice I store a deadline alongside the checkpoint and run a job that, past the delay, resumes the thread with a default decision (usually reject) or escalates to another approver.

one deadline per pending interrupt, written with the state
a periodic sweep of runs suspended past their delay
a safe default: on a destructive action, expiring means refusing
an optional escalation to a second approver before giving up
06

Checkpoint isn't durable execution

A checkpointer gives you resumption: state is written at each step, you can re-run a thread by its thread_id, even after a crash — well documented on the Persistence side and the durable execution side. But resuming isn't automatically running to completion. The checkpoint saves your place; it's still on you to detect the failure and re-run with the right identifier.

checkpointer

"I saved your state."

resumable by thread_id
you detect the failure and re-run
watch for replayed side effects
durable execution

"it will run to completion."

automatic resume and retry
finished steps cached, not replayed
coordination in a distributed setup
i

Diagrid lays out the nuance well in Checkpoints are not durable execution. For plain human-in-the-loop, the checkpointer is more than enough. If your workflows run for hours with automatic retries and multiple workers, look at a real durable-execution engine (Temporal, Restate) that caches finished steps instead of replaying them.

07

Sources & further reading

01LangGraph — InterruptsThe reference on interrupt(): suspension, resume via Command, and the trap of the node re-running from the top.02LangGraph — PersistenceCheckpointers, thread_id and PostgresSaver: how graph state is persisted to make a run resumable.03LangGraph — Overview (durable execution)JS/TS overview: durable execution, human-in-the-loop and persistence as core building blocks.04Anthropic — Building effective agentsWhy to place human checkpoints before irreversible actions, and stopping conditions for an agent.05Diagrid — Checkpoints are not durable executionThe clear distinction between saving state (checkpoint) and guaranteeing run-to-completion (durable execution).06AWS — Durable AI agents with LangGraph & DynamoDBAn AWS-maintained checkpointer for DynamoDB: an alternative to Postgres for persisting agent state in prod.

Human-in-the-loop isn't about “asking more,” it's about asking better: act by default, pause only when it matters, and resume cleanly thanks to durable state. The agent becomes a colleague you trust, not a form.