Skip to main content

Building a ReAct Agent From Scratch (No Framework)

Introduction

In the last lesson you learned that an agent is an LLM calling tools in a loopThought → Action → Observation → repeat. That was the concept. This lesson is the craft: you'll build that loop from scratch, in about 40 lines of Python, with no framework at all.

Why build it by hand when LangChain, LangGraph, the OpenAI Agents SDK, CrewAI, and a dozen others will do it for you? Because of the single most clarifying fact in agent engineering: those frameworks are not magic — they are this loop, wrapped. Build it once yourself and every framework stops being a black box; you'll read their docs and think "ah, that's just my while loop with state and retries bolted on." As the practitioners' refrain goes: build it once by hand, then use a framework with your eyes open. (This is the same move as L87 — Retrieve → Augment → Generate, where you built RAG from scratch before touching a framework.)

By the end you'll have:

  • A complete, runnable ReAct agent using native Claude tool calling — tools, schemas, the loop, the stop condition
  • A clear view of native function-calling vs. the original text-parsing ReAct
  • The demystification: exactly what LangChain's AgentExecutor and LangGraph add on top of this loop — and an honest read on when to build vs. when to adopt a framework.
An infographic titled 'Building a ReAct Agent From Scratch — No Framework'. You understand the loop; now build it in about 40 lines, zero framework. ONE TURN OF THE LOOP as a native function-calling round-trip: You send tools=[...] plus the transcript, the Model returns a tool_use block (name + input + id), You run the real function and send back a tool_result with the same id, and if stop_reason is no longer tool_use the agent is done. THE WHOLE AGENT FITS IN YOUR HEAD as six parts: Tools (plain functions — the agent never runs them, your code does), Schemas (name + description + typed args; the model picks from these words alone), System (one line of policy), The Loop (call the LLM with transcript and tools, append, repeat — this is the agency), Stop (no tool call = done; a max-step cap guarantees it halts), and Dispatch + Observe (look the name up, call the function, feed the result back as a user turn). A contrast: classic text-parsing ReAct (2022 paper) prompts the model to print Thought/Action/Observation and regex-parses the text — transparent but brittle and about 3 to 4 times the tokens; native function-calling returns a structured tool_use block with no parsing, the modern default, and OpenAI is the same loop with finish_reason equals tool_calls. The reveal: frameworks aren't magic — LangChain's old AgentExecutor was this while loop; its limits (no branching, pause/resume, parallelism, human-in-the-loop, or crash recovery) led to LangGraph, which makes the loop an explicit graph, and in 2026 LangChain v1.0's create_agent runs on LangGraph's engine under the hood. Banner: build the loop once by hand, then a framework is a known quantity — it buys you state, retries, human-in-the-loop and crash recovery, at the cost of abstraction and lock-in.

The Four Ingredients

A from-scratch agent has exactly four moving parts. Hold these in your head and the code below will read like prose:

  1. 🔧 Tools — the actual functions the agent can call (search, a calculator, a DB query). Plain Python.
  2. 📋 Schemas — a JSON description of each tool (name, what it does, typed arguments) that you hand the model so it knows what's available and how to call it.
  3. 📜 System prompt — one or two lines telling the model it's an agent and when to reach for tools.
  4. 🔁 The loop — the while/for that calls the model, runs whatever tool it asks for, feeds the result back, and repeats until the model stops asking.

That's the entire surface area. No "agent class," no orchestration DAG, no memory subsystem — just functions, their schemas, a prompt, and a loop. Let's build each piece.

Step 1 — Tools Are Just Functions

The first myth to kill: a "tool" is nothing special — it's an ordinary function. The model can't run code; you run it. The model only ever asks you to ("please call calculator with expression=\"18*9/5+32\""), and your loop does the calling. So tools are whatever Python you can write:

def calculator(expression: str) -> str:
    return str(eval(expression))     # ⚠️ toy only — NEVER eval() untrusted input

def web_search(query: str) -> str:
    return search_api(query)             # call your real search backend here

⚠️ Security, stated once and meant forever: the arguments to these functions come from the model, which can be steered by untrusted input (the user, a web page, a retrieved doc). eval() on model-supplied text is a remote-code-execution hole — it's here only because it makes the demo one line. Real tools validate and sandbox their inputs. We'll treat tool safety seriously in the MCP and production lessons.

Step 2 — Schemas: The Contract the Model Reads

The model can't see your Python — it only sees the schema you send. Each tool gets a JSON description with three parts: a name, a description, and an input_schema (JSON Schema for the arguments). This is how native function calling works: you pass these in the tools= parameter, and the model replies with a structured request to call one — no string parsing on your end.

Two things matter enormously here:

  • The description is a prompt, not a comment. The model decides whether and when to call a tool entirely from these words. "Evaluate an arithmetic expression. Use this for any math." is doing real work — vague descriptions cause the model to mis-pick or skip tools.
  • You need a way back from name → function. The model returns a tool's name (a string); you keep a REGISTRY dict to look up the real callable.
TOOLS = [
    {
        "name": "calculator",
        "description": "Evaluate an arithmetic expression. Use this for any math.",
        "input_schema": {
            "type": "object",
            "properties": {"expression": {"type": "string"}},
            "required": ["expression"],
        },
    },
    {
        "name": "web_search",
        "description": "Search the web for current facts the model may not know.",
        "input_schema": {
            "type": "object",
            "properties": {"query": {"type": "string"}},
            "required": ["query"],
        },
    },
]
REGISTRY = {"calculator": calculator, "web_search": web_search}   # name -> real fn

That input_schema is plain JSON Schema — the same vocabulary you saw for structured outputs. The model is constrained to produce arguments that fit it, which is why native function calling is so much more robust than hoping the model formats things correctly in free text.

Step 3 — The Loop (The Whole Agent)

Now the heart of it. Add a one-line system prompt and the loop, and the agent is complete. Read it once top-to-bottom — it's the L110 (The Agent Loop) cycle made literal:

import anthropic
client = anthropic.Anthropic()          # reads ANTHROPIC_API_KEY from the env

# ── 1. TOOLS — real Python functions. The agent never runs these; YOUR code does. ──
def calculator(expression: str) -> str:
    return str(eval(expression))        # ⚠️ toy only — NEVER eval() untrusted input

def web_search(query: str) -> str:
    return search_api(query)            # plug in your real search here

# ── 2. SCHEMAS — the contract the model reads to choose a tool and fill its args ──
TOOLS = [
    {
        "name": "calculator",
        "description": "Evaluate an arithmetic expression. Use this for any math.",
        "input_schema": {
            "type": "object",
            "properties": {"expression": {"type": "string"}},
            "required": ["expression"],
        },
    },
    {
        "name": "web_search",
        "description": "Search the web for current facts the model may not know.",
        "input_schema": {
            "type": "object",
            "properties": {"query": {"type": "string"}},
            "required": ["query"],
        },
    },
]
REGISTRY = {"calculator": calculator, "web_search": web_search}   # name -> real fn

SYSTEM = "You are a helpful agent. Use a tool when it helps; then answer the user."

# ── 3. THE LOOP — reason -> act -> observe -> repeat. This is the entire agent. ──
def run_agent(task: str, max_steps: int = 8) -> str:
    messages = [{"role": "user", "content": task}]

    for _ in range(max_steps):                       # 🛑 the cap from L110
        resp = client.messages.create(
            model="claude-opus-4-8", max_tokens=1024,
            system=SYSTEM, tools=TOOLS, messages=messages,
        )
        messages.append({"role": "assistant", "content": resp.content})

        if resp.stop_reason != "tool_use":           # 🛑 no tool call -> the model is answering
            return next(b.text for b in resp.content if b.type == "text")

        results = []                                 # 🎯 run every tool the model asked for
        for block in resp.content:
            if block.type == "tool_use":             # a structured request: .name, .input, .id
                output = REGISTRY[block.name](**block.input)   # call the REAL function
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,         # ← must echo the block's id
                    "content": str(output),
                })
        messages.append({"role": "user", "content": results})  # 👁️ observe -> loop again

    return "Stopped: hit the step cap without finishing."

Walk the four moves — this is every agent runtime, ever:

  1. Call the model with the full transcript, the system prompt, and tools=TOOLS.
  2. Append the model's reply to messages (so the next turn sees what it just said).
  3. Check stop_reason. If it's not "tool_use", the model emitted no tool call — it's answering — so return its text. This is the exit.
  4. Otherwise, dispatch: for each tool_use block (.name, .input, .id), look the name up in REGISTRY, call the real function with **block.input, and append a tool_result carrying the same tool_use_id. Then loop — the model now sees the real result and reasons again.

Three details that trip people up the first time:

  • tool_use_id must match. Each result is tied to the request that asked for it; mismatch it and the API rejects the turn. (With parallel tool calls in one reply, you run them all and return all results in one user message.)
  • You append the assistant turn before the results. The transcript must read: user task → assistant(tool_use) → user(tool_result) → assistant(...) — strict alternation.
  • The for _ in range(max_steps) IS the safety cap from L110 (The Agent Loop). Without it, a confused agent loops until the token limit or your wallet gives out. Never ship the unbounded version.

Run it and the loop turns a few times before answering:

print(run_agent(
    "What is 18°C in Fahrenheit, and which city hosted the 2024 Olympics?"
))
# Behind the scenes the loop turns a few times:
#   step 1: tool_use web_search("2024 Summer Olympics host city")  -> "Paris"
#   step 2: tool_use calculator("18*9/5+32")                       -> "64.4"
#   step 3: stop_reason == "end_turn"  -> "It's 64.4°F, and Paris hosted the 2024 Olympics."

Click Through the Anatomy

Here's that same agent, dissected. Click each part — Tools, Schemas, System, Loop, Stop, Dispatch, Observe — to highlight its lines and see what it does and why. The thing to feel: there's no "agent class" and no framework import anywhere. The agency lives entirely in the loop and what it does with stop_reason.

Click each part of the from-scratch agent — Tools, Schemas, System, Loop, Stop, Dispatch, Observe — to highlight its lines and read what it does. Notice there is no “agent class” and no framework import: the agency lives entirely in the loop and what it does with stop_reason.

Once this clicks, you've internalized the load-bearing idea of the whole container: an agent is a control-flow pattern, not a special model. Everything else — planning, memory, multi-agent, MCP — is additions to this loop.

Native Function-Calling vs. the Original Text-Parsing ReAct

It's worth seeing the old way, because it makes why native function calling won obvious — and because plenty of tutorials still teach it. The original ReAct paper (2022) predates native tool APIs. There was no tools= parameter, so you prompted the model to print a rigid Thought / Action / Observation format and then parsed the text with regex to extract the action:

import re

# The ORIGINAL ReAct (Yao 2022): there was NO native tool API. You PROMPT for a
# rigid format, then PARSE the model's free text to find the action.
SYSTEM = """Solve the task using this EXACT format, one step at a time:
Thought: <your reasoning>
Action: <tool_name>[<input>]
(now STOP — the runtime appends:) Observation: <result>
...repeat Thought/Action/Observation... then finish with:
Thought: I now know the answer
Final Answer: <answer>"""

prompt = SYSTEM + "\nTask: " + task
while True:
    out = llm(prompt)                                  # a plain completion — no tools= param
    if "Final Answer:" in out:
        return out.split("Final Answer:")[-1].strip()
    m = re.search(r"Action:\s*(\w+)\[(.*?)\]", out)    # ← brittle regex on free text
    tool, arg = m.group(1), m.group(2)
    obs = REGISTRY[tool](arg)
    prompt += f"{out}\nObservation: {obs}\n"            # paste the observation back as TEXT

Same loop, same Thought→Action→Observation rhythm — but the action is recovered by string-matching free text, which is exactly as fragile as it looks (the model adds a stray word, changes bracket style, or explains itself, and your parser breaks).

Text-parsing ReAct (2022)Native function calling (today)
How the action is readregex on free texta structured tool_use block
Robustnessbrittle (format drift breaks it)schema-validated args
Token costhigher (~3–4× — the format lives in the prompt every turn)lower
Reasoning visibilityexplicit (Thought: is right there)in thinking blocks / less visible
When to usemodels with no tool API; teaching the mechanismeverything else

The control flow is identical — that's the point. And it's not Claude-specific: OpenAI's loop is the same, just with different field names (the reply carries finish_reason == "tool_calls" and a tool_calls array instead of stop_reason == "tool_use" and tool_use blocks). Learn the loop once and you can write an agent against any provider.

The Reveal — Frameworks Just Wrap This Loop

Now the payoff. Look at your run_agent next to the "real" framework code and watch the mystery evaporate:

# ── What you just wrote by hand — the whole agent is this loop: ──
def run_agent(task, max_steps=8):
    messages = [{"role": "user", "content": task}]
    for _ in range(max_steps):
        resp = client.messages.create(model="claude-opus-4-8", tools=TOOLS, messages=messages, ...)
        if resp.stop_reason != "tool_use":
            return final_text(resp)
        messages += dispatch_tools(resp)        # run tools + append observations

# ── The SAME agent in LangChain (pre-1.0) — your loop is hidden inside AgentExecutor: ──
from langchain.agents import AgentExecutor, create_tool_calling_agent
agent    = create_tool_calling_agent(llm, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools, max_iterations=8)   # ← the while-loop, wrapped
executor.invoke({"input": task})

# ── LangChain 1.0 (2026): create_agent runs on LangGraph's graph engine under the hood ──
from langchain.agents import create_agent
agent = create_agent(model, tools)      # same reason/act/observe loop — now a resumable GRAPH
agent.invoke({"messages": [{"role": "user", "content": task}]})

LangChain's AgentExecutor was literally this while loop. create_tool_calling_agent builds the model+prompt+tools; AgentExecutor runs reason→act→observe until the model stops calling tools, with a max_iterations cap — the exact thing you wrote.

So what do frameworks actually add? Real things — but bounded, nameable things, not magic:

  • State & persistence — checkpoint the transcript so a run can pause, resume, or survive a crash.
  • Human-in-the-loop — pause before a risky tool call and wait for approval.
  • Branching & parallelism — run sub-tasks concurrently, fan out and back in.
  • Observability, retries, streaming — traces, automatic re-tries, token streaming to a UI.

Here's the telling history: the plain AgentExecutor loop couldn't do those last items — no branching, no pause/resume, no parallelism, no human-in-the-loop, no crash recovery. A loop is not a graph. That limitation is why LangGraph exists: it replaces the implicit loop with an explicit, stateful graph (checkpoints, time-travel debugging, interrupts). And in 2026, LangChain v1.0's create_agent replaced the legacy AgentExecutor and runs on LangGraph's engine under the hood. Same reason→act→observe loop you just wrote — now a resumable graph. It's your while loop, all the way down.

The Honest Take — Build by Hand, or Use a Framework?

Having built it yourself, you can make this call on evidence instead of hype. Neither answer is universal:

Write the loop yourself when:

  • You're learning (you are — do it at least once).
  • The agent is simple (a handful of tools, a linear-ish loop) — a 40-line function you fully understand beats a 200 MB dependency you don't.
  • You need total control — custom retry logic, bespoke logging, an unusual stop condition, tight token budgets, or a runtime with no framework support.

Reach for a framework when:

  • You need durable state / crash recovery (long runs you can't afford to lose), human-in-the-loop approvals, parallel sub-agents, or graph-shaped control flow with branches and cycles.
  • You want batteries included — streaming, tracing/observability, retries, memory backends, and a hundred prebuilt tool integrations — and don't want to maintain them.

The costs of a framework are real too, and now you can see them clearly: abstraction debt (when it breaks, you debug through layers you didn't write), lock-in, churn (LangChain's agent API has been rewritten more than once — AgentExecutorcreate_agent), and the temptation to reach for a heavy graph when a 40-line loop would do.

The mature pattern, and the one this course teaches: build the loop from scratch once so frameworks are never a black box — then choose the lightest tool that fits the job, knowing exactly what it's doing for you and what you're giving up. You did the hard part already; the frameworks are just your loop with the rough edges sanded off.

🧪 Try It Yourself

Reason about the agent you just built (and the anatomy widget):

  1. The model's reply comes back with stop_reason == "end_turn" and no tool_use block. What does run_agent do — and why is that the correct exit?
  2. You delete the line "tool_use_id": block.id from the tool_result. What breaks, and why does the API care?
  3. Your agent must call a tool that needs a secret API key. Where does that key live — in the schema you send the model, or in your Python function? Why?
  4. A colleague writes their agent the old way: prompt for Action: tool[arg] and regex-parse it. Give them two concrete failure modes native function calling avoids.
  5. Your teammate says "let's start with LangGraph so we don't reinvent the wheel." The agent has 3 tools and a simple loop. Make the case for the 40-line version — and name the one requirement that would change your mind.

(1) It returns the model's text as the final answer and exits the loop. "No tool call" is the model's signal that it's done acting and is now answering — that's the intended termination. (2) The API rejects the turn: every tool_result must reference the tool_use block that requested it via the matching id, so the model can pair each result with the right call (essential when there are parallel calls). (3) In your Python function — never in the schema. The schema is sent to the model (and is essentially untrusted/observable); the function runs on your machine and holds the secret. The model only asks you to call the tool; it never sees the key. (4) Any two: format drift breaks the regex (the model adds a word or changes brackets); no schema validation of arguments; higher token cost from carrying the format every turn; ambiguous parsing when the model "thinks out loud" inside the Action line. (5) Three tools + a simple loop = a function you fully understand, no dependency/lock-in, trivial to debug — exactly what AgentExecutor does. The thing that would flip the decision: needing durable state / crash recovery, human-in-the-loop approval, or parallel/branching control flow — i.e., when you need a graph, not a loop.

Mental-Model Corrections

  • "A tool is a special agent primitive." No — a tool is an ordinary function. The model never runs it; your loop does, when the model asks. The "tool" abstraction is just function + a JSON schema describing it.
  • "The model executes the tools." It can't run code. It returns a structured request (tool_use: name + args); your code executes and feeds back the result. Agency is split between model and harness.
  • "You have to parse the model's text to find tool calls." That's the 2022 way. Native function calling returns a structured block — no parsing, schema-validated args. Only hand-parse when a model has no tool API.
  • "Frameworks do something fundamentally different from my loop." No — LangChain's AgentExecutor was this while loop. Frameworks add state, persistence, HITL, branching, observability around it — useful, but not magic.
  • "Real agents need a framework." A correct agent is ~40 lines. Frameworks earn their keep on durable state, human-in-the-loop, parallelism, and graph control flow — reach for them for those, not by default.
  • "Building from scratch means you'll never use a framework." Backwards — building it once is what lets you adopt a framework wisely, seeing exactly what it buys and what it costs.

Key Takeaways

  • You can build a complete ReAct agent in ~40 lines, no framework: tools (plain functions), schemas (the JSON contract), a one-line system prompt, and the loop.
  • Native function calling is the modern mechanism: pass tools=, the model returns a structured tool_use block (name + input + id), you run the function and return a tool_result with the matching tool_use_id, and stop_reason != "tool_use" means done.
  • The original text-parsing ReAct (prompt a Thought/Action/Observation format, regex the action out) is the same loop but brittle and token-heavy — use it only when a model has no tool API. OpenAI's loop is identical (finish_reason == "tool_calls").
  • Frameworks just wrap this loop. LangChain's AgentExecutor was this while loop; LangGraph turns it into an explicit, resumable graph; LangChain v1.0's create_agent runs on LangGraph under the hood.
  • Build vs. buy: hand-write the loop for learning, simple agents, and total control; adopt a framework for durable state, human-in-the-loop, parallelism, and observability — and know it's your loop with the edges sanded off.
  • Next: with the loop in hand, we start hardening it — beginning with reactive vs. deliberative agents and planning, the strategies that decide what the loop reasons about at each step.