Chapter 2: Run your first agent loop
Run the smallest agent loop with the Claude Agent SDK's query(), and see the engine it runs for you: perceive, call a tool, observe, decide.
Xiaoman · The Reaching Hall
Until now you have fed Xiaoman its files. Today it learns to fetch them itself.
Draft chapter. First cut to prove the format; it will be hardened before it is indexed.
What you’ll build
Your first real agent loop, run with the Claude Agent SDK. It’s still the PR reviewer that runs through the whole book: you will write the review contract, give it a single Read tool, run it with query(), and watch it fetch src/payments/refund.py on its own and hand back a review checklist. By the end you will see what every agent framework runs under the hood, the four-step cycle the Microsoft “Tool Use Design Pattern” lesson describes: the model perceives, decides on a tool, the tool runs, the model observes the result, then loops.
In Chapter 1 you wrote a reviewer contract that assumed the diff was already pasted in. Here it changes a little: the agent fetches its own context. Instead of you pasting a file, it asks to read it. That single change, the model requesting tools instead of you supplying everything up front, is what turns a prompt into an agent. And query() runs that loop for you, so you only have to hand it the goal, the tools, and the limits.
Prerequisites
- Chapter 1 finished: the reviewer contract, the project scaffold, and a working API key (
ANTHROPIC_API_KEY). - The Claude Agent SDK installed: Python
pip install claude-agent-sdk(Python 3.10+, the CLI is bundled), or TypeScriptnpm install @anthropic-ai/claude-agent-sdk(Node 18+). - A repo you can experiment in safely, containing the file to review,
src/payments/refund.py.
Steps
1. Write the contract as a system prompt, and grant one tool
Use the reviewer contract from Chapter 1 as the system prompt. The key move is allowed_tools: it narrows the auto-approved tools to a single Read, so this loop can read files but not write them and not run commands. Fewer tools means a loop that is easier to understand and safer.
import anyio
from claude_agent_sdk import query, ClaudeAgentOptions, AssistantMessage, TextBlock, ToolUseBlock
CONTRACT = """You are a PR reviewer. To judge any file you must first read it with Read.
When you have read enough, output the review checklist: severity, file:line, one-line risk.
Never invent code you have not read."""
options = ClaudeAgentOptions(
system_prompt=CONTRACT,
allowed_tools=["Read"], # auto-approve only the read-file tool
max_turns=5, # cap the loop
cwd="./repo", # confine it to the repo directory
)
import { query } from "@anthropic-ai/claude-agent-sdk";
const CONTRACT = `You are a PR reviewer. To judge any file you must first read it with Read.
When you have read enough, output the review checklist: severity, file:line, one-line risk.
Never invent code you have not read.`;
const options = {
systemPrompt: CONTRACT,
allowedTools: ["Read"], // auto-approve only the read-file tool
maxTurns: 5, // cap the loop
cwd: "./repo", // confine it to the repo directory
};
2. Run it with query() and a goal
query() takes a goal and these options and returns a stream of messages. The goal is the trigger: it names what to review but deliberately omits the file body. query() runs the whole loop for you, so you never write the “send, branch, run tool, feed back, send again” cycle by hand.
async def main():
async for message in query(
prompt="Review src/payments/refund.py for bugs and risks.",
options=options,
):
handle(message) # defined next
anyio.run(main)
const q = query({
prompt: "Review src/payments/refund.py for bugs and risks.",
options,
});
for await (const message of q) {
handle(message); // defined next
}
3. Iterate the message stream and see the four steps
As the loop runs, query() yields messages one at a time. All you do is read them. An AssistantMessage can carry two kinds of block: a ToolUseBlock (the model requesting a tool, such as Read) or a TextBlock (the model’s text answer). That branch is the heart of the agent; everything else is plumbing, and the SDK wired the plumbing for you.
def handle(message):
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, ToolUseBlock):
print(f"[tool] {block.name} {block.input}") # perceive -> decide on Read
elif isinstance(block, TextBlock):
print(block.text) # answer after observing
function handle(message) {
if (message.type === "assistant") {
for (const block of message.message.content) {
if (block.type === "tool_use") {
console.log(`[tool] ${block.name}`, block.input); // perceive -> decide on Read
} else if (block.type === "text") {
console.log(block.text); // answer after observing
}
}
}
}
4. Cap the loop and lock it to the repo
Two guardrails are not optional. max_turns caps the loop: a confused model will request reads forever, and the cap stops it. allowed_tools grants only Read, and cwd locks the agent to the repo directory so the model cannot touch anything else on disk. The narrower the capability, the more you can trust it, and that idea carries all the way through to the guardrails in Chapter 11.
Learned: reaching outXiaoman no longer waits for you to hand over a diff. It can call the Read tool and read into the files to fetch its own context.
A real trace
Running the loop on the refund file, the message stream looks like this, four steps, one tool call:
[tool] Read {'file_path': 'src/payments/refund.py'} <- perceive + decide
(the SDK runs Read and feeds the file back to the model) <- tool runs + observe
- [ ] (high) refund.py:13 : `<` rejects exact-total refunds;
full refunds silently fail. <- no further tool call, loop ends
How to verify
- Run it once. Confirm it calls
Readexactly once, then answers with the checklist shape from Chapter 1. - Print every message. You should see the tool request (
Read) first, then the final text review. - Set
allowed_toolsto[](no tools at all) and run again. The model cannot read the file, so it either says plainly that it cannot, or stops cleanly, rather than inventing code. That confirms the contract clause “never invent code you have not read.” - Set
max_turnsto1and give it a vague goal (“review everything”). Watch it stop at the cap. That proves why the guardrail matters.
Learned: reading the loopYou can print every message and watch the four steps, perceive, call, observe, decide. From now on you can debug any agent by asking what was in the message stream at the step it went wrong.
Why it works
What query() runs for you is a very plain dispatcher: show the model its tools, execute whichever one it picks, feed the result back as new context, and loop until the model stops calling tools. There is no hidden intelligence in the runtime; the only “thinking” happens on the model’s side. Frameworks (this SDK included) add retries, parallel tool calls, streaming, and session stores, but they all wrap this same four-step loop. Knowing that, you can debug any agent by asking one question: what was in the message stream at the step it went wrong?
Recap
You now have the four steps every agent runs: perceive, call a tool, observe, decide. You did not hand-write the loop, because the SDK runs it, but you saw exactly what it runs. The reviewer can now fetch its own context instead of being fed a diff by hand. The next chapter covers context engineering: as that message stream grows, how to decide what goes into it (which files, how much of each, what to drop) so the loop does not lose track on long reviews.
Common pitfalls
- No iteration cap. Without
max_turns, a confused model loops forever. Always set one. - Granting too many tools. By default the agent has the full Claude Code toolset (including Write and Bash). For a read-only review, narrow
allowed_toolsto["Read"]so it cannot change your code. - Not confining the working directory. Without
cwd, the model may read files outside the repo. Lock it to the project directory. - Reading only the final text and ignoring tool blocks. When debugging, the intermediate
ToolUseBlockis exactly what you want, it tells you what the model requested and at which step.
A door that was always locked clicks open. Xiaoman walks into where the files live and fetches one on its own, then adds, a little timidly, there are two more files I did not dare open, want me to? You never taught it to ask that. The Reaching Hall lights up.
Just lit The Reaching Hall · 3 / 16 lit
Sources
- Claude Agent SDK docs (Python) · official