How-to › Use AI to do the integration

How to require human approval before an agent calls a delete tool#

Pause the run when the model calls a delete tool, show a person the exact arguments, resume with their decision, and test that a rejection deletes nothing.

Audience
Agent builder
Level
intermediate
Topic
Guard agents and add approvals
Languages
TypeScript and Python
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Claude Agent SDK canUseTool and PreToolUse hookAgents on Claude Code’s harness, with its built-in tools and MCP serversHooks, deny rules, the mode, and allow rules can each settle a call before your callback runsYou want the approval flow to be your own code, end to end
LangGraph interrupt() with a checkpointerThe approver answers hours later, from another process or machineA checkpoint store to run, and the node re-runs from its first line on resumeOne process, with the approver at the keyboard
OpenAI Agents SDK needsApprovalAgents on that SDK, where the paused run must survive a restartSaved state holds the arguments and decisions, unsigned, so it must stay on your serverThe loop belongs to another framework
Vercel AI SDK toolApprovalChat interfaces where the approval travels in the message streamThe history is client input, so a forged approval runs without a signing secretThe 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.

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

  1. The option had another name first. The release notes for ai 7.0.0-beta.99 list a change that adds a toolNeedsApproval option, and the tool approvals guide for the released version configures it as toolApproval. The tool calling guide calls the older needsApproval property deprecated and says existing code still works, so one codebase can ask the same question under two names. ↩︎ Back to text

  2. 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

  3. The permissions guide describes bypassPermissions as 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 under sudo outside a recognized sandbox. The setting that approves everything draws its line at who is asking. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.