Human in the loop for AI agents: pause, approve and resume in LangGraph, OpenAI Agents SDK, Claude Agent SDK and Bedrock
An approval step is only as good as the pause behind it. How each of the 4 main agent frameworks stops a tool call, keeps the run while a person decides, and picks it up again: how long it can wait, what the reviewer can change, and where it breaks.
Tecyfy · Published · 9 min read

Human in the loop means a tool call stops before it runs and waits for a person, and the hard part is the wait, not the button. The framework has to keep the whole run (the conversation, the pending call, its arguments) somewhere safe while someone is in a meeting, then pick it up as if nothing happened. LangGraph and the OpenAI Agents SDK can keep a paused run for as long as you store it. The Claude Agent SDK holds it in your process, or on disk for 30 days by default once you defer it. Bedrock Agents loses it after at most 90 minutes.
Which tools deserve a pause is a separate question, covered in how to stop an AI agent from deleting things without asking. This article is about the mechanics once you have decided: how each framework stops, what the reviewer can say back, and the details that break approvals in production. For the idea itself, see Human in the LoopHuman in the LoopA person approves, corrects or stops an AI system at set points, such as before an agent sends, pays or deletes something..
The 4 frameworks side by side
| Pause with | Run kept in | Can wait | Reviewer can | |
|---|---|---|---|---|
| LangGraph / LangChain | interrupt(), or HumanInTheLoopMiddleware | Your checkpointer, by thread_id | No limit | Approve, edit, reject, respond |
| OpenAI Agents SDK | needs_approval on the tool | RunState, saved as JSON by you | No limit | Approve, reject (with a message) |
| Claude Agent SDK | canUseTool callback, or a hook that defers | Your process; on defer, the session on disk | Process lifetime; 30 days on disk by default | Allow (with changed input), deny |
| Bedrock Agents | requireConfirmation: ENABLED on a function | The agent session | 90 minutes at most | Confirm or deny |
"No limit" means the framework sets none. Your storage, and how long a person's answer stays relevant, set the real one. Try the wait against your own approvers:
LangGraph / LangChain
Holds- The graph waits with no time limit; the state lives in the checkpointer, found again by thread_id.
OpenAI Agents SDK
Holds- result.to_state() then state.to_json() stores the paused run for as long as you keep it.
Claude Agent SDK
Holds- canUseTool waits inside your process until it returns; nothing times it out.
Bedrock Agents
Holds- With requireConfirmation ENABLED the agent returns the call; you answer CONFIRM or DENY in sessionState.
All 4 can hold a run paused for 10 minutes; the lines below say what each needs.
From each framework's own documentation, read 23 October 2026.
LangGraph and LangChain: a checkpoint and a thread ID
A LangGraph graph pauses inside a node by calling interrupt(). The checkpointer writes the graph's state, and the graph waits, in LangGraph's words, indefinitely. You resume by invoking the graph again with Command(resume=...) and the same thread_id. That ID is the only thing your approval screen needs to carry.
For a tool-calling AgentAgentA model that uses tools in a loop, deciding each next step from what the last one returned, until the task is done. built with create_agent, LangChain wraps this in a middleware, so you list tools instead of writing nodes:
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.types import Command
agent = create_agent(
model="anthropic:claude-sonnet-5",
tools=[write_file, execute_sql, read_data],
middleware=[
HumanInTheLoopMiddleware(
interrupt_on={
"write_file": True,
"execute_sql": {"allowed_decisions": ["approve", "reject"]},
"read_data": False,
},
interrupt_mode="per_call",
),
],
checkpointer=checkpointer, # a Postgres checkpointer, not InMemorySaver
)
config = {"configurable": {"thread_id": "ticket-4182"}}
result = agent.invoke({"messages": [{"role": "user", "content": "Archive old orders, then delete them."}]},
config=config, version="v2")
# Later, in the request that carries the reviewer's answer:
decisions = {i.id: {"type": "reject", "message": "Archive the rows first."} for i in result.interrupts}
result = agent.invoke(Command(resume=decisions), config=config, version="v2")
Per-call mode, which needs langchain>=1.4.6, gives each paused call its own interrupt and ID, and LangChain plans to make it the default. An answer you have not sent yet just stays pending. Each interrupt also carries a response_schema, and LangGraph checks an answer against it before saving, so a malformed edit sent on its own can be corrected and sent again. If one of several answers sent together is invalid, the tools for the others can still run, so check answers against response_schema first or send them one at a time.
3 rules decide whether this works in production:
- The node runs again from its start on resume. Code before
interrupt()executes twice. Anything there that writes, sends or charges must be safe to repeat, or must move after the pause. InMemorySaveris for tests. A paused run in memory is gone when the process restarts. LangChain namesAsyncPostgresSaverandMongoDBSaverfor production.- A
whencondition must give the same answer on resume. If it says "no review needed" the second time, the tool runs and a reviewer's reject is ignored.
To deny a call, use reject. The respond decision is meant for "ask the user" tools: its message reaches the model as a successful tool result. Used to say no, the model reads the refusal as the tool having worked.
OpenAI Agents SDK: the run becomes a file
The OpenAI Agents SDK puts the rule on the tool. needs_approval=True always pauses. An async function decides per call, and it fails closed: if the arguments are missing or malformed, the call goes to a person.
from agents import Agent, Runner, RunState
from agents.decorators import tool
async def is_refund(_ctx, params, _call_id) -> bool:
return "refund" in params.get("subject", "").lower()
@tool(needs_approval=is_refund)
async def send_email(subject: str, body: str) -> str:
return f"Sent '{subject}'"
agent = Agent(name="Support agent", instructions="Handle tickets.", tools=[send_email])
result = await Runner.run(agent, "Tell order 4182 their refund is approved.")
if result.interruptions:
save(run_id, result.to_state().to_json()) # your database, not the browser
# Hours later, when the reviewer answers:
state = await RunState.from_json(agent, load(run_id))
for item in state.get_interruptions():
if approved(item):
state.approve(item)
else:
state.reject(item, rejection_message="Ask finance first.")
result = await Runner.run(agent, state)
The paused run is plain data, so it waits as long as your database keeps it. The same flow covers tools reached through a handoff and tools inside an agent used as a tool: their pauses surface on the outer run, and you resume the top-level agent. always_approve=True remembers a decision for later calls of that tool. For the shell and patch tools that memory follows the tool's name across agents, so OpenAI suggests per-call decisions when agents have different permissions.
There is no edit decision. When the reviewer wants a different call, reject it with a rejection_message that says what to do instead; the model reads it and tries again.
The SDK documentation is blunt about the risk: RunState.from_json() does not check who made the snapshot or who sent it back. The state holds pending calls and their arguments. Anyone who can swap the snapshot can change what runs.
Claude Agent SDK: a callback that can wait, and a defer that can leave
In the Claude Agent SDK, a tool call that no permission rule or permission mode already approves goes to your canUseTool callback. So does AskUserQuestion, when Claude has a question for the user. The run stays paused until the callback returns, with no timeout:
from claude_agent_sdk import ClaudeAgentOptions
from claude_agent_sdk.types import PermissionResultAllow, PermissionResultDeny
async def can_use_tool(tool_name, input_data, context):
answer = await ask_reviewer(tool_name, input_data) # your UI
if answer.approved:
return PermissionResultAllow(updated_input=answer.input) # may differ from input_data
return PermissionResultDeny(message=answer.reason)
options = ClaudeAgentOptions(can_use_tool=can_use_tool)
Returning a changed updated_input runs the tool with the reviewer's version. Claude sees the result but is not told the input changed, so say so in your own message if it matters. A denial's message goes to Claude, which may try another way.
Waiting in a callback means keeping a process alive. For approvals that take hours, a PreToolUse hook can return permissionDecision: "defer" instead. The turn ends with stop_reason: "tool_deferred" and the pending call in deferred_tool_use. Your app stores the session ID, collects the answer, then runs claude -p --resume <session-id>. The hook fires again for the same call and now returns allow or deny.
The defer path has 3 limits:
- Non-interactive runs only. It works only in
-pruns, the way an Agent SDK app drives Claude Code. An interactive session logs a warning and ignores it. - One tool call per turn. If Claude makes several calls in one turn, defer is ignored and the calls go through the normal permission flow.
- 30 days on disk. The session file stays until the retention sweep, which
cleanupPeriodDayssets to 30 days by default.
To tell someone a run is waiting, the PermissionRequest hook can send the Slack message or the email.
Bedrock Agents: confirm or deny, inside 90 minutes
Amazon Bedrock Agents, now called Bedrock Agents Classic and closed to new customers as of 2026-10-30 (existing customers can keep using it), turns confirmation on per function in an action group, with requireConfirmation set to ENABLED. When the agent decides to call that function, InvokeAgent returns instead of running it. The response has a returnControl block with the function, its parameters and an invocationId. Your app shows it and sends the answer back on the next call:
{
"sessionState": {
"invocationId": "79e0feaa-c6f7-49bf-814d-b7c498505172",
"returnControlInvocationResults": [{
"functionResult": {
"actionGroup": "RefundAPIs",
"function": "issueRefund",
"confirmationState": "CONFIRM"
}
}]
}
}
CONFIRM runs the function and DENY does not. AWS frames confirmation as a defence against Prompt InjectionPrompt InjectionAn attack that hides instructions in text a model reads, such as a web page, an email or a file, so the model follows the attacker instead of you.: a page or record the agent reads can tell it to call a tool, and a person in between stops that.
The catch is the session. The agent keeps a conversation for idleSessionTTLInSeconds, which can be set from 60 to 5,400 seconds. After that the session expires and Bedrock deletes what was in it. An approval that arrives after 90 minutes has nothing to resume. For slower approvals, keep the request in your own store and start a new session carrying the decision and the facts the agent needs. Or use return of control without confirmation: your app runs the function itself and sends the result back with the same invocationId, so the session timeout still applies. If you run your own agent code on Bedrock AgentCore instead, the pause is whatever your framework does, so one of the 3 patterns above applies.
The approval screen is part of the security
Every framework above hands you the pending call and lets you build the screen. The OpenAI documentation spells out the first 4 rules for that screen, and they apply to all 4 frameworks; the fifth is ours:
- Keep the paused run on your server. Send the reviewer only the details they may see, plus an ID that means nothing on its own.
- Take the reviewer's identity from your own session, never from the body of the approval request. Holding a run ID is not permission to approve it.
- Escape the tool name and arguments when you show them. The model wrote them, and a Prompt InjectionPrompt InjectionAn attack that hides instructions in text a model reads, such as a web page, an email or a file, so the model follows the attacker instead of you. can put anything there.
- Use each pending decision once. A double-clicked or replayed approval must not resume the same run twice. Mark the request taken in the same database step that starts the resume.
- Show the arguments that will run, not a summary the model wrote. "Delete old test data" and
DELETE FROM ordersare the same request in different clothes.
If the reviewer's answer reaches the model as text, it can steer what happens next. Write rejection messages as instructions ("archive the rows first"), not just "no". Agents that cost money per step add a second reason to pause: a paused run uses no tokens, and one that loops keeps spending them (AI agent budget limits).
Which one fits your approvals
Choose by how long a person takes to answer:
- Approvals in seconds, while the user watches. Any of the 4 works, though Bedrock Agents is open only to existing customers. The Claude Agent SDK's callback and Bedrock's confirmation are the least code.
- Approvals in hours or days. You need a run that survives a restart. LangGraph with a Postgres checkpointer and the OpenAI Agents SDK with
RunStatein your database are built for it. The Claude Agent SDK gets there with defer, one call per turn. - The reviewer needs to fix a value, not just say no. LangChain's
editand Claude'supdated_inputdo that directly. On the OpenAI Agents SDK and Bedrock, reject with instructions, or run the function yourself.
For how these frameworks compare beyond approvals, see LangChain vs LangGraph and what the OpenAI Agents API includes.
Questions
- What is human in the loop in an AI agent?
- It is a point in the agent's run where a tool call stops before it executes and waits for a person to approve, change or reject it. The framework saves the run while it waits, and the person's answer decides whether the tool runs and what the model is told.
- How long can an AI agent wait for human approval?
- It depends on where the paused run is kept. LangGraph waits with no time limit as long as a durable checkpointer holds the state, and the OpenAI Agents SDK keeps a paused run as JSON for as long as you store it. A deferred Claude Code session stays on disk until the 30-day retention sweep by default. A Bedrock Agents session expires after its idle timeout, at most 5,400 seconds, or 90 minutes.
- How do I resume a LangGraph agent after a human approves?
- Invoke the graph again with Command(resume=...) and the same thread_id in the config. With LangChain's HumanInTheLoopMiddleware in per-call mode, pass a dictionary of decisions keyed by interrupt ID, such as {"type": "approve"} or {"type": "reject", "message": "..."}. The node that paused runs again from its start.
- How does the OpenAI Agents SDK handle tool approval?
- Mark a tool with needs_approval. When the model calls it, the run stops and result.interruptions lists the pending calls. Turn the result into a RunState with result.to_state(), call state.approve() or state.reject() on each item, and resume with Runner.run(agent, state). The state can be saved with to_json() and loaded later.
- Can a reviewer change a tool call's arguments before it runs?
- In LangChain's middleware, yes, with an edit decision; the model is told the call was replaced. The Claude Agent SDK's canUseTool callback can return allow with updated_input. The OpenAI Agents SDK's approval flow is approve or reject, and Bedrock Agents' user confirmation is CONFIRM or DENY.
- Is it safe to send the paused agent state to the browser for approval?
- No. OpenAI's documentation warns that a serialized RunState holds pending tool calls and arguments and that loading it does not authenticate it. Keep the state on your server, send the reviewer only what they may see plus an opaque ID, and check who the reviewer is from your own session, not from the request.