LangGraph ReAct Agent: Tool-Calling from Scratch

Build a LangGraph ReAct agent with tool-calling from scratch. Learn the think-act-observe loop, bind tools to LLMs, and handle multi-step reasoning.

What Is a ReAct Agent and Why Build It in LangGraph

Most LLM apps stop at one prompt → one response. A ReAct agent loops: it reasons, picks a tool, observes the result, then reasons again until it has an answer.

LangGraph gives you explicit control over that loop. You define the graph nodes, edges, and state — so you know exactly what runs, when, and why. This matters in production when you need to debug a wrong answer or add a new tool without breaking existing ones.

You'll build:

  • A working ReAct agent that calls real tools (web search + calculator)
  • A typed AgentState to track messages across steps
  • A conditional edge that decides "call tool" vs "return answer"
  • A pattern you can extend to 10+ tools without changing the loop

Time: 25 min | Difficulty: Intermediate


How ReAct Works

The ReAct pattern (Reasoning + Acting) was introduced in a 2022 paper and is now the default loop behind most tool-calling agents.

User query
    │
    ▼
┌─────────────┐
│   Reason    │  ◀── LLM sees: system prompt + messages + tool schemas
│  (LLM call) │
└──────┬──────┘
       │
  Tool call?
  ┌────┴────┐
 YES        NO
  │          │
  ▼          ▼
┌──────┐   Final
│ Act  │   answer
│(tool)│
└──┬───┘
   │
   ▼
Observe (append tool result to messages)
   │
   └──▶ back to Reason

Each iteration appends to the message list. The LLM always sees the full history: original query, every tool call, every tool result. This is how it accumulates context across steps.

LangGraph models this as a state graph. Each node is a function. Edges are transitions — either fixed or conditional.


Setup

# Python 3.11+ required
pip install langgraph langchain-openai langchain-core
# Set your API key
export OPENAI_API_KEY="sk-..."

This guide uses gpt-4o-mini — it's cheap, fast, and reliable at tool-calling. Swap in any model that supports function calling.


Step 1: Define Agent State

LangGraph passes a State object between every node. You define exactly what it holds.

from typing import Annotated
from langgraph.graph.message import add_messages
from langchain_core.messages import BaseMessage
from typing_extensions import TypedDict


class AgentState(TypedDict):
    # add_messages appends new messages instead of overwriting
    # This is critical — without it, each node would replace the list
    messages: Annotated[list[BaseMessage], add_messages]

add_messages is a reducer. When a node returns {"messages": [new_msg]}, LangGraph appends new_msg to the existing list rather than replacing it. That's the whole message history mechanism.


Step 2: Define Tools

Tools are Python functions decorated with @tool. The docstring becomes the tool description the LLM reads to decide when to use it — write it like you're explaining to a smart intern.

from langchain_core.tools import tool


@tool
def calculate(expression: str) -> str:
    """
    Evaluate a mathematical expression and return the result.
    Use this for any arithmetic: addition, multiplication, percentages.
    Example input: '(42 * 1.08) + 15'
    """
    try:
        # eval is safe here — restrict to math only in production
        result = eval(expression, {"__builtins__": {}}, {})
        return str(result)
    except Exception as e:
        return f"Error: {e}"


@tool
def get_word_count(text: str) -> str:
    """
    Count the number of words in a given text string.
    Use this when the user asks about word counts or text length.
    """
    count = len(text.split())
    return f"{count} words"


tools = [calculate, get_word_count]

Step 3: Bind Tools to the LLM

Binding tools sends their JSON schemas to the model on every call. The model responds with either a text message (final answer) or an AIMessage with tool_calls (wants to run something).

from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

# llm_with_tools sends tool schemas automatically on every invocation
llm_with_tools = llm.bind_tools(tools)

Step 4: Build the Graph Nodes

Two nodes: one for the LLM, one to execute tool calls.

from langchain_core.messages import SystemMessage
from langgraph.prebuilt import ToolNode


# Node 1: call the LLM
def call_model(state: AgentState) -> dict:
    system = SystemMessage(
        content="You are a helpful assistant. Use tools when they help answer accurately."
    )
    # Prepend system message; model sees full history every call
    response = llm_with_tools.invoke([system] + state["messages"])
    return {"messages": [response]}


# Node 2: execute whatever tool the LLM chose
# ToolNode handles tool dispatch, error catching, and result formatting
tool_node = ToolNode(tools)

ToolNode is LangGraph's built-in tool executor. It reads the tool_calls field from the last AIMessage, runs the matching function, and wraps the result in a ToolMessage. You don't need to write dispatch logic yourself.


Step 5: Add the Conditional Edge

This edge reads the last message and routes to either the tool node or END.

from langgraph.graph import END
from langchain_core.messages import AIMessage


def should_continue(state: AgentState) -> str:
    last_message = state["messages"][-1]

    # If the LLM returned tool_calls, run them
    if isinstance(last_message, AIMessage) and last_message.tool_calls:
        return "tools"

    # Otherwise the LLM gave a final text answer
    return END

Step 6: Assemble the Graph

from langgraph.graph import StateGraph

builder = StateGraph(AgentState)

# Register nodes
builder.add_node("agent", call_model)
builder.add_node("tools", tool_node)

# Entry point
builder.set_entry_point("agent")

# After the agent runs: check if we need tools or are done
builder.add_conditional_edges(
    "agent",
    should_continue,
    {
        "tools": "tools",  # route to tool_node
        END: END,          # or finish
    },
)

# After tools run: always go back to the agent
builder.add_edge("tools", "agent")

graph = builder.compile()

The graph now looks like this:

START
  │
  ▼
agent ──(has tool_calls?)──YES──▶ tools
  ▲                                  │
  └──────────────────────────────────┘
  │
 NO
  │
  ▼
 END

Step 7: Run It

from langchain_core.messages import HumanMessage


def run_agent(query: str) -> str:
    result = graph.invoke({"messages": [HumanMessage(content=query)]})
    return result["messages"][-1].content


# Test 1: needs calculator tool
print(run_agent("What is 15% tip on a $47.80 dinner?"))

# Test 2: needs word count tool
print(run_agent("How many words are in this sentence: The quick brown fox jumps over the lazy dog"))

# Test 3: no tool needed
print(run_agent("What is the capital of France?"))

Expected output (Test 1):

A 15% tip on a $47.80 dinner is $7.17, making your total $54.97.

Verification

Turn on LangGraph tracing to see every node execution and message:

# See every step the agent takes
for step in graph.stream(
    {"messages": [HumanMessage(content="What is 12 * 8 + 100?")]},
    stream_mode="values",
):
    last = step["messages"][-1]
    last.pretty_print()

You should see:

  1. HumanMessage with the query
  2. AIMessage with a tool_calls block targeting calculate
  3. ToolMessage with 196
  4. AIMessage with the final answer: "12 × 8 + 100 = 196"

If the agent answers without using the calculator, your tool docstring isn't clear enough — rewrite it.


Adding More Tools

The loop doesn't change when you add tools. Just define the function and append to tools:

@tool
def get_current_date(timezone: str = "UTC") -> str:
    """
    Return the current date and time for a given timezone.
    Use this when the user asks about today's date or current time.
    Accepts timezone strings like 'US/Eastern' or 'Europe/London'.
    """
    from datetime import datetime
    import zoneinfo
    tz = zoneinfo.ZoneInfo(timezone)
    now = datetime.now(tz)
    return now.strftime("%A, %B %d, %Y at %H:%M %Z")


tools = [calculate, get_word_count, get_current_date]

# Re-bind and recompile — everything else stays the same
llm_with_tools = llm.bind_tools(tools)
tool_node = ToolNode(tools)

Recompile the graph after updating tool_node. The should_continue logic and state schema don't need to change.


Production Considerations

Loop limits. Add a step_count field to AgentState and terminate after N iterations. Without this, a hallucinating model can loop indefinitely.

class AgentState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]
    step_count: int  # increment in call_model, check in should_continue

Tool errors. ToolNode catches exceptions and returns them as ToolMessage content. The LLM sees the error and can retry or explain — but log these. Repeated tool errors usually mean your docstring is misleading the model.

Streaming. Use graph.astream() for async endpoints. Token-level streaming from the LLM requires stream_mode="messages" and an async node.

Observability. Set LANGCHAIN_TRACING_V2=true and LANGCHAIN_API_KEY to get full traces in LangSmith. Each graph run becomes a traceable session — you can see which tool was called, with what inputs, and how long it took.


What You Learned

  • ReAct is a message-accumulation loop: reason → act → observe → repeat
  • add_messages reducer is what makes the history persist across nodes
  • Tool docstrings are model instructions — precision matters more than length
  • ToolNode handles dispatch; should_continue handles routing
  • Adding tools requires no changes to the loop — only rebinding and recompiling

When not to use this pattern: If your task is a fixed pipeline with no branching decisions, a simple LangChain chain is faster and cheaper. ReAct is for tasks where the model needs to decide which tool and when — not for predetermined sequences.

Tested on LangGraph 0.2.x, LangChain Core 0.3.x, Python 3.12, gpt-4o-mini