Skip to content

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 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.

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].

Checkpoint · match

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

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.

Checkpoint · predict

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.

This example is not run in CI; the output was checked by hand.

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].

Checkpoint · scenario

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?

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.

Checkpoint · choice

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

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 [2].
  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 [2].
  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 [2].
  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 [5] [6].

You can now

  • Rebuilds the loop on an agent SDK and explains what the SDK took over

  1. Anthropic. Agent SDK reference - Python. Claude Code documentation. Reference. Claude Code agent sdk python
  2. Anthropic. How the agent loop works. Claude Code documentation. Reference. Claude Code agent loop
  3. Anthropic. Give Claude custom tools. Claude Code documentation. Reference. Claude Code custom tools
  4. 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
  5. Anthropic. Tool runner (SDK). Claude Platform documentation. Reference. Claude docs tool-runner
  6. Anthropic. Handle tool calls. Claude Platform documentation. Reference. Claude docs handle-tool-calls
  7. Anthropic. Claude Platform 101. Claude Academy. Course. Academy claude-platform-101
  8. Anthropic. Building with the Claude API. Claude Academy. Course. Academy building-with-the-claude-api