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

Source: https://voxgig.com/howto/require-approval-before-a-delete-tool-call

- Audience: agent-builder
- Level: intermediate
- Languages: typescript, python
- Verified: 2026-10-15
- Published: 2026-10-15

## 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](https://code.claude.com/docs/en/agent-sdk/permissions) | 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`](https://docs.langchain.com/oss/python/langgraph/interrupts) | 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`](https://openai.github.io/openai-agents-js/guides/human-in-the-loop/) | 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`](https://ai-sdk.dev/docs/agents/tool-approvals) | 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.

```js title="openai-agents.mjs"
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](https://openai.github.io/openai-agents-python/human_in_the_loop/) 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.

```js title="vercel.mjs"
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](https://vercel.com/blog/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.

```bash
node demo.mjs
```

```text output
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()`.

```python title="approval_graph.py"
    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`](https://pypi.org/project/langgraph-checkpoint-sqlite/)
as the only thing they share.

```bash
PYTHONPATH=vendor python3 approval_graph.py
```

```text output
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](https://docs.langchain.com/oss/python/langgraph/interrupts) 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](https://code.claude.com/docs/en/agent-sdk/permissions) says hooks still
execute and can block a tool in `bypassPermissions` mode, which approves everything that reaches
the mode check.

```ts
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](https://code.claude.com/docs/en/agent-sdk/user-input)
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.

```bash
node --test approval.test.mjs
```

```text output
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.

```bash
PYTHONPATH=vendor python3 test_approval_graph.py
```

```text output
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`.

```bash
node pitfall.mjs
```

```text output
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](https://openai.github.io/openai-agents-python/human_in_the_loop/) 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

- [Wire an API client into the OpenAI Agents SDK](/howto/wire-an-api-client-into-openai-agents-sdk)

- [Sanitize tool results before they re-enter the prompt](/howto/sanitise-tool-results-before-reprompting)

- [Sandbox an agent's shell and code execution](/howto/sandbox-agent-shell-and-code-execution)

- [Add a dry-run mode to an agent's write tools](/howto/add-dry-run-to-agent-write-tools)

- [Build a stateful API agent with LangGraph](/howto/stateful-api-agent-with-langgraph)

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

[^1]: The option had another name first. The release notes for
[ai 7.0.0-beta.99](https://github.com/vercel/ai/releases/tag/ai%407.0.0-beta.99) list a change
that adds a `toolNeedsApproval` option, and the
[tool approvals guide](https://ai-sdk.dev/docs/agents/tool-approvals) for the released version
configures it as `toolApproval`. The
[tool calling guide](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling) calls the older
`needsApproval` property deprecated and says existing code still works, so one codebase can ask
the same question under two names.

[^2]: The [interrupts guide](https://docs.langchain.com/oss/python/langgraph/interrupts) 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.

[^3]: The [permissions guide](https://code.claude.com/docs/en/agent-sdk/permissions) 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.