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.
A refund nobody checked
Section titled “A refund nobody checked”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.
The refund without a check
Section titled “The refund without a check”Run the program without an approval step.
python3 start.pyuser: '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.
Pause before the call that changes data
Section titled “Pause before the call that changes data”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.
An approved refund
Section titled “An approved refund”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))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.
A refusal is a tool result
Section titled “A refusal is a tool result”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.
Predict the refused run
Section titled “Predict the refused run”In the lesson, a support agent has two tools. lookup_order, called with the argument order_id, runs freely and returns {'ok': True, 'item': 'kettle', 'paid': '40.00 EUR'} for order A17. issue_refund needs approval, and when it runs it adds the pair (order id, amount) to a list called REFUNDS and returns {'ok': True, 'refunded': ...}. The question is Order A17 arrived broken, please refund 400.00 EUR. and the reviewer refuses with the reason the customer paid 40.00 EUR. The fake model looks the order up, then asks for issue_refund with the arguments order_id and amount, taking the amount from the question. Before a call that needs approval, the loop prints approve? and the tool name, then one line per argument with two spaces, the name, a space, =, a space and the value in Python notation, then allow? [y/n] and the typed answer, and after a no it prints reason: and the typed reason. A refusal makes the tool result {'ok': False, 'error': ...} with the text Refused by the reviewer:, a space and the reason, and the tool doesn't run. The fake model answers that result with I did not refund order A17., a space, the error text and a full stop. After the run the fixture prints user: and the question in Python notation, each assistant message as assistant: and the request dict in Python notation (a dict with the keys tool and args), each tool message as tool: and the result in Python notation, then stop: and the stop reason (end_turn when the model answered), answer: and the answer, and refunds: and the REFUNDS list.
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))approve? issue_refund
order_id = 'A17'
amount = '400.00 EUR'
allow? [y/n] n
reason: the customer paid 40.00 EUR
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': False, 'error': 'Refused by the reviewer: the customer paid 40.00 EUR'}
stop: end_turn
answer: I did not refund order A17. Refused by the reviewer: the customer paid 40.00 EUR.
refunds: []Output verified in CI from site/examples/building-agents/approving-a-tool-call/refused.py.
Does issue_refund run at all after the no, and what does the model read in the tool message?
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.
The reviewer says no
Section titled “The reviewer says no”An agent's loop asks a person before it runs a refund tool. The person refuses one refund because the amount is wrong.
The reviewer refuses the refund. What should the loop do next?
After the no, what does the model get to read, and does the run go on?
Which tools wait for a person
Section titled “Which tools wait for a person”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.
Which tools wait?
Section titled “Which tools wait?”A support agent has the six tools listed in the items. The harness asks a person before a tool runs only when that tool is marked as needing approval.
If the model calls this tool with wrong arguments, can you undo the result, and does anyone outside the agent see it?
Approval or not?
Section titled “Approval or not?”An agent has tools that read, tools that write only to its own scratch space, and tools that change shared data or reach other people. The harness can mark each tool as needing a person's approval before it runs.
Match each tool to whether it waits for approval.
What does one wrong call of this tool cost, and can it be taken back?
What the person sees
Section titled “What the person sees”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.
What goes on the approval prompt?
Section titled “What goes on the approval prompt?”An agent's loop pauses before a refund tool runs and shows a person a prompt to allow or refuse the call.
The refund tool waits for approval. What should the prompt show the reviewer?
Which of these shows the person exactly what will run if they say yes?
What does the reviewer need?
Section titled “What does the reviewer need?”An agent's loop pauses before a tool that sends an email and asks a person to allow or refuse the call.
The email tool waits for approval. Select the two things the prompt must show.
If the person says yes, what exactly is sent, and to whom?
The same step in the Agent SDK
Section titled “The same step in the Agent SDK”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
- 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.
- The person sees the tool name and every argument as the model sent it, because that is what will run.
- 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. - The Agent SDK does the same with a
canUseToolcallback 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
References
Section titled “References”- Anthropic. Handle approvals and user input. Claude Code documentation. Reference.
Claude Code agent sdk user input - Anthropic. Configure permissions (Agent SDK). Claude Code documentation. Reference.
Claude Code agent sdk permissions