Claude Certification Guides
CCDV-F/Domain 19 min read

Lesson 1.1

Building an Agent Loop

How a Claude agent actually runs in code, from the first request through tool calls to the stop reason that ends the loop.

An agent is not a special endpoint. It is an ordinary Messages API call wrapped in a loop that your code controls: send the conversation, inspect why Claude stopped, run any tools it asked for, append the results, and call again. The exam tests whether you understand each step of that cycle and what the stop_reason field tells you to do next.

The loop in one picture

Every iteration follows the same sequence:

  1. Call POST /v1/messages with the full message history and your tool definitions.
  2. Read stop_reason on the response.
  3. If it is tool_use, execute every tool_use block, append the assistant message and a user message of tool_result blocks, then go to step 1.
  4. If it is end_turn, the agent is finished. Return the text.
  5. Handle the other stop reasons deliberately rather than falling through.

Because the API is stateless, you resend the whole history each time. Nothing is remembered server-side between calls.

Stop reasons that drive control flow

stop_reasonMeaningWhat your loop should do
end_turnClaude finished naturallyExit the loop
tool_useClaude wants one or more tools runExecute tools, append results, call again
max_tokensOutput hit the max_tokens ceilingTreat as truncated; raise the limit or retry, never run a half-formed tool call
stop_sequenceA custom stop string was hitUsually exit
pause_turnA long-running server tool paused the turnResend the conversation unchanged to continue
refusalSafety system declinedStop; inspect stop_details if present

A loop that only checks for end_turn and otherwise runs tools will misbehave on max_tokens and refusal. Check the reason explicitly.

A minimal loop in Python

import anthropic

client = anthropic.Anthropic()
MODEL = "claude-opus-5-5"  # pick a current ID from the models page

def run_agent(user_input: str, tools: list, execute) -> str:
    messages = [{"role": "user", "content": user_input}]
    for _ in range(20):  # hard cap on iterations
        resp = client.messages.create(
            model=MODEL, max_tokens=4096, tools=tools, messages=messages
        )
        if resp.stop_reason == "end_turn":
            return "".join(b.text for b in resp.content if b.type == "text")
        if resp.stop_reason == "pause_turn":
            messages.append({"role": "assistant", "content": resp.content})
            continue
        if resp.stop_reason != "tool_use":
            raise RuntimeError(f"unexpected stop_reason {resp.stop_reason}")

        messages.append({"role": "assistant", "content": resp.content})
        results = []
        for block in resp.content:
            if block.type == "tool_use":
                try:
                    out = execute(block.name, block.input)
                    results.append({"type": "tool_result",
                                    "tool_use_id": block.id, "content": out})
                except Exception as e:
                    results.append({"type": "tool_result", "tool_use_id": block.id,
                                    "content": str(e), "is_error": True})
        messages.append({"role": "user", "content": results})
    raise RuntimeError("iteration cap reached")

Three details matter here. The assistant message you append must contain the complete resp.content, including the tool_use blocks, so the following tool_result has something to refer to. Every tool_result carries the tool_use_id from the matching block. And all results for one turn go back in a single user message, which keeps parallel tool calling working.

The same loop in TypeScript

import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();

async function runAgent(input: string, tools: Anthropic.Tool[], exec: Exec) {
  const messages: Anthropic.MessageParam[] = [{ role: "user", content: input }];
  for (let i = 0; i < 20; i++) {
    const resp = await client.messages.create({
      model: MODEL, max_tokens: 4096, tools, messages,
    });
    if (resp.stop_reason === "end_turn") {
      return resp.content.filter((b) => b.type === "text").map((b) => b.text).join("");
    }
    if (resp.stop_reason !== "tool_use") throw new Error(resp.stop_reason ?? "unknown");

    messages.push({ role: "assistant", content: resp.content });
    const results: Anthropic.ToolResultBlockParam[] = [];
    for (const b of resp.content) {
      if (b.type === "tool_use") {
        results.push({ type: "tool_result", tool_use_id: b.id,
                       content: await exec(b.name, b.input) });
      }
    }
    messages.push({ role: "user", content: results });
  }
  throw new Error("iteration cap reached");
}

Guard rails every production loop needs

  • Iteration cap. Agents can loop indefinitely on a tool that keeps failing. Bound the number of turns.
  • Tool errors as data. Return failures with is_error: true instead of throwing out of the loop; Claude will usually recover or ask for clarification.
  • Parse tool input as JSON, never by string matching. The SDK gives you a parsed object in block.input; validate it against your schema before acting on it.
  • Budget awareness. Sum usage.input_tokens and usage.output_tokens across iterations; the history grows every turn, so prompt caching becomes valuable quickly.
  • Streaming for long turns. When max_tokens is large, use the SDK's stream() helper with get_final_message() / finalMessage() to avoid HTTP timeouts.

SDK helpers that run the loop for you

The Anthropic SDKs ship a beta tool runner (client.beta.messages.tool_runner in Python, toolRunner in TypeScript) that executes this cycle automatically from decorated functions or Zod schemas. It is still the same loop underneath, so the stop-reason logic above is what you are relying on. The separate Claude Agent SDK goes further and supplies built-in tools; it is covered in lesson 1-3.

Exam focus

  • The agent loop is client code around POST /v1/messages; the API itself is stateless and remembers nothing between calls.
  • stop_reason: "tool_use" means run the tools and call again; end_turn means finished; max_tokens means truncated output, not a completed turn.
  • A tool_result must reference the tool_use_id of the block it answers, and all results for one assistant turn are returned in one user message.
  • The assistant message appended to history must include the full content array with its tool_use blocks.
  • Tool failures are returned with is_error: true so Claude can adapt, rather than aborting the loop.
  • pause_turn is resumed by resending the conversation; refusal should end the loop.

Check yourself

5 questions

  1. 1.A developer writes a minimal agent loop: while True: response = client.messages.create(model=MODEL, max_tokens=4096, tools=tools, messages=messages) if response.stop_reason == "tool_use": ... # run tools, append results else: break Which stop_reason value indicates that Claude has completed its turn naturally and is not requesting any further tool calls?

  2. 2.After receiving a response with stop_reason "tool_use", a developer runs the tool and sends: messages.append({"role": "user", "content": [{"type": "tool_result", "tool_use_id": block.id, "content": result}]}) response = client.messages.create(..., messages=messages) They never appended the assistant response to messages. What happens on the second request?

  3. 3.In an agent loop a response arrives with stop_reason "max_tokens" and its content ends in a tool_use block whose input looks incomplete: {"type": "tool_use", "id": "toolu_01", "name": "write_file", "input": {"path": "app.py", "contents": "import os\nde"}} What should the loop do?

  4. 4.A production agent loop is written as: while (true) { const res = await client.messages.create({...}); if (res.stop_reason !== "tool_use") return res; messages.push({ role: "assistant", content: res.content }); messages.push({ role: "user", content: await runTools(res) }); } A flaky tool keeps returning errors and the agent keeps retrying it, running for 40 minutes and burning budget. Which change most robustly fixes this class of problem?

  5. 5.Where must tool_result blocks be placed when continuing an agent loop after Claude requests a tool?