← All posts

Liza KatzReAct agentstool calling

How to Build ReAct Agents in 2026

The basic concept of ReAct agents hasn't changed much since we started building them in 2024. The paper is from 2022, and its core idea is still exactly what everyone means when they say "agent."

What changed drastically is everything around it: how they're built, how they're orchestrated, and the ergonomics of doing it. In 2024 we wrote a whole post that was essentially a tutorial on surviving the implementation details. Most of that post is now obsolete — not because the idea was wrong, but because the idea won so completely that it stopped being something you implement.

So I wanted to take the opportunity to write down what building a ReAct agent actually looks like in 2026, and — more usefully — what's left to get wrong.

What is ReAct

Instead of hardcoding a flow, or having an LLM plan five steps in advance and then executing them blindly, ReAct does the very basic human thing:

  • define a goal
  • look at what you have
  • ask yourself what the next step is, restricted by your current options
  • do it
  • repeat until the goal is achieved
goal observe decide act not done yet done goal reached
The whole idea. Everything else is plumbing.

Really, that's all there is to it in essence — and yet a lot of people never look under the hood to see how simple it is. The mystique comes from the plumbing, not from the concept.

And a few years ago the plumbing was genuinely hard. The model had no notion of a tool. It didn't know what a tool was. So you described your tools in English in the system prompt and then picked your poison for getting a machine-readable answer back out.

Option one: invent a text format and hope. You'd show a few hand-written example trajectories and ask for something like this:

Thought:      I need the current price of the product.
Action:       search_products
Action Input: {"query": "snoopy t-shirt"}
Observation:  [{"sku": "AX-42B", "price": 24.99, ...}]

Then you parsed it:

# 2024. Do not do this.
match = re.search(r"Action:\s*(.+?)\nAction Input:\s*(.+?)(?:\n|$)", output, re.S)
action, action_input = match.group(1).strip(), match.group(2).strip()
result = self.tools[action](**json.loads(action_input))  # 🙏

Every line of that is a failure mode. The model writes a trailing comma and json.loads throws. It writes a thought containing the word "Action:" and the regex grabs the wrong span. It invents a tool that doesn't exist. It wants to do two things at once and you get one of them.

Option two: skip the invented format and just ask for JSON. "Respond with only a JSON object containing action and action_input. Do not include any other text." And it would oblige — most of the time. The rest of the time you'd get the JSON wrapped in a ```json fence, or a cheerful "Sure! Here's the JSON:" in front of it, or a perfectly valid object with "action_input" renamed to "input", or single quotes. So you'd write a stripping function, and then a repair function, and then a retry loop that fed the parse error back to the model and asked it to try again. Everyone wrote that function. Nobody enjoyed it.

Add a small context window and a model that forgets the output format around step six, and you understand why our 2024 post was mostly scaffolding.

Two things fixed this.

The first is that the ecosystem grew up. Nobody hand-rolls the loop anymore because a dozen good implementations ship it for you — LangChain and LangGraph, LlamaIndex, Pydantic AI, the Vercel AI SDK, Mastra, and the vendor SDKs: Anthropic's tool runner, OpenAI's Agents SDK, and the Claude Agent SDK for a batteries-included coding agent.

The second, and the one that actually mattered, is that the loop stopped being a prompting trick and became a feature of the API.

Tool calling

Native tool calling turned every parsing problem into someone else's problem:

ReAct did this by prompting2026
Describe tools in English prosetools=[...] with JSON Schema per tool
Beg for a parseable Action: linetool_use block, structured
Regex + json.loads the argumentsblock.input, already parsed
Hope the tool name is realModel is constrained to the tool list
Observation: text glued into the prompttool_result block, matched by id
Few-shot trajectories to teach the formatNothing — it's trained in
Thought: prefix to elicit reasoningThinking is a first-class parameter
Detect "Final Answer:" to stopstop_reason

Here's a complete agent. This is not a simplified version:

from langchain.agents import create_agent
from langchain.tools import tool

@tool
def search_products(query: str, max_price: float | None = None) -> str:
    """Search the product catalog.

    Args:
        query: Natural language description of what the shopper wants.
        max_price: Optional ceiling, in USD.
    """
    return json.dumps(catalog.search(query, max_price=max_price))

@tool
def get_inventory(sku: str) -> str:
    """Check stock for a specific SKU.

    Args:
        sku: The exact product SKU, e.g. "AX-42B".
    """
    return json.dumps(inventory.check(sku))

agent = create_agent(
    model="anthropic:claude-opus-5",
    tools=[search_products, get_inventory],
)

result = agent.invoke({"messages": [
    {"role": "user", "content": "something with that dog from peanuts for my kid, he's four. is it in stock?"}
]})

print(result["messages"][-1].content)

The schema comes from the type hints, the description comes from the docstring, and the loop is the framework's job.

(If you have existing code, this is the function formerly known as create_react_agent from langgraph.prebuilt — it moved to langchain.agents and got renamed to create_agent, since by now every agent is a ReAct agent and the qualifier stopped meaning anything.)

Swapping providers is the model string: "openai:gpt-5", "google_genai:...". That portability is most of why people reach for a framework — the underlying tool-calling wire formats differ per vendor, and you'd rather not care.

Note what happened to Thought:. It was a trick to make the model reason out loud where you could see it. Now reasoning is a real thing the model does, configured rather than coaxed — thinking={"type": "adaptive"} lets it decide how much to think per step and interleave that thinking between tool calls. "Think step by step" in your system prompt is dead weight in 2026.

Schemas are prompts

Two lines above are doing more work than they look like.

The docstring decides when the tool fires. It's the single highest-leverage text in the agent — more than the system prompt. Write it prescriptively: say when to call it, not just what it does. A vague one-liner is the most common cause of an agent that mysteriously "doesn't use its tools."

max_price prevents a whole class of wrong. Every constraint you push into the schema is a constraint the model can't get subtly wrong. Enums beat free-form strings. A well-named parameter carries intent that no amount of prompting will.

Parallel calls

The model can ask for several tools in one step when the calls don't depend on each other — check three SKUs, read four files — and the framework runs them together. You get the speedup for free.

What isn't free is your tools actually being safe to run at the same time. Shared state, a module-level client that isn't thread-safe, a rate limit you were only ever hitting one call at a time: none of that shows up until the day the model decides to fan out.

Stopping

The 2024 loop watched for the string Final Answer:. Now the model just stops, and the framework notices.

What still reaches you is the abnormal case. Set a recursion limit so a confused agent can't loop forever:

result = agent.invoke(
    {"messages": [...]},
    config={"recursion_limit": 25},
)

And two provider-level endings leak through whatever framework you use: the model can hit your output cap mid-sentence and hand you a truncated answer, or it can refuse. Both come back looking enough like a normal response to slip past code that only checks for content.

What should be a tool, and what shouldn't

This is the design decision people get wrong most often, and it has nothing to do with the API.

Giving the model a bash tool grants enormous reach. But your harness then receives an opaque command string — the same shape for every action — and can't tell a harmless grep from an rm -rf. Promoting an action to a dedicated tool gives you a typed, named hook you can gate, log, render, or parallelize.

Promote an action to its own tool when:

  • It's hard to reverse. send_email, issue_refund, delete_account. A dedicated tool is trivial to put behind a confirmation; bash -c "curl -X POST ..." is not.
  • It needs an invariant enforced. An edit_file tool can reject a write if the file changed since the model last read it. Bash can't.
  • The UI needs to render it. Anything the user should see as a distinct step.
  • It's parallel-safe and you want that. Your harness can only parallelize what it can identify.

And the flip side, which matters more: anything deterministic should not be a tool call at all. If the answer is fully determined by the inputs — validation, routing, tallying, formatting, filtering, unit conversion — put it in code. It'll be faster, free, and correct every time. Reserve the model for the judgment step.

The useful test on an existing agent: read a transcript. If it's the same three tool calls in the same order every single time, you didn't build an agent. You built a for loop that costs money and occasionally lies.

State management

First turns almost always work. The demo is one question, a few tool calls, a good answer. Then someone asks a follow-up and the thing falls over.

The reason is that the model API is stateless. Every request re-sends the entire conversation, so "state" really means: what did you keep, and what did you throw away?

Frameworks handle the bookkeeping. Add a checkpointer and a thread id, and the conversation persists across calls:

from langgraph.checkpoint.memory import InMemorySaver

agent = create_agent(
    model="anthropic:claude-opus-5",
    tools=[search_products, get_inventory],
    checkpointer=InMemorySaver(),
)

config = {"configurable": {"thread_id": "shopper-123"}}
agent.invoke({"messages": [{"role": "user", "content": "..."}]}, config=config)

Swap InMemorySaver for Postgres or Redis and you have real persistence. That part is solved.

What isn't solved is that the conversation grows, and a growing conversation gets slower, more expensive, and eventually doesn't fit. Every tool result stays in there. A tool that returns 40KB of JSON has spent your context window on data the model needed for exactly one step, and it'll keep paying for it on every turn after.

So you decide what to drop. Trim old messages once you're past some threshold, or summarize the early part of the conversation into a paragraph and keep only the recent turns verbatim. Both are a few lines with trim_messages or a summarization step before the model call, and both are a real tradeoff rather than a setting — trimming is cheap and forgets things, summarizing costs a call and blurs details.

The other half is state that outlives the conversation entirely. The crude version works surprisingly well: give the agent a file it can write to, and tell it to read that file at the start of the next session. Notes to itself, surviving your process restarting.

When tool selection becomes a retrieval problem

A dozen tools fit comfortably in a prompt. Three hundred don't — every description is in context on every single call, and the model gets worse at choosing as near-duplicates pile up.

The fix is to stop sending all of them. Index your tool descriptions, retrieve the handful that match the user's request, and pass only those to the agent. Some providers now do this for you server-side, with BM25 over the tool descriptions — the same ranking function from the hybrid search post, aimed at tools instead of products.

Which is a funny place to end up: your agent has become a retrieval system over its own capabilities. That's its own post.

Orchestration and observability

The loop is one function call now. Everything that makes an agent shippable is what happens around it when a step goes wrong.

Let the model see the error. When a tool fails, return the failure as the tool's result instead of letting it raise. Given "InventoryServiceTimeout: no response after 5s", the model will usually try something else — a different tool, a narrower query, or telling the user it can't check stock right now. Given an exception, your process dies.

The expensive failure isn't the crash. It's the tool that returns plausible garbage — a stale cache, the wrong tenant, an empty result set that should have had rows. The model reasons over it as fact and produces a confident wrong answer, and no prompt fixes that. The fix is that the tool doesn't lie: if the data might be stale, say so in the result.

Gate anything you can't undo. Reads can run freely; anything that sends, charges, or deletes needs a confirmation step. Easiest place to put it is inside the tool function — ask, and return "the user declined" as an ordinary result if they say no.

Log the path, not just the answer. This is the part teams skip and then regret. For every run you want each tool call with its arguments, each result, and the tokens and seconds it took. When quality drops, an output-only log tells you that something broke and nothing about where. LangSmith, Langfuse, or plain structured logs all work — having it at all matters more than which.

Worth knowing before you build a lot of this: you don't have to run the loop yourself anymore. Managed Agents hosts the loop and a container where the tools execute. If you were about to write session persistence and a cron runner, price that first.

When you should not use ReAct

The loop is a general-purpose answer to open-ended problems, and you pay for that generality in latency, cost, and unpredictability. Plenty of things built as agents shouldn't be.

Four questions before you reach for it:

  1. Is the task genuinely hard to specify in advance? "Turn this design doc into a PR" — yes. "Extract the total from this invoice" — no, that's one call.
  2. Does the outcome justify the cost? Agents are slower and more expensive than a pipeline by construction.
  3. Is the model actually good at this? Sometimes it isn't.
  4. Can errors be caught? Tests, review, rollback. If a wrong answer ships silently, this is the wrong tool.

Any "no" means step down a tier:

  • One call. Classification, extraction, summarization. Ask for structured output and move on.
  • A workflow. Multi-step, but you write the sequence. The steps are known; only the content varies. This covers far more production "AI features" than anyone admits.
  • An agent. The model picks the sequence, because you genuinely can't know it up front.

Single-shot extraction dressed up as an agent is the most common version of this mistake. The second is the fixed pipeline with an LLM steering it — all the cost and variance of an agent, none of the benefit, because the path never actually varies.

Evaluation

An agent gives you two things to score, and conflating them is a classic mistake.

Outcome — did it get the right answer? This is what you care about, and what should gate releases.

Trajectory — did it take a sensible path? The right tools, a reasonable order, without eight redundant searches.

You need both, because they fail apart. An agent that reaches the right answer through twelve flailing tool calls is a latency and cost incident waiting to happen, and outcome scoring alone gives it a clean bill of health. An agent with a beautiful trajectory and a wrong answer is just wrong.

The practical version: score outcomes on a fixed set of real tasks, and log trajectories so that when the outcome score drops you can find out where. We wrote about building that evaluation loop — the method has held up better than the code around it.

Conclusion

Strip out everything the frameworks and the API absorbed, and the 2026 job looks like this:

  • Writing tool descriptions that fire when they should
  • Designing schemas that make wrong calls impossible to express
  • Deciding what's a tool and what's just code
  • Deciding what stays in context and what gets dropped
  • Handling tools that fail, and tools that lie
  • Knowing when not to use a loop at all
  • Measuring trajectory and outcome separately

Not one of those is the reason-act-observe cycle. That part is free now.

Which is the normal shape of progress, if slightly deflating for anyone who enjoyed writing the parser: the clever bit from the paper became infrastructure, and the boring bits around it turned out to be the engineering.