# Rebuilding the loop on an agent SDK

> Put the weather agent on the Claude Agent SDK, name the five parts of the hand-written loop the SDK took over, set the turn and cost limits the defaults leave open, and see where the SDK's compaction gets in the way of an evaluation.

Page: https://schubergphilis.github.io/ai-training/building-agents/loop-on-an-sdk/
License: CC BY-SA 4.0, https://creativecommons.org/licenses/by-sa/4.0/

In this lesson we take the weather agent from [The agent loop](https://schubergphilis.github.io/ai-training/building-agents/agent-loop/)
and rebuild it on an agent SDK (software development kit), the Claude Agent
SDK for Python. The loop we wrote by hand across the last four lessons is
still there. It runs inside the library now. This lesson names what moved
and what the SDK decides for you unless you say otherwise. The last section
shows where the library gets in the way of an evaluation.

The code on this page needs a real model and an API key, so it doesn't
run in CI, and the one prediction on the page is marked as checked by hand.
Every identifier on this page (the package, the classes, the option names,
the result codes) is on the SDK's reference pages
(Agent SDK reference - Python, Claude Code documentation) (How the agent loop works, Claude Code documentation), and the plan for
this lesson records the date they were checked.

## The same agent, twice

The hand-written version from the live lessons is this loop, here in the
form [Stopping the loop on purpose](https://schubergphilis.github.io/ai-training/building-agents/stopping-the-loop/)
left it. The tool is looked up by name, and the request and the result are
appended to the message list. The harness checks run after every round, and
the step limit is the last line.

```python
def run(question, model, tools=TOOLS, max_steps=4, max_errors=2, budget=5000, progress=None):
    messages = [{"role": "user", "content": question}]
    spent = 0
    try:
        for step in range(1, max_steps + 1):
            reply = model(messages)
            if "answer" in reply:
                return outcome("end_turn", reply["answer"], messages)
            tool = tools[reply["tool"]]
            result = tool["fn"](**reply["args"])
            messages.append({"role": "assistant", "content": reply})
            messages.append({"role": "tool", "content": result, "is_error": is_error(result)})
            if progress is not None:
                progress(step, reply, result)
            spent += len(str(messages))
            reason = check(messages, spent, max_errors, budget)
            if reason is not None:
                return outcome(reason, None, messages)
    except KeyboardInterrupt:
        return outcome("interrupted", None, messages)
    return outcome("max_steps", None, messages)
```

The SDK version of the same agent has no loop in it. The package is
`claude-agent-sdk`, installed with `pip`, and it bundles the Claude Code
binary that runs the loop (How the agent loop works, Claude Code documentation). The tool keeps its
description and its function. The `@tool` decorator takes the name, the
description and the argument types, and `create_sdk_mcp_server` groups the
decorated functions into an in-process tool server that the options refer
to by name (Agent SDK reference - Python, Claude Code documentation). The handler returns a `content`
list, and `"is_error": True` marks a failed call so the model reads it as
one (Give Claude custom tools, Claude Code documentation). The tool itself is the one from the first
lesson, a function plus a description written for the caller (Building your first agent: the loop from scratch, then with an SDK, Agent Engineer Course).
A tool on the server listed under the key `weather` in `mcp_servers` has
the full name `mcp__weather__get_weather`, and listing that name in
`allowed_tools` lets it run without a permission prompt
(Give Claude custom tools, Claude Code documentation).

```python
import asyncio

from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, create_sdk_mcp_server, query, tool

WEATHER = {"Amsterdam": "14°C, rain", "Lisbon": "27°C, sun"}


@tool("get_weather", "Current weather for a city. Args: city (str).", {"city": str})
async def get_weather(args):
    city = args["city"]
    if city not in WEATHER:
        return {"content": [{"type": "text", "text": f"error: no data for {city}"}], "is_error": True}
    return {"content": [{"type": "text", "text": WEATHER[city]}]}


weather_server = create_sdk_mcp_server(name="weather", tools=[get_weather])


async def main():
    options = ClaudeAgentOptions(
        mcp_servers={"weather": weather_server},
        allowed_tools=["mcp__weather__get_weather"],
        max_turns=4,
        max_budget_usd=0.10,
    )
    try:
        async for message in query(prompt="What is the weather in Amsterdam?", options=options):
            if isinstance(message, ResultMessage):
                if message.subtype == "success":
                    print(message.result)
                else:
                    print(f"stop: {message.subtype}")
    except Exception as error:
        print(f"ended: {error}")


asyncio.run(main())
```

`query` returns an async iterator, and the `for` body sees every message
the loop produces. The program above ignores every one of them except the last,
the `ResultMessage`, which holds the final text in `result` when the run
succeeded and a `subtype` that names what ended the run otherwise
(How the agent loop works, Claude Code documentation). The `try` is there because a single-shot `query`
raises after it has yielded an error result (How the agent loop works, Claude Code documentation). On a
limit stop this program prints two lines: the `stop:` line from the `for`
body, then the `ended:` line from the `except` with the error text the SDK
raised. A program that wants to carry on after a limit needs that `except`.

## What moved into the library

Read the two programs side by side and five parts of the hand-written loop
have no counterpart in the SDK version. Each one is still running, inside
the SDK, with a name you can look up.

**Messages.** The hand loop appended two entries per round,
`messages.append({"role": "assistant", "content": reply})` and the tool
message after it. The SDK keeps the conversation itself and yields each
entry as it is made: an `AssistantMessage` per content block of the
model's reply, and a `UserMessage` with the tool result after each tool
runs (How the agent loop works, Claude Code documentation). Your code reads them as they pass and
never edits the list.

**Dispatch.** The hand loop looked the tool up and called it,
`result = tool["fn"](**reply["args"])`, and the parallel lesson's
`results = execute(reply["calls"])` ran a whole list. The SDK runs each
requested tool and, when the model asks for more than one in one turn, runs the
read-only ones together and the state-changing ones one after another. A
custom tool runs one at a time unless its annotations mark it read-only
with `readOnlyHint` (How the agent loop works, Claude Code documentation). The permission check runs
before dispatch: a tool that isn't allowed gets a rejection message
as its result, which is the same path the hand loop's `error:` string took
back to the model.

**Retries.** The hand loop had one kind of retry, the model's own: a tool
error went back as a result, `result = f"error: {exc}"`, and the model
could ask again. It had no retry for the model call itself, because the
fake model never failed. The SDK adds that one. The Claude Code binary
retries a failed API call, up to the count in the `CLAUDE_CODE_MAX_RETRIES`
environment variable, which defaults to 10 (Agent SDK reference - Python, Claude Code documentation).
A tool error still goes back to the model as a result, as in your loop.

**Stopping.** The hand loop had `for step in range(1, max_steps + 1):`,
`if spent > budget:` and the `KeyboardInterrupt` handler. The SDK ends the
loop when the model replies without a tool call, when `max_turns` tool-use
turns have run, or when the client-side cost estimate reaches
`max_budget_usd`, and it reports which one in `ResultMessage.subtype`:
`success` for the first, `error_max_turns` and `error_max_budget_usd` for
the limits, and `error_during_execution` when something cut the run off
(How the agent loop works, Claude Code documentation). The list isn't complete. The Python reference
adds a `terminal_reason` field next to `subtype`, with values such as
`aborted_streaming` for an interrupted turn, so a program that branches on
how a run ended reads both (Agent SDK reference - Python, Claude Code documentation). Ctrl-C is
`interrupt()` on a `ClaudeSDKClient` session.

**Streaming.** The hand loop's `progress(step, reply, result)` printed a
line per round so a person could watch. The SDK's message stream is that
hook, made permanent: handle `AssistantMessage` for a line per tool call,
and set `include_partial_messages=True` in the options to get
`StreamEvent` messages with the text as it is generated
(How the agent loop works, Claude Code documentation).

#### Checkpoint: Where did each part go?

Match each line of the hand-written loop with the SDK feature that does
its job now.

Statements:

- messages.append({"role": "tool", "content": result, "is_error": is_error(result)})
- result = tool["fn"](**reply["args"])
- result = f"error: {exc}" if str(exc) else f"error: {type(exc).__name__}"
- for step in range(1, max_steps + 1):
- progress(step, reply, result)

Options:

- AssistantMessage and UserMessage yielded per block and per tool result
- The SDK runs the requested tools, read-only ones together
- CLAUDE_CODE_MAX_RETRIES on the API call, and a tool error still returns as a result
- max_turns and max_budget_usd, reported in ResultMessage.subtype
- include_partial_messages and StreamEvent

## The limits the defaults leave open

Both options in the SDK program above are set on purpose. Without them the
SDK has no turn limit and no cost limit, and the loop runs until the model
stops asking for tools (How the agent loop works, Claude Code documentation). That default suits a
demo, where a person watches the run and can stop it. A production agent
gets a prompt no one is watching, and a wide one ("make the tests in this
repository faster") keeps a model reading and editing for a long time. The
SDK's reference page recommends a spend cap for an agent that runs
unattended, and the hand loop taught the same rule when
`model_never_answers` ran into `max_steps`.

`max_turns` and `max_budget_usd` count differently from your own loop's limits.
`max_turns` counts tool-use turns, so a run that answers in text on its
first reply uses none, and `max_turns=2` in a run that needs three tool
rounds stops before the third (How the agent loop works, Claude Code documentation). `max_budget_usd`
compares a running cost estimate that the client keeps, and a subagent's
spend counts toward it (How the agent loop works, Claude Code documentation). When either limit ends
the run, `result` is `None`. A program reads `subtype` first and `result`
only after `success`.

#### Checkpoint: Predict the stop line

Suppose a model keeps asking for one more city until the turn limit ends
the run. The program prints two lines, and the second is the `ended:` line
from the `except`. Which line does the `async for` body print? This
example doesn't run in CI, and you check it against the SDK's reference
page yourself.

#### Pitfall: Trusting the defaults in production

A team ships an SDK agent that worked in every demo. In production one
prompt sends the model reading files for an hour, and the bill arrives
before anyone sees the run. The SDK had no turn limit and no cost limit
because nobody set one, and the defaults were chosen for a run that a
person watches. The rule: every limit your hand loop had, set explicitly in
the SDK options, and test that each one stops the agent before you deploy.

## What the SDK makes harder

An SDK is a set of decisions someone else made, and most of them are the
ones you would have made. Some aren't, and the one that surprises
evaluation work is context management. When the conversation nears the
context window's limit, the SDK compacts it: older history is replaced with
a summary, and a `SystemMessage` with subtype `compact_boundary` marks
where that happened (How the agent loop works, Claude Code documentation). For a long run this is what
keeps the agent going. For an evaluation that grades the trajectory, the
tool results it wanted to read are now a summary, and the SDK's reference
pages don't document an option that turns compaction off.

The way around it is the message stream. Every tool result passes your
code as a `UserMessage` before compaction can touch it, and a `PostToolUse`
hook sees it as well. An evaluation harness that records the results as
they arrive grades its own log. A `PreCompact` hook runs before each
compaction and is the documented place to archive the full transcript
(How the agent loop works, Claude Code documentation). Persistent instructions go in `CLAUDE.md`,
loaded through `setting_sources`, because that file is sent on every
request and a summary might drop an instruction from the first prompt
(How the agent loop works, Claude Code documentation).

#### Checkpoint: The evaluation reads a summary

Your evaluation grades the tool results of each run. In the longer runs
the results it needs are gone from the history, and a `compact_boundary`
message sits where they were. What do you change?

- Raise max_turns so the run ends before compaction triggers.
- Record each tool result from the UserMessage stream as it arrives, and grade that log.
- Ask the model in the prompt to keep every tool result in the conversation.
- Read the summary and grade what it kept.

### When to drop to the raw API

The SDK is the top of a stack of layers. Below it, the client library for the
Claude API has a tool runner that loops over your functions with
`client.beta.messages.tool_runner`, bounds the loop with `max_iterations`,
and hands back the message history when you take it over for one
iteration (Tool runner (SDK), Claude Platform documentation). Below that runs the manual loop
against the Messages API, where you build the `tool_result` block yourself
(Handle tool calls, Claude Platform documentation). Drop a layer when the layer above makes
a decision you need to make yourself. A message list you must edit between
rounds is the usual case. The Academy's platform course walks
that manual loop against the real API, and it is the loop you wrote in
this course with the field names changed
(Claude Platform 101, Claude Academy) (Building with the Claude API, Claude Academy).

## Exercise

From the two programs on this page, write a five-row table. The rows are
messages, dispatch, retries, stopping, and streaming. The first column is
the line of the hand-written loop where that part lived, copied from the
`agent.py` of the lesson that had it. The second is the SDK message type,
option or documented behavior that does the job now, and the third is the
page of the SDK reference where you found it. Doing this once gives you the
map you need when the SDK's version behaves differently from yours.

A good result: every row quotes a real line from `agent-loop`,
`stopping-the-loop` or `parallel-tool-calls`, and every SDK entry is a
name you can search for on the reference page. Which row was hardest to
fill, and is that because the SDK hides it or because your loop never had
it?

Stretch: Rebuild the weather agent on the SDK with max_turns=1 and a small max_budget_usd, run it twice with a prompt that needs two tool rounds and once with a long prompt, and record which subtype ends each run. Both limits must stop it at least once.

## More practice

#### Checkpoint: A tool the agent may not use

The model asks for a tool that isn't in `allowed_tools`, and no
permission callback is set. What happens next?

- The SDK raises on the tool call and the run ends with error_during_execution.
- The SDK waits for a person to approve it.
- The model receives a rejection as the tool result and tries another approach.

## Recap

1. An agent SDK runs the loop you wrote inside the library: messages,
   dispatch, retries, stopping and streaming each have a name in the
   reference, and the pairing is your map for debugging it
   (How the agent loop works, Claude Code documentation).
2. `max_turns` and `max_budget_usd` are unset by default, and the run
   ends when the model stops asking for tools. Set both for a production
   agent and read `subtype` before `result` (How the agent loop works, Claude Code documentation).
3. Compaction replaces older history with a summary, so an evaluation
   records tool results from the message stream or a hook instead of
   reading them back from the conversation (How the agent loop works, Claude Code documentation).
4. Below the agent SDK are the client library's tool runner and the manual
   loop. Drop a layer when the layer above makes a decision you need to
   make yourself (Tool runner (SDK), Claude Platform documentation) (Handle tool calls, Claude Platform documentation).

## References

- Agent SDK reference - Python, Claude Code documentation, https://code.claude.com/docs/en/agent-sdk/python
- How the agent loop works, Claude Code documentation, https://code.claude.com/docs/en/agent-sdk/agent-loop
- Give Claude custom tools, Claude Code documentation, https://code.claude.com/docs/en/agent-sdk/custom-tools
- Building your first agent: the loop from scratch, then with an SDK, Agent Engineer Course, https://lsimons.github.io/agent-engineer-course/13-building-your-first-agent/
- Tool runner (SDK), Claude Platform documentation, https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-runner
- Handle tool calls, Claude Platform documentation, https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls
- Claude Platform 101, Claude Academy, https://academy.claude.com/courses/claude-platform-101
- Building with the Claude API, Claude Academy, https://academy.claude.com/courses/building-with-the-claude-api
