Skip to content
NewClientOS is live — the self-hosted client portal for agencies.
writing/ai agent tool calling

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
Architecture sketch of a tool call: the model proposes refund_order, an executor checks permission and limits, retries safely and logs, then calls the order API and payments

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.

Sequence diagram of one tool call: your code sends the task and tool definitions to the model, the model returns a lookup_order call, your code checks it, sends a real request to the order API, gets the response and returns the result to the model, which answers or calls again

The two main APIs use different names for the same parts. If you build against both, or plan to switch, this is the mapping:

PartOpenAI Responses APIClaude Messages API
Tool definitiontype: "function", name, description, parametersname, description, input_schema
The model asks for a callA 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 resultA function_call_output item with the same call_id and an output stringA tool_result block with the same tool_use_id, content and optional is_error
Force a calltool_choice: "required" or a named functiontool_choice of any or a named tool
One call at a timeparallel_tool_calls: falsedisable_parallel_tool_use: true
Arguments always match the schemastrict: truestrict: 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_result blocks 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:

  1. 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.
  2. There is no customer_id argument. 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.
  3. enum for anything with a fixed set of values. It turns free text into a choice your code can branch on.
  4. strict: true. With strict mode, Claude's docs say the input "strictly follows the input_schema" and the tool name is always valid. OpenAI's strict mode needs additionalProperties: false and 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:

Five permission layers for a tool call: offered tools, arguments bound from context, one scoped key per tool, limits in code, and human approval for irreversible actions, each with the failure it stops
  1. Offered tools. Build the tool list per run. A triage run gets lookup_order and draft_reply; it does not get refund_order at 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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:

Four kinds of tool-call failure: transient errors your code retries with backoff, unknown outcomes your code checks with the same key, bad arguments the model corrects, and refusals nobody retries
FailureExampleWho handles itWhat to do
TransientTimeout, HTTP 408, 429, 5xxYour codeRetry with exponential backoff and jitter, capped
Unknown outcomeA payment request timed out after it was sentYour codeRepeat with the same idempotency key, or look the result up first
Bad argumentsAn order ID that does not existThe modelReturn the error with what to fix; cap the attempts
RefusedNo permission, over a limitNobodyReturn "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 Transient for timeouts, 408, 429 and 5xx; policy checks raise Refused.
  • execute handles 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 a tool_result the model can read. A refusal tells the model not to retry.
  • Every call writes one log record, whatever the outcome.
  • run is 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.

Illustrative log record for one refund_order call, grouped into who and what, inputs with a redacted email, the policy decision and approver, execution details with attempts and idempotency key, and the model and instruction versions, next to a photo of hands at a keyboard
An illustrative record; the values are examples, not data from a real system.
Field groupFieldsWhy you need it
Who and whatRun ID, call ID, step number, tool nameTies a model decision to the request it caused
InputsArguments, with personal data redactedShows what the model actually asked for
DecisionAllowed, refused (and why), approved by whomShows which check stopped or passed it
ExecutionAttempts, idempotency key, status, durationSeparates flaky APIs from bad calls
VersionsModel ID, instructions version, tool schema versionTells 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:

  1. Each run is offered only the tools its task needs, and IDs that decide whose data it touches come from the session.
  2. Every tool uses its own least-privilege credential, and the model never sees one.
  3. Tool definitions use strict: true, enums for fixed values, and descriptions that say what the tool will not do.
  4. Limits are enforced in the executor, and irreversible actions go to a person first.
  5. Transient failures are retried in one layer, with backoff, jitter and a cap.
  6. Every write carries an idempotency key derived from the business action.
  7. Errors returned to the model say what went wrong and whether to try again.
  8. One log record per call, with redaction, versions and the approver.
  9. 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.

Start here

Have a problem worth solving?

Tell us what is slow, manual or missing. You will hear back within one working day — then get a written scope and a fixed quote, or an honest no.

brief.md
  1. # New project brief
  2. ## What is slow, manual or missing?
  3. - [x] Written scope
  4. - [x] Fixed quote
  5. - [x] Or an honest no
Reply within one working daySend brief
Message Us