Claude Certified Architect, Week 1: The Orchestrator-Worker Loop

Week one of preparing for the Claude Certified Architect – Foundations exam: the four-stage agent lifecycle, a production orchestrator-worker loop that branches on every stop_reason, and the failure modes that separate a demo from a service.
AI
Architecture
Claude
Certification
Author

Ravi Kalia

Published

August 29, 2026

Claude Certified Architect, Week 1

An agent loop is a while loop around one API call. Each time round, the model tells you why it stopped. There are six things it can say, and a loop that only handles one of them will hand back a half-finished answer and call it done.

These are notes from the first of a few weeks studying for the Claude Certified Architect – Foundations exam. Its biggest topic is Agentic Architecture and Orchestration.

The full implementation is orchestrator.py, in this post’s directory. Apart from the single-call example below, every code block is copied straight out of it; the only change is the indentation.

I learned to think this way at SoFi (Social Finance, Inc.), building apps that use LLMs to take over repetitive regulatory work. Nobody reads the loop’s output there before it goes into a filing. A wrong answer that looks finished does more damage than a crash.

1 Scope

This week covers one pattern properly instead of skimming all six exam domains.

  • Domain: Agentic Architecture and Orchestration — 27% of the exam, the largest single block.
  • Pattern: orchestrator-worker — one model plans and splits the work, then hands each piece to a worker loop that can reach only the tools it needs.
  • Surface: the Claude Messages API and a hand-written loop. The SDK’s client.beta.messages.tool_runner handles this loop for you and is the right default in production; writing it out by hand once is what makes the rest of the material make sense.
  • Model: claude-opus-5 with adaptive thinking, which is on by default. Depth and spend are set with output_config.effortlow through max — not with a token budget; budget_tokens is rejected on this model.

2 Prompting versus architecture

Prompting and agent design break in different places. Moving from one to the other changes what you are on the hook for.

Ad-hoc prompting Agentic architecture
Unit of work One request, one response A loop over many requests
Who decides the next step A human reading the output Your control flow, reading stop_reason
Primary failure surface Prompt wording State handling, tool boundary, budgets
Effect of a bad output A human notices A malformed argument reaches a database
Cost ceiling One call Unbounded until you impose one

This does not make prompting matter less. It moves where it matters. The prompt is still a lever, and a wider one than plain text: Soft Prompts vs. Hard Prompts starts a prompt exactly on GPT-2’s vocabulary and, sixty gradient steps later, it has drifted somewhere no sentence can reach. But no prompt of either kind tells your code what to do when the model stops mid-sentence.

So the model’s output stops being an answer. It becomes an instruction to your code, and the rest of this post is about reading it correctly.

3 A single Messages API call

The API is stateless, so every call carries the whole conversation. This is the call the loop wraps.

import anthropic

client = anthropic.Anthropic()  # ANTHROPIC_API_KEY, or an `ant auth login` profile

response = client.messages.create(
    model="claude-opus-5",
    max_tokens=4096,
    system="Answer in one sentence.",
    messages=[{"role": "user", "content": "Why should an agent loop check stop_reason?"}],
)

print(response.stop_reason)          # "end_turn"
print(response.usage.output_tokens)

for block in response.content:       # a list of typed blocks, not a string
    if block.type == "text":
        print(block.text)

Three things in that response matter later.

  • stop_reason rides on every response. The next sections are the six values it can take, and what each one needs your code to do.
  • content is a list of typed blocks, not a string. On claude-opus-5 thinking is on by default, so the list can hold thinking blocks beside text ones — which is why the loop checks block.type, and why it appends the whole list rather than the text.
  • messages is the entire history, sent again every call. Nothing is kept server-side.

The rest of that call — the roles messages takes, the content-block types, how a tool is defined, how retrieved text goes in as a document block — is surveyed in Claude API with the Anthropic Python SDK. This post assumes that surface and stays on the loop around it.

The loop that wraps this call is the one LLM Agents from First Principles builds from scratch, against a raw model with its own text parsing. The Messages API changes what is hard: a tool call arrives as a typed tool_use block instead of text to be picked apart, so the parsing step disappears and the branching step is what is left to get right.

4 The four-stage lifecycle

One turn of an agent loop has four stages. Three of the four are code you own; only the first is prompting.

flowchart LR
    A["Stage 1: initial prompt<br/>system + tools + history"] --> B{"Stage 2<br/>why did it stop?"}
    B -->|end_turn / stop_sequence| Z["Return to caller"]
    B -->|refusal| R["Stop here:<br/>read stop_details"]
    B -->|max_tokens| T["Retry bigger,<br/>then fail loudly"]
    B -->|pause_turn| P["Resume,<br/>capped"]
    B -->|tool_use| C["Stage 3: tool execution<br/>check, run, catch"]
    C --> D["Stage 4: state hand-off<br/>append content + results"]
    D --> A
    P --> A

4.1 Stage 1: initial prompt

The request carries the system prompt, the tool definitions, and the entire history — the API is stateless.

  • Put the parts that never change first (fixed system text, a tool list in a fixed order) and the parts that change every call last, so the cache still hits.
  • A timestamp or a request id in the system prompt quietly breaks the cache on every call. If usage.cache_read_input_tokens is zero across calls that should share a prefix, that is what happened.

4.2 Stage 2: stop reason check

The model tells you why it stopped. Everything else in the loop hangs off this one check, and it is the part most code gets wrong.

4.3 Stage 3: tool execution

Each tool_use block names a tool and carries already-parsed JSON input.

  • Check the input before you run anything. strict: true on the tool definition makes the API reject bad input before it ever reaches your handler.
  • One turn can carry several tool_use blocks. Running them concurrently is the point of that, though the loop below dispatches them in order to keep the error path readable.
  • Never match on the raw JSON text; models escape Unicode and forward slashes differently.

4.4 Stage 4: state hand-off

The results go back as the next user turn, and this stage has three hard rules.

  1. Append the whole response.content list, not just its text. Dropping the tool_use and thinking blocks makes the next request invalid.
  2. Every tool_use_id from the assistant turn needs a matching tool_result. Miss one and the next request is rejected.
  3. All results from one turn go in a single user message. Splitting them trains the model out of making parallel tool calls.

5 Stop reasons

Six values. Each one needs different code.

stop_reason Meaning Required action
end_turn Finished naturally Return the text
stop_sequence Hit a configured stop sequence Return the text
tool_use Wants one or more tools run Run them, hand the results back, go round again
max_tokens Output cut off part-way Retry with a bigger cap, then fail loudly
pause_turn A server-side tool hit its own limit Send the history back unchanged to resume, and cap the resumes
refusal Turned the request down on safety grounds Stop here; read stop_details

stop_details is filled in only for refusal. It is null for every other reason, so check before you read it.

The loop gives each of these its own outcome, and sets limits the model never sees:

class Outcome(str, Enum):
    """How a loop ended. Every value maps to one branch in `run_loop`."""

    COMPLETED = "completed"  # stop_reason end_turn / stop_sequence
    TRUNCATED = "truncated"  # stop_reason max_tokens, retry exhausted
    REFUSED = "refused"  # stop_reason refusal
    TURN_LIMIT = "turn_limit"  # our own ceiling, not the model's
    STALLED = "stalled"  # same tool call repeated, no progress
    ABORTED = "aborted"  # ToolAbort from a handler
    UNKNOWN_STOP = "unknown_stop"  # a stop_reason this code has never seen


@dataclass(frozen=True)
class Budget:
    """Ceilings the model cannot see and therefore cannot talk its way past."""

    max_turns: int = 12
    max_tool_calls: int = 40
    #: `pause_turn` means a server-side tool loop hit its own iteration cap.
    #: Resuming is correct; resuming without a bound is an infinite loop.
    max_pause_resumes: int = 5
    #: One retry at a larger `max_tokens` before declaring truncation.
    max_token_retries: int = 1
    #: Identical (name, input) calls tolerated before the run is called stalled.
    max_repeats: int = 2

The checks are a flat ladder, and nothing drops off the bottom. A reason this code has never seen — one the API adds after it ships — stops the run instead of being taken for success:

stop = response.stop_reason
log.debug("turn %d stop_reason=%s", result.turns, stop)

if stop in ("end_turn", "stop_sequence"):
    result.outcome = Outcome.COMPLETED
    return result

if stop == "refusal":
    # `stop_details` is populated only for refusals; guard before use.
    details = response.stop_details
    result.outcome = Outcome.REFUSED
    result.detail = getattr(details, "explanation", None) or "no explanation"
    log.error(
        "refused (%s): %s",
        getattr(details, "category", None),
        result.detail,
    )
    return result

if stop == "max_tokens":
    # Output was cut mid-sentence. Continuing as if it were complete is
    # how a half-written tool argument reaches a database.
    if token_retries < budget.max_token_retries:
        token_retries += 1
        turn_max_tokens *= 2
        messages.pop()  # discard the truncated turn before retrying
        result.text = previous_text  # ... and the text it contributed
        log.warning("truncated; retrying at max_tokens=%d", turn_max_tokens)
        continue
    result.outcome = Outcome.TRUNCATED
    result.detail = f"still truncated after {token_retries} retry(ies)"
    return result

if stop == "pause_turn":
    # A server-side tool (web search, code execution) hit its own
    # iteration limit. Re-send the history unchanged -- the API sees the
    # trailing server_tool_use block and resumes; an added "continue"
    # message confuses it.
    pause_resumes += 1
    if pause_resumes > budget.max_pause_resumes:
        result.outcome = Outcome.TURN_LIMIT
        result.detail = f"paused {pause_resumes} times without finishing"
        return result
    continue

if stop != "tool_use":
    # A reason added to the API after this code was written. Stopping
    # is the honest response; falling through would invent an answer.
    result.outcome = Outcome.UNKNOWN_STOP
    result.detail = f"unhandled stop_reason {stop!r}"
    log.error(result.detail)
    return result

Note the messages.pop(). The cut-off turn is thrown away before the retry. Leave it in and the retry just adds a second turn carrying on from half a sentence.

6 Tool execution and errors

A broken tool is something you tell the model, not an exception that tears down the loop. Whatever goes wrong — no such tool, bad arguments, the handler blew up, it took too long — comes back as a tool_result marked is_error: True. The model reads it and tries something else.

started = time.monotonic()
try:
    output = _call_with_deadline(
        lambda: tool.handler(**block.input),
        tool.timeout_s,
    )
except TimeoutError:
    log.warning("%s exceeded %gs; abandoned", tool.name, tool.timeout_s)
    return _error_result(
        block.id,
        f"{tool.name} did not finish within {tool.timeout_s:g}s and was "
        "abandoned. Narrow the request and try again.",
    )
except ToolFailure as exc:
    log.warning("%s failed: %s", tool.name, exc)
    return _error_result(block.id, str(exc))
except ToolAbort:
    raise
except Exception as exc:  # noqa: BLE001 - a handler bug must not kill the run
    log.exception("%s raised", tool.name)
    return _error_result(
        block.id,
        f"{tool.name} failed with {type(exc).__name__}: {exc}",
    )

log.debug("%s ran in %.2fs", tool.name, time.monotonic() - started)

return {
    "type": "tool_result",
    "tool_use_id": block.id,
    "content": output if isinstance(output, str) else json.dumps(output),
}

ToolAbort is the exception: dead credentials, a missing binary, a tripped breaker. The model cannot fix any of those, so the run stops instead of retrying against something that is already broken.

Error results carry the same tool_use_id as any other:

def _error_result(tool_use_id: str, message: str) -> dict[str, Any]:
    return {
        "type": "tool_result",
        "tool_use_id": tool_use_id,
        "content": message,
        "is_error": True,
    }

7 Loop limits

Two limits in the tool_use branch stop a run going forever: a hard cap on tool calls, and a check for repeats.

# --- stop_reason == "tool_use" -------------------------------------
calls = [b for b in response.content if b.type == "tool_use"]
granted = {t.get("name") for t in tools}
tool_results: list[dict[str, Any]] = []
terminal: tuple[Outcome, str] | None = None
for block in calls:
    if result.tool_calls >= budget.max_tool_calls:
        terminal = (
            Outcome.TURN_LIMIT,
            f"exceeded {budget.max_tool_calls} tool calls",
        )
        break

    # An agent that repeats a call verbatim is not making progress; it
    # is usually re-reading a resource whose result it misread. Say so
    # in-band before the turn ceiling burns the whole budget.
    signature = (block.name, json.dumps(block.input, sort_keys=True))
    seen_calls[signature] = seen_calls.get(signature, 0) + 1
    if seen_calls[signature] > budget.max_repeats:
        # Answer the block anyway -- every tool_use id in the turn needs
        # a result or the history cannot be resent -- then stop.
        tool_results.append(
            _error_result(
                block.id,
                f"{block.name} has already been called with these exact "
                "arguments and returned the same result. Use a different "
                "approach or state what is blocking you.",
            ),
        )
        terminal = (
            Outcome.STALLED,
            f"{block.name} repeated {seen_calls[signature]} times",
        )
        break

    try:
        result.tool_calls += 1  # counted only when it actually runs
        tool_results.append(registry.dispatch(block, granted))
    except ToolAbort as exc:
        terminal = (Outcome.ABORTED, str(exc))
        log.error("aborted by %s: %s", block.name, exc)
        break

# However the turn ended, every tool_use id in it needs a result or
# the history cannot be resent -- so answer the blocks that never ran
# before handing it back. This is why the loop breaks rather than
# returning from inside the loop.
answered = {r["tool_use_id"] for r in tool_results}
tool_results.extend(
    _error_result(b.id, "Not run: the loop stopped part-way through this turn.")
    for b in calls
    if b.id not in answered
)

# All results go back in one user message. Splitting them across
# several messages trains the model out of parallel tool calls.
messages.append({"role": "user", "content": tool_results})

if terminal is not None:
    result.outcome, result.detail = terminal
    return result

The repeat check is the cheaper of the two. An agent calling the same tool with the same arguments is stuck, usually re-reading something whose answer it misread the first time. Saying so in the tool result gives it a chance to recover before the turn limit eats the whole budget.

8 Delegation as a tool call

The orchestrator has one tool, and it starts a worker loop. Because handing off work is a plain tool call and not a special case, it gets the whole error path above for free: a worker that gets stuck, cut off, or refused comes back as an is_error result the planner can work around.

def delegate(task: str, tools: list[str]) -> str:
    spend["workers"] += 1
    if spend["workers"] > max_workers:
        # Its own ceiling, deliberately below the planner's tool-call cap.
        # Sharing that cap made this unreachable: run_loop stops before it
        # dispatches the call that would exceed it.
        raise ToolAbort(f"worker budget exhausted after {max_workers} workers")

    unknown = [name for name in tools if name not in registry.names]
    if unknown:
        raise ToolFailure(f"no such tool(s): {', '.join(unknown)}")

    worker = run_loop(
        client,
        system=worker_system,
        messages=[{"role": "user", "content": task}],
        tools=registry.params(tools),  # run_loop enforces this grant
        registry=registry,
        budget=worker_budget,
        model=model,
    )
    if not worker.ok:
        # Surfaced to the planner as a tool failure, with the outcome name
        # so it can distinguish "ran out of turns" from "was refused".
        raise ToolFailure(
            f"worker ended as {worker.outcome.value} ({worker.detail}). "
            f"Partial output: {worker.text[:500] or '(none)'}",
        )
    return worker.text

Two things fall out of this.

  • The worker starts clean. It sees the task string and nothing else, so a long planner history never bloats worker requests.
  • Each worker gets only what it needs. The planner names the tools that worker may call, and run_loop derives the allowed set from the definitions it sent, so a call to anything else comes back refused. Narrowing the definitions alone would not do it: a model can name a tool it was never shown, either by hallucinating a name from an earlier result or because injected text told it to.

9 Anti-patterns and production failure modes

Each of these passes a demo and fails in production. The first two hurt most in regulatory work, where nothing about a cut-off answer looks different from a finished one.

  1. while response.stop_reason == "tool_use". The most common bug by far. A max_tokens truncation, a refusal, and a pause_turn all exit the loop as though the model had finished, and the caller gets a partial answer with no error. Branch on every value by name.
  2. Unhandled schema validation errors. A handler that raises on a bad argument tears down the loop and throws away a turn you could have saved. Worse, catching it and dropping the block leaves a tool_use_id unanswered, and the next request comes back as a 400 that names neither the tool nor the turn. Always send a result, and set is_error.
  3. Infinite execution loops. Three separate causes: no turn limit, resuming pause_turn forever, and a model retrying the same call forever. Each needs its own limit. One max_turns hides the other two until the bill shows up.
  4. Appending only the text. messages.append({"role": "assistant", "content": response.text}) loses the tool_use and thinking blocks. It breaks on the next request, well away from the line that caused it.
  5. Splitting parallel tool results. The API accepts each tool_result in its own user message, and doing that quietly teaches the model to stop batching calls. Things get slower over a session and nothing errors.
  6. One broad except. Catching APIStatusError alone lumps the errors worth retrying (429, 5xx, connection) in with the ones that will never succeed (400, 404). Catch the specific ones first.
  7. A volatile cache prefix. A clock, a UUID, or an unsorted json.dumps() in the system prompt or tool list breaks the prefix on every call. The bill triples and nothing errors.
  8. Uniform tool grants. Give every worker the full tool list and one bad call can reach anything.

10 Takeaways

  • Treat stop_reason as six named cases, not a yes or no.
  • A broken tool sends a message back. A broken dependency raises. Use different exception types for the two.
  • Limit turns, tool calls, pause resumes, and repeated calls separately. They run away in four different ways.
  • Give each worker the smallest tool set its job needs, and one self-contained instruction instead of the whole history.
  • Use the SDK’s tool runner in production. Write the loop by hand once, to see what it is doing for you.
  • Check caching with usage.cache_read_input_tokens. Do not assume you put the breakpoint in the right place.

Stop. Reasons. Branch. Errors. Return. Inline. Budgets. Bound. Loops. Delegate. Narrowly.

11 References