Claude tool_use End to End: The Agentic Loop in Three Iterations
Claude tool_use end to end: one Messages API request through the agentic loop, with tool definitions, tool_choice, parallel calls and tool_result rules.
This article is published in English.
Sources: Part III | ‘tool_use’, End to End: The Agentic Loop in Three Iterations
Why the loop is yours to write
Working with tool_use through the Messages API directly (anthropic.Anthropic() followed by client.messages.create(...)) puts the loop in your hands. Your code declares the tools, inspects the stop reason, parses Claude's requests, executes them and returns what they produced. Writing that loop by hand is the foundation of every tool-using system, and it's a standard topic on the Claude Certified Architect syllabus.
First, a reminder: the Messages API is stateless. Nothing is remembered between calls, so each request carries the whole conversation, and a turn that uses tools is usually several requests rather than one, repeated until Claude has what it needs. The rest follows a single request through that complete cycle.
The running example is a small scheduling assistant. A user asks whether to take an umbrella to a London meeting tomorrow and when to leave home to arrive on time. Answering takes three separate facts: the meeting details, the weather forecast and the travel time. Watch how Claude splits the question across three iterations, and how much of the work falls on your code rather than the model.
1. The loop's shape
Every tool-using exchange follows one four-phase cycle:
- Declare the tools in the request.
- Claude asks for one or more of them, signalled by
stop_reason: "tool_use". - Your code runs them and gathers the results.
- You send back the results and call the API again.
Phases two through four repeat, and each pass counts as one iteration. The loop finishes when Claude stops asking for tools and returns a normal completion (end_turn) or another stop reason. Since the API is stateless, you build up the conversation yourself and resend it each time. Nothing moves forward unless your code moves it.
2. Declaring the tools
Claude is limited to the tools you spell out. They live in the request's top-level tools parameter, which holds an array of definitions. Whenever tools is present, the API assembles a hidden system prompt out of them ahead of the user's message. That hidden prompt is billed: every name, description and schema takes up input tokens on every request, even if Claude never touches a tool.
Each client tool needs three fields:
nameis a unique identifier that must match^[a-zA-Z0-9_-]{1,64}$. It's the string that comes back in thetool_useblock, and your code uses it to dispatch to the right function.descriptionis a plain-language account of the tool's purpose, the right moment to use it, and the meaning of every parameter. Nothing else influences correct calling as much, so write it as a specification instead of a label.input_schemais a JSON Schema for the arguments: their types, which arerequired, and what each stands for.
Here are the three tools for the example:
"tools": [
{
"name": "get_calendar_event",
"description": "Look up a calendar event by a natural-language query. Returns the event's start time and location.",
"strict": true,
"input_schema": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "What to search for, e.g. 'meeting in London tomorrow'" }
},
"required": ["query"],
"additionalProperties": false
}
},
{
"name": "get_weather",
"description": "Get the forecast for a city on a given date. Returns condition and temperature in Celsius.",
"strict": true,
"input_schema": {
"type": "object",
"properties": {
"city": { "type": "string", "description": "City name, e.g. 'London'" },
"date": { "type": "string", "description": "ISO date, e.g. '2026-07-14'" }
},
"required": ["city", "date"],
"additionalProperties": false
}
},
{
"name": "get_travel_time",
"description": "Estimate door-to-door travel time between two places. Returns minutes.",
"strict": true,
"input_schema": {
"type": "object",
"properties": {
"origin": { "type": "string", "description": "Starting location" },
"destination": { "type": "string", "description": "Ending location" }
},
"required": ["origin", "destination"],
"additionalProperties": false
}
}
]
Define them with care. Anthropic's guidance on effective tools boils down to a few habits:
- Be specific with tool names.
get_current_weathertells Claude more thanweather. - Name and describe parameters unambiguously. Prefer
user_idoveruserso the right kind of value gets supplied, and describe every parameter, since a well-named field with a vague description still attracts malformed input. - Keep the toolbox small. An oversized set clutters the generated system prompt and makes the model's selection fuzzy.
- Validate everything. A schema constrains structure, not intent, so check each input your code receives.
Two additions in those definitions deserve a close look.
strict: truematters most. It sits at the top level of each tool next toname,descriptionandinput_schema, and turns on grammar-constrained sampling for the tool's arguments, so the model is physically unable to generate aninputthat breaks your schema. Leave it off and Claude mostly emits valid arguments yet may stray, for example"2"where the number2was needed, a value outside anenum, a missing required field. Each drift becomes a runtime failure in the code consuming the call. Turnstrict: trueon and theinputinside anytool_useblock is certain to fitinput_schema, while the name is guaranteed to be one of your own tools.- One schema rule comes with the guarantee: each object has to set
additionalProperties: false, hence its presence on every tool above. Strict tool use is a piece of Anthropic's structured outputs feature. When this was written it was generally available on current models like Opus 4.8, and older ones such as Sonnet 4.5 asked for the beta headerstructured-outputs-2025-11-13. Compiled schemas stay cached for as long as 24 hours, so a warm cache means no compilation overhead.
You can also attach cache_control to tool definitions to cache them across requests and skip reprocessing charges, which pays off once the tool set is large and stable.
Definitions describe input only
A tool definition covers just the input, meaning what Claude sends you. That surprises people from frameworks that declare return types. Put plainly:
- A definition holds
name,descriptionandinput_schemaas mandatory parts, withstrict,input_examplesandcache_controlas optional extras. Neither anoutput_schemanor anerror_schemaexists. - Return values are settled at runtime by your code, then handed back inside the
tool_resultblock described in section 6. Thecontentfield takes either a string or a list of blocks, and the API checks it against nothing, as there's no schema to check against. One practical caution: a tool may hand back a DTO object, and the standard parser flattens it into a plain string through itstoStringmethod. Supplying that flattened form works but isn't ideal. Convert DTOs to structured JSON instead. - Errors are also a runtime matter. Report a failed call with
is_error: trueon thetool_resultand an informative message incontent; the definition has no declarative error type. Out of the box, the SDK relays a raised exception to Claude as an error result, and Claude adjusts. - The lopsidedness is deliberate. Definitions are there to show Claude how to invoke a tool. A result is just additional context rejoining the loop, so apart from the
tool_use_idlinking it to its request, no contract is needed. - Want a guaranteed output format? Check it yourself in code before sending anything back, or apply structured outputs (
output_config.format) to the closing answer Claude writes from those results. MCP tools bring anoutputSchemaof their own, but that is a feature of the MCP layer rather than of native definitions.
Controlling whether Claude calls a tool
tool_choice decides whether tools may be called on a turn and which ones.
- The field lives at the request's top level next to
toolsandmessages, and belongs to no particular tool. - What it restricts is the output Claude may produce. The reasoning is untouched, as Claude reads the whole prompt regardless.
- Its scope is a single request, never global. The API is stateless, so
tool_choicegoverns only the response it's sent with, which is one iteration. To keep it across iterations, resend it every time. Varying it per iteration is legitimate: force a tool early, useautolater, andnonefor a final text answer.
The object looks like this:
{
"type": "auto" | "any" | "tool" | "none",
"name": "get_weather", // required ONLY when type is "tool"
"disable_parallel_tool_use": false // optional; default false
}
type is required and takes one of four values. name is needed only when type is "tool". disable_parallel_tool_use is optional and limits how many tools may be called in a turn.
auto. Claude decides: zero, one or several tools, or a text answer. Parallel calls are allowed. A plain-text reply is allowed, and Claude may narrate before the calls. It's the default whenever tools are supplied. Setting disable_parallel_tool_use: true caps it at a single tool, though zero is still possible. It suits chat assistants and agentic loops where different turns need different actions. It is the only mode that lets Claude think out loud before acting, and the only mode apart from none that works with extended thinking.
any. Claude must call a tool but picks which. At least one call is made, and since parallel use is on by default, several tool_use blocks can come back, not necessarily one. Exactly one happens only with disable_parallel_tool_use: true. No plain text is allowed and there is no leading reasoning, and it is never the default. Use it when a tool-mediated result is mandatory but the tool varies, as with routers and classifiers.
tool. Claude must call the one tool you name, so name is required. At least one call to that tool is made (it may call it several times in parallel), and exactly one with disable_parallel_tool_use: true. No plain text, no leading reasoning. It fits single-shot structured extraction, a mandatory first step, or the JSON-via-tool pattern, for example {"type": "tool", "name": "get_weather"}. A request naming a tool that isn't in tools comes back as a 400, and forcing a tool doesn't limit it to one call unless parallel use is disabled.
none. Claude may call no tools this turn even though they're defined, and the stop_reason can't be tool_use. Plain text is allowed, and so is leading reasoning. It's the default when no tools are provided. Use it to force a text-only turn, such as a final synthesis, while leaving the tools array untouched. It disables tools without editing the array, though switching to it still invalidates cached message blocks.
Several cross-cutting details are easy to miss:
- Forced modes drop the preamble. Under
anyortoolthe API pre-fills the assistant turn, so thetool_useblock arrives bare: no explanation or reasoning, whatever you ask. Visible reasoning calls forauto. - Extended thinking needs
autoornone. Forced tool use can't be combined with it, and the API rejects the pairing. - Count rule for
disable_parallel_tool_use.automeans at most one,anyandtoolmean exactly one, and the default offalseallows parallel calls on Claude 4 models. - Caching cost. Changing
tool_choicebetween requests invalidates cached message blocks. Tool definitions and the system prompt stay cached, but the conversation is reprocessed. - Pair it with
strict.tool_choicefixes that a tool runs, andstrictfixes that its arguments conform to the schema. Together, a step returns a typed, structured result every turn.
In a request:
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
tools=tools,
tool_choice={"type": "any", "disable_parallel_tool_use": True}, # must call exactly one tool
messages=messages,
)
Two design rules come out of this. Reserve forcing for steps that truly must yield a tool result, and stay on auto if prose is an acceptable reply. Also, when a classification tool is forced, add an "other" option to its enum so Claude can reject a category in place of mislabelling input it can't place.
3. Iteration one: Claude asks for tools
Request one packs together the tool array, a tool_choice of your pick and the user's question:
messages = [{"role": "user",
"content": "I have a meeting in London tomorrow
— should I bring an umbrella, and
when should I leave home to be on time?"}]
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
tools=tools, # the three strict definitions from Section 2
tool_choice={"type": "auto"}, # let Claude decide whether, and what, to call
messages=messages,
)
Claude analyses the question and notices that two of its three needs don't depend on each other: the calendar lookup and the forecast can run side by side. So it requests both tools in one response. Independent calls returned together are called parallel tool use, and they show up as several tool_use blocks in a single turn.
{
"id": "msg_01...",
"role": "assistant",
"stop_reason": "tool_use",
"content": [
{ "type": "text", "text": "Let me check your meeting details and the London forecast." },
{ "type": "tool_use", "id": "toolu_01Cal", "name": "get_calendar_event",
"input": { "query": "meeting in London tomorrow" } },
{ "type": "tool_use", "id": "toolu_01Wx", "name": "get_weather",
"input": { "city": "London", "date": "2026-07-14" } }
]
}
Three things stand out in that response.
stop_reasonistool_use, meaning the turn is unfinished and Claude is waiting on you.- The
contentarray holds a mixture. Atextblock of narration frequently precedes thetool_useblocks, so tool calls don't sit at a fixed position. - Each
tool_useblock has anid(prefixedtoolu_), anameand aninputobject that already conforms to your schema.
Find the calls by iterating through the content and filtering by type, never by index:
tool_calls = [block for block in response.content if block.type == "tool_use"]
That yields two calls here. Each id is the handle for returning that call's result, and the pairing of id to result is how Claude matches answers to questions when several are outstanding.
A note for Claude 4 models: extended thinking may be interleaved, so reasoning can happen between tool calls as well as before them. With thinking switched on, hand the thinking blocks back along with the remainder of the assistant turn, or the continuation forgets that reasoning.
4. Running the tools
Now the work passes to your code. Claude has asked for two tools and run none. Execute everything it requested, gather all the results and send a single follow-up carrying them together. Claude holds off until each call it made has a matching result.
Whether execution is parallel or sequential is your call, since Claude doesn't specify it. The response says what to call, not how to schedule it. Independent calls, like the calendar and weather lookups, can run concurrently to cut latency. When one depends on another's output, run them in order. The model hints at dependency by grouping independent work into parallel blocks and deferring dependent work to a later turn, which is what happens in iteration two. Threads, async or a job queue are choices of your loop, not the API.
Treat execution in production as unreliable I/O that can fail:
- Give every tool a timeout, so a single slow call can't freeze the turn.
- Prefer idempotent operations where possible, since retries and replays are inevitable in an agent.
- Contain failures call by call. When one of two parallel tools raises an error, still send back a result for it (more on
is_errorbelow) instead of aborting the turn. - Measure the loop. Latency per tool, call counts, token usage and error rates reveal whether an agent is healthy or slowly getting worse.
5. Steps before and after a tool runs
Owning the loop means owning two gaps: the one between "Claude asked for a tool" and "the tool ran", and the one between "the tool ran" and "the result went back". They deserve names, because how well they are designed is most of the difference between a safe agent and a risky one.
A pre-tool-use step fires ahead of execution. It examines the requested call and decides to run it unchanged, alter it or refuse it. Here enforcement happens, with Claude suggesting and your code deciding.
A post-tool-use step runs after execution and before the result returns to Claude. It processes the raw output by redacting, truncating, reformatting or logging.
Inside a Client SDK loop, these are simply functions wrapped around execution:
for block in tool_calls:
if not pre_tool_use(block): # validate / guard
results.append(error_result(block.id, "Blocked by policy."))
continue
output = execute(block.name, block.input) # run the tool
output = post_tool_use(block, output) # redact / log / reshape
results.append(tool_result(block.id, output))
The pre-tool-use step is where real guardrails live, and they're the implementer's job, not the model's. Common production patterns:
- Check arguments against your own policy as well as the schema.
- Refuse destructive operations flatly:
rm -rffor a shell tool,DROP TABLEfor a database tool. - Ask a person to approve anything high-impact, such as writing to production.
- Scrub secrets out of arguments before they travel to an external service.
- Log every attempted call so audits and compliance have a trail.
Afterwards, the post-tool-use step deals with what came back, removing credentials or personal data before results enter the context, trimming oversized payloads and recording outcomes.
6. Returning the results
Reply to a tool_use turn with a user message holding a list of tool_result blocks, one per tool_use block in the response, each tied to its tool_use_id. Add the assistant's turn to the running message list first, then the results as a fresh user turn. Iteration one looks like this:
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01Cal",
"content": "{\"start\": \"2026-07-14T15:00\", \"location\": \"Canary Wharf, London\"}"
},
{
"type": "tool_result",
"tool_use_id": "toolu_01Wx",
"content": "{\"condition\": \"rain\", \"temp_c\": 12}"
}
]
}
Two content forms. A result's content can be a plain string, the serialised return value as in the example, or a list of content blocks. Blocks carry more than text, since text and image can be mixed, letting a chart or screenshot tool give Claude a genuine picture. Strings fit scalar and JSON data, and block lists fit multimodal output.
Say clearly when something failed. Mark a failed call with is_error: true and an explanatory message such as "Error: no calendar event found matching that query", never an empty value or a bare "Failed". Of all the habits around results, this one matters most. A clear error lets Claude retry with other arguments, choose a different tool or tell the user what happened, while a hidden error leaves the loop stuck. A large result can carry cache_control too, saving reprocessing costs later.
The continuation contract
Three strict rules, each a common failure point:
- One
tool_resultpertool_use. Omit one and the request fails with a 400 error, because the models expect a one-to-one reply for every tool call they output. - Resend the same
toolsarray. Any request that includestool_useortool_resultblocks has to define the tools they point to, or it is refused. - Results form a turn of their own. Deliver the
tool_resultblocks as the user message, positioned at the start of its content. Trailing instructions in that turn are off limits. Any fresh instruction goes in a separate user message once the tool turn has resolved.
Every result is untrusted input
A tool_result isn't neutral data. It's text that enters the model's context and shapes the next turn. Should it originate on the open web, in a document a user supplied, or in any system outside your full control, it can carry injected instructions meant to hijack the agent, such as an order to disregard earlier instructions and mail out the database. The model might comply. That's prompt injection, and in an agentic loop it's a live production risk, not a hypothetical one.
Defences are structural and sit inside the loop you write:
- Give the agent the least privilege it needs. A tool it lacks is an action it can't be tricked into.
- Run risky tools in a sandbox.
- Sanitise outputs that turn into actions, for instance by resolving file paths against a fixed directory to prevent traversal.
- Let the post-tool-use step redact, stripping anything that looks like an instruction and not data.
- Put anything with real consequences behind human approval.
A tool result should never be able to authorise what a tool call itself couldn't.
Think of results as context you are curating too. Each result token stays in the window for the rest of the conversation, so aim for short, well-structured output: consistent JSON or labelled fields beat prose the model must decode. Loops that pile up many results can use tool result clearing, a context-management beta that discards stale results as the limit nears. Use it before the loop gets big enough to exhaust the window.
7. Iteration two: a dependent call
Both results are back, so the API gets called once more passing the entire message list and the tool definitions. The calendar answer says 15:00 at Canary Wharf, and the forecast says rain, yet it still can't say when to leave, because travel time hinges on a location it only just learned. That dependency is the reason the call couldn't be made in parallel during iteration one. It had to land in a later turn.
{
"role": "assistant",
"stop_reason": "tool_use",
"content": [
{
"type": "tool_use",
"id": "toolu_02Tt",
"name": "get_travel_time",
"input": {
"origin": "home",
"destination": "Canary Wharf, London"
}
}
]
}
You run the tool through the same path of pre-tool-use check, execution and post-tool-use processing, and return the single result:
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": "toolu_02Tt", "content": "{\"minutes\": 45}" }
]
}
The loop's structure matches iteration one. Only the number of calls and their dependency differ. That is the pattern to absorb: the loop doesn't change shape from turn to turn, and it repeats until Claude stops asking.
8. Iteration three: synthesis and end_turn
The third call to the API finds Claude holding every fact it needs. No more tools are requested, so it writes the answer. end_turn replaces the earlier stop reason, and the loop ends.
{
"role": "assistant",
"stop_reason": "end_turn",
"content": [
{
"type": "text",
"text": "Yes, bring an umbrella — rain is forecast in London tomorrow, around 12°C. Your meeting is at 3:00 PM in Canary Wharf, roughly 45 minutes away, so leave home by about 2:00 PM to arrive with a buffer."
}
]
}
Altogether that is three iterations: two parallel calls, a dependent call and a synthesis. The model chose what to fetch and when, and your code chose how each call ran, whether it was permitted and how results went back. Context grows every turn because the full history is resent, but three turns fits the window easily.
9. The whole loop in code
Everything so far condenses into one loop on the Client SDK. It checks stop_reason, routes every requested tool via your pre- and post-tool-use steps, answers each call with one result, and keeps going until Claude finishes.
import anthropic
client = anthropic.Anthropic()
messages = [{"role": "user", "content": user_request}]
MAX_ITERATIONS = 10
for _ in range(MAX_ITERATIONS):
response = client.messages.create(
model="claude-opus-4-8", max_tokens=1024, tools=tools, messages=messages,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break # end_turn (or another reason): done
results = []
for block in (b for b in response.content if b.type == "tool_use"):
if not pre_tool_use(block): # your guard - may block the call
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": "Blocked by policy.", "is_error": True})
continue
try:
output = execute(block.name, block.input) # run it (concurrently if independent)
output = post_tool_use(block, output) # redact / log / reshape
results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
except Exception as e:
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": f"Error: {e}", "is_error": True})
messages.append({"role": "user", "content": results}) # one result per tool_use
print(response.content[-1].text)
The loop spells out some points. You hold messages yourself because the API keeps no state. Any stop_reason besides tool_use ends it. A firm iteration cap stops a runaway agent, so never run an unbounded loop in production. Failures of every kind, blocked calls and exceptions alike, return as is_error results instead of crashing the turn. Finished code also covers max_tokens (a truncated turn, so raise the limit or continue) and pause_turn (a server-tool turn that must be resent). Either may turn up in the same loop.
Checklist to remember
- Tools are declared in the
toolsarray withname,descriptionandinput_schema, and every request pays input tokens for those definitions. tool_choicegoverns whether a tool gets called:auto,any,toolornone. Extended thinking works only withautoandnone, andstrict: trueforces arguments to match the schema exactly.- A
stop_reasonof"tool_use"hands the turn to you. Spot calls by filteringcontentfortype == "tool_use"and never by position. - Parallel tool use means several
tool_useblocks in one response. The execution order, parallel or sequential, is yours to choose and not the model's. Dependent calls arrive in later turns. - Run all requested tools and answer once: a single user turn holding a
tool_resultfor everytool_use, matched throughtool_use_id, with thetoolsarray unchanged. A missing result produces a 400. - You own the gaps around execution. Validate and guard before running, and process and redact afterwards. These are steps you write, not features you configure.
tool_result.contentcan be a string or a block list. On failure setis_error: truewith a helpful message, and keep results short and structured.- Treat tool results as untrusted context. Least privilege, sandboxes, sanitisation and human sign-off on consequential actions are the answer to prompt injection.
- The loop runs until
end_turn, holds its own state and needs a hard cap on iterations.