Human-in-the-Loop
How eight agent frameworks implement the pause — interrupt(), InputRequiredEvent, human_input, needs_approval and the rest — graded against one rubric: pause, persist, resume, identify.
Every agent framework demo runs start to finish without stopping. Almost no production agent is allowed to. The moment an agent can send an email, move money, edit a file, or dismiss a job posting, someone asks the only question that matters to a reviewer: where does a human get to say no?
That question has a name — human-in-the-loop (HITL) — and it is not one feature. It is four distinct interaction shapes:
- Approval gate — the agent proposes an action and waits for yes/no before executing it. The classic "confirm before sending".
- Tool-call review — a specific tool is marked dangerous; every call to it is intercepted, shown to a human, and executed only on approval (possibly with edited arguments).
- State edit — a human inspects and corrects the agent's intermediate state (a wrong plan, a bad extraction) before the run continues.
- Multi-turn input — the agent discovers mid-run that it is missing information and asks for it, then continues with the answer.
Every framework on this page implements some subset of those shapes, and every one of them hides the same iceberg below the waterline: a pause that must survive until a human shows up. A human might answer in four seconds or four days. If your pause lives in a Python stack frame, it dies with the process — which turns human-in-the-loop into a durability problem wearing a UX hat.
This page is the framework-by-framework drill-down. For where on the autonomy spectrum to put a human — which decisions deserve a gate at all — see the autonomy field guide. For what it takes to make any pause survive a deploy, see durable execution. This page covers the third question: what primitive does each framework actually give you, and what does it quietly leave on your plate?
| Chapter | When you need it |
|---|---|
| Why agents ask permission | The four HITL shapes, and why they exist |
| Pause, persist, resume, identify | The rubric every framework is graded against |
| LangGraph | interrupt() + checkpointer — the most complete story |
| LlamaIndex Workflows | InputRequiredEvent in, HumanResponseEvent back |
| CrewAI | human_input=True and its sharp limits |
| OpenAI Agents SDK | needs_approval + a serializable RunState |
| AutoGen and AG2 | The human as a conversation participant |
| Pydantic AI and Claude Agent SDK | Approval at the tool boundary, two ways |
| Comparison and production checklist | The table, the rule, and what no framework solves |
Why agents ask permission
Autonomy is not a binary, and the interesting engineering lives exactly at the seam where it stops. An agent that must ask before everything is a chat UI with extra steps; an agent that never asks is a liability with an API key. The useful middle — act freely on reversible things, pause on consequential ones — is what human-in-the-loop machinery implements.
The four shapes from the intro are worth keeping distinct, because frameworks support them unevenly:
Approval gates sit between steps: the plan is drawn, the draft is written, and the run parks until someone blesses it. This is the shape compliance teams ask for by name, and the one where the pause is most likely to last hours or days.
Tool-call review sits inside a step: the model has already decided to
call delete_file(path="/prod/db") and the framework intercepts the call
itself. The reviewer sees the exact arguments, not a summary — which is why
several newer frameworks (the OpenAI Agents SDK, Pydantic AI, the Claude Agent
SDK) chose the tool boundary as their only HITL surface. It is the narrowest
gate with the highest information content.
State edits are the underrated shape. A reviewer who can only approve or reject a bad plan sends the agent back to square one; a reviewer who can fix the plan and resume converts a failed run into a cheap one. Only frameworks that expose mutable, persisted state (LangGraph most explicitly) support this well.
Multi-turn input inverts the direction: the agent asks, the human answers. Mechanically it is the same pause/resume machinery — which is the first hint that all four shapes reduce to the same underlying problem.
Here is the approval gate as a sequence — deceptively simple, and the diagram smuggles in the hard part as a single dashed arrow labelled "hours pass":
Everything between "persist" and "rehydrate" is the actual feature. The next chapter names its four mechanics; the framework chapters then grade each implementation against them.
Name the four human-in-the-loop interaction shapes, and say which one sits INSIDE a step rather than between steps.
Show answer
Approval gate, tool-call review, state edit, and multi-turn input. Tool-call review sits inside a step: the model has already emitted the call with its exact arguments, and the framework intercepts the call itself — which is why it's the highest-information gate and why several newer SDKs made it their only HITL surface.
From the research: Retrieval practice (the testing effect) — Roediger & Karpicke — Test-Enhanced Learning: Taking Memory Tests Improves Long-Term Retention (2006)
Why does human-in-the-loop turn into a durability problem?
Options: Because the human might answer in four seconds or four days. A pause held in a Python stack frame dies with the process, so any gate that must outlive a restart needs the run persisted somewhere a different process can find it. · Because LLM outputs are non-deterministic, so the same approval can produce different actions on replay. · Because approval UIs need their own database, and two databases must be kept in sync. · Because pausing an agent mid-run corrupts its context window unless the framework re-serializes it.
Show answer
Because the human might answer in four seconds or four days. A pause held in a Python stack frame dies with the process, so any gate that must outlive a restart needs the run persisted somewhere a different process can find it.
From the research: Multiple choice with competitive alternatives (wrong options are themselves retrieval events) — Little, Bjork, Bjork & Angello — Multiple-Choice Tests Exonerated, at Least of Some Charges (2012)
Pause, persist, resume, identify
Strip away the API names and every human-in-the-loop implementation must solve the same four mechanics:
1. Pause. Stop a run mid-flight — between steps or mid-step — without
losing what has been computed so far, and without burning a worker. A pause
that holds a thread hostage (a blocking input() call, a spinning await
with no persistence) only works while the human is already at the keyboard.
2. Persist. Write the paused run somewhere a different process can find it: message history, intermediate state, and a marker saying exactly where the run stopped and what it is waiting for. This is the mechanic that separates demo HITL from production HITL, and it is where most frameworks quietly hand the job back to you.
3. Resume. Take the human's payload — an approval, a rejection with a reason, edited arguments, a free-text answer — and inject it at the pause point, not at the beginning. Resume-by-replaying-the-whole-run and resume-from-the-exact-line are very different guarantees, and frameworks that replay (LangGraph re-executes the interrupted node from its top) turn that difference into a footgun you must design around.
4. Identify. When the approval arrives — from a Slack button, an email
link, a web form — which of the thousand paused runs does it belong to? Every
framework needs a durable handle: LangGraph's thread_id, LlamaIndex's
serialized Context, the OpenAI SDK's stored RunState string. Your
approval UI carries this handle in every link it renders.
Grade any framework's HITL story by asking four questions in order: can it stop? does the stop survive a restart? where does the answer re-enter? and what names the waiting run? The chapters that follow do exactly that, framework by framework — and the comparison table at the end is just this rubric in grid form.
The four mechanics every HITL implementation must solve: ____ the run without burning a worker, ____ it where another process can find it, ____ by injecting the payload at the pause point, and ____ which waiting run an approval belongs to.
Show answer
pause, persist, resume, identify
From the research: The generation effect (produce the term, don't reread it) — Slamecka & Graf — The Generation Effect: Delineation of a Phenomenon (1978)
Each framework names the waiting run with a different durable handle. Give the handle for LangGraph, LlamaIndex Workflows, and the OpenAI Agents SDK.
Show answer
LangGraph: the `thread_id` in the config. LlamaIndex: the serialized Context blob (`ctx.to_dict()`), keyed by your own run id. OpenAI Agents SDK: the stored `RunState.to_string()` string, again under your key. Whatever the handle, your approval UI carries it in every link it renders.
From the research: Retrieval practice (the testing effect) — Roediger & Karpicke — Test-Enhanced Learning: Taking Memory Tests Improves Long-Term Retention (2006)
Explain to a teammate why 'resume by replaying the run' and 'resume from the exact pause point' are different guarantees, and name one framework on each side.
From the research: Elaborative interrogation (answer the why, not just the what) — Dunlosky, Rawson, Marsh, Nathan & Willingham — Improving Students' Learning With Effective Learning Techniques (2013)
LangGraph — the pause is a save point
LangGraph has the most complete pause story of any framework here, and it
falls out of a decision covered in the framework
comparison:
LangGraph checkpoints graph state after every superstep. Once every step is a
save point, "pause here and wait for a human" stops being a feature and
becomes a one-liner — the run is already persisted; interrupt() just
declines to continue it.
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command, interrupt
def human_review(state: State) -> Command:
# Parks the WHOLE graph here. The value passed to interrupt() is
# surfaced to the caller; the return value is the human's payload,
# delivered on resume.
decision = interrupt({
"question": "Send this outreach email?",
"draft": state["draft"],
})
if decision["action"] == "edit":
return Command(goto="send", update={"draft": decision["draft"]})
if decision["action"] == "approve":
return Command(goto="send")
return Command(goto=END)
builder = StateGraph(State)
builder.add_node("draft", draft_email)
builder.add_node("human_review", human_review)
builder.add_node("send", send_email)
builder.add_edge(START, "draft")
builder.add_edge("draft", "human_review")
graph = builder.compile(checkpointer=SqliteSaver.from_conn_string("state.db"))
config = {"configurable": {"thread_id": "outreach-42"}}
result = graph.invoke({"topic": "intro email"}, config)
# Hours (or a deploy) later, in a different process:
graph.invoke(Command(resume={"action": "approve"}), config)
Walk the rubric. Pause: interrupt(), callable anywhere inside a node,
mid-logic, even in a loop. Persist: the checkpointer — swap
SqliteSaver for the Postgres saver and the pause survives restarts,
deploys, and horizontal scaling. Resume: Command(resume=payload); the
payload becomes interrupt()'s return value. Identify: the
thread_id in config — that string is what your approval email links
carry. Four for four, and the same machinery covers all four HITL shapes:
Command(goto=..., update={...}) on resume is the state-edit shape, and
calling interrupt() with a question is multi-turn input.
The footgun: resume re-executes the node from its top. LangGraph does not
freeze a Python stack frame — on resume it replays the interrupted node from
its first line, and this time interrupt() returns the human's payload
instead of parking. Every line above the interrupt() call runs twice.
An LLM call or a charge placed before the interrupt in the same node will
happen again on resume. The discipline: nodes that interrupt should do
side effects after the interrupt line, or split the side effect into its own
node so the checkpoint boundary protects it.
Two design consequences worth naming. First, because the interrupt payload
passes through the checkpointer's serializer, it must be plain data — no live
objects, no callbacks. Second, multiple interrupts can be pending at once (two
parallel branches each asking for review); the __interrupt__ entry is a
list, and resume payloads map to interrupts by id. The decision rule from the
comparison page — durability, pause/resume, or human-in-the-loop →
LangGraph — is really one rule, not three: HITL is durability, demanded
by a user instead of a crash.
LangGraph HITL in three calls: park the graph with ____ inside a node, persist it via the ____ passed to compile(), and pick it up with `invoke( ____ , config)` on the same ____ .
Show answer
interrupt(), checkpointer, Command(resume=...), thread_id
From the research: The generation effect (produce the term, don't reread it) — Slamecka & Graf — The Generation Effect: Delineation of a Phenomenon (1978)
A LangGraph node calls an LLM, then calls interrupt() for approval. The human approves. What happens to that LLM call?
Options: It runs again. Resume re-executes the interrupted node from its top — LangGraph does not freeze the stack frame — and this time interrupt() returns the payload instead of parking. Side effects placed before the interrupt line run twice; put them after it or in their own node. · Nothing — the checkpointer cached the LLM response and replays it from the snapshot. · The node resumes from the exact interrupt() line, so earlier statements never re-run. · LangGraph raises a DoubleExecutionError unless the node is marked idempotent.
Show answer
It runs again. Resume re-executes the interrupted node from its top — LangGraph does not freeze the stack frame — and this time interrupt() returns the payload instead of parking. Side effects placed before the interrupt line run twice; put them after it or in their own node.
From the research: Multiple choice with competitive alternatives (wrong options are themselves retrieval events) — Little, Bjork, Bjork & Angello — Multiple-Choice Tests Exonerated, at Least of Some Charges (2012)
Why does LangGraph's HITL story fall out of its checkpointing design rather than being a separate feature?
Show answer
The checkpointer already saves graph state after every superstep, so every step is a save point. 'Pause here for a human' is then just declining to continue an already-persisted run — interrupt() adds the pause marker, and durability, resume, and identity (thread_id) were already there. HITL is durability demanded by a user instead of a crash.
From the research: Retrieval practice (the testing effect) — Roediger & Karpicke — Test-Enhanced Learning: Taking Memory Tests Improves Long-Term Retention (2006)
LlamaIndex Workflows — the pause is an event
LlamaIndex Workflows route typed events between steps, so its HITL primitive
is — inevitably — a pair of events. A step that needs a human emits
InputRequiredEvent and awaits ctx.wait_for_event(HumanResponseEvent);
whoever drives the workflow catches the first on the event stream and sends
the second back. The pause is just an unresolved await on the event queue.
from llama_index.core.workflow import (
Context, HumanResponseEvent, InputRequiredEvent,
StartEvent, StopEvent, Workflow, step,
)
class OutreachFlow(Workflow):
@step
async def draft(self, ctx: Context, ev: StartEvent) -> StopEvent:
draft = await write_draft(ev.topic)
response = await ctx.wait_for_event(
HumanResponseEvent,
waiter_id="approve-draft",
waiter_event=InputRequiredEvent(
prefix="Send this outreach email?", payload=draft,
),
)
if response.response == "approve":
await send_email(draft)
return StopEvent(result=response.response)
workflow = OutreachFlow()
handler = workflow.run(topic="intro email")
async for event in handler.stream_events():
if isinstance(event, InputRequiredEvent):
answer = input(event.prefix) # stand-in for your real UI
handler.ctx.send_event(HumanResponseEvent(response=answer))
result = await handler
The rubric. Pause: ctx.wait_for_event() — the step coroutine suspends;
other steps keep running, so one pending approval does not freeze unrelated
work. Persist: opt-in and manual. The waiting shown above lives in
process memory. To survive a restart you serialize the workflow context —
ctx.to_dict(serializer=JsonSerializer()) — store the dict, and later
rebuild with Context.from_dict(workflow, data) and re-run; the
waiter_id deduplicates the re-emitted request so the human is not asked
twice. Resume: handler.ctx.send_event(HumanResponseEvent(...)) — typed,
so arbitrary payloads ride in event fields. Identify: the serialized
context blob is the handle; keying blobs by your own run id is your job.
So LlamaIndex scores four for four on mechanics but leaves persistence as an
exercise — the exact exercise the deployed version solves with a Postgres-backed
context store, which is the /llama-agents page's whole
subject. Read that page for the ops half (server pauses on
InputRequiredEvent, context snapshots in Postgres, resume across pod
restarts); this chapter is the primitive underneath it.
One sharp edge mirrors LangGraph's, inverted. LangGraph replays the node, so pre-interrupt side effects run twice. LlamaIndex suspends a real coroutine, so nothing replays — but an in-process pause dies silently with the process unless you snapshotted the context before parking. LangGraph makes you defend against double execution; LlamaIndex makes you defend against forgetting to save. Pick which failure mode you'd rather design around.
The LlamaIndex pause is an event pair: the step emits ____ and suspends on `ctx.wait_for_event( ____ )`; the driver catches the first on ____ and answers with `handler.ctx. ____ (...)`.
Show answer
InputRequiredEvent, HumanResponseEvent, stream_events(), send_event
From the research: The generation effect (produce the term, don't reread it) — Slamecka & Graf — The Generation Effect: Delineation of a Phenomenon (1978)
Your LlamaIndex workflow was waiting for approval when the pod restarted. The human clicks approve. What determines whether the run can continue?
Options: Whether you serialized the context before parking. The suspended coroutine lives in process memory; only a `ctx.to_dict()` snapshot stored externally lets you rebuild with `Context.from_dict()` and re-run, with the waiter_id deduplicating the re-emitted request. No snapshot, no run. · Whether the workflow's timeout had expired — within the timeout, LlamaIndex re-hydrates the run automatically. · Whether the HumanResponseEvent carries the original run_id so the event bus can route it to the restarted pod. · Nothing can continue it — LlamaIndex workflows cannot resume across processes in any configuration.
Show answer
Whether you serialized the context before parking. The suspended coroutine lives in process memory; only a `ctx.to_dict()` snapshot stored externally lets you rebuild with `Context.from_dict()` and re-run, with the waiter_id deduplicating the re-emitted request. No snapshot, no run.
From the research: Multiple choice with competitive alternatives (wrong options are themselves retrieval events) — Little, Bjork, Bjork & Angello — Multiple-Choice Tests Exonerated, at Least of Some Charges (2012)
This chapter covers the primitive; the deployed version — context snapshots in Postgres, resume across pod restarts — is the /llama-agents page. Read them as one story.
Pair with: LlamaIndex agents in production
From the research: Interleaved practice (mix related-but-distinct cases) — Rohrer & Taylor — The Shuffling of Mathematics Problems Improves Learning (2007)
CrewAI — the pause is a console prompt
CrewAI's headline HITL feature is one flag:
from crewai import Agent, Crew, Task
reviewer = Agent(
role="Outreach writer",
goal="Draft outreach emails worth sending",
backstory="Writes short, specific, non-cringe cold emails.",
)
draft_task = Task(
description="Draft an outreach email about {topic}",
expected_output="A subject line and a 120-word body",
agent=reviewer,
human_input=True, # <- the whole feature
)
crew = Crew(agents=[reviewer], tasks=[draft_task])
result = crew.kickoff(inputs={"topic": "intro email"})
When the task completes, the process blocks on stdin: the human reads the output in a terminal, types feedback, and the agent revises. As a feedback loop for a developer babysitting a crew locally, it is genuinely pleasant. As production HITL, it fails the rubric almost everywhere: the pause is a blocking read on a live process (pause: barely), nothing about the wait is written anywhere (persist: no), resume means typing into the same terminal (resume: same-process only), and there is no handle because there is no externally visible waiting state (identify: no). If the process dies while waiting, the run is gone. There is no web-app story in the primitive itself.
CrewAI's real answer for approval gates lives a level up, in Flows — the
event-driven orchestration layer around crews. A flow can run a crew as one
step, stop at a decision point, persist its state with @persist (SQLite by
default), and route on the stored decision:
from crewai.flow.flow import Flow, listen, router, start, persist
@persist()
class OutreachFlow(Flow[OutreachState]):
@start()
def draft(self):
self.state.draft = run_draft_crew()
self.state.status = "awaiting_review"
# stop here; your app notifies a human and later re-kicks the
# flow with the decision recorded in state
@router(draft)
def route_on_review(self):
return "send" if self.state.approved else "revise"
Note what happened: the framework did not pause for you — you restructured
the workflow so it ends at the decision point and a later kickoff continues
it. That is a fine pattern (it is essentially how you'd hand-roll HITL with a
job queue), but it is your pattern to build and test, not a primitive you
inherit. The honest summary: human_input=True is a developer-loop tool;
production approval gates in CrewAI are a Flows architecture you design.
What exactly does `Task(human_input=True)` do in CrewAI, and where does it stop being enough?
Show answer
After the task completes, the process blocks on stdin: a human reads the output in the terminal, types feedback, and the agent revises. It's a good developer feedback loop — but the pause is a blocking read on a live process, nothing is persisted, and there's no external handle, so it has no web-app or restart story.
From the research: Retrieval practice (the testing effect) — Roediger & Karpicke — Test-Enhanced Learning: Taking Memory Tests Improves Long-Term Retention (2006)
How do you build a production approval gate in CrewAI?
Options: Restructure with Flows: run the crew as a flow step, end the flow at the decision point with state persisted via @persist, notify the human from your app, and kick the flow again with the decision recorded in state — a router routes on it. The framework doesn't pause for you; you design the workflow to stop and continue. · Set human_input=True and run the crew behind a web socket so stdin is bridged to the browser. · Pass a checkpointer to Crew.kickoff() so the blocking prompt survives restarts. · Mark the task with approval_required=True, which parks it in CrewAI's built-in review queue.
Show answer
Restructure with Flows: run the crew as a flow step, end the flow at the decision point with state persisted via @persist, notify the human from your app, and kick the flow again with the decision recorded in state — a router routes on it. The framework doesn't pause for you; you design the workflow to stop and continue.
From the research: Multiple choice with competitive alternatives (wrong options are themselves retrieval events) — Little, Bjork, Bjork & Angello — Multiple-Choice Tests Exonerated, at Least of Some Charges (2012)
OpenAI Agents SDK — the pause is serialized state
The framework comparison found that the OpenAI Agents SDK has no durability story: the run lives in the process, full stop. Human-in-the-loop is the one place the SDK carves out an exception — because tool approval forced it to. You cannot intercept a tool call for review without somewhere to put the half-finished run while the human thinks.
The shape: mark a tool with needs_approval, and the run — instead of
finishing — returns with interruptions:
from agents import Agent, Runner, function_tool
from agents.run_state import RunState
@function_tool(needs_approval=True) # or an async callable for per-call logic
async def cancel_order(order_id: int) -> str:
return f"Cancelled order {order_id}"
agent = Agent(name="Support", tools=[cancel_order])
result = await Runner.run(agent, "Cancel order 123")
if result.interruptions:
state = result.to_state()
serialized = state.to_string() # -> your database, keyed by run id
# ... a different process, after the human decides ...
state = RunState.from_string(agent, serialized)
for interruption in state.interruptions:
# interruption carries tool_name + arguments — show them verbatim
state.approve(interruption) # or state.reject(interruption)
result = await Runner.run(agent, state)
print(result.final_output)
Against the rubric: pause is the run returning early with
interruptions — cooperative, clean, no blocked thread. Persist is
state.to_string(): the entire run — history, pending tool calls, approval
flags — becomes a JSON string you store wherever you like. Resume is
Runner.run(agent, state) with the decisions recorded; approved calls
execute, rejected ones return a rejection message to the model, which reacts
accordingly. Identify is whatever key you filed the string under. It's a
genuinely tidy design — the pause is a value, and values are easy to store,
inspect, and ship across processes.
The limits are the mirror image of the design. Only the tool boundary pauses:
this is tool-call review (and, via approve(..., always_approve=True)-style
options, a lighter approval memory), not arbitrary mid-graph pauses, not
state edits, not "stop between planning and execution" unless you make those
things tools. And the serialized state is a snapshot you manage — there is
no checkpointer writing every step, so a crash between pauses still loses the
run. HITL got a durability carve-out; the rest of the run didn't.
OpenAI Agents SDK approval flow: mark the tool with ____ , the run returns early with ____ , persist `state. ____ `, later rebuild via `RunState.from_string()`, record ____ per interruption, and re-run with the state.
Show answer
needs_approval, interruptions, to_string(), approve/reject
From the research: The generation effect (produce the term, don't reread it) — Slamecka & Graf — The Generation Effect: Delineation of a Phenomenon (1978)
The framework comparison found the OpenAI Agents SDK has no durability story. How does that square with its HITL support?
Options: Tool approval forced a carve-out: a paused run has to live somewhere while the human thinks, so RunState is fully serializable — the pause is a value you store. But it's a snapshot you manage at the pause boundary only; there's no checkpointer writing every step, so a crash between pauses still loses the run. · It doesn't — HITL in the Agents SDK only works while the original process stays alive. · The SDK added a full checkpointer in the same release, so runs are now durable throughout. · Approvals are stored server-side by OpenAI and re-attached by run_id, so no client persistence is needed.
Show answer
Tool approval forced a carve-out: a paused run has to live somewhere while the human thinks, so RunState is fully serializable — the pause is a value you store. But it's a snapshot you manage at the pause boundary only; there's no checkpointer writing every step, so a crash between pauses still loses the run.
From the research: Multiple choice with competitive alternatives (wrong options are themselves retrieval events) — Little, Bjork, Bjork & Angello — Multiple-Choice Tests Exonerated, at Least of Some Charges (2012)
AutoGen and AG2 — the human is another agent
AutoGen predates every other framework on this page, and its HITL design
shows the era: instead of pausing machinery, the human is modeled as a
participant in the conversation. A UserProxyAgent sits in the chat
and, depending on human_input_mode, forwards messages to a real person:
from autogen import AssistantAgent, UserProxyAgent
assistant = AssistantAgent("assistant", llm_config={"model": "gpt-4o"})
user_proxy = UserProxyAgent(
"human",
human_input_mode="ALWAYS", # ALWAYS | TERMINATE | NEVER
code_execution_config=False,
)
user_proxy.initiate_chat(assistant, message="Draft an outreach email.")
The three modes are the whole dial. ALWAYS asks the human before every
reply — maximum oversight, chat-with-extra-steps. TERMINATE runs
autonomously and only asks when the conversation would otherwise end — a
review gate at the finish line. NEVER is full autonomy. In the newer
Microsoft AutoGen (AgentChat, the 0.4+ rewrite), the same idea survives as a
UserProxyAgent with a pluggable input_func — swap the default console
prompt for a callback into your UI. AG2 — the community fork that kept the
original autogen package name and API after the projects split in late
2024 — keeps the classic shape above; Microsoft's own line has since folded
into the Microsoft Agent Framework alongside Semantic Kernel. Two lineages,
one design idea.
Conceptually the framing is lovely — approval, correction, and extra input
are all just messages, so every HITL shape collapses into "the human says
something". Mechanically, the rubric grades it like CrewAI: the default input
function blocks a live process on the console (pause: blocking), the wait
is not persisted (persist: no), and resume means the same process is
still alive when the human answers (identify: the open socket). A custom
input_func can bridge to a web UI, but making that durable — parking
the conversation state while the human is away — is again an exercise for
your infrastructure. The human-as-agent framing solved the interaction
design; it never claimed to solve the durability problem underneath it.
AutoGen's dial is ____ on the ____ : ____ asks before every reply, ____ asks only when the conversation would otherwise end, and NEVER is full autonomy.
Show answer
human_input_mode, UserProxyAgent, ALWAYS, TERMINATE
From the research: The generation effect (produce the term, don't reread it) — Slamecka & Graf — The Generation Effect: Delineation of a Phenomenon (1978)
What is elegant about AutoGen's human-as-agent framing, and what does it deliberately not solve?
Options: Every HITL shape collapses into 'the human says something' — approval, correction, and extra input are all just messages in the conversation. But the default input function blocks a live process on the console and nothing about the wait is persisted, so durable, away-from-keyboard gates remain your infrastructure's job. · It gives the human veto power over tool calls specifically, which the message framing makes automatic and durable. · It lets the human participate without appearing in message history, keeping the context window clean but losing auditability. · It guarantees the model can't act between human turns, which also makes horizontal scaling trivial.
Show answer
Every HITL shape collapses into 'the human says something' — approval, correction, and extra input are all just messages in the conversation. But the default input function blocks a live process on the console and nothing about the wait is persisted, so durable, away-from-keyboard gates remain your infrastructure's job.
From the research: Multiple choice with competitive alternatives (wrong options are themselves retrieval events) — Little, Bjork, Bjork & Angello — Multiple-Choice Tests Exonerated, at Least of Some Charges (2012)
Pydantic AI and Claude Agent SDK — approval at the tool boundary
Two newer SDKs land on the same conviction from different directions: the tool call is the only boundary worth gating, so make that gate excellent.
Pydantic AI makes the pause a typed output. Tools marked
requires_approval=True don't fire — instead the run ends, returning a
DeferredToolRequests object listing the calls that need sign-off. There is
no suspended coroutine to keep alive: the run is over, and the "pause" is
whatever you do with the returned value. A later run carries the decisions
back in:
from pydantic_ai import (
Agent, DeferredToolRequests, DeferredToolResults, ToolApproved, ToolDenied,
)
agent = Agent("anthropic:claude-opus-5", output_type=[str, DeferredToolRequests])
@agent.tool_plain(requires_approval=True)
def delete_file(path: str) -> str:
return f"deleted {path}"
result = agent.run_sync("Clean up the temp directory")
if isinstance(result.output, DeferredToolRequests):
messages = result.all_messages() # persist these + the requests
decisions = DeferredToolResults()
for call in result.output.approvals:
decisions.approvals[call.tool_call_id] = (
ToolApproved() if is_safe(call) else ToolDenied("Not allowed")
)
result = agent.run_sync(
message_history=messages, deferred_tool_results=decisions,
)
Because the first run genuinely ends, persistence is trivially honest: the
message history and the requests are plain data; store them anywhere, resume
from any process, days later. Approve-with-edited-args
(ToolApproved(override_args=...)) covers the state-edit shape at the tool
level, and ToolDenied's message goes back to the model as the tool result,
so a rejection steers rather than crashes the run.
The Claude Agent SDK ships the permission system that Claude Code runs on,
as a library. HITL is a callback: can_use_tool fires when no
allow/deny rule, hook, or permission mode has already settled a tool call,
and your code decides — allow (optionally with modified inputs), or deny with
a reason the model sees:
from claude_agent_sdk import ClaudeAgentOptions, query
async def can_use_tool(tool_name, input_data, context):
if tool_name == "Bash" and is_destructive(input_data["command"]):
decision = await ask_human(tool_name, input_data) # your UI
if not decision.approved:
return {"behavior": "deny", "message": decision.reason}
return {"behavior": "allow", "updatedInput": input_data}
async for message in query(
prompt="Tidy up the repo",
options=ClaudeAgentOptions(
permission_mode="default", # default | acceptEdits | plan | bypassPermissions
can_use_tool=can_use_tool,
),
):
...
What is distinctive is the layering: hooks run first, then deny rules, ask
rules, the permission mode, allow rules — and only unresolved calls reach your
callback. That yields graduated autonomy out of the box (auto-approve reads,
gate writes, forbid the deploy command everywhere, loosen the mode as trust
builds mid-session via set_permission_mode), with the well-documented
sharp edge that an allow rule or permissive mode silently bypasses your
callback — approval logic that must always run belongs in a PreToolUse
hook instead. The callback itself is in-process and the session is live while
it waits, so long absences are handled by the session-resume machinery rather
than by parking the callback: durable multi-day gates are Pydantic AI's
strength; rich, layered, interactive permissioning is this SDK's.
Pydantic AI has no suspended coroutine while a human decides. What happens instead, and why does that make persistence 'trivially honest'?
Show answer
The run ends, returning a DeferredToolRequests object listing the calls that need sign-off. The pause is a returned value: message history plus requests are plain data you store anywhere, and a later run carries DeferredToolResults (ToolApproved — optionally with override_args — or ToolDenied) back in with the message history. Nothing has to be kept alive.
From the research: Retrieval practice (the testing effect) — Roediger & Karpicke — Test-Enhanced Learning: Taking Memory Tests Improves Long-Term Retention (2006)
Your Claude Agent SDK can_use_tool callback contains a mandatory compliance check, but it never fires for Read calls. Why, and what's the fix?
Options: The callback is the LAST step of permission evaluation — hooks, deny rules, ask rules, the permission mode, and allow rules all run first, and a matching allow rule (or acceptEdits/bypassPermissions) approves the call before the callback is consulted. Checks that must run on every call belong in a PreToolUse hook, which runs first and can deny even in bypassPermissions. · Read is a built-in tool, and can_use_tool only fires for MCP and custom tools. · The callback must be registered before the first query() call; late registration silently no-ops. · can_use_tool only receives calls the model flagged as sensitive, and Read is never flagged.
Show answer
The callback is the LAST step of permission evaluation — hooks, deny rules, ask rules, the permission mode, and allow rules all run first, and a matching allow rule (or acceptEdits/bypassPermissions) approves the call before the callback is consulted. Checks that must run on every call belong in a PreToolUse hook, which runs first and can deny even in bypassPermissions.
From the research: Multiple choice with competitive alternatives (wrong options are themselves retrieval events) — Little, Bjork, Bjork & Angello — Multiple-Choice Tests Exonerated, at Least of Some Charges (2012)
The comparison and the production checklist
One table, rubric against framework:
| Pause primitive | Persistence | Resume carries | Best-fit shapes | |
|---|---|---|---|---|
| LangGraph | interrupt() in a node | ✅ checkpointer, every superstep | any payload; Command(resume=, update=) | all four |
| LlamaIndex Workflows | InputRequiredEvent / wait_for_event | ⚠️ manual ctx.to_dict() snapshot | typed HumanResponseEvent | approval, input |
| CrewAI | human_input=True blocks stdin | ❌ (Flows @persist = DIY gate) | console text | dev feedback loop |
| OpenAI Agents SDK | run returns interruptions | ✅ RunState.to_string(), on you to store | approve/reject per call | tool review |
| AutoGen / AG2 | UserProxyAgent awaits input | ❌ in-process | a chat message | conversational review |
| Pydantic AI | run ends with DeferredToolRequests | ✅ plain data by construction | ToolApproved(override_args) / ToolDenied | tool review, edits |
| Claude Agent SDK | can_use_tool callback | ⚠️ live session; resume machinery for gaps | allow / deny / modified input | layered permissions |
The decision rule, said out loud: if the human might be away longer than the process, you need durable HITL — LangGraph if the pause can land anywhere in a graph, Pydantic AI or the OpenAI Agents SDK if gating tool calls is enough (Pydantic AI's run-ends design is the cleanest to persist). If a human is present at a terminal, CrewAI's and AutoGen's console loops are honest, useful tools. If you need graduated permissions richer than one yes/no — rules, modes, hooks, then a callback — the Claude Agent SDK is the only one that ships that layering.
And then the part no framework solves, because it lives in your product, not their runtime:
- Timeouts and escalation. Every pause needs an answer to "what if nobody responds?" — expire and abandon, escalate to another approver, or proceed with the safe default. A pause without a timeout is a leak.
- Notification.
interrupt()does not send the Slack message. The pause is only as good as the ping that tells a human it exists, and the deep link that carries the run handle back. - Audit. Who approved what, when, seeing which exact arguments? If the gate exists for compliance, the record of the gate is the deliverable — store decision, identity, and payload next to the run.
- Idempotent resume. Approval UIs get double-clicked, email links get re-opened, Slack retries deliveries. Resuming the same pause twice must be a no-op, not a second wire transfer. LangGraph's replay footgun is one face of this; de-duplicating the resume call itself is another. This is where HITL hands back to durable execution — same iceberg, next chapter of it.
The one-sentence takeaway: every framework can stop; the ones worth trusting in production are the ones that can still find the run after everyone has gone home.
Say the decision rule out loud — the one that starts with how long the human might be away.
Show answer
If the human might be away longer than the process, you need durable HITL: LangGraph if the pause can land anywhere in a graph, Pydantic AI or the OpenAI Agents SDK if gating tool calls is enough. If a human is at the terminal, CrewAI's and AutoGen's console loops are honest tools. If you need graduated permissions richer than one yes/no, the Claude Agent SDK ships that layering.
From the research: Retrieval practice (the testing effect) — Roediger & Karpicke — Test-Enhanced Learning: Taking Memory Tests Improves Long-Term Retention (2006)
Name the four production concerns no framework's HITL primitive solves for you.
Options: Timeouts and escalation (what happens when nobody answers), notification (the ping and deep link that tell a human the pause exists), audit (who approved what, seeing which exact arguments), and idempotent resume (a double-clicked approve button must be a no-op, not a second wire transfer). · Rate limiting, retries, structured logging, and schema validation of the approval payload. · Model fallback, context compaction, token budgeting, and prompt caching across the pause. · RBAC, SSO, encryption at rest, and GDPR deletion of the paused run's history.
Show answer
Timeouts and escalation (what happens when nobody answers), notification (the ping and deep link that tell a human the pause exists), audit (who approved what, seeing which exact arguments), and idempotent resume (a double-clicked approve button must be a no-op, not a second wire transfer).
From the research: Multiple choice with competitive alternatives (wrong options are themselves retrieval events) — Little, Bjork, Bjork & Angello — Multiple-Choice Tests Exonerated, at Least of Some Charges (2012)
Idempotent resume is where HITL hands back to durable execution — same iceberg, next chapter. Re-read that page with this table in hand.
Pair with: Durable execution
From the research: Interleaved practice (mix related-but-distinct cases) — Rohrer & Taylor — The Shuffling of Mathematics Problems Improves Learning (2007)
Glossary — the domain terms, grounded in the code
10terms, each defined from this subsystem’s real source.
interrupt()
LangGraph's pause: called inside a node, it parks the whole graph and surfaces its payload to the caller. Resume delivers the human's answer as interrupt()'s return value — but the node re-executes from its top, so side effects above the interrupt line run twice.
Memory hook A save point in a game that reloads from the start of the room, not the exact pixel you stood on.
Command(resume=...)
The resume half of LangGraph HITL: streamed or invoked on the same thread_id, its payload becomes the pending interrupt()'s return value. With goto/update it also covers the state-edit shape — fix the plan, then continue.
Memory hook The reply envelope pre-addressed to the exact question that was asked.
checkpointer
The LangGraph object passed to compile() that records graph state after every superstep, keyed by thread_id. It is why interrupt() is durable for free: the run was already saved before it paused. Swap SqliteSaver for the Postgres saver and the pause survives deploys.
Memory hook HITL is durability demanded by a user instead of a crash — the save file serves both.
InputRequiredEvent / HumanResponseEvent
LlamaIndex Workflows' HITL event pair: a step emits InputRequiredEvent and suspends on ctx.wait_for_event(HumanResponseEvent); the driver catches the request on the event stream and answers with handler.ctx.send_event(). Typed events mean arbitrary payloads ride in fields.
Memory hook A letter posted out and a letter awaited back — the mailroom keeps routing everyone else's post meanwhile.
ctx.to_dict() / Context.from_dict()
LlamaIndex's manual persistence: serialize the workflow context before parking, store the blob, later rebuild and re-run — the waiter_id deduplicates the re-emitted request so the human isn't asked twice. Without the snapshot, an in-process pause dies silently with the process.
Memory hook LangGraph makes you defend against double execution; LlamaIndex makes you defend against forgetting to save.
human_input=True
CrewAI's task flag: after the task completes, the process blocks on stdin for feedback and the agent revises. A developer feedback loop, not a production gate — nothing is persisted and there is no external handle. Production approval gates in CrewAI are a Flows architecture (@persist + router) you design yourself.
Memory hook The pause is a console prompt: great while you're at the keyboard, gone when the terminal is.
needs_approval / RunState
The OpenAI Agents SDK's tool gate: a tool marked needs_approval makes the run return early with interruptions; result.to_state().to_string() serializes the whole run to a string you store, and RunState.from_string() + approve()/reject() + Runner.run(agent, state) resumes it in any process.
Memory hook The pause is a value — and values are easy to store, inspect, and ship across processes.
human_input_mode
AutoGen/AG2's dial on the UserProxyAgent: ALWAYS asks the human before every reply, TERMINATE asks only when the conversation would otherwise end, NEVER is full autonomy. The human is modeled as a conversation participant, so every HITL shape is just a message.
Memory hook Three positions on one dial: always ask, ask at the finish line, never ask.
DeferredToolRequests / DeferredToolResults
Pydantic AI's stop-the-world flow: tools marked requires_approval end the run with a DeferredToolRequests output listing the pending calls; a later run passes DeferredToolResults mapping each tool_call_id to ToolApproved (optionally override_args) or ToolDenied, plus the message history. The run genuinely ends, so persistence is plain data.
Memory hook No suspended coroutine to babysit: the pause is the return value.
can_use_tool
The Claude Agent SDK's runtime approval callback — the LAST step of permission evaluation, after hooks, deny rules, ask rules, the permission mode, and allow rules. It returns allow (optionally with modified input) or deny with a message. Checks that must run on every call belong in a PreToolUse hook instead, which even bypassPermissions can't skip.
Memory hook The court of last resort: it only hears cases no earlier rule already settled.
Flashcards for this topic