Rebuilding the loop on an agent SDK
In this lesson we take the weather agent from The 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 [1] [2], and the plan for this lesson records the date they were checked.
The same agent, twice
Section titled “The same agent, twice”The hand-written version from the live lessons is this loop, here in the form Stopping the loop on purpose 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.
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 [2]. 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 [1]. The handler returns a content
list, and "is_error": True marks a failed call so the model reads it as
one [3]. The tool itself is the one from the first
lesson, a function plus a description written for the caller [4].
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
[3].
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
[2]. The try is there because a single-shot query
raises after it has yielded an error result [2]. 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
Section titled “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 [2]. 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 [2]. 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 [1].
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
[2]. 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 [1]. 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
[2].
Where did each part go?
Section titled “Where did each part go?”The lesson shows a hand-written agent loop (a message list, a tool lookup and call, tool errors returned as results, a step limit and a character budget, and a progress callback) next to the same agent on the Claude Agent SDK, where each of those parts is a library feature.
Match each line of the hand-written loop with the SDK feature that does its job now.
For each line of the hand loop, ask which SDK message, option or behavior does the same job now.
What does the SDK take over?
Section titled “What does the SDK take over?”A developer moves a hand-written agent loop, with its own message list, tool dispatch, error handling, stop rules and progress printing, onto an agent SDK that runs the loop inside the library.
Which of these jobs move from your code into the SDK?
Which of these were lines in the hand loop, and which were never the loop's job?
The limits the defaults leave open
Section titled “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 [2]. 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 [2]. max_budget_usd
compares a running cost estimate that the client keeps, and a subagent’s
spend counts toward it [2]. When either limit ends
the run, result is None. A program reads subtype first and result
only after success.
Predict the stop line
Section titled “Predict the stop line”The lesson's SDK program iterates query() inside a try. The loop body prints message.result when the ResultMessage subtype is success and otherwise prints stop: followed by the subtype. After an error result the single-shot query() raises, and the except prints ended: followed by the error text. It sets max_turns=4 and max_budget_usd=0.10.
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.
stop: error_max_turns
This example is not run in CI; the output was checked by hand.
Which subtype does the SDK report when the turn limit ends a run, and which branch of the program prints it?
What the SDK makes harder
Section titled “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 [2]. 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
[2]. 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
[2].
The evaluation reads a summary
Section titled “The evaluation reads a summary”An evaluation grades an SDK agent's trajectory by reading the tool results in the conversation after the run. In long runs the SDK compacts the conversation, replacing older messages with a summary, and the vendor documents no option that turns compaction off.
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?
What does your code see that the compacted history no longer holds?
Which limit applies?
Section titled “Which limit applies?”An agent SDK runs the loop inside the library and exposes options for a maximum number of turns and a maximum cost. Neither is set unless the developer sets it.
A developer ships an SDK agent without setting max_turns or
max_budget_usd. What ends a run?
What ends the run when neither option is set, and who decides?
Who does it now?
Section titled “Who does it now?”A developer moved a hand-written agent loop onto the Claude Agent SDK, which runs the loop inside the library. Some jobs moved into the SDK, and some stay with the developer.
Is it part of running the loop, or a decision about limits and records?
When to drop to the raw API
Section titled “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 [5]. Below that runs the manual loop
against the Messages API, where you build the tool_result block yourself
[6]. 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
[7] [8].
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
Extra checkpoints on the same ideas, if you want them. You can finish the lesson without them.
A tool the agent may not use
Section titled “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?
What did the hand loop do with an unknown tool name, and what does the SDK do with a denied one?
Recap
- 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 [2].
max_turnsandmax_budget_usdare unset by default, and the run ends when the model stops asking for tools. Set both for a production agent and readsubtypebeforeresult[2].- 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 [2].
- 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 [5] [6].
You can now
- Rebuilds the loop on an agent SDK and explains what the SDK took over
References
Section titled “References”- Anthropic. Agent SDK reference - Python. Claude Code documentation. Reference.
Claude Code agent sdk python - Anthropic. How the agent loop works. Claude Code documentation. Reference.
Claude Code agent loop - Anthropic. Give Claude custom tools. Claude Code documentation. Reference.
Claude Code custom tools - Addy Osmani, Ivar Soares Urdalen, Leo Simons. Building your first agent: the loop from scratch, then with an SDK. Agent Engineer Course. Course.
AEC-13 - Anthropic. Tool runner (SDK). Claude Platform documentation. Reference.
Claude docs tool-runner - Anthropic. Handle tool calls. Claude Platform documentation. Reference.
Claude docs handle-tool-calls - Anthropic. Claude Platform 101. Claude Academy. Course.
Academy claude-platform-101 - Anthropic. Building with the Claude API. Claude Academy. Course.
Academy building-with-the-claude-api