A windswept bonsai on a floating island

Get to know Cadenya

We’re developers who love to build. We set out to create a yes-code platform that makes building agents feel like the best parts of building software.

Human-in-the-loop approvals for AI agents (2026)

Human in the loop

By Cadenya

Every serious option for human-in-the-loop approvals in 2026 does the same basic thing: it pauses a tool call before it runs and waits for a person to approve or deny it. The differences are where the paused state lives and how much of the Slack message, the approval screen, and the resume code you write yourself.

So pick LangGraph interrupts, Pydantic AI deferred tools, or the OpenAI Agents SDK when you already build with them and you’re happy to store checkpoints, message history, or run state in your own database. Pick the Vercel AI SDK when the approval happens in a chat UI between two model calls. Pick Temporal, Inngest, DBOS, or Vercel Workflow when the approval is one step in a longer durable workflow you already run. Pick Claude Managed Agents when you build on Anthropic’s API and want Anthropic to host the session. And if you’d rather have approvals, the agent loop, and the approval screen with nothing to host, Cadenya is a hosted agent loop with tool approval built in. What it offers is at the end.

Every price and limit below comes from each vendor’s own pages, checked on October 8, 2026.

Human-in-the-loop approval options side by side

How a tool call pausesHow a person answersWhat deny doesWhere the paused state lives
LangGraphinterrupt(), or HumanInTheLoopMiddleware with interrupt_onCommand(resume=...) on the same thread IDreject skips the tool and returns your message to the modelA checkpointer you run, such as Postgres or MongoDB
OpenAI Agents SDKneeds_approval on the toolstate.approve() or state.reject(), then Runner.run(agent, state)Standard rejection text, or your rejection_messageSerialized RunState in your database or queue
Vercel AI SDK 7toolApproval set to 'user-approval' (needsApproval is deprecated)A tool-approval-response in the messages, then a second call”The model receives the denial”, with an optional reasonThe message array you store
TemporalA workflow waits on condition(), set by a Signal or an UpdateA Signal or Update from a Temporal clientWhatever your workflow code doesTemporal’s event history, on Temporal Cloud or your cluster
Inngeststep.waitForEvent()Send a matching event, for example from a Slack buttonYour code reads the eventInngest’s step state
Pydantic AIrequires_approval=True on the tool, or raise ApprovalRequiredA DeferredToolResults passed to a new run with the message historyToolDenied, with an optional message to the modelThe message history you store
DBOSA workflow waits on DBOS.recv()DBOS.send() from your endpoint or the DBOS ClientWhatever your workflow code doesYour database, where DBOS checkpoints the workflow
Vercel WorkflowA workflow awaits a hook (createHook or defineHook)resumeHook(token, payload) from your API routeWhatever your workflow code doesVercel’s managed persistence, or a Postgres World you run
Claude Managed AgentsAn always_ask permission policyA user.tool_confirmation event with allow or denyThe tool doesn’t run. The agent gets your deny_messageAnthropic’s hosted session

LangGraph interrupt() for human-in-the-loop approvals

LangGraph pauses a run when a node or a tool calls interrupt(). “When an interrupt is triggered, LangGraph saves the graph state using its persistence layer,” and the value you pass to interrupt() reaches your app under __interrupt__, or on stream.interrupts when you use event streaming. It needs three things: “A checkpointer to persist the graph state,” “A thread ID in your config,” and the interrupt() call itself.

You can place the call “directly inside tool functions,” which “makes the tool itself pause for approval whenever it’s called.” Or you skip the hand-written interrupt and add LangChain’s HumanInTheLoopMiddleware to create_agent. Its interrupt_on setting maps each tool name to the decisions a reviewer may make: approve, edit, reject, or respond. The middleware raises the same interrupts, so it’s still the LangGraph interrupt human in the loop pattern underneath. A when predicate (it needs langchain>=1.3.3) pauses only the calls whose arguments match, so a SELECT runs and a DELETE waits.

Your app resumes the same thread with a Command:

agent.invoke(
    Command(
        resume={"decisions": [{"type": "approve"}]}  # or "reject"
    ),
    config=config, # Same thread ID to resume the paused conversation
    version="v2",
)

The resume value “becomes the return value of the interrupt call” inside the node. A reject tells the middleware to “skip executing this tool call entirely and return rejection feedback to the agent,” and its message explains “what it should do instead.” Don’t use respond to deny a tool with side effects, “because its message is treated as a successful tool result.”

Two rules matter in production. The checkpointer is yours to run: the docs name “a persistent checkpointer like AsyncPostgresSaver or MongoDBSaver,” and keep InMemorySaver for tests. And on resume, “the node restarts from the beginning of the node where the interrupt was called,” so “side effects called before interrupt should (ideally) be idempotent.” (Also, a bare try/except around interrupt() catches the exception that pauses the run, so the graph never sees the interrupt.)

OpenAI Agents SDK needs_approval and RunState

The OpenAI Agents SDK puts needs_approval on the tool. Set it “to True to always require approval or provide an async function that decides per call.” That function gets the run context, the parsed tool parameters, and the tool call ID. The option works on function_tool, Agent.as_tool, ShellTool, and ApplyPatchTool, and local MCP servers take require_approval.

@tool(needs_approval=True)
async def cancel_order(order_id: int) -> str:
    return f"Cancelled order {order_id}"

When a call needs a decision, the run stops and result.interruptions holds ToolApprovalItem entries, including approvals raised after a handoff or inside a nested Agent.as_tool(). Then you “convert the result to a RunState with result.to_state(), call state.approve(...) or state.reject(...), and then resume with Runner.run(agent, state).” You don’t have to answer every pending call at once: resolved calls continue, and the rest pause the run again. And always_approve=True keeps a decision for the rest of the run.

A rejection returns “the SDK’s standard rejection text” by default. A rejection_message on state.reject() overrides it for one call, and RunConfig.tool_error_formatter sets it for the whole run.

The paused run is a record in your own database. “RunState is designed to be durable”: you write it with state.to_json() to “a database or queue” and load it with RunState.from_json(). Those loaders “do not authenticate the snapshot or the person submitting it,” so the docs tell you to keep snapshots on the server, authenticate each reviewer, and stop a replayed decision from resuming the same snapshot twice. For approvals that stay open across releases, the docs also say to “store a version marker for your agent definitions or SDK alongside the serialized state.”

Tool approval: pausing a tool call before it runs

Every option here can stop a tool call before it executes. They differ in how much policy you can write. LangGraph and the Agents SDK are covered above.

Pydantic AI calls these deferred tools. Pass requires_approval=True on the tool, or raise ApprovalRequired from it when approval depends on the arguments. The run then ends with a DeferredToolRequests output, and you start a new run with the message history plus a DeferredToolResults that maps each tool call ID to an approval or a denial. (When the approver lives in the same process, a HandleDeferredToolCalls handler resolves the calls inline instead.)

The Vercel AI SDK moved this in version 7. “The older needsApproval property on tool() definitions is deprecated,” and new code sets toolApproval on generateText, streamText, or ToolLoopAgent. It works differently from the rest: “generateText and streamText don’t pause execution.” The call ends and returns tool-approval-request parts, so “the manual approval flow requires two calls to the model.” (One more catch from the same page: provider-executed tools ignore the approval setting.)

Temporal, Inngest, DBOS, and Vercel Workflow don’t know what a tool call is. You write the pause yourself: a Temporal workflow awaits a condition that a Signal flips, and an Inngest function awaits step.waitForEvent(). A DBOS workflow calls DBOS.recv(), your endpoint answers with DBOS.send(), and “all messages are persisted to the database.” A Vercel Workflow tool awaits a hook that uses the tool call ID as its token, and “no compute resources are consumed while waiting for the human to take action.” Claude Managed Agents sets a permission policy per toolset or per tool, and MCP toolsets “default to always_ask.”

Human approval wait: where the paused run lives

While a person decides, each option keeps the paused run somewhere, and that place decides what you run.

LangGraph keeps the paused thread in a checkpointer you run, such as Postgres or MongoDB. The OpenAI Agents SDK keeps it in the RunState you store. Claude Managed Agents keeps it in Anthropic’s hosted session. Temporal keeps it in the workflow’s event history, and its AI cookbook says that “while waiting, the agent consumes no compute resources.” Inngest keeps it in its step state.

The Vercel AI SDK holds nothing open while it waits. It keeps the request in the message array that you store, so the paused run is a record in your own database, as it is with the Agents SDK.

Who answers: API, Slack, or a UI

Every option gives you an API to call. None of the libraries sends the Slack message or draws the button for you. LangGraph resumes with a Command, and the Agents SDK resumes with Runner.run(agent, state), so your Slack handler calls one of those.

Inngest’s human-in-the-loop guide posts to Slack in one step and waits in the next. “When the human clicks a button (Slack, email, dashboard, etc.), send an event to Inngest so step.waitForEvent() resolves.” The Slack interaction handler is your code. With Temporal, the same button handler sends a Signal (fire and forget) or an Update, which “can wait for a response on completion or an error on failure.”

Claude Managed Agents takes a user.tool_confirmation event on the session, and you can “subscribe to webhooks to be notified when a session pauses for input.” The Vercel AI SDK fits a chat UI best, since the approval request arrives as a message part that the page can render.

DBOS lets a workflow publish its status with DBOS.set_event, so your page can list every agent that’s waiting for approval. Vercel Workflow’s guide renders approval buttons in the chat, and notes that “you could instead create a webhook and send the approval link over email or Slack to resume the agent.”

What happens on deny

A denial is more useful when it tells the model what to do instead. Most options let you send a reason.

LangChain’s reject and the Agents SDK’s rejection_message both carry a message to the model (see above). In Claude Managed Agents, “Denied tools do not run, and the agent receives a tool result saying the call was rejected, including your deny_message.” Pydantic AI’s ToolDenied takes “an optional custom message to provide to the model.” The Vercel AI SDK passes an optional reason with approved: false, and its docs suggest a system instruction like “When a tool execution is not approved, do not retry it.”

Temporal, Inngest, DBOS, and Vercel Workflow leave deny to you. A Signal, an Inngest event, a DBOS message, or a hook payload carries whatever you define.

Durable execution and human in the loop across deploys

A long approval can outlive a few deploys. Each option handles that differently.

LangGraph reruns the interrupted node from its start, and the Agents SDK asks for a version marker next to the stored state (both covered above). Temporal offers Pinned workflows, which “complete on a single Worker Deployment Version,” or Auto-Upgrade workflows that you keep replay-safe with patching. Inngest memoizes by step ID: “completed steps are never re-executed, even across deployments.”

For more on the durable engines, see Inngest vs Temporal for AI agents and Restate vs Temporal for AI agents. For hosted loops, see hosted agent runtimes in 2026 and Claude Managed Agents alternatives.

What does Cadenya offer that these options don’t?

The libraries pause a tool call and leave the storage, the Slack message, and the screen to you. Cadenya pauses the tool call, holds the agent loop, and hands the decision to your API, your Slack, or a ready-made widget, with nothing to host. Here’s what that covers:

  1. Approval is a setting, not code. Require approval on a tool set for every call, or use filters so only matching tools need it. A single tool takes requiresApproval too, so a read stays automatic and a refund waits.
  2. The loop parks before the tool runs. “Cadenya parks the Agent loop until it receives approval or a denial with optional steering.” Several tool calls can wait at once, and each has a toolcall_ ID you can list with the status TOOL_CALL_STATUS_WAITING_FOR_APPROVAL.
  3. No checkpointer, no run state to store. There’s no Postgres saver to run and no RunState snapshot to sign, store, and authenticate. The objective holds its own state on Cadenya, and your app stays a stateless web tier.
  4. A deny that steers. A denial carries a memo, and the docs say to “use a memo to steer the LLM to a different decision or usage of the tool” (“Use the read-only lookup tool instead”). The toolDenied event keeps the reviewer’s memo in the log.
  5. Slack through one signed webhook. Set one URL on the agent and Cadenya posts objective_event.tool_approval_requested with the objective ID and toolCallId. Your handler posts to Slack, and the button calls approveToolCall. Every delivery is signed per Standard Webhooks, and every attempt is recorded.
  6. The approval screen, already built. The React ConversationsPanel ships “the conversation list, message thread, composer, tool approvals, and live event stream.” For your own interface, @cadenya/widgets exposes the same tool approvals.
  7. Questions, not only yes or no. A Bare tool set is for “this type of human-in-the-loop workflow.” Your app gets a tool_called webhook, asks a person over Slack or email, and sets the answer with setContent. The agent continues to the next loop.
  8. Deploys never touch a waiting approval. The loop runs on Cadenya, not in your workers. And an objective keeps its variation’s configuration snapshot even if you edit the variation, so there’s no version marker to store and no old code to keep running.
  9. Any model, on your own keys. The model is a setting on a variation. Use any model on OpenRouter or your own endpoint that speaks the OpenAI chat format.
  10. SDKs and a price in one line. TypeScript, Python, Ruby, and Go SDKs are all at 1.9.0 under Apache-2.0, plus a CLI. Free covers 1,000 loops a month, and Growth is $49 for 50,000. A loop is one LLM request.

How objectives work shows the approve and deny calls, and the free plan needs no card.

Create an account at app.cadenya.com/signup, then copy your API key from the API keys page.

Grow wherever AI goes next.

Start shipping agents that are equipped to evolve.

A pine bonsai overlooking a mountain lake