Study guides / CCDV-F / Domain 3

Agents & Workflows · Lesson 1 of 5

3.1 - Building an Agentic Loop

Implement the send-inspect-execute cycle that turns a single Messages API call into an autonomous agent, and know every stop_reason it can hand you.

The Messages API is stateless: every call is a self-contained request with no memory of previous calls. An agentic loop is the deterministic control flow you write in your own code that turns that stateless API into something that behaves like an autonomous agent - send a request, inspect what came back, act on it, and send again. It is not a prompting technique, not a retry wrapper, and not "keep chatting until the user stops." It is a loop with an explicit exit condition your code enforces.

The lifecycle, step by step

Four steps, repeated until the model signals it is finished:

  1. Send a request. The request includes the full conversation so far - system prompt, prior user/assistant turns, and any tool results already produced. Nothing is remembered server-side between calls; if it isn't in the messages array you send, the model cannot see it.
  2. Inspect stop_reason. This field, not the content of the response, is what tells your code what to do next.
  3. If the model requested tools, execute them and append the results. The assistant's response - including its tool_use blocks - becomes a new assistant message in history. Your tool execution results become a new user message containing one tool_result block per tool call, matched by tool_use_id. Both must be appended before you call the API again.
  4. If the model is finished, stop and surface the result. No more tool execution, no more calls - hand the final text to whatever consumes it.

Step 3 is where most home-grown loops break in practice. If you execute a tool but forget to append its result - or you append it in the wrong shape, or with a tool_use_id that doesn't match - the next call to the API will either fail outright or, worse, silently give the model no way to know what its own tool call returned. From the model's perspective in that second case, it asked a question and got nothing back.

Every stop_reason value, and how to handle it

A loop that only branches on tool_use vs. everything-else will misbehave the first time it hits a value it wasn't built for. The values you need to plan for:

Common exam distractor

Checking for the presence of text content, or pattern-matching phrases like "I'm done" or "task complete," as your termination signal is always wrong - on the exam and in production. Claude can emit explanatory text ("Let me check that order for you") in the very same response as a tool_use block requesting a tool call. A loop that sees text at content[0] and calls it finished will cut the task off mid-stream. Only stop_reason tells you what happens next; content type and content text are not reliable signals for anything about loop control.

Parallel tool calls and the bookkeeping they require

A single response with stop_reason: "tool_use" can contain more than one tool_use block - the model is allowed to request several independent tool calls in one turn instead of one at a time. Your loop has to execute all of them, not just the first, and it has to return exactly one tool_result block per tool_use_id in the follow-up user message. Skip one, and the next API call will error because the model is still waiting on a result it never got back. The order the results appear in doesn't need to match the order the calls were requested in, but every id must be accounted for.

Key concept

stop_reason is the only reliable, deterministic signal for loop control. A fixed iteration cap is a legitimate safety net against a runaway loop, but it is never the primary way the loop decides it's finished - that decision belongs to stop_reason alone.

Exam traps

Practice question

A developer's agent loop checks if response.stop_reason == "tool_use", and treats every other value identically as "finished, show the user the text." A user later reports the agent showed a truncated, mid-sentence answer. What's the most likely cause?

  • A The model refused the request and the refusal text was rendered as if it were a complete answer.

    A refusal is usually a clear decline, not a mid-sentence cutoff - that symptom points more specifically at a truncation stop_reason being mishandled.

  • B stop_reason was max_tokens or model_context_window_exceeded, and the loop displayed the cut-off partial output as if it were complete. Correct

    Both of these mean the response was truncated, not finished - a loop that lumps them in with end_turn will show incomplete output as if it were the final answer.

  • C The tool_use_id didn't match on a parallel tool call.

    An id mismatch would break tool result handling, not produce a mid-sentence truncated final answer to the user.

  • D Prompt caching expired mid-conversation.

    Cache expiry affects cost/latency of reprocessing, not whether a response gets cut off mid-sentence.

Build exercise: Build a loop that handles five distinct stop_reason values

Intermediate · 40 minutes

You'll practice:

  1. Set up an Anthropic SDK client with one real tool (e.g. a get_order_status stub that returns a canned JSON object) and send an initial request that should trigger a tool call.

    You need a real response object to branch on before you can build correct branching logic - guessing at the shape of stop_reason and content is how bugs like the content[0] anti-pattern get written in the first place.

    You should see: A response where response.stop_reason === "tool_use" and response.content contains at least one block with type "tool_use".

    Hints
    1. What does a tool definition need at minimum for the API to know how to call it?
    2. Each tool needs name, description, and input_schema (a JSON Schema object). Log the full response object after the first call before writing any branching logic, so you can see the actual shape.
    3. const tools = [{
        name: "get_order_status",
        description: "Look up the status of a customer order by id",
        input_schema: {
          type: "object",
          properties: { order_id: { type: "string" } },
          required: ["order_id"]
        }
      }];
      const response = await client.messages.create({
        model: "claude-sonnet-5",
        max_tokens: 1024,
        tools,
        messages: [{ role: "user", content: "What's the status of order A-1002?" }]
      });
      console.log(response.stop_reason, JSON.stringify(response.content, null, 2));
  2. Write the loop body: on tool_use, execute every tool_use block in the response (not just the first), and append exactly one matching tool_result per tool_use_id in a single follow-up user message.

    This is the step that breaks most home-grown loops - missing a result, or handling only the first tool call when the model requested several in parallel, leaves the conversation structurally invalid for the next API call.

    You should see: Conversation history growing by exactly one assistant message (the raw response.content) and one user message (an array of tool_result blocks, one per tool_use_id) each iteration.

    Hints
    1. If a response contains two tool_use blocks, how many tool_result blocks does the next message need, and how are they matched to the calls?
    2. Filter response.content for every block where type === "tool_use", execute each, and build one tool_result block per call using that block's own id as tool_use_id. Push the assistant response content verbatim, then push a single user message containing all the tool_result blocks together.
    3. const toolUses = response.content.filter(b => b.type === "tool_use");
      const results = toolUses.map(tu => ({
        type: "tool_result",
        tool_use_id: tu.id,
        content: JSON.stringify(executeTool(tu.name, tu.input))
      }));
      messages.push({ role: "assistant", content: response.content });
      messages.push({ role: "user", content: results });
  3. Force a max_tokens truncation on purpose (set max_tokens very low, e.g. 15, on a prompt that needs a long answer) and add a branch that handles it distinctly from end_turn - log a warning and retry with a higher limit rather than returning the partial text.

    You can't verify correct truncation handling by reading docs - you need to actually trigger stop_reason: "max_tokens" and confirm your loop doesn't silently treat the cut-off text as a finished answer.

    You should see: A response with stop_reason === "max_tokens", your loop logging a truncation warning instead of returning the partial content, and a retry succeeding with a higher max_tokens value.

    Hints
    1. What's the observable difference between a response that's genuinely finished and one that just ran out of room?
    2. Check response.stop_reason === "max_tokens" (and model_context_window_exceeded the same way) before ever treating content as final. On either, don't show the text - log it and re-issue the call with a larger max_tokens (or a trimmed conversation, for the context-window case).
    3. if (response.stop_reason === "max_tokens" || response.stop_reason === "model_context_window_exceeded") {
        console.warn(`Truncated (${response.stop_reason}), retrying with more room`);
        response = await client.messages.create({ ...request, max_tokens: request.max_tokens * 4 });
      } else if (response.stop_reason === "end_turn") {
        return response.content.find(b => b.type === "text")?.text ?? "";
      }
  4. Add explicit branches for pause_turn (resubmit the conversation unchanged to continue the turn) and refusal (surface it distinctly to the caller instead of retrying or displaying it as a normal answer).

    These two are easy to skip because they're rare in casual testing, but the exam and production both expect you to have designed for them rather than lumping them into a generic else-branch.

    You should see: A switch/if-chain over stop_reason with a distinct, named branch for tool_use, end_turn, max_tokens, model_context_window_exceeded, pause_turn, and refusal - no catch-all that treats unknown values as done.

    Hints
    1. pause_turn isn't an error and isn't completion - what's the simplest thing your code can do to correctly continue that kind of turn?
    2. For pause_turn, just send the same messages array back in another request without modifying it - the model continues where it left off. For refusal, don't retry blindly; return a distinct result type (e.g. { status: "refused", detail: ... }) so the caller can decide how to handle it (show a message, escalate, etc.).
    3. switch (response.stop_reason) {
        case "tool_use": /* execute + append, loop again */ break;
        case "end_turn": return { status: "done", text: extractText(response) };
        case "max_tokens":
        case "model_context_window_exceeded": /* retry with adjustment */ break;
        case "pause_turn": /* resend unchanged */ break;
        case "refusal": return { status: "refused", detail: extractText(response) };
        default: throw new Error(`Unhandled stop_reason: ${response.stop_reason}`);
      }
  5. Add a safety iteration cap (e.g. 20) that logs a warning and breaks the loop if hit, and confirm through testing that it never triggers on a normal multi-step task - only stop_reason should end the loop under normal conditions.

    The exam distinguishes a cap used as a safety net (acceptable) from a cap used as the primary stopping mechanism (an anti-pattern). Proving your cap is a net, not a control, means showing it stays well clear of triggering during real use.

    You should see: A counter incrementing each iteration, a MAX_ITERATIONS guard that only fires as a last resort, and normal test runs finishing via end_turn several iterations before the cap.

    Hints
    1. Where does the counter check belong relative to the stop_reason check - before or after?
    2. Check the counter first as a hard ceiling, but let stop_reason remain what actually ends the loop in every normal run. If your test task ever hits the cap, that's a sign of a real bug (e.g. missing tool_result), not proof the cap is doing its job.
    3. const MAX_ITERATIONS = 20;
      let iterations = 0;
      while (true) {
        if (++iterations > MAX_ITERATIONS) {
          console.warn("Safety cap reached - loop did not terminate via stop_reason");
          break;
        }
        const response = await client.messages.create({ model: "claude-sonnet-5", max_tokens: 1024, tools, messages });
        if (response.stop_reason === "end_turn") { return extractText(response); }
        // ...tool_use / max_tokens / pause_turn / refusal branches from above
      }

Sources