Building an end-to-end Coding Agent with LangGraph

Tools & ReAct Loop: Wiring an Agent That Acts

May 3, 2026 · 9 min read

This chapter adds tools, so the model can do more than talk back: it can read your files, search your repo, and use the result to decide what to say next.

terms in this chapter
tool
A normal Python function the model is allowed to call. The docstring is its manual.
tool call
The model's request to run one: a name plus arguments, carried on an AIMessage.
ToolMessage
The result we send back, tied to its call by tool_call_id.
ReAct loop
Act, observe, decide: chat asks for a tool, tools runs it, back to chat, until the model answers.

1. What is a tool?#

A tool is just a normal Python function the model is allowed to call.

Without tools, the LLM can only produce text. That's all it has. With tools, it can ask things like "list this directory for me" or "read this file", get the answer back, and then decide what to do next.

Here is the whole pattern as a live run. The graph on top shows the loop we build in this chapter, and the context window below it is the message list the model sees. Step through it:

the smallest ReAct loopstep 1 of 4
START
chat
END
tools

Nothing has run yet. The thread holds the system prompt and this request is still in the composer. Hit send (or press Enter) to run it.

context windowwhat chat sends to the model1 msg · 0 chars
you>what's in src/?
statemessages 1

Send the message to start the run, or click the bar and use ← → to step through.

Step 1, you: message

Each loop turn: the model acts (calls a tool), observes (reads the result), then decides (call another tool, or finish). This pattern has a name: ReAct (Yao et al., 2022), short for Reason + Act.

Two pieces have to fit together:

  • Our side: write the function, give it a clear name and description.
  • Model side: see the list of available tools, pick one, send the right arguments.

The rest of this chapter is about making those two pieces fit.

2. Writing a tool with @tool#

Here's the smallest tool we ship: list_dir. It returns the names of the files and folders in a directory.

python
# src/agent/tools/list_dir.py
from pathlib import Path
from langchain_core.tools import tool
 
@tool
def list_dir(path: str = ".") -> str:
    """List files and subdirectories in the given directory.
 
    Args:
        path: Directory path to list. Defaults to the current directory.
 
    Returns:
        Newline-separated entries; directories are suffixed with '/'.
    """
    target = Path(path).expanduser()
    if not target.exists():
        return f"Error: '{path}' does not exist."
    if not target.is_dir():
        return f"Error: '{path}' is not a directory."
 
    entries = sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))
    return "\n".join(f"{e.name}/" if e.is_dir() else e.name for e in entries)

Every part of this code talks to the model in some way. Let's go through them:

  • @tool turns the function into a tool object the model can be told about. Without it, this is just a regular Python function the model has no idea exists.
  • The function name (list_dir) is what the model uses to call this tool. If the model decides "I need to list a directory", it sends back {"name": "list_dir", ...}.
  • The type hint (path: str = ".") tells the model what arguments to send and which are optional. Here, path is a string and defaults to "." if the model leaves it out.
  • The docstring is the description the model reads to decide when this tool should be used. More on this below; it's the most important part.
  • The return value is the observation the model receives on its next turn. Whatever string we return is what the model reads.

A few rules we follow for return values:

  • Always return a string. Tool results travel as text, so even errors are strings.
  • Never raise. An exception breaks the whole graph. Returning "Error: ..." lets the model read the problem and try again with different arguments.

3. Why the docstring matters#

The docstring is your tool's instruction manual, written for the model, not for human readers. It's the only thing telling the model when to reach for this tool.

Compare:

python
# Weak: describes WHAT the function does
"""Lists files in a directory."""
 
# Strong: describes WHEN to use it
"""List files and subdirectories in the given directory.
 
Use this when you need to discover what exists in a folder
before reading specific files.
"""

The first one is technically correct but doesn't tell the model when to pick it over another tool. The second one gives the model a clear trigger.

A simple checklist for tool docstrings:

  1. First line: plain description of what the tool does.
  2. When to use it (and when not to). One short sentence is enough.
  3. Args: block explaining each parameter: defaults, units, formats.
  4. Returns: what the result looks like, including the shape (one line, JSON, list, etc.).

Strong docstrings are the cheapest way to make an agent more reliable. Most "the model called the wrong tool" failures are docstring problems, not model problems.

4. What the model actually sees#

We have a tool. How does it get to the model?

Each request to OpenAI carries our tool list as JSON Schema. bind_tools (next section) generates this from the function's type hints and docstring:

jsonc
// 1. We send the tool list with our request
{
  "messages": [...],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "list_dir",                          // ← function name
        "description": "List files and subdirectories...",  // ← docstring
        "parameters": {                              // ← type hints
          "type": "object",
          "properties": {"path": {"type": "string", "default": "."}}
        }
      }
    }
  ]
}

Notice how the JSON mirrors the Python function: the docstring became description, the type hints became parameters. That's why the docstring is so important: it is what the model reads when picking a tool.

The model replies with a tool call:

jsonc
// 2. The model picks list_dir and gives us the arguments
{
  "role": "assistant",
  "tool_calls": [
    {"id": "call_abc", "function": {"name": "list_dir", "arguments": "{\"path\": \"src\"}"}}
  ]
}

We run the function and send the result back:

jsonc
// 3. We send the observation, tagged with the same id
{"role": "tool", "tool_call_id": "call_abc", "content": "agent/\nagent.egg-info/"}

The tool_call_id ties each result to the call it answers, which matters when the model asks for several tools at once.

Everything else in this chapter is wiring around this three-step exchange.

5. bind_tools: telling the model about our tools#

python
TOOLS = [list_dir, read_file]
LLM = get_llm().bind_tools(TOOLS)

bind_tools does the small amount of work needed to plug our tools into the model:

  1. Generates the JSON Schema from each tool (the request payload above).
  2. Adds it to every model call.
  3. Parses the model's tool_calls back out of the reply into AIMessage.tool_calls, a clean Python list we can loop over.

We let LangChain do this part because it's the same code every time. Nothing about agent design is hidden in here.

6. tool_node: running what the model asked for#

When the model returns tool calls, somebody has to actually run them. That's the tool_node:

python
TOOLS_BY_NAME = {t.name: t for t in TOOLS}
 
def tool_node(state: State) -> dict:
    last = state["messages"][-1]                    # the AIMessage with tool_calls
    outputs = []
    for tc in last.tool_calls:
        tool = TOOLS_BY_NAME[tc["name"]]            # find the tool
        result = tool.invoke(tc["args"])            # run it
        outputs.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))
    return {"messages": outputs}

For each tool call on the last message:

  1. Find the tool by name.
  2. Run it with the model's arguments. (@tool validates the arguments with Pydantic before running the function, so wrong types fail fast.)
  3. Wrap the result in a ToolMessage with the matching tool_call_id.

Returning {"messages": outputs} adds the new tool messages to state, and on the next turn, chat_node will pick them up as fresh observations.

LangGraph ships langgraph.prebuilt.ToolNode that does the same thing plus parallel calls and exception-to-error-string conversion. We'll switch to it once we need those features.

7. The conditional edge: when to loop#

After chat_node runs, we need to ask: did the model want a tool, or is it done?

python
def route_after_chat(state: State) -> str:
    last = state["messages"][-1]
    if last.tool_calls:
        return "tools"      # model wants a tool: loop through tool_node
    return END              # model gave a final answer: stop

The string we return is the name of the next node (or the special END). LangGraph reads it through add_conditional_edges.

This three-line function is what makes this an agent loop instead of a single-shot call.

8. The full graph#

python
# src/agent/graph.py
def build_graph():
    builder = StateGraph(State)
    builder.add_node("chat", chat_node)
    builder.add_node("tools", tool_node)
    builder.add_edge(START, "chat")
    builder.add_conditional_edges("chat", route_after_chat)
    builder.add_edge("tools", "chat")
    return builder.compile()

Two nodes, one conditional edge, one straight edge back. That's the whole loop. Click a node to see its role, contract, and code:

the chapter 2 graphPick a node to see its role, contract, and code.

The branch after chat is the only place a decision is made. Everything else is straight line.

9. Up next#

The tool surface is read-only for now: list_dir and read_file. The MVP needs write tools (write_file, edit_file) and search tools (glob, grep); those land in this same chapter as we add them to the codebase.

Once write tools exist, Chapter 3 makes them safe with LangGraph's interrupt(): destructive operations pause the graph and wait for the human to confirm before continuing.