Home / Articles

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.

Publisher: REACTAPP.TOP19 min read4379 words

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:

  1. Declare the tools in the request.
  2. Claude asks for one or more of them, signalled by stop_reason: "tool_use".
  3. Your code runs them and gathers the results.
  4. 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:

  • name is a unique identifier that must match ^[a-zA-Z0-9_-]{1,64}$. It's the string that comes back in the tool_use block, and your code uses it to dispatch to the right function.
  • description is 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_schema is a JSON Schema for the arguments: their types, which are required, 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_weather tells Claude more than weather.
  • Name and describe parameters unambiguously. Prefer user_id over user so 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.

  1. strict: true matters most. It sits at the top level of each tool next to name, description and input_schema, and turns on grammar-constrained sampling for the tool's arguments, so the model is physically unable to generate an input that breaks your schema. Leave it off and Claude mostly emits valid arguments yet may stray, for example "2" where the number 2 was needed, a value outside an enum, a missing required field. Each drift becomes a runtime failure in the code consuming the call. Turn strict: true on and the input inside any tool_use block is certain to fit input_schema, while the name is guaranteed to be one of your own tools.
  2. 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 header structured-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, description and input_schema as mandatory parts, with strict, input_examples and cache_control as optional extras. Neither an output_schema nor an error_schema exists.
  • Return values are settled at runtime by your code, then handed back inside the tool_result block described in section 6. The content field 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 its toString method. 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: true on the tool_result and an informative message in content; 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_id linking 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 an outputSchema of 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 tools and messages, 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_choice governs 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, use auto later, and none for 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 any or tool the API pre-fills the assistant turn, so the tool_use block arrives bare: no explanation or reasoning, whatever you ask. Visible reasoning calls for auto.
  • Extended thinking needs auto or none. Forced tool use can't be combined with it, and the API rejects the pairing.
  • Count rule for disable_parallel_tool_use. auto means at most one, any and tool mean exactly one, and the default of false allows parallel calls on Claude 4 models.
  • Caching cost. Changing tool_choice between requests invalidates cached message blocks. Tool definitions and the system prompt stay cached, but the conversation is reprocessed.
  • Pair it with strict. tool_choice fixes that a tool runs, and strict fixes 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.

  1. stop_reason is tool_use, meaning the turn is unfinished and Claude is waiting on you.
  2. The content array holds a mixture. A text block of narration frequently precedes the tool_use blocks, so tool calls don't sit at a fixed position.
  3. Each tool_use block has an id (prefixed toolu_), a name and an input object 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_error below) 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 -rf for a shell tool, DROP TABLE for 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_result per tool_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 tools array. Any request that includes tool_use or tool_result blocks has to define the tools they point to, or it is refused.
  • Results form a turn of their own. Deliver the tool_result blocks 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 tools array with name, description and input_schema, and every request pays input tokens for those definitions.
  • tool_choice governs whether a tool gets called: auto, any, tool or none. Extended thinking works only with auto and none, and strict: true forces arguments to match the schema exactly.
  • A stop_reason of "tool_use" hands the turn to you. Spot calls by filtering content for type == "tool_use" and never by position.
  • Parallel tool use means several tool_use blocks 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_result for every tool_use, matched through tool_use_id, with the tools array 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.content can be a string or a block list. On failure set is_error: true with 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.