Chapter 5 of 8 · 18 min read
The client-executed tool loop
On this page
Chapter 1 separated two entries that sit in the same tools array. A
provider-executed tool is defined and run by the provider inside your turn, and
you learn of it from a result block. A client-executed tool is the other one:
a function you describe to the model, which the model may ask you to run, which
your process runs between two API calls, and whose result you send back for the
model to continue with. Four steps, of which you own two — the decision and the
action.
The reason it splits that way is worth stating before the code, because everything else in the chapter rests on it. What you send the provider is a description of the function: a name, a sentence about what it does, and a schema for its arguments. You never send the function. The provider therefore has nothing to execute — no code, no network route to your ledger, no credential that your ledger would accept — and the only thing that can be done with a description is to produce text shaped like a call to it. This is not a restriction a provider imposed for safety. It is the whole of what is available to a party that has been handed a name and a shape.
So the loop has a gap in it by construction, and the gap is where you are. The
model emits output that the SDK parses into a structured block, the response
comes back with stop_reason set to tool_use, and at that instant nothing has
happened: no money has moved, no row has changed, and the only artefact in
existence is a piece of text sitting in your process's memory. Whatever happens
next happens because your code does it. The two steps in the middle of the four
are not steps the provider is able to take on your behalf.
The loop is where the only enforceable controls in this topic can be placed, and chapter 6 attacks it directly. A tool call looks like this when it arrives, and it is worth noticing how ordinary it is:
{
"type": "tool_use",
"id": "toolu_01Qk3v8dRz",
"name": "issue_refund",
"input": {
"customer_id": "cus_88213",
"order_id": "ord_4471",
"amount_cents": 124000
}
}Well-formed, schema-valid, and carrying an identity in its arguments. Whether that last part is a parameter or a mistake is the whole subject of the chapter.
Schema-valid is a smaller claim than it sounds, and the distance between what it
checks and what a reader assumes it checks is worth closing before the loop
starts. A schema is a statement about form: that amount_cents is
an integer, that order_id is a string, that all three fields are present.
Nothing expressible in it can say that the integer is the amount this customer
is owed, or that the string names an order that is theirs, because those are
facts about where the values came from rather than about what the values look
like. Validation reads the document in front of it, and provenance is not in the
document. The habit of reading "validated" as "safe" is earned honestly in
ordinary API work, where a schema at the edge does eliminate a class of
malformed-input attack, and it does not transfer here, because nothing arriving
is malformed.
The loop, and the two places you are actually in it
In code, with the parts that matter left in:
def run_turn(session, messages):
resp = client.messages.create(
model=MODEL, max_tokens=2048, tools=TOOLS, messages=messages
)
messages.append({"role": "assistant", "content": resp.content})
if resp.stop_reason != "tool_use":
return resp, messages
results = []
for block in resp.content:
if block.type != "tool_use":
continue
results.append({
"type": "tool_result",
"tool_use_id": block.id,
**dispatch(session, block.name, block.input),
})
messages.append({"role": "user", "content": results})
return run_turn(session, messages)
def dispatch(session, name, args):
if name != "issue_refund":
return {"content": f"unknown tool {name}", "is_error": True}
# Identity comes from the session, never from args.
ledger = LedgerClient(token=session.scoped_token) # cus_71004 only
order = ledger.get_order(args["order_id"]) # 404s if not theirs
if args["amount_cents"] > order.refundable_cents:
return {"content": "amount exceeds refundable balance", "is_error": True}
if args["amount_cents"] > 5000:
approval.request(session, order, args["amount_cents"])
return {"content": "Refund above £50 requires an agent to approve. "
"A request has been queued.", "is_error": False}
ref = ledger.refund(order.id, args["amount_cents"])
return {"content": f"Refunded £{args['amount_cents']/100:.2f}, ref {ref}"}Two lines carry the entire security posture. LedgerClient(token=session.scoped_token)
mints a credential bound to the customer who authenticated to your application, so
the handler's reach is defined before it reads a single argument. And args never
contributes an identity — the order is looked up through that scoped client, so an
order belonging to someone else returns 404 rather than a refund, no matter how
confidently the model asked.
The order in which those two lines run is the mechanism, and swapping them would
leave the same code with none of the property. Minting the credential from the
session first fixes the set of objects the handler is able to reach — for this
session, that set is cus_71004's orders and nothing else. Only then are the
arguments consulted, and an argument can select
from within a set but cannot extend one. An order_id naming somebody else's
order therefore does not fail a check; it fails to resolve, because the client
doing the asking has no way to name that object at all. A validation step
decides whether an argument is permitted, and this decides whether the argument
is able to matter, which is the stronger property because it does not depend on
anyone having thought of the case in advance.
Three mechanical details in that loop are easy to get wrong and produce bugs that
look like model problems. The assistant's content is appended verbatim, every
block of it, rather than the extracted text — dropping a tool_use block leaves
the next request describing a result for a call that no longer exists, which is a
400 at best and a confused model at worst. A single assistant turn may contain
several tool_use blocks, and all of their results go back in one user message;
splitting them across two messages is accepted by the API and teaches the model,
over the course of a conversation, to stop issuing parallel calls. And a tool that
fails returns a tool_result with is_error set, carrying a description the model
can act on, rather than being omitted; a missing result for an issued call is the
one shape the protocol has no way to interpret.
All three follow from one fact about the format: the messages array is not a
record of the conversation, it is the input the next request is assembled from,
and it has to describe a coherent history. A tool_result refers to a call by
id, so a request carrying the result without the call is describing an event
with no cause, and the API rejects that rather than guessing at it. The
parallel-call case is softer and belongs on the page as a tendency rather than a
rule: the model conditions on the history it is shown, and a history in which
several requested calls came back one at a time is evidence about how this
particular conversation goes, so the behaviour drifts over a long thread instead
of failing outright. The omitted result is the hard case again, because the
format has no way to express "this call was declined" other than a result saying
so, which is why a refusal has to be returned as a tool_result with is_error
set and a description inside it.
The approval branch is the second control and it is doing something different from the credential. The scoped token decides whose money can move; the threshold decides how much moves without a human. Both are in the handler, both are enforced rather than requested, and the threshold's return value is deliberately a normal, non-error result — the model is told an approval was queued and can say so to the customer, which keeps the conversation coherent without the queueing being negotiable. Where the approval is granted matters as much as the threshold: an agent clicking approve in your internal tool is a second principal making a decision with information the conversation does not contain. An approval that consists of the model asking "shall I proceed?" and the same context answering "yes" is not a second principal and adds nothing.
Why self-approval adds nothing is worth being precise about, because a confirmation step looks like a control and is one of the more common things to ship in place of one. A second principal is useful because it holds information the first one does not — the agent in the internal tool can see the account history, the earlier refunds, the note from the fraud team. When the model asks and the same conversation answers, both the question and the answer are produced by continuing one context, so the answer cannot contain anything the question did not already have access to. Nothing has been checked; a step has been added to the transcript. The threshold in the handler is a control because the thing it consults is an amount compared against a constant, and the constant is not in the conversation at all.
Asking is not authorisation
Every step of the chain that makes a tool call untrusted is checkable, and none
of it requires the model to behave badly. A tool_use block is text the model
produced. It produced that text by continuing the context it was handed, in the
way topic 1.0 established — a score over every candidate piece of text, one of
them picked, appended, and the function run again. The context being continued
holds your system prompt, your tool descriptions, whatever your user typed and,
once retrieval is enabled, whatever was in the documents that came back. No step
in that process consults who is entitled to what, because no step in it has
access to such a fact. So the block carries no attestation about its own origin,
and there is nowhere further up the pipe to look for one: the identity of the
party whose request this is lives in your session and has never been near the
model.
The useful way to hold this is by analogy to something you already do without
thinking. A tool_use block has exactly the standing of an unauthenticated HTTP
request arriving at your public API. It is a well-formed message, from a party you
did not vet, proposing an action. Nobody would write an endpoint that accepts
customer_id from the request body and refunds against it because the caller said
so; the identity comes from the session cookie or the bearer token, and the body
carries only the parameters the caller is genuinely entitled to choose. A tool
handler is that endpoint. The model is the caller.
What makes the analogy easy to lose is that the model feels like part of your system. It is running on your prompt, it has been reliable for weeks, it has access to your tool schemas, and its output is shaped like the internals of your application rather than like a request from outside. None of that is authority. The model's tool call is a proposal derived from a context window whose contents include, at minimum, whatever your user typed — and, once hosted retrieval is enabled, whatever a stranger wrote on a web page.
The comparison is exact on the point it is being used for and inexact elsewhere, and the inexact part is worth marking before it becomes a belief. An unauthenticated HTTP request is sent by a party with an interest of their own, so the traffic you distrust is traffic somebody chose to send you. A tool call is not adversarial in that sense. Most of them are the model doing exactly what it was built to do, and the ones that are not were shaped by text a third party planted somewhere upstream, which makes the thing you are defending against a channel rather than a caller. That difference matters for how you reason about frequency and it changes nothing in the handler, because a handler that never consults the caller's claims about identity is indifferent to whether the caller meant any harm.
The mistake this framing prevents is specific and near-universal in first drafts:
putting the constraint in the wrong artefact. Constraints written into the tool
description ("only use this for the authenticated customer"), into the system
prompt ("never refund more than £50 without approval"), or into the schema's field
documentation are all instructions competing with other instructions in the same
context. They shape behaviour, which is worth something, and they guarantee
nothing, which is worth remembering when someone asks whether the £50 limit is
enforced. In the handler above the limit is a Python if and the identity is a
token; both are statements about what the system will do, not about what the model
should do.
What makes those three artefacts equivalent is not a judgement about how
reliably models follow instructions. It is that all three reach the model the
same way. The tools array is serialised into the input alongside the messages,
so a field's description is a sentence the model read, standing in the same
window as the system prompt and as anything retrieval brought back. Once a
sentence is in there it competes on the same terms as every other sentence, and
how that competition resolves is a property of the model rather than of your
code. The if comparing an amount against a threshold never enters the
competition, because it is evaluated by an interpreter that has read nothing.
| Provider-executed tool | Tool you own | |
|---|---|---|
| Who runs it | The provider, inside your turn | Your process, between two API calls |
| Runs under | The provider's egress identity | A credential you minted for an authenticated principal |
| You can refuse a call | No — you learn of it from the result | Yes — the handler is the decision point |
| Appears in your logs | Only as whatever the response reports | As an ordinary call in your own service |
| Argument validation | The provider's, if any | Yours, before anything happens |
| Where a control can sit | Nowhere you operate | The call site, the credential, the threshold |
| Removing it | Delete a line from tools | Delete a line from tools, and the handler |
One row in that table produces most of the others. If you cannot refuse a call, there is no instant at which your code is holding the proposal and has not yet acted on it, and every control in this chapter is something that happens at exactly that instant. The credential is chosen there and the amount is compared there. A provider-executed tool has no such instant anywhere in your process, which is why the honest entry in that row is not "weaker controls" but no place to put one.
Two smaller consequences follow from treating the call as an inbound request rather than an internal function call. Retries are the first: a model that does not see a clear result, or a turn that is re-run after a timeout, can issue the same refund twice, and the handler is the only place that can stop it. Deriving an idempotency key from the tool-use id and the order — and passing it to the ledger — costs one line and turns a duplicate call into a no-op. Logging is the second. The record your service writes at dispatch time is the only artefact that ties the action to a principal: the session's authenticated user, the tool-use id, the arguments as received, the decision the handler made, and the identifier the downstream system returned. Chapter 7 is about what you can establish after an incident, and this log line is one of the few pieces of that picture nobody else can take away from you.
The idempotency key in the first of those two works because of where the id
comes from, and knowing that also fixes its boundary. toolu_01Qk3v8dRz is
minted once, when the model issues the call, and it travels with every replay of
that same assistant turn — so a handler that has already acted on that id can
recognise a second arrival as the same instruction rather than a new one. What
it cannot recognise is a fresh decision. If the turn is re-run from the messages
array and the model issues the call a second time, the new block carries a new
id, and the ledger sees two distinct instructions because that is what they are.
The key protects against your own infrastructure repeating itself rather than
against the conversation asking twice, and closing the second case needs the
ledger's view of the order, which is the refundable_cents comparison in the
handler above.
Where the result lands
Look at what messages contains after one tool round trip, because this is what
carries into the next chapter:
[
{"role": "user", "content": "where is order 4471?"},
{"role": "assistant", "content": [
{"type": "web_search_tool_result", "content": [
{"type": "web_search_result", "url": "https://forum.example/t/9912",
"title": "Delivery delays — megathread",
"encrypted_content": "…3.4 kB of somebody's forum post…"}]},
{"type": "text", "text": "Let me check the refundable balance."},
{"type": "tool_use", "id": "toolu_01Qk3v8dRz", "name": "issue_refund",
"input": {"order_id": "ord_4471", "amount_cents": 124000}}]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "toolu_01Qk3v8dRz",
"content": "Refund above £50 requires an agent to approve."}]}
]Four kinds of text are in that array and the model receives them as one stream of
context: instructions you wrote, a question your user typed, a forum post a
stranger wrote, and a result your own code produced. The roles do not encode trust
— user here holds both a genuine customer utterance and a tool result generated
by your handler, and the assistant turn holds retrieved text nobody at your
company chose. There is no field that says "this text is data, not instruction".
There is no per-block provenance the model is obliged to respect. The format
simply has no vocabulary for the distinction.
That absence has a cause, and knowing it explains why no amount of care with
roles closes the gap. Roles and block types are a convention for laying the
input out: the provider
renders the array into one sequence of pieces of text, with markers the model
was built to recognise, and the model then does to that sequence what it does to
any sequence. The markers are made of the same material as the content between
them. A user block and a tool_result block therefore differ in what they
signal about who spoke, and not in what the model is able to do with the words
inside them, which is why a sentence that reads like an instruction reads like
one wherever it happens to be standing.
There is some movement here worth knowing about, none of it sufficient yet. Providers have begun shipping a genuine operator channel — a system-role message you can append mid-conversation, which is not spoofable by anything that writes into user or tool content, and which is the right home for instructions that arrive after the conversation starts. Search-result content blocks with source attribution exist, so the model at least sees where a passage came from. Neither changes the fundamental position: attribution is information the model may weigh, and an operator channel raises the authority of your instructions without lowering the authority of anyone else's. That is a design property of these APIs rather than an oversight, and it is why the whole burden falls on the handler. If the format marked untrusted spans, you could enforce a rule like "text originating outside the operator may not motivate a privileged tool call". It does not, so you cannot.
Two things this chapter is not claiming. A client-executed tool is not inherently safer than a hosted one; the hosted code-execution sandbox is far better isolated than a Python function running in your service, and if the tool is "run this arithmetic" the hosted one is the better choice by a wide margin. The difference is not safety, it is where a control can be placed: a client tool has a call site you own, and controls need a call site. Nor does this loop solve the problem it makes visible. A scoped credential means the model cannot refund a stranger's order. It does not mean the model cannot be talked into refunding the authenticated customer's own order, at the maximum allowed amount, for a reason that came out of a web page. Scoping bounds the blast radius; it does not remove the capability.
That residue is the hard part of this material, and it does not get a clean answer in this chapter or the next one. Every control described here works by taking a choice away from the model: the identity is not a choice, the reach of the credential is not a choice, an amount above the threshold is not a choice. What is left is the set of actions you deliberately delegated, taken for reasons assembled out of text you did not write. Shrinking that set is the whole of the design work, and shrinking it to nothing means having built no agent at all.
The loop is four steps and the discipline is one sentence: the model proposes, and your handler decides using an identity the model never supplied. Everything upstream of the handler — the prompt, the tool description, the schema's field documentation, the model's reasoning — is advisory, and everything you want to be able to state as true belongs in the ten lines that mint a credential, look up an object through it, and compare an amount against a threshold. That is a small amount of code to be carrying the whole boundary, which is exactly why it is worth being able to point at it.
Which is also exactly the attack. The forum post in that message array was retrieved by a hosted tool, arrived with no marker distinguishing it from your instructions, and sat one model decision away from a privileged action. The next chapter puts an instruction in that page on purpose and follows what happens.
Provider-hosted state and provider-executed tools — read the current version at https://daviddominguez.dev/learn/en/provider-hosted-state-and-provider-executed-tools/the-client-executed-tool-loop