AI Blog

LangGraph vs OpenAI Agents SDK vs AI SDK vs Temporal: where the pause lives

All four pause an agent before a tool call and wait for a human, and they differ on one axis that decides everything downstream: where the pending approval is stored and what re-executes on resume. LangGraph re-runs the whole node from its first line; Temporal continues at the next statement; the two SDKs sit in between and hand you the persistence problem.

By Agentic AI Wiki 15 min read

All four of these pause an agent before a risky tool call and wait for a human, and the APIs look close enough that teams pick on taste. They should pick on one axis: where the pending approval is stored, and what re-executes when it resumes. LangGraph's documented behaviour is to re-enter the node at its first line — so a write that happened before the pause happens twice. Temporal continues at the statement after the wait. The two SDKs sit between them and hand you the persistence problem. Choose wrong and the bug you ship is a duplicate side effect you will not see in a test, because in a test nobody takes nine minutes to click approve.

At a glance

One sentence orients the whole table: an approval is a durable wait, and a durable wait needs a place to live. These four pick four different places.

FrameworkWhere the pause livesWhat resume re-executesWho owns the clock
LangGraph interrupt() A checkpointer you run, keyed by thread ID The whole node, from its first line You — nothing built in
OpenAI Agents SDK needs_approval A serialisable run state you persist yourself The paused tool call You — nothing built in
Vercel AI SDK tool approval The UI message history, in the browser The tool call, on the next request The user's tab
Temporal signal + wait condition The cluster's workflow history Nothing — the next statement runs The engine, via a durable timer
Where a pending approval is stored, by framework Four columns: LangGraph stores the pause in a checkpointer you run, keyed by thread id; the OpenAI Agents SDK hands you a serialisable run state to persist yourself; the Vercel AI SDK keeps it in the UI message history in the browser; and Temporal keeps it in the cluster's workflow history behind a durable timer. The pause has to live somewhere, and that is the whole choice LangGraph A checkpointer you run, keyed by thread id Survives a restart Resume re-runs the whole node Agents SDK A serialisable run state you persist yourself Survives if you store it Resume continues at the tool call AI SDK The UI message history, in the browser Dies with the tab Resume is just the next request Temporal The cluster's workflow history Survives deployments Resume continues at the next line Durability rises left to right; so does what you have to run. Nobody gives you a default for silence.
Durability rises left to right, and so does what you have to operate.
Approval mechanics compared across four axes Feature matrix with four frameworks as rows and four axes as columns: durability of the pause, precision of resume, built-in timeout handling, and whether the approval channel is decoupled from the UI. Temporal is strong on durability, resume precision and timeouts; LangGraph is strong on durability and channel decoupling but weak on resume precision; the Agents SDK is medium throughout; the Vercel AI SDK is weak on durability and channel decoupling. Four axes, and nobody wins all four pause survives a restart resume is exact timeout is built in channel is decoupled LangGraph Strong Weak (node replay) Weak Strong Agents SDK Medium (you store it) Medium (per call) Weak Medium AI SDK Weak (the tab) Medium (per call) Weak Weak (the chat UI) Temporal Strong Strong Strong (durable timer) Strong (signals) Weak Medium Strong "Built in" means the framework owns the clock. None of the four decides what silence means — that branch is always yours.
Where each one leans hardest. The timeout column is weak almost everywhere, and that is not an accident.

LangGraph: durable, graph-native, and it replays the node

What re-executes when an approval resumes Two lanes compared. In the whole-node replay model, resuming re-runs the node from its first line, so a write that happened before the pause happens a second time. In the exactly-resumed model, execution continues at the statement after the wait and the earlier write is not repeated. Resume is not the same operation in both models Whole-node replay resume re-enters the node at its first line write_audit() (side effect) pause for human do_the_thing() on resume, write_audit() runs a second time Exactly resumed execution continues at the statement after the wait write_audit() (side effect) await decision (durable timer) do_the_thing() The history remembers that write_audit() already returned, so it is not re-run. The cost is a cluster and a programming model that forbids non-determinism.
The dashed arrow is the part that costs money. It is documented behaviour, not a bug.

How it works

Calling interrupt() inside a node stops the graph and surfaces a JSON-serialisable payload to the caller. It requires a checkpointer and a thread ID in the config — without persistence there is nothing to resume from. You resume by invoking the graph again with Command(resume=value), and that value becomes the return value of the interrupt() call. Because the pause is a checkpoint row, the approval can arrive from anywhere that can reach your database: a Slack action, an email link, a back-office queue, a different process entirely.

The replay semantics, which are the thing to internalise

The documentation is explicit that execution starts at the beginning of the node on resume. It does not jump back into the middle of the function at the line after interrupt() — it re-runs the node from its first statement, with the interrupt call now returning the human's value instead of pausing. Everything above that line therefore executes a second time. A node that logs an audit row, charges a card, posts a message or increments a counter before pausing does all of it twice.

The guidance that follows from this is to put irreversible actions after the interrupt, or make them idempotent — upserts rather than inserts — or to wrap externally visible operations in a persisted task so the stored result is returned on resume instead of the operation re-running. All three are correct and all three are conventions rather than guarantees, which means the rule lives in code review. There is also a reported sharp edge for multi-interrupt nodes: a node containing two interrupts can re-run after only one of them is resumed. Time-travel replay has the same property by design — rewinding to a checkpoint re-executes the nodes after it, model calls and API requests included.

Where it is the right answer

When the agent is already a graph, the approval is one node among many, and you want the decision to be deliverable from outside the request that started the run. That last property is genuinely valuable and the two SDKs below make it harder.

OpenAI Agents SDK: the cleanest model, and you own the storage

How it works

A tool declares needs_approval — a boolean, or a function that receives the run context, the arguments and the call ID, so the gate can depend on the amount being transferred rather than on the tool's identity. When it fires, the run stops and the pending calls appear as approval items in the result's interruptions. You call approve or reject on the run state and hand the state back to the runner. Partial approval works: resolve one item, leave another pending, and the run continues as far as it can.

The argument-aware gate is the best thing in this comparison. "Approve transfer when the amount exceeds a threshold" is a policy; "approve transfer" is a tool name. The others can reach the same place, but only this one puts the predicate where the tool is defined.

The catch

Persistence is yours. The run state is serialisable — it converts to JSON and back — and nothing stores it for you. That is a defensible design, and it means every production deployment writes the same three things: a table of pending approvals, a writer that saves state at the pause, and a reaper that decides what happens to a row nobody touched. If you skip the third, your answer to silence is "the row sits there forever", which is a policy you chose by not choosing.

Treat the serialised state as a resumable capability and store it accordingly. A blob that will re-issue a tool call when replayed is not a log line; anyone who can write to that table can change what gets executed on resume.

Vercel AI SDK: the pause is a message, and the tab is the database

How it works

Mark the tool; when the model calls it, the call surfaces in the UI message stream as a tool part in an approval-requested state, and the client renders approve and deny buttons and responds through the chat hook's approval function. In AI SDK 6 the flag is needsApproval on the tool. In AI SDK 7 it moved to a toolApproval setting on generateText, streamText and ToolLoopAgent, taking 'approved', 'denied' or 'user-approval'; the old per-tool flag is deprecated but still honoured, and the migration guide maps a boolean needsApproval: true onto toolApproval: { myTool: 'user-approval' }.

This is by far the fastest of the four to ship, and for a chat product where a human is already watching the stream, it is the right shape. The approval round trip is just the next request.

Three sharp edges

First, the pause is not durable in any useful sense: it lives in the message history the client holds. Close the tab and the pending approval is gone, which is fine for "shall I send this email?" and not fine for anything a reviewer was supposed to look at tomorrow.

Second, the resume has to be wired. The documented pattern pairs the approval response with an automatic send condition so the conversation continues once every approval is resolved; without it there are reports of a parallel auto-executing tool leaving an orphaned tool-use block and the provider rejecting the next message outright. That is the sort of bug that appears only when one tool needs approval and another does not.

Third, and this is the one worth a design-review veto: with a function-form toolApproval, returning undefined for a tool the function does not recognise runs that tool without approval. A function written as a switch over known tools therefore fails open the moment someone adds a tool and forgets the case. If you use the function form, return an explicit 'user-approval' for anything you do not recognise — the general version of this rule is that a gate must fail closed, and a per-tool opt-in is a gate whose default is "no gate".

Temporal: the only one where waiting is the native operation

How it works

The approval pattern is a blocking wait with a timeout — workflow.wait_condition() in Python, condition() in TypeScript, Workflow.await() in Java, AwaitWithTimeout() in Go — and the human's decision arrives as a signal that sets the flag the condition is waiting on. If the timer expires first, the workflow unblocks on the timeout branch. The durable timer is persisted in the server's database and survives worker restarts, deployments and infrastructure moves, so a nine-day approval is not architecturally different from a nine-second one.

Because the event history is deterministic and single-threaded, a signal and a timer cannot actually race: a late signal arrives after the timeout has already returned, and the ordering is reproducible rather than lucky. Use an update instead of a signal when the decision can be rejected by business rules and the person should learn that in the same click; use the updatable-timer pattern when the deadline itself moves.

The cost, stated honestly

You are running a cluster and writing workflow code under determinism constraints — no clock reads, no random values, no direct I/O outside activities. For a team that already runs Temporal this is free; for a team that does not, adopting it to get human approvals is a large bill for one feature, and the honest comparison is against a checkpointer plus a reaper, not against nothing.

What you buy is the one property the other three cannot give you cheaply: resume is exact. No node replays, no idempotency convention to maintain, no second audit row. If your approvals gate operations that are expensive or irreversible — and that is usually why they exist — that property is the feature.

Cross-cutting: three things none of them solve

Silence has no default anywhere

Not one of the four decides what happens when nobody answers. Temporal gives you a durable timer and makes you write the branch; the other three do not even give you the timer. This matters more than the API ergonomics, because an unanswered approval is the common case in production, not the edge case: reviewers go on holiday, Slack channels get archived, the on-call rotation changes. Decide explicitly whether silence means reject, escalate, or proceed-with-reduced-scope, and write that branch before you ship the happy path. An approval queue with no expiry policy becomes a list nobody reads, and then the review queue is the bottleneck rather than the control.

The approval binds to arguments rendered at pause time

All four show the human the tool call as it stood when the agent proposed it, and all four then execute a call on resume. Nothing in the default path re-verifies that the arguments are still the ones that were shown, and nothing re-checks that the state the human reasoned about still holds. For a transfer of a fixed amount this is unremarkable; for "delete the resources matching this filter" it is a time-of-check-to-time-of-use gap with a human as the check. If the gap can matter, include a precondition in the approved payload and verify it inside the tool.

Related: only the Vercel ecosystem has an open request for editing the input at approval time; everywhere else, approve-or-deny with an optional reason is the whole vocabulary. A reviewer who wants to change one field has to deny and wait for the agent to propose again, which is why plan editing keeps getting rebuilt above these primitives.

Idempotency is the tax you pay for cheap resume

Rank the four by how much idempotency discipline they demand and you get exactly the inverse of their infrastructure cost. Temporal demands almost none. The SDKs demand some, at the boundary of whatever loop you drive. LangGraph demands the most: every node that can interrupt is a function that may run its prefix twice, forever, and the enforcement mechanism is a human reading a diff. If you choose it, make the convention mechanical — a lint rule, a wrapper, or a hard rule that nodes containing interrupt() do nothing above that line — because idempotency by good intentions does not survive a team growing.

When to pick which

SituationPickBecause
Chat product, human watching the stream, reversible actionsAI SDK tool approvalThe pause only has to outlive a few seconds, and the UI is already there
Agent is already a LangGraph graph, approvals arrive from SlackLangGraph interrupt()Durable, decoupled channel — pay for it with a node-replay rule
Policy depends on the arguments, not the toolAgents SDK needs_approvalThe predicate belongs next to the tool definition
Irreversible or expensive actions, approvals measured in daysTemporalExact resume and a durable timer are the actual requirements
You already run a durable-execution engineThat engineA second pause mechanism is a second place state can be lost

The one combination to avoid is a durable backend with an ephemeral approval. If the pending decision lives in a browser tab while the work it gates is a multi-day pipeline, you have built a system whose control plane is less reliable than the thing it controls — and that asymmetry is where blast radius stops being theoretical.

FAQ

Does LangGraph really run my code twice around an interrupt?

It re-enters the node at its first line on resume, so everything above the interrupt() call executes again, with the interrupt now returning the human's value. The fix is to keep side effects below that line, make them idempotent, or wrap them so a stored result is returned instead of re-running.

Can I use the OpenAI Agents SDK for approvals that take days?

Yes, but you are building the persistence. Serialise the run state at the pause, store it with the pending approval, and write the expiry job yourself — the SDK gives you a resumable state object and no opinion about where it goes.

Is the Vercel AI SDK's approval safe to rely on for destructive tools?

Only with the function form returning an explicit approval requirement for unrecognised tools, since returning nothing runs the tool ungated. Even then the pause is held in the browser's message history, so a destructive action whose review may outlive the session needs a server-side queue instead.

Why does Temporal keep coming up for human-in-the-loop?

Because a human approval is a durable wait, and a durable wait is the primitive a workflow engine already implements. The timer is persisted server-side and the resume continues at the next statement, so you get no replay and no idempotency homework.

What should happen when nobody approves?

Whatever you decide — none of the four has a default. Write the timeout branch explicitly as reject, escalate, or proceed with reduced scope, and treat an approval queue with no expiry as an outage waiting for a holiday.

Further reading

On this wiki:

Project sources: