Whetstone.
Building the LoopThe agent loop, by hand
Module 2, Lesson 225 min

The agent loop, by hand

Every helper in the previous lesson is wrapping this. Learn it once at wire level and every product on that list becomes obvious, including the ones that have not shipped yet.

The five beats

One. Send messages plus a tools array. Nothing special, this is just a request.

Two. Read stop_reason. end_turn means the model is finished talking and you are done. tool_use means the model wants something run. Those two drive the loop; the full documented set is end_turn, max_tokens, stop_sequence, tool_use, pause_turn, refusal and model_context_window_exceeded. max_tokens is the one that will genuinely embarrass you in production, and pause_turn is the one that belongs in this loop: if you use server tools, their internal loop can hit its iteration cap, and the correct response is to append the assistant turn and send again rather than to treat it as an ending.

Read stop_reason the way you would read the discriminant on a tagged union, because that is what it is. You are not inspecting the text to guess intent; the response tells you which branch you are in, and the whole loop is a switch on that one field.

Three. The assistant’s content is a list of blocks. Some are text. Some are tool_use, each carrying an id, a name, and an input object matching your schema. Execute each one.

Four. Build the next message and send the results back.

Five. Append both turns to your message list and go again.

The bit everyone gets wrong the first time

Tool results go back as a user message, containing a tool_result block per call.

Sending results back
{
  "role": "user",
  "content": [
    {
      "type": "tool_result",
      "tool_use_id": "toolu_abc123",
      "content": "18C, light rain"
    }
  ]
}

Two rules that follow from the shape and are worth saying explicitly. Every tool_use block needs a matching tool_result, so if the model requested three tools in parallel you send three results in one user message, not three messages. And the ids have to match exactly, because that is the only thing stitching a result to its call.

Failure is a content block, not an exception

Your tool will throw. That is not an error condition for the loop, it is an input to it.

Handing a failure back to the model
{
  "type": "tool_result",
  "tool_use_id": "toolu_abc123",
  "content": "WeatherAPI returned 503",
  "is_error": true
}

is_error on the tool_result block lets the model see what went wrong and decide what to do: try a different tool, ask you a question, or tell the user it could not find out. Throwing out of your loop instead removes every one of those options and turns a recoverable hiccup into a dead process.

The thing to actually internalise: an error you hand back is a turn the model gets to use. An exception you raise is a conversation you ended.

Termination, or the infinite bill

Nothing in the protocol stops your loop. A model that keeps asking for tools keeps getting them until something external intervenes, and the something is usually your invoice.

So every hand-rolled loop needs a max-iteration counter, and a decision about what happens when you hit it. Silently returning the last text block is the wrong choice: hitting the ceiling is a real outcome and your caller needs to know it happened. The Tool Runner exists precisely so you do not have to keep getting this right by hand.

Practice

Try it yourself

Quiz

Which role carries the tool result

If you have written against other model APIs, your muscle memory is about to lose you a mark.

  1. AThe user role, containing a tool_result content block
  2. BA dedicated tool role, keyed by the tool's name
  3. CThe assistant role, continuing its own turn
  4. DA system message appended before the next user turn
Show answer

Correct answer: A — The user role, containing a tool_result content block

Tool results go back as a user-role message whose content contains one tool_result block per tool_use block. The dedicated-tool-role answer is the trap, because that is exactly how several other major model APIs do it and the habit transfers badly. The assistant answer is wrong for a structural reason worth holding onto: the assistant turn already ended, which is what stop_reason tool_use was telling you.

Recall

The loop, from memory

Five beats. Say them in order without looking, then check the ids.

Walk the tool loop from the first request to termination, naming the stop_reason values and the id that stitches a result to its call.

Reveal answer

Send messages plus tool definitions. Read stop_reason: end_turn means you are done, tool_use means work to do. On tool_use, take each tool_use block from the assistant content, execute it, and build a user message containing one tool_result block per call, each carrying the matching tool_use_id. Append the assistant turn and your new user turn to the message list, send again, and repeat until stop_reason is end_turn. Guard the whole thing with a max-iteration counter, because nothing else will stop it.

Quiz

Trace the next request

The model just returned the turn below. Your loop reads it and builds the next message.

Assistant response, stop_reason tool_use
{
  "role": "assistant",
  "stop_reason": "tool_use",
  "content": [
    { "type": "text", "text": "Let me check that." },
    { "type": "tool_use", "id": "toolu_01A", "name": "get_weather", "input": { "city": "Totnes" } }
  ]
}
  1. AA tool-role message whose name is get_weather and whose content is the output
  2. BAn assistant message continuing the same turn, with the tool output appended after the text block
  3. CA user message containing the tool output as plain text, since the id is only there for logging
  4. DA user message containing one tool_result block whose tool_use_id is toolu_01A
Show answer

Correct answer: D — A user message containing one tool_result block whose tool_use_id is toolu_01A

You append the assistant turn as-is, then send a user message carrying one tool_result block with tool_use_id set to toolu_01A. The plain-text answer is the genuinely tempting one, because the model can obviously read prose and the output would be right there in the conversation, but the id is the only thing stitching a result to its call, and a tool_use block with no matching tool_result leaves the turn unsatisfied. There is no tool role on this API. And the assistant turn is over, which is precisely what stop_reason tool_use meant.

Quiz

Three calls, one turn

Parallel tool use is on by default, so this arrives in a single assistant turn.

One assistant turn, three tool_use blocks
{
  "role": "assistant",
  "stop_reason": "tool_use",
  "content": [
    { "type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {} },
    { "type": "tool_use", "id": "toolu_02", "name": "get_tides", "input": {} },
    { "type": "tool_use", "id": "toolu_03", "name": "get_moonrise", "input": {} }
  ]
}
  1. AOne user message containing three tool_result blocks, each with its matching id
  2. BThree user messages, one per result, in the order the calls appeared
  3. COne user message with the three outputs concatenated into a single tool_result
  4. DOne assistant message containing three tool_result blocks
Show answer

Correct answer: A — One user message containing three tool_result blocks, each with its matching id

One user message, three tool_result blocks, ids matched to the three tool_use ids. The three-messages answer is the tempting one and it is very nearly right: it honours the one-result-per-call rule correctly and only gets the packaging wrong. The results belong to a single turn, so they travel together. Concatenating them into one block loses the ids, and the assistant answer repeats the role mistake the first card in this lesson is about.

Quiz

When your tool throws

Your weather tool 500s. The exam wants to know what a competent implementation does next.

  1. AReturn the tool_result with is_error set to true and the error text as content, letting the model recover
  2. BOmit the tool_result block for the failed call and send the other results as normal
  3. CThrow out of the loop and surface the exception to your caller to decide what happens
  4. DRetry the tool silently until it succeeds, then continue the loop as if nothing failed
Show answer

Correct answer: A — Return the tool_result with is_error set to true and the error text as content, letting the model recover

A tool_result block takes is_error, so a failure is data you hand back rather than an exception you propagate. Omitting the block is the genuinely tempting wrong answer and it is the worst of the four: every tool_use block needs a matching result, so a missing one is a malformed request, not a graceful skip. Silent retry is not wrong so much as incomplete, since it has no answer for a tool that is simply broken.

Do

Hand-simulate three turns

No network, no keys, no account. A scratch file and your memory. This is the one that makes the wire format stick.

  • In a scratch JSON file, write the initial request: model, max_tokens, one tool definition, one user message asking something that needs the tool.
  • Write the assistant response you expect, including a tool_use block with an id, and the stop_reason it should carry.
  • Write the next user message: the tool_result block, with the tool_use_id copied exactly from the block above.
  • Write the final assistant turn and its stop_reason.
  • Now write a second version of the third message where the tool failed, using is_error.
Done whenBoth versions parse as valid JSON, every tool_use id appears exactly once as a tool_use_id, and you did not use a tool role anywhere.
Sign in to track your progress →