Build an Agent Loop From Scratch in 50 Lines of Python
An AI agent that calls tools sounds like it needs a framework. It doesn't. The model never runs anything itself: it returns a structured request to call a tool, your code runs the tool, and you send the result back in the next request. Repeat that until the model says it is finished, and you have an agent. The Claude docs describe the canonical shape as a while loop keyed on stop_reason.
This guide builds that loop in about 50 lines of Python on the raw Claude Messages API, with two read-only tools (list_files and read_file) that can answer questions about a codebase. It covers each part a real loop needs: tool definitions, the message history, the pairing of tool_use and tool_result blocks, every stop reason, a turn limit, and error handling. Then it ports the same loop to the OpenAI Responses API, so you can see which parts are universal and which are one vendor's wire format.
The code was run against the official anthropic (1.15) and openai (3.35) Python SDKs using a local stub server that returns scripted API responses, including parallel tool calls, a path-escape attempt, an unknown tool and malformed JSON arguments. It was not run against the live APIs, so check the model name before you use it. For the pattern behind the loop, see The ReAct Pattern; for what frameworks add on top, see LangChain vs LlamaIndex vs Agent SDK.
What an agent loop is: the model asks, your code acts
Tool use is a contract. You describe the operations available and the shape of their inputs; the model decides when to call them. When it wants one, the API response ends with stop_reason: "tool_use" and contains one or more tool_use blocks, each with an id, the tool name and an input object. The model has not run anything. Your application runs the tool and replies with a user message containing a tool_result block that names the same id.
The model has no memory between requests, so every request carries the whole conversation: the original task, every assistant turn and every tool result so far. The loop therefore has one data structure, the messages list, and one decision per iteration, made from stop_reason:
tool_use: run the requested tools, append the results, call the API again.end_turn: the model has finished; return its text.- Anything else: the response is incomplete or declined, so stop and handle it.
The Claude docs list the full process as five steps: send the tools and the user message, receive tool_use, execute each tool, send back the history plus the results, and repeat while stop_reason is tool_use. That is the entire mechanism. A ReAct agent, a coding agent and a research agent are all this loop with different tools, prompts and limits.
Quick reference
- These are client tools: you run them. Server tools such as web search run on Anthropic's side and return results inside the response, so the loop never builds a
tool_resultfor them. - A tool call costs at least one extra API round trip. If the model can answer from the prompt alone (summarising, translating), a single request is cheaper and faster than an agent.
- If you are parsing free text to decide what the model wants to do next, that decision should be a tool call; the docs give this as the clearest sign you need tools.
Remember this
An agent is a loop: the model requests a tool, you run it and send the result back, until stop_reason says it is done.
The whole program
Here is the complete agent. It answers questions about the files in the directory where you run it, using two tools that can only read inside that directory:
1import pathlib, sys2import anthropic3 4client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY5MODEL = "claude-opus-5-5"6ROOT = pathlib.Path(".").resolve()7MAX_TURNS = 108 9PATH_ARG = {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}10TOOLS = [11 {"name": "list_files", "input_schema": PATH_ARG,12 "description": "List the entries of a directory, relative to the project root. Use '.' for the root."},13 {"name": "read_file", "input_schema": PATH_ARG,14 "description": "Return the text of one file, relative to the project root. Output stops at 20,000 characters."},15]16 17def safe_path(path):18 p = (ROOT / path).resolve()19 if not p.is_relative_to(ROOT):20 raise ValueError(f"{path} is outside the project root; use a relative path")21 return p22 23def run_tool(name, args):24 if name == "list_files":25 return "\n".join(sorted(p.name for p in safe_path(args["path"]).iterdir())) or "(empty)"26 if name == "read_file":27 return safe_path(args["path"]).read_text()[:20_000] or "(empty file)"28 raise ValueError(f"unknown tool: {name}")29 30def agent(task):31 messages = [{"role": "user", "content": task}]32 for _ in range(MAX_TURNS):33 resp = client.messages.create(model=MODEL, max_tokens=4096, tools=TOOLS, messages=messages)34 messages.append({"role": "assistant", "content": resp.content})35 if resp.stop_reason == "end_turn":36 return "".join(b.text for b in resp.content if b.type == "text")37 if resp.stop_reason != "tool_use":38 raise RuntimeError(f"stopped early: {resp.stop_reason}")39 results = []40 for block in resp.content:41 if block.type != "tool_use":42 continue43 try:44 out, failed = run_tool(block.name, block.input), False45 except Exception as e:46 out, failed = f"{type(e).__name__}: {e}", True47 results.append({"type": "tool_result", "tool_use_id": block.id, "content": out, "is_error": failed})48 messages.append({"role": "user", "content": results})49 raise RuntimeError(f"no final answer after {MAX_TURNS} turns")50 51if __name__ == "__main__":52 print(agent(" ".join(sys.argv[1:])))Run it with ANTHROPIC_API_KEY set, from the root of a small project: python agent.py "What does this project do, and where is the entry point?". A typical run lists the root, reads a README or a manifest, opens one or two source files and answers, using three to five API calls.
It is roughly 50 lines: about 15 define the tools, 10 implement them and 20 are the loop. The next sections go through each part and the rule it follows. MODEL is a placeholder for whichever current Claude model you use; the loop does not depend on the model.
Quick reference
resp.contentis appended as is. The SDK turns its block objects back into JSON when it sends the next request, so text andtool_useblocks reach the API exactly as the model produced them.- The SDK reads
ANTHROPIC_API_KEYfrom the environment; never hard-code a key into the file or pass it through a client-side variable. pathlib.Path.is_relative_toneeds Python 3.9 or later.
Remember this
About 15 lines of tool definitions, 10 of tool code and 20 of loop make a working agent.
Tool definitions: the only thing the model sees
Each tool has three fields: a name, a description and an input_schema (a JSON Schema object). The model never sees your Python; it chooses tools and fills in arguments from these three fields alone. That makes the description the main way to steer it: say what the tool does, what each argument means, and any limit the model should know about. Both descriptions above say paths are relative to the project root, and read_file says output stops at 20,000 characters, so the model is not surprised by a truncated file.
The schema is a contract about shape, not a guarantee. By default the model usually fills the schema correctly but may not always; adding "strict": true to a tool definition makes the API constrain the input to match the schema exactly. Even with strict mode, the schema cannot express everything: "a path inside this directory" is a rule only your code can enforce, which is why safe_path resolves the path and rejects anything outside ROOT, including .. tricks and symlinks that point elsewhere.
Keep the tool set small and the tools distinct. Two tools that overlap (read_file and a cat shell tool) force the model to guess which one you meant. For more on naming, descriptions and versioning tool schemas, see Tool Calling Reliability Patterns.
Quick reference
- Tool definitions are sent with every request and count as input tokens, so each extra tool costs a little on every turn.
- Return text the model can use directly. Here
list_filesreturns names, one per line, rather than a Python repr ofPathobjects. - Treat tool output as untrusted. A file or web page can contain instructions aimed at the model; the docs recommend keeping such content inside
tool_resultblocks rather than the system prompt.
Remember this
The model sees only the name, description and schema; enforce every rule the schema cannot express in the tool code.
Message history: pairing tool_use with tool_result
The history must follow rules the API checks, and breaking them returns a 400 error:
- Results come immediately after the call. The user message with the
tool_resultblocks must directly follow the assistant message with thetool_useblocks. Nothing goes in between. - Every
tool_usegets atool_resultwhosetool_use_idmatches itsid. Missing one produces the error "tool_use ids were found without tool_result blocks immediately after". - Results go first. In that user message, all
tool_resultblocks come before any text you add. - One message for all results. When the model calls several tools in one turn, send every result in a single user message. The parallel tool use docs warn that separate messages teach the model to stop making parallel calls.
The loop gets all four right by construction. It appends the assistant's whole content (text and tool_use blocks together), builds a results list with one entry per tool_use block, and appends that list as one user message. Parallel calls need no special code: the for loop over resp.content handles one call or five.
Append the content unchanged. If you turn on extended thinking, the assistant turn also contains thinking blocks, and the docs require you to pass them back complete and unmodified when you return tool results. Code that rebuilds the assistant message from only the text and tool calls breaks as soon as thinking is enabled.
Quick reference
- The loop runs parallel calls one after another. Running them concurrently (threads or
asyncio) is a small change if your tools are slow and independent. - To prevent parallel calls, set
disable_parallel_tool_use: trueinsidetool_choice; it is not a top-level request parameter. - The history grows every turn and is resent in full, so input tokens grow with each call. Long runs need truncation, summarising or compaction; see Context Rot in AI Agents.
Remember this
Append the assistant turn unchanged, then one user turn with a matching tool_result for every tool_use, results first.
Stop conditions: handle every stop_reason
stop_reason is in every successful response and tells you why the model stopped generating. A loop that only checks if stop_reason == "tool_use" and treats everything else as success will one day return half an answer. Each value needs a decision:
end_turn: the model finished. Return the text.tool_use: run the tools and continue.max_tokens: the response hit yourmax_tokenscap. If the last block is atool_use, it may be incomplete, and the docs say to retry with a higher limit rather than run it.refusal: the model declined. This is an HTTP 200, not an error; readstop_details, and the docs suggest retrying on a fallback model.pause_turn: a server-side tool loop hit its iteration limit. It only occurs with server tools; send the conversation back to continue.stop_sequence: one of yourstop_sequencesmatched. It only occurs if you set them.model_context_window_exceeded: the response filled the context window; treat it as truncated.
The 50-line loop returns on end_turn, continues on tool_use and raises on everything else, carrying the reason in the message. That is the right default for a small agent: failing loudly is better than silently returning a truncated answer. A production loop would add specific handling where it pays off, such as retrying max_tokens with a larger cap or resending on pause_turn if you add server tools.
Quick reference
stop_reasondescribes a successful response. Failed requests (rate limits, overload, bad requests) raise exceptions instead and never reach this check.max_tokenslimits one response, not the whole run. A run of ten turns can use ten times that many output tokens.- If a client tool and a server tool are called in the same turn, the response stops with
tool_useand aserver_tool_useblock without a result; send only the clienttool_resultblocks back.
Remember this
Return on end_turn, loop on tool_use, and treat every other stop reason as incomplete until you decide otherwise.
Guards: turn limits and what they protect against
Nothing in the API stops a loop that never reaches end_turn. A model that keeps reading files or retries a failing tool can run until you notice the bill. MAX_TURNS caps the number of API calls per task, and when it is reached the loop raises instead of returning a guess.
Pick the limit from observed runs, not intuition: log how many turns real tasks take, and set the cap a little above the slowest successful run. Ten is generous for a two-tool read-only agent. An agent that edits code and runs tests may need more, and should probably also cap total tokens or wall-clock time. The usage field on every response reports input and output tokens, so a token budget is a running sum and a comparison.
The other guards are in the tools. safe_path means a confused or manipulated model cannot read /etc/passwd, and the 20,000-character cut stops one large file from filling the context window. Neither tool can write, so the worst case is a wrong answer, not damage. Once tools have side effects, a turn limit is not enough; you need permission checks or human approval before each risky call. For choosing limits and detecting stalls, see Autonomous Agent Stop Conditions.
Quick reference
- When the limit is hit, save the
messageslist. The transcript shows whether the model was looping, stuck on an error, or doing a task too large for the limit. - A repeated identical tool call is a cheap stall signal: the same name and input twice in a row usually means the model did not understand the result.
- Read-only tools keep the cost of a mistake low; add write tools only with a way to review or undo what they do.
Remember this
Cap turns, size tool output, and keep tools read-only until you have permission checks for side effects.
Error handling: return errors to the model
There are two kinds of error, and they belong in different places.
Tool errors go back to the model. When run_tool raises (a missing file, a path outside the root, an unknown tool name), the loop catches the exception and returns its message in a tool_result with "is_error": true. The model reads it and adapts: it lists the directory to find the right path, or explains that it cannot access the file. The docs recommend instructive messages, saying what went wrong and what to try next, which is why the path error ends with "use a relative path". The Claude docs also note that when a tool request is invalid or missing parameters, Claude retries two to three times with corrections before apologising to the user, so a clear error usually leads to a corrected call.
API errors go to your code. Rate limits (429), server errors (5xx), timeouts and connection failures are raised by the SDK, not shown to the model. The Anthropic Python SDK retries 408, 409, 429 and 5xx responses and connection errors twice by default with backoff before raising; set max_retries on the client to change that. The loop lets those exceptions propagate, because the model cannot fix a rate limit.
The except Exception in the loop is deliberately broad, since any tool failure should become a result rather than crash the run. The boundary is that it also hides bugs in your own tool code from you, so log the exception before returning it in a real system.
Quick reference
- Return the error as text the model can act on, such as
FileNotFoundError: [Errno 2] No such file or directory: 'src/main.py', not justfailed. - Do not retry a non-idempotent tool automatically after a timeout; the action may already have happened. See Durable Execution for AI Agents for recording which steps already ran.
- Setting
strict: trueremoves the malformed-input class of error but not the semantic one: a valid string can still be a path that does not exist.
Remember this
Tool failures become is_error results the model can recover from; API failures are retried by the SDK and then raised to you.
The same loop on the OpenAI Responses API
The loop's structure carries over unchanged, but the wire format does not. Here is the same agent on the OpenAI Responses API, reusing TOOLS, MAX_TURNS and run_tool from the Claude version:
1import json, sys2from openai import OpenAI3from agent import TOOLS, MAX_TURNS, run_tool4 5oai = OpenAI() # reads OPENAI_API_KEY6OAI_TOOLS = [{"type": "function", "name": t["name"], "description": t["description"],7 "parameters": t["input_schema"]} for t in TOOLS]8 9def agent_openai(task, model):10 items = [{"role": "user", "content": task}]11 for _ in range(MAX_TURNS):12 resp = oai.responses.create(model=model, tools=OAI_TOOLS, input=items)13 if resp.status != "completed":14 raise RuntimeError(f"stopped early: {resp.status} {resp.incomplete_details}")15 items += resp.output16 calls = [item for item in resp.output if item.type == "function_call"]17 if not calls:18 return resp.output_text19 for call in calls:20 try:21 out = run_tool(call.name, json.loads(call.arguments))22 except Exception as e:23 out = f"error: {type(e).__name__}: {e}"24 items.append({"type": "function_call_output", "call_id": call.call_id, "output": out})25 raise RuntimeError(f"no final answer after {MAX_TURNS} turns")26 27if __name__ == "__main__":28 print(agent_openai(" ".join(sys.argv[2:]), sys.argv[1]))Three differences matter in practice. First, the Responses API has no stop_reason: the loop continues while the output contains function_call items and checks status for truncation. Second, arguments arrives as a JSON string, so json.loads can fail, and the loop catches that like any other tool error. Third, the Responses API has no error flag on results; the error is just text in output.
The Responses API can also keep the conversation on OpenAI's side with previous_response_id, in which case you send only the new function outputs each turn. Resending the full list, as here, keeps the code parallel to the Claude version and keeps the transcript in your hands. Pass the model name on the command line (python agent_openai.py <model> "question").
| Part of the loop | Claude Messages API | OpenAI Responses API |
|---|---|---|
| Tool definition | name, description, input_schema | type: "function", name, description, parameters |
| Model asks for a tool | tool_use block with id and input (an object) | function_call item with call_id and arguments (a JSON string) |
| You return a result | tool_result with tool_use_id, in a user message | function_call_output item with call_id |
| Signal to keep looping | stop_reason == "tool_use" | output contains function_call items |
| Truncated or blocked | max_tokens, refusal and other stop reasons | status is incomplete, with incomplete_details.reason |
| Report a failed tool | is_error: true | No flag; describe the error in output |
| Turn off parallel calls | tool_choice.disable_parallel_tool_use | parallel_tool_calls: false |
Quick reference
- OpenAI recommends
strict: trueon function definitions, which makes calls follow the schema using structured outputs; it adds requirements such asadditionalProperties: falseand listing every property as required. items += resp.outputmatters for reasoning models: it returns their reasoning items with the function calls, the Responses equivalent of passing Claude's thinking blocks back.- A wrapper that hides both formats behind one interface is easy to write; most frameworks are partly that wrapper.
Remember this
The loop is portable; only the message shapes and the stop signal change between vendors.
What frameworks add, and when to reach for one
The 50-line loop covers what every agent needs. It leaves out the parts that become work as an agent grows:
- Context management: trimming, summarising or compacting a history that outgrows the context window.
- Streaming: showing text and tool calls as they are generated, and cancelling mid-run.
- Concurrency: running independent tool calls in parallel.
- Persistence: saving and resuming a run after a crash or across sessions.
- Approval and permissions: pausing before risky tool calls for a human or a policy check.
- Tracing: recording each turn, tool call and token count for debugging and evals.
Anthropic's SDKs include a beta Tool Runner (client.beta.messages.tool_runner in Python) that runs this loop for you from decorated functions, with a max_iterations limit. The docs suggest the manual loop when you need human approval, custom logging or conditional execution. The Claude Agent SDK goes further, with built-in file, shell and search tools, permissions, hooks, sessions and subagents. LangChain, LlamaIndex and the OpenAI Agents SDK each package the loop with their own abstractions.
Write the loop yourself first, even if you later replace it. When a framework agent misbehaves, the cause is nearly always in this loop: a dropped tool result, an unhandled stop reason, a tool error that became an exception, a history that grew too long. Knowing the loop tells you where to look. For a side-by-side of the frameworks, see LangChain vs LlamaIndex vs Agent SDK; for the Claude Agent SDK specifically, see Claude Agent SDK Guide.
Quick reference
- Stay on the manual loop when the agent is small, the tools are yours, and you need exact control over each request.
- Move to a framework when you need several of the items above; rebuilding all of them yourself takes longer than learning the framework.
- Whichever you choose, keep the transcript of every run. It is the only reliable record of what the agent saw and did.
Remember this
Frameworks add context management, streaming, persistence and approvals around the same loop; learn the loop first.
Practice: break the loop on purpose
Build the agent, then break each part to see the failure:
1. Run agent.py in a small project with a question that needs two or three files.
2. Print resp.stop_reason and the tool calls in each iteration to see the trace.
3. Set max_tokens=50 and ask for a long answer. The loop should raise stopped early: max_tokens rather than return a fragment.
4. Ask it to read ../../etc/passwd. The model should get an is_error result and explain that it cannot.
5. Set MAX_TURNS = 1 and ask a question that needs a file. The loop should raise after one call.
6. Comment out messages.append({"role": "user", "content": results}) and run it once. Read the 400 error: it names the tool_use ids that had no result.
7. Add a third tool, search(pattern), that greps the project, and check from the trace whether the model uses it instead of reading every file.
Pass criterion: you can explain each failure from the transcript alone, and the agent answers the original question correctly with the guards in place.
Quick reference
- Keep the tools read-only while you experiment.
- Log
resp.usageper turn to see how input tokens grow as the history gets longer. - Run step 6 only against a test key; it makes one failed request and nothing more.
Remember this
Breaking each guard once is the fastest way to understand what it protects against.
Key takeaway
An agent loop is a while loop over stop_reason. Send the history and tool definitions, run every tool_use the model returns, append one user message with a matching tool_result for each, and repeat until end_turn. The rest is guarding the edges: treat every other stop reason as incomplete, cap the number of turns, enforce in code the rules the schema cannot express, return tool failures to the model with is_error, and leave API errors to the SDK's retries and your own code.
The same structure runs on the OpenAI Responses API with different shapes, and every agent framework is this loop with more around it. For the reasoning pattern the loop implements, see The ReAct Pattern; for when to stop building your own, see LangChain vs LlamaIndex vs Agent SDK.
Practice (45 min): build the agent and work through the seven steps above.
References
- Claude docs: How tool use works — checked Oct 19, 2026
- Claude docs: Handle tool calls — checked Oct 19, 2026
- Claude docs: Handling stop reasons — checked Oct 19, 2026
- Claude docs: Parallel tool use — checked Oct 19, 2026
- Claude docs: Tool runner (SDK) — checked Oct 19, 2026
- OpenAI docs: Function calling — checked Oct 19, 2026
Shoaib Hossain
Lead Author & Systems ArchitectSoftware engineer and distributed systems architect specializing in backend scalability, cloud-native infrastructure, databases, and AI engineering workflows. Author and maintainer of Core Concept Learning.
Related Articles
Explore this topic