Skip to content

Approving a risky tool call

In this lesson we give the agent a tool that changes data and make the loop stop before it runs. A person sees the call with its exact arguments and says yes or no. When the answer is no, the loop sends the refusal to the model as the tool’s result. The model can then tell the user what happened, and the run ends the normal way.

Agent risk asked where a person must approve. This lesson builds that approval step into the loop.

Every example runs against a fixture with a fake model, so you can predict the output and check yourself. The programs are in site/examples/building-agents/approving-a-tool-call/ in the repository. start.py has no approval step, and agent.py adds it. Both use the loop from When a tool fails, with its is_error flag and its max_errors policy, and two new tools for a support agent.

The agent has two tools. lookup_order reads an order, and issue_refund sends money back to the customer. In the fixture the refund adds a line to a list called REFUNDS, so you can see whether it ran.

ORDERS = {"A17": {"item": "kettle", "paid": "40.00 EUR"}}
REFUNDS = []
def lookup_order(order_id: str) -> dict:
order = ORDERS.get(order_id)
if order is None:
return {"ok": False, "error": f"no order {order_id}"}
return {"ok": True, "item": order["item"], "paid": order["paid"]}
def issue_refund(order_id: str, amount: str) -> dict:
REFUNDS.append((order_id, amount))
return {"ok": True, "refunded": amount}

The fake model looks the order up first. Then it asks for a refund of the amount that the customer’s message names, without comparing it with what the customer paid. A real model can make the same mistake, or a customer can type a wrong number. The question in this run asks for 400.00 euros (EUR) for a kettle priced at 40.00 EUR. The fixture prints the message list as in the last lesson, then the stop reason, the answer and the contents of REFUNDS.

Example · run it

Run the program without an approval step.

Terminal window
python3 start.py
Output
user: 'Order A17 arrived broken, please refund 400.00 EUR.'
assistant: {'tool': 'lookup_order', 'args': {'order_id': 'A17'}}
tool: {'ok': True, 'item': 'kettle', 'paid': '40.00 EUR'}
assistant: {'tool': 'issue_refund', 'args': {'order_id': 'A17', 'amount': '400.00 EUR'}}
tool: {'ok': True, 'refunded': '400.00 EUR'}
stop: end_turn
answer: Refunded 400.00 EUR for order A17.
refunds: [('A17', '400.00 EUR')]

Output verified in CI from site/examples/building-agents/approving-a-tool-call/start.py.

The order lookup shows that the customer paid 40.00 EUR, and the model refunded 400.00 EUR anyway. The loop did what it was built to do: it ran every call the model asked for. A wrong lookup doesn’t change anything, but this wrong refund sends the customer more money than they paid, and that money is hard to get back.

Tool approval puts a person between the model’s request and the tool. The harness decides which tools need it. Each tool entry gets a flag for that. lookup_order doesn’t change anything and runs freely. issue_refund moves money and waits.

TOOLS = {
"lookup_order": {"fn": lookup_order, "description": "...", "needs_approval": False},
"issue_refund": {"fn": issue_refund, "description": "...", "needs_approval": True},
}

The approval step shows the person the tool name and every argument exactly as the model sent it, one per line. Then it asks. Any answer other than y is a refusal, and a refusal asks for a reason. When the input ends before an answer, the step refuses with the reason no answer.

def ask_person(name, args, read=input) -> dict:
print(f"approve? {name}")
for key, value in args.items():
print(f" {key} = {value!r}")
try:
if read("allow? [y/n] ").strip() == "y":
return {"allow": True}
return {"allow": False, "reason": read("reason: ").strip()}
except EOFError:
return {"allow": False, "reason": "no answer"}

Before a flagged tool, the loop asks, and the tool runs only after a yes. After a no, the loop never calls the tool and builds an error result from the reason, in the same ok: False form that the last lesson used for a tool that failed.

tool = tools[reply["tool"]]
decision = {"allow": True}
if tool["needs_approval"]:
decision = approve(reply["tool"], reply["args"])
if decision["allow"]:
result = tool["fn"](**reply["args"])
else:
result = {"ok": False, "error": f"Refused by the reviewer: {decision['reason']}"}

read is input when a person is at the keyboard. The fixture steps pass a stand-in that gives a fixed answer and prints it after the prompt, the way a terminal shows what you typed. So continuous integration (CI) can run both answers without a person. The approval lines print while the loop runs, before the fixture prints the message list. Here a person approves a refund of the right amount.

Example · run it

Run the approved step with python3 agent.py approved. It asks for the right amount and answers y.

def reviewer(name, args):
return ask_person(name, args, read=typed("y"))
show(run("Order A17 arrived broken, please refund 40.00 EUR.", approve=reviewer))
Output
approve? issue_refund
  order_id = 'A17'
  amount = '40.00 EUR'
allow? [y/n] y
user: 'Order A17 arrived broken, please refund 40.00 EUR.'
assistant: {'tool': 'lookup_order', 'args': {'order_id': 'A17'}}
tool: {'ok': True, 'item': 'kettle', 'paid': '40.00 EUR'}
assistant: {'tool': 'issue_refund', 'args': {'order_id': 'A17', 'amount': '40.00 EUR'}}
tool: {'ok': True, 'refunded': '40.00 EUR'}
stop: end_turn
answer: Refunded 40.00 EUR for order A17.
refunds: [('A17', '40.00 EUR')]

Output verified in CI from site/examples/building-agents/approving-a-tool-call/approved.py.

The approval prompt appeared once, for the refund. The lookup ran without one, because its flag is False.

Now the customer asks for 400.00 EUR again, and the reviewer checks the amount against the order. The fake model reads a result with ok set to False the way it did in the last lesson. Its answer is I did not refund order A17. followed by a space, the error text and a full stop.

Checkpoint · predict

The refused step (python3 agent.py refused) answers n and gives a reason. What does it print?

def reviewer(name, args):
return ask_person(name, args, read=typed("n", "the customer paid 40.00 EUR"))
show(run("Order A17 arrived broken, please refund 400.00 EUR.", approve=reviewer))

Output verified in CI from site/examples/building-agents/approving-a-tool-call/refused.py.

REFUNDS is empty, so no money moved. The run still ended with end_turn, and the user heard why. A refusal that raised an exception would have ended the run with a stack trace, and a loop that stopped without a word would leave the user waiting. As a tool result, the refusal is one more fact the model can act on. A real model reading the customer paid 40.00 EUR could ask for the smaller refund on its next turn, and that call would come back to the reviewer too. A refusal is an error result, so it counts toward max_errors. If the reviewer refuses that second call as well, the run stops with too_many_errors after two refusals in a row. That is the policy from the last lesson doing its job: a model that keeps asking for something a person keeps refusing doesn’t get to ask forever.

Write the reason for the model. no tells it only that something was wrong. the order is over 90 days old, offer store credit tells it what to do instead.

An approval prompt costs the reviewer’s attention every time it appears. If every lookup asks, people learn to press y without reading, and then the prompt before the refund protects nothing. So sort the tools by what a wrong call can do. A tool that only reads, or that writes to a draft the agent keeps to itself, runs freely. A tool whose call is hard to undo, or that reaches a customer, a shared system or money, waits for a person. These are the first two of the questions that Agent risk used to choose where a person approves.

Checkpoint · sort

An approval is only as good as what the reviewer can check. A prompt that says approve? issue_refund asks the person to trust the model, because the amount isn’t on the screen. ask_person prints every argument as the model sent it, which is exactly what will run. A longer argument, such as the body of an email, belongs on the screen in full for the same reason.

Checkpoint · choice

The refund tool waits for approval. What should the prompt show the reviewer?

The Claude Agent software development kit (SDK) has this step built in. You pass a canUseTool callback (can_use_tool in Python), and the SDK calls it with the tool’s name and the input the model sent. The callback returns allow or deny. A deny carries a message, which goes to the model so it can plan its next step. An allow can also carry a changed input. The tool then runs with your version, and nothing tells the model about the edit [1].

The callback is the last step of the SDK’s checks. Before it, hooks run, then deny rules, ask rules, the permission mode and allow rules, and the callback hears only about a call that none of those settled [2]. So a check that you put only in the callback never sees a call that an allow rule or a mode such as bypassPermissions already approved. A check that must see every call belongs in a PreToolUse hook, because hooks run first, before the rules and the mode [2]. Your needs_approval flag is the simple form of the same idea: the harness settles most calls by rule, and only the rest wait for a person.

Exercise

Copy start.py from the repository. Add an approval step to its loop for issue_refund, the one tool that changes data. Show the person the tool name and its arguments, and when the person refuses, send the reason back to the model as the tool’s result. Then make the prompt easier to check: print what the customer paid for the order next to the amount the model asks for. Run your copy with python3 start.py and answer at the keyboard, once with y and once with n and a reason of your own. Fifteen minutes is enough. After this you can put a gate in any loop you build, so a wrong call stops at a person instead of reaching a customer.

A good result: the lookup never asks. The refund prompt shows the order, the amount asked for and the amount paid. After y the last line holds the refund, and after n it is refunds: [] and the answer holds your reason. Compare your loop with agent.py, or run python3 agent.py ask to try the lesson’s version. Which other fact would you want on the screen before you approve a refund, and where would the loop get it?

Stretch: Add a third tool, cancel_order, decide whether it needs approval, and add a branch to the fake model that calls it. Run it once and say why you set the flag the way you did.

Recap

  1. The harness marks the tools whose wrong call is hard to undo or reaches other people, and the loop asks a person before it runs one of those. Reads and private drafts run without asking.
  2. The person sees the tool name and every argument as the model sent it, because that is what will run.
  3. A refusal skips the tool and goes back to the model as the tool’s result, with a reason the model can act on, so the run ends the normal way. It counts as an error, so refusals in a row hit max_errors.
  4. The Agent SDK does the same with a canUseTool callback that returns allow or deny with a message [1], and it calls the callback only for calls that its hooks, rules and mode didn’t settle [2].

You can now

  • Decides which tools must wait for a person's approval and what that person is shown

  1. Anthropic. Handle approvals and user input. Claude Code documentation. Reference. Claude Code agent sdk user input
  2. Anthropic. Configure permissions (Agent SDK). Claude Code documentation. Reference. Claude Code agent sdk permissions