Writing13 min readFahim
AI Agent Tool Calling: Permissions, Retries and Logs
How AI agent tool calling works in the OpenAI and Claude APIs, and how to scope permissions, retry without doing things twice and log every call.
- Build

AI agent tool calling is a request, not an action. You send the model a task and a list of tools described in JSON Schema. When it decides a tool would help, it replies with the tool's name and the arguments it wants. Your code runs the call (or refuses to), sends the result back, and the model carries on. The model never touches your systems.
That makes every safety question a question about your code. Which tools the model is offered, whose data a call can reach, what happens when an API times out halfway through a payment, and what record you keep: all of it lives in the piece of software that sits between the model and your systems. This post calls it the executor and covers the four things it has to get right: tool design, permissions, retries and logs. The API details are current as of October 2026.
How AI agent tool calling works
OpenAI's function calling guide describes the flow in five steps: send a request with the tools the model could call, receive a tool call, run code on your side with its input, send a second request with the tool output, and receive a final answer or more tool calls. Anthropic's tool use overview describes the same round trip for Claude. An agent is that round trip in a loop.

The two main APIs use different names for the same parts. If you build against both, or plan to switch, this is the mapping:
| Part | OpenAI Responses API | Claude Messages API |
|---|---|---|
| Tool definition | type: "function", name, description, parameters | name, description, input_schema |
| The model asks for a call | A function_call item with call_id and arguments (a JSON string) | stop_reason: "tool_use" and a tool_use block with id and input (an object) |
| You send the result | A function_call_output item with the same call_id and an output string | A tool_result block with the same tool_use_id, content and optional is_error |
| Force a call | tool_choice: "required" or a named function | tool_choice of any or a named tool |
| One call at a time | parallel_tool_calls: false | disable_parallel_tool_use: true |
| Arguments always match the schema | strict: true | strict: true |
Three details from the docs matter in production:
- The model can ask for several calls in one turn. Both APIs allow parallel calls by default. Your executor has to handle a list, and return every result: Anthropic's handling guide requires the
tool_resultblocks to come immediately after the turn that asked for them, before any other text, or the request fails. - Every result is matched by ID. Store the call ID with the log record. It is the thread that ties a model decision to the API request it caused.
- Tool results are untrusted input. Anthropic's docs warn that results often carry content from outside your control (web pages, inbound email, uploads) and that an attacker can plant instructions in it. The executor cannot assume the next call was the model's own idea.
Designing tools the model calls correctly
Most wrong tool calls come from vague tools, not from the model. Here is a write tool for a support agent, in Claude's format:
{
"name": "refund_order",
"description": "Refund all or part of one order for the current customer. Use only after lookup_order shows the order is eligible. Does not cancel the order or email the customer.",
"strict": true,
"input_schema": {
"type": "object",
"properties": {
"order_id": { "type": "string", "description": "Order number from lookup_order, e.g. A-4471" },
"amount": { "type": "number", "description": "Amount to refund in the order's currency" },
"reason": { "type": "string", "enum": ["damaged", "not_delivered", "wrong_item", "other"] }
},
"required": ["order_id", "amount", "reason"],
"additionalProperties": false
}
}
What each choice is doing:
- The description says when to use it and what it will not do. OpenAI's guide asks for "clear and detailed function names, parameter descriptions, and instructions". Saying what the tool leaves alone is the cheapest guardrail there is, because it shapes which tool the model picks.
- There is no
customer_idargument. OpenAI's advice is "Don't make the model fill arguments you already know". The customer comes from the session, so the model cannot point the refund at someone else. That is a permission decision as much as a design one. enumfor anything with a fixed set of values. It turns free text into a choice your code can branch on.strict: true. With strict mode, Claude's docs say the input "strictly follows theinput_schema" and the tool name is always valid. OpenAI's strict mode needsadditionalProperties: falseand every field listed as required, with optional fields typed as nullable. Strict mode guarantees the shape. It does not guarantee the amount is allowed; that check stays in code.
Keep the list short. OpenAI suggests aiming for "fewer than 20 functions available at the start of a turn" and combining functions that are always called in sequence. Anthropic's guide to writing tools for agents adds two habits we use: return readable fields (names, statuses) rather than internal IDs alone, and keep responses small with pagination and filters, since every returned token is read again on every later turn.
Whether these tools are plain functions in your code or exposed through an MCP server makes no difference to the rules above. We compared the two routes in what an MCP server is.
Scoping permissions: the model asks, your code decides
Treat every tool call as a request from an untrusted client. It usually reflects what the user wanted. Sometimes it reflects a sentence in a customer's email that was written to look like an instruction. The OWASP entry on excessive agency names the root causes as "excessive functionality; excessive permissions; excessive autonomy", and every fix it lists is enforced outside the model.
In practice that means five places where the executor can refuse, in order:

- Offered tools. Build the tool list per run. A triage run gets
lookup_orderanddraft_reply; it does not getrefund_orderat all. A tool that is not offered cannot be run: the executor rejects any name outside the run's list, and Claude's strict mode guarantees the name is valid in the first place. - Arguments bound from context. Customer, tenant and user IDs come from the authenticated session and are added by the executor. If the model supplies an ID, check it belongs to that customer before using it.
- One scoped credential per tool. The read tool holds a read-only key. The model never sees any key. OWASP's advice is to "execute extensions in user's context" and to implement authorisation in the downstream system rather than relying on the model, so a support agent working for one customer cannot read another's orders even by accident. Our TeamOS build applies the same idea to outside agents: people issue API keys for their own agents, scoped to read, tasks or full access.
- Policy limits in code. Amount caps, allowed order statuses, time windows, rate limits per run. A prompt that says "never refund more than the order total" is a hope. An executor that rejects it is a rule.
- Approval for anything irreversible. Sending, paying, deleting and changing prices wait for a person. The pattern is simple to state: the tool records a proposed action and returns "pending approval"; nothing happens until someone confirms. The uContents MCP server works this way: anything an outside agent wants to write is proposed first and has to be confirmed within fifteen minutes.
When a check refuses, say so in the result, clearly. Anthropic recommends instructive errors over a bare "failed", and a refusal is the one case where the instruction should be stop: "Refused: amount is above this agent's refund limit. Do not retry; tell the user."
Retries without doing things twice
A retry is only safe if repeating the call cannot repeat its effect. Before deciding what to retry, sort the failure into one of four kinds:

| Failure | Example | Who handles it | What to do |
|---|---|---|---|
| Transient | Timeout, HTTP 408, 429, 5xx | Your code | Retry with exponential backoff and jitter, capped |
| Unknown outcome | A payment request timed out after it was sent | Your code | Repeat with the same idempotency key, or look the result up first |
| Bad arguments | An order ID that does not exist | The model | Return the error with what to fix; cap the attempts |
| Refused | No permission, over a limit | Nobody | Return "do not retry" and hand the case to a person |
Transient errors are your code's job, not the model's. Google Cloud's retry guidance lists HTTP 408, 429 and 5xx responses, socket timeouts and TCP disconnects as signs of a transient problem, and recommends exponential backoff with jitter. Retry inside the executor, a small fixed number of times, and only then report the failure to the model. If the model sees every blip, it starts improvising: trying a different tool, or telling the customer something is broken when it isn't.
Writes need an idempotency key. The same Google guidance defines an idempotent request as one that can run repeatedly with the same end state, and warns that retrying non-idempotent operations can create duplicates. Payment and CRM APIs solve this with a key sent alongside the request. Stripe's documentation describes the behaviour: the server saves the status and body of the first request for a key and returns the same result for repeats, compares the parameters and errors if they differ, and may prune keys once they are at least 24 hours old.
The design question is what the key is made of. Do not use the model's call ID on its own: if the model asks for the same refund again in a later turn, that call has a new ID, and the second request goes through. Derive the key from the business action instead (the run, the tool and the order, for example) so that "refund order A-4471 in this run" can only happen once however many times it is asked for. If a run can legitimately make two refunds on one order, add what makes them different, such as a line item. And because keys expire, a job that resumes days later after an approval should check the order's state before it acts.
Bad arguments are the model's to fix, within a limit. Return the error as a tool result with is_error: true (Claude) or as the output string (OpenAI) and say exactly what was wrong. Anthropic's docs note that Claude retries an invalid call "2-3 times with corrections before apologizing to the user". Count those attempts in your loop anyway, and stop the run when they keep failing.
Retry in one layer only. If the HTTP client retries three times, the executor retries three times and the model tries again three times, one outage becomes 27 requests. Pick the executor, and turn off the others or keep them small.
A minimal executor, in code
This is a sketch in Python against the Claude Messages API, written to show where each rule above sits. It compiles and its executor logic was checked with stub tools, but the two tool bodies are placeholders and it has not been run against a live system. Treat it as a shape, not production code.
import json
import logging
import random
import time
import anthropic
client = anthropic.Anthropic()
MODEL = "claude-opus-5-5" # pin the version you tested with
log = logging.getLogger("tool_calls")
class Transient(Exception):
"""Timeout, HTTP 408, 429 or 5xx: worth another attempt."""
class Refused(Exception):
"""A permission or policy check said no. Never retried."""
def lookup_order(args, ctx, key):
# GET from your order API, filtered by ctx["customer_id"], not by the model.
raise NotImplementedError
def refund_order(args, ctx, key):
if args["amount"] > ctx["refund_limit"]:
raise Refused("Amount is above this agent's refund limit.")
# POST to your payment API with the header Idempotency-Key: key
raise NotImplementedError
HANDLERS = {"lookup_order": lookup_order, "refund_order": refund_order}
def execute(block, ctx):
"""Run one tool_use block; return the matching tool_result block."""
name, args = block.name, block.input
key = f"{ctx['run_id']}:{name}:{args.get('order_id', '')}"
record = {"run_id": ctx["run_id"], "call_id": block.id, "tool": name,
"args": args, "key": key, "attempts": 0}
started = time.monotonic()
try:
if name not in ctx["allowed_tools"]:
raise Refused(f"{name} is not allowed in this run.")
for attempt in range(1, 4):
record["attempts"] = attempt
try:
output = HANDLERS[name](args, ctx, key)
break
except Transient:
if attempt == 3:
raise
time.sleep(min(8, 2 ** attempt) * random.uniform(0.5, 1))
record["outcome"] = "ok"
result = {"content": json.dumps(output)}
except Refused as exc:
record["outcome"] = "refused"
result = {"content": f"Refused: {exc} Do not retry; tell the user.",
"is_error": True}
except Transient:
record["outcome"] = "failed"
result = {"content": "Service unavailable after 3 attempts. Stop and "
"hand this case to a person.", "is_error": True}
record["ms"] = round((time.monotonic() - started) * 1000)
log.info(json.dumps(record))
return {"type": "tool_result", "tool_use_id": block.id, **result}
def run(task, tools, ctx, max_turns=8):
messages = [{"role": "user", "content": task}]
for _ in range(max_turns):
response = client.messages.create(
model=MODEL, max_tokens=1024, tools=tools, messages=messages)
if response.stop_reason != "tool_use":
return response
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": [
execute(b, ctx) for b in response.content if b.type == "tool_use"]})
raise RuntimeError("Turn limit reached: hand the run to a person.")
What it does, top to bottom:
- Two exception types separate "try again" from "never try again". Your HTTP wrapper raises
Transientfor timeouts, 408, 429 and 5xx; policy checks raiseRefused. executehandles one call: checks the tool is allowed in this run, builds the idempotency key from the run, tool and order, retries transient failures three times with exponential backoff and jitter, and turns every outcome into atool_resultthe model can read. A refusal tells the model not to retry.- Every call writes one log record, whatever the outcome.
runis the loop: it returns all tool results for a turn in one message, as the API requires, and stops at a turn limit instead of looping forever.
What it leaves out on purpose: the approval queue (in production, refund_order would record a proposed refund and return "pending approval"), argument redaction in the log, and a cost budget per run. Those are covered in our guide to building an AI agent that takes actions.
Logging every tool call
When an agent does something odd, the tool-call log is how you find out why. Write one structured record per call, not per run, and make it something you can query.

| Field group | Fields | Why you need it |
|---|---|---|
| Who and what | Run ID, call ID, step number, tool name | Ties a model decision to the request it caused |
| Inputs | Arguments, with personal data redacted | Shows what the model actually asked for |
| Decision | Allowed, refused (and why), approved by whom | Shows which check stopped or passed it |
| Execution | Attempts, idempotency key, status, duration | Separates flaky APIs from bad calls |
| Versions | Model ID, instructions version, tool schema version | Tells you which rules were in force |
Three practical points:
- Redact at write time. Tool arguments and results contain customer data. Decide what you store and for how long before go-live; masking an email address in the logger is easy, cleaning a year of logs is not.
- Log refusals as carefully as successes. A spike in refused calls is often the first sign of a prompt-injection attempt or a tool description that misleads the model.
- Read the logs. A weekly sample of runs, with the failures turned into test cases, is how tool descriptions and limits get better. The model does not learn from its mistakes; the team does, from the log.
The tool-call log is one layer of a wider trace (model calls, guardrails, approvals). How the layers fit together is covered in AI agent architecture.
A checklist before an agent calls real tools
Before an agent with tool access touches production data, we would want a yes to each of these:
- Each run is offered only the tools its task needs, and IDs that decide whose data it touches come from the session.
- Every tool uses its own least-privilege credential, and the model never sees one.
- Tool definitions use
strict: true, enums for fixed values, and descriptions that say what the tool will not do. - Limits are enforced in the executor, and irreversible actions go to a person first.
- Transient failures are retried in one layer, with backoff, jitter and a cap.
- Every write carries an idempotency key derived from the business action.
- Errors returned to the model say what went wrong and whether to try again.
- One log record per call, with redaction, versions and the approver.
- The loop has a turn limit and a hand-back path to a person.
Most of this is ordinary integration engineering: credentials, error handling, idempotent writes against real APIs. That is the work behind our API and workflow integration projects, and the reason an agent built as part of an AI automation project spends more time on the executor than on the prompt. If you are wiring an agent into systems that hold money or customer data, start with the read-only tools and the log, and add write tools one at a time once the records show the agent gets them right.