Your agent can delete tickets, and the model picks the ids. A spam sweep that also removes a customer’s real ticket is one wrong id away, and you find out from the customer. A line in the system prompt asking the model to be careful is advice it may ignore, so the delete runs the moment the model asks for it.
What you get
You will end up with a delete tool that stops the run, shows a person the exact arguments, and resumes with their decision, in four agent frameworks. This is for you if an agent of yours can remove data.
Short answer
Mark the delete tool as needing approval in your agent framework, so a call to it stops the run with a pending decision instead of executing. Show the approver the tool name and the exact arguments, store the paused state, and resume with approve or reject. Test the reject path as hard as the approve path, because an always-approve option or a matching allow rule skips the question entirely.
You will need
Node 22 or later, Python 3.10 or later, and an agent with a tool that deletes or overwrites data.
Verified 2026-10-15 against Node 22.22.0, @openai/agents 0.20.0, ai 7.0.142, Python 3.13.16,
LangGraph 1.2.15 and langgraph-checkpoint-sqlite 3.1.2. npm ci in the sample directory also
installs LangGraph under vendor/, so the Python commands carry PYTHONPATH=vendor. Every run
uses a scripted model stub in place of a provider, so no request leaves the machine. The Claude
Agent SDK needs a live model to run at all, so its configuration was checked against the type
definitions in version 0.3.301 and its documentation, and not run.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
Claude Agent SDK canUseTool and PreToolUse hook | Agents on Claude Code’s harness, with its built-in tools and MCP servers | Hooks, deny rules, the mode, and allow rules can each settle a call before your callback runs | You want the approval flow to be your own code, end to end |
LangGraph interrupt() with a checkpointer | The approver answers hours later, from another process or machine | A checkpoint store to run, and the node re-runs from its first line on resume | One process, with the approver at the keyboard |
OpenAI Agents SDK needsApproval | Agents on that SDK, where the paused run must survive a restart | Saved state holds the arguments and decisions, unsigned, so it must stay on your server | The loop belongs to another framework |
Vercel AI SDK toolApproval | Chat interfaces where the approval travels in the message stream | The history is client input, so a forged approval runs without a signing secret | The approver is not the person chatting |
The OpenAI Agents SDK and the Vercel AI SDK give you approval in one option, and in exchange the
flow is theirs: the state format, the decision shape, and the bypasses. LangGraph asks more of
you, a graph and a checkpointer, and keeps the paused run in a store any process can resume from.
The Claude Agent SDK layers several mechanisms, and the order they run in decides whether your
callback is ever reached.
Pause the run at the call
In the OpenAI Agents SDK, needsApproval on the tool turns a call into an interruption. The run
returns without executing it, and its state serializes to a string you can keep anywhere.
export function buildAgent(store) {
const deleteTicket = tool({
name: 'delete_ticket',
description: 'Delete a ticket by id. This cannot be undone.',
parameters: z.object({ id: z.string() }),
// true pauses every call; an async (runContext, input) => boolean decides per call.
needsApproval: true,
execute: ({ id }) => store.delete(id),
})
return new Agent({ name: 'Triage agent', instructions: 'Delete spam tickets.', tools: [deleteTicket], model: new ScriptedModel() })
}
// Run until the agent stops asking. Between runs the state goes through a string, as it would
// when the approver answers from another process, hours later.
export async function runWithApprovals(agent, decide, { always = false } = {}) {
const log = []
let result = await run(agent, TASK)
while (result.interruptions.length > 0) {
const saved = result.state.toString()
const state = await RunState.fromString(agent, saved)
for (const item of state.getInterruptions()) {
// A person answers later, so wait for the answer, and approve only on a literal true: a
// pending promise or the string 'no' is truthy, and either would delete without consent.
const approved = (await decide(item.name, JSON.parse(item.arguments))) === true
log.push(`${approved ? 'approved' : 'rejected'} ${item.name} ${item.arguments}`)
if (approved) state.approve(item, { alwaysApprove: always })
else state.reject(item, { message: 'A person rejected this delete. Do not retry it.' })
}
result = await run(agent, state)
}
return { log, final: result.finalOutput }
}
Each interruption carries the tool’s name and its arguments as the model wrote them, which is
what the approver sees. The loop awaits the answer and approves only on true, because an
approver that asks a person returns a promise, and a test of truthiness would approve the promise.
reject takes a message, the text the model reads in place of a result. Tell it not to retry,
or a model that takes rejection as an error will ask again. The
Python SDK has the same flow
in openai-agents 0.24.0, read in its source and not run: needs_approval, approve, reject,
to_string and RunState.from_string.
The Vercel AI SDK does not pause at all. generateText returns tool-approval-request parts,
and the decision goes back in on the next call as a tool-approval-response, so the messages
array is the state.
export async function runWithApprovals(store, decide, { approval = 'user-approval' } = {}) {
const messages = [{ role: 'user', content: TASK }]
const log = []
for (let turn = 0; turn < 5; turn++) {
const result = await generateText({
model: scriptedModel(),
tools: buildTools(store),
toolApproval: { delete_ticket: approval },
messages,
stopWhen: stepCountIs(5),
})
messages.push(...result.responseMessages)
const requests = result.content.filter((p) => p.type === 'tool-approval-request' && !p.isAutomatic)
if (requests.length === 0) return { log, final: result.text, messages }
const responses = []
for (const r of requests) {
// Wait for the person, and approve only on a literal true, as in the OpenAI loop.
const approved = (await decide(r.toolCall.toolName, r.toolCall.input)) === true
log.push(`${approved ? 'approved' : 'rejected'} ${r.toolCall.toolName} ${JSON.stringify(r.toolCall.input)}`)
responses.push({ type: 'tool-approval-response', approvalId: r.approvalId, approved, reason: approved ? undefined : 'A person rejected this delete.' })
}
messages.push({ role: 'tool', content: responses })
AI SDK 6 put this on the tool as needsApproval. Version 7 deprecates that property in favor of
toolApproval on the call, and the earlier property still works.1 A denial reaches the model as an
execution-denied result carrying your reason.
The scripted model asks to delete t3, the spam, then t1, a real ticket. One approver says
yes to t3 and no to anything else.
node demo.mjs
OpenAI Agents SDK, needsApproval: true
asked: delete_ticket {"id":"t3"}, tickets now t1 t3, approved
asked: delete_ticket {"id":"t1"}, tickets now t1, rejected
deleted: t3, left: t1
Vercel AI SDK, toolApproval: 'user-approval'
asked: delete_ticket {"id":"t3"}, tickets now t1 t3, approved
asked: delete_ticket {"id":"t1"}, tickets now t1, rejected
deleted: t3, left: t1
Both tickets were still there when the first question was asked. That column is the evidence: the delete waited for the person.
Survive a restart with LangGraph
LangGraph’s interrupt() stops a node and writes the run to the checkpointer. A later
Command(resume=...) on the same thread id hands the decision back as the return value of
interrupt().
def delete_ticket(state):
# This line runs twice: before the pause, and again when the run resumes. Anything here
# with a side effect happens twice, so the demo counts it instead of doing harm.
with api:
api.execute("INSERT INTO entries VALUES ('delete_ticket')")
args = {"id": state["ticket_id"]}
decision = interrupt({"tool": "delete_ticket", "args": args})
if not isinstance(decision, dict) or decision.get("approved") is not True:
reason = decision.get("reason", "no reason given") if isinstance(decision, dict) else "no decision"
return {"outcome": f"rejected: {reason}"}
# The node reads the state again on resume, so the approval must name what it approves.
if decision.get("args") != args:
return {"outcome": f"rejected: the approval names {decision.get('args')}, not {args}"}
with api:
api.execute("DELETE FROM tickets WHERE id = ?", (args["id"],))
return {"outcome": f"deleted {args['id']}"}
Only a literal True beside the arguments the person saw approves. The node reads the state
again when it resumes, so a bare {"approved": true} would cover whatever the state held by then,
even after an edit to the thread swapped t3 for t1. A resume value of "yes", 1, a bare
True, or an approval naming other arguments rejects, so a sloppy client fails closed. The script runs each step as a separate
Python process, with a SQLite checkpointer
as the only thing they share.
PYTHONPATH=vendor python3 approval_graph.py
process 1, thread a
waiting for a person: {"tool": "delete_ticket", "args": {"id": "t3"}}
tickets after process 1 exited: t1 t3
process 2, thread a
resumed with {"approved": false, "reason": "keep it for the audit"}: rejected: keep it for the audit
tickets: t1 t3
process 3, thread b
waiting for a person: {"tool": "delete_ticket", "args": {"id": "t3"}}
process 4, thread b
resumed with {"approved": true, "args": {"id": "t3"}}: deleted t3
tickets: t1
delete_ticket node entered 4 times for 2 decisions
The process that asked had exited before anyone answered. The last line is the cost: the node
ran from its first line again on every resume, as the
interrupts guide warns.2 Keep
every side effect after interrupt().
Gate the delete with a hook or a callback
The Claude Agent SDK gives you two places to ask. canUseTool is the callback, and it runs last.
A PreToolUse hook runs first, and the
permissions guide says hooks still
execute and can block a tool in bypassPermissions mode, which approves everything that reaches
the mode check.
import type { HookCallback, Options } from '@anthropic-ai/claude-agent-sdk'
// askApprover(toolName, input) is yours: it shows a person the call and resolves to their answer.
const DELETE = 'mcp__tickets__delete_ticket'
// The callback: asked only when no hook, rule or permission mode has decided first.
export const withCallback: Options = {
canUseTool: async (toolName, input) => {
if (toolName !== DELETE) return { behavior: 'allow', updatedInput: input }
return (await askApprover(toolName, input))
? { behavior: 'allow', updatedInput: input }
: { behavior: 'deny', message: 'A person rejected this delete. Do not retry it.' }
},
}
// The hook: runs before every rule and mode, and its deny holds in bypassPermissions.
const gate: HookCallback = async (input) => {
if (input.hook_event_name !== 'PreToolUse') return {}
if (await askApprover(input.tool_name, input.tool_input)) return {}
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: 'A person rejected this delete.',
},
}
}
export const withHook: Options = { hooks: { PreToolUse: [{ matcher: DELETE, hooks: [gate] }] } }
That code type-checks against the package and was not run. An entry for the tool in
allowedTools, or bypassPermissions mode, resolves the call before canUseTool is consulted.
Use the hook when anything else in the configuration could approve first.3 For an answer that
outlives the process, the user input guide
describes a hook returning defer, so the session can resume later.
Check it worked
Nine TypeScript tests pin what the demo showed. Three more cases ride along: a run restored from its string, an approver that answers later, and a forged approval tried with the signing secret set.
node --test approval.test.mjs
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 673.803504
Seven Python tests pin the graph, including that an approval covers only the arguments it names and that a resume sent to a thread that never started approves nothing.
PYTHONPATH=vendor python3 test_approval_graph.py
ok nothing_is_deleted_while_the_run_waits
ok a_rejection_keeps_the_ticket
ok only_a_literal_true_approves
ok an_approval_covers_only_the_arguments_it_names
ok a_resume_sent_to_an_unknown_thread_approves_nothing
ok a_finished_thread_ignores_a_second_resume
ok the_node_reruns_from_its_first_line_on_resume
When it goes wrong
One approval deleted two tickets. Each line below runs the same approver, which would have
refused t1.
node pitfall.mjs
approve t3 with alwaysApprove asked about: t3, left: none
toolApproval set to 'approved' asked about: nothing, left: none
forged approval, no approval secret asked about: nothing, left: t3
forged approval, with approval secret asked about: nothing, left: t1 t3, threw AI_InvalidToolApprovalSignatureError
alwaysApprove approves the tool for the rest of the run, whatever the arguments. The
'approved' status records an automatic yes, as an allow rule does in the Claude Agent SDK.
Use neither on a delete.
A client that writes its own history can add an approved delete the model never requested.
Set experimental_toolApprovalSecret so the server signs each request and refuses a response
it did not issue. The OpenAI Agents SDK’s
human-in-the-loop guide says
the same of RunState.from_string: it does not authenticate the snapshot, so keep saved state on
the server.
When not to do this
Do not put approval on every tool. An approver asked about every read learns to click yes, and the one delete that mattered goes through on habit. Gate the calls that remove or overwrite data.
Do not treat an approval flow as the authorization check. The API should refuse a delete the caller may not make, whatever a person clicked.
Do not reach for LangGraph and a checkpointer for an approver who answers within seconds in the
same process. The SDK option costs one line and no store to run.
Do not ship a gate whose deny path has never run. A test that only approves cannot tell a working gate from one that a rule or a mode is skipping.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0, @openai/agents 0.20.0, ai 7.0.142, Zod 4.6.7, Python 3.13.16 and 3.12.3, LangGraph 1.2.15 and langgraph-checkpoint-sqlite 3.1.2. Every output block is what the command preceding it printed, with a scripted model in place of a provider. The Claude Agent SDK configuration was type-checked against @anthropic-ai/claude-agent-sdk 0.3.301 with TypeScript 5.9.3 and checked against its documentation; it was not run.
Footnotes
-
The option had another name first. The release notes for ai 7.0.0-beta.99 list a change that adds a
toolNeedsApprovaloption, and the tool approvals guide for the released version configures it astoolApproval. The tool calling guide calls the olderneedsApprovalproperty deprecated and says existing code still works, so one codebase can ask the same question under two names. ↩︎ Back to text -
The interrupts guide goes on to warn against a loop of
interrupt()calls inside one node. The first resume replays one iteration, the second replays two, and so on, which it calls exponential re-execution. The total after n resumes is n(n+1)/2, which grows with the square of n. The warning is sound, and the adjective is generous. ↩︎ Back to text -
The permissions guide describes
bypassPermissionsas approving tool uses without prompting and asks for extreme caution. It also records what the mode refuses outright: on Linux and macOS, Claude Code will not start in it as root or undersudooutside a recognized sandbox. The setting that approves everything draws its line at who is asking. ↩︎ Back to text