Skip to content

Build an Agent Loop From Scratch in 50 Lines of Python

Core Concept LearningOctober 19, 202614 min read

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.

The agent loop: send the history and tools, read stop_reason, run tools and append results on tool_use, return the text on end_turn, and raise on anything else
The agent loop: send the history and tools, read stop_reason, run tools and append results on tool_use, return the text on end_turn, and raise on anything else

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_result for 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.content is appended as is. The SDK turns its block objects back into JSON when it sends the next request, so text and tool_use blocks reach the API exactly as the model produced them.
  • The SDK reads ANTHROPIC_API_KEY from the environment; never hard-code a key into the file or pass it through a client-side variable.
  • pathlib.Path.is_relative_to needs 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_files returns names, one per line, rather than a Python repr of Path objects.
  • 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_result blocks 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_result blocks must directly follow the assistant message with the tool_use blocks. Nothing goes in between.
  • Every tool_use gets a tool_result whose tool_use_id matches its id. 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_result blocks 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.

How the message history grows: every assistant tool_use turn is followed by one user turn whose tool_result blocks carry the same ids
How the message history grows: every assistant tool_use turn is followed by one user turn whose tool_result blocks carry the same ids

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: true inside tool_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 your max_tokens cap. If the last block is a tool_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; read stop_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 your stop_sequences matched. 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.

What the loop does for each stop_reason value
What the loop does for each stop_reason value

Quick reference

  • stop_reason describes a successful response. Failed requests (rate limits, overload, bad requests) raise exceptions instead and never reach this check.
  • max_tokens limits 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_use and a server_tool_use block without a result; send only the client tool_result blocks 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 messages list. 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.

Four failure paths in an agent loop and where each is handled: tool exceptions become is_error results, bad input is caught by the tool, API errors are retried by the SDK, and runaway loops hit MAX_TURNS
Four failure paths in an agent loop and where each is handled: tool exceptions become is_error results, bad input is caught by the tool, API errors are retried by the SDK, and runaway loops hit MAX_TURNS

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 just failed.
  • 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: true removes 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").

The same agent loop on two APIs
Part of the loopClaude Messages APIOpenAI Responses API
Tool definitionname, description, input_schematype: "function", name, description, parameters
Model asks for a tooltool_use block with id and input (an object)function_call item with call_id and arguments (a JSON string)
You return a resulttool_result with tool_use_id, in a user messagefunction_call_output item with call_id
Signal to keep loopingstop_reason == "tool_use"output contains function_call items
Truncated or blockedmax_tokens, refusal and other stop reasonsstatus is incomplete, with incomplete_details.reason
Report a failed toolis_error: trueNo flag; describe the error in output
Turn off parallel callstool_choice.disable_parallel_tool_useparallel_tool_calls: false

Quick reference

  • OpenAI recommends strict: true on function definitions, which makes calls follow the schema using structured outputs; it adds requirements such as additionalProperties: false and listing every property as required.
  • items += resp.output matters 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.usage per 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.

Share:

References

  1. Claude docs: How tool use works — checked Oct 19, 2026
  2. Claude docs: Handle tool calls — checked Oct 19, 2026
  3. Claude docs: Handling stop reasons — checked Oct 19, 2026
  4. Claude docs: Parallel tool use — checked Oct 19, 2026
  5. Claude docs: Tool runner (SDK) — checked Oct 19, 2026
  6. OpenAI docs: Function calling — checked Oct 19, 2026
SH

Shoaib Hossain

Lead Author & Systems Architect

Software engineer and distributed systems architect specializing in backend scalability, cloud-native infrastructure, databases, and AI engineering workflows. Author and maintainer of Core Concept Learning.

Human-Engineered & Fact-CheckedOriginal Visual DiagramsEditorial Standards →Send Feedback

Related Articles

Jul 30, 2026 · 9 min read

RAG taxonomy gets confusing because people mix three different ideas: architecture levels, retrieval tricks, and product…

Read

Jul 30, 2026 · 8 min read

The infographic is useful because it names the eight shelves most agentic AI systems touch: deployment infrastructure, e…

Read

Tool Calling Reliability Patterns matters when a team has to turn an AI idea into a system other people can trust. The u…

Read

Explore this topic

Keep learning

Follow a structured path or browse all courses to go deeper.