A hand-built tool-calling agent

LangGraph · Practical · 14 min

Knowing the loop explicitly lets you customize any part of it, which is the reason to use LangGraph.

An agent is a model in a loop with tools. In LangGraph that loop is three pieces:

1. A model node calls a model that knows the tools (model.bind_tools(tools) attaches their schemas). The reply is an AIMessage whose tool_calls list says which tools to run, or is empty when the model is done. 2. A tools node runs each requested tool and returns ToolMessages, each carrying the tool_call_id it answers. 3. A conditional edge after the model: if the last message has tool calls, go to tools; otherwise go to END. An edge from tools back to the model closes the loop.

Because you own every piece, you can:

  • add a step before the model that trims or summarizes history,
  • check tool calls against a policy before running them, or pause for approval,
  • route some tools to special nodes,
  • stop after N tool rounds by counting in state.

A few rules keep the history valid. Every tool call must get exactly one ToolMessage with the matching ID, or the next model call fails (LangGraph documents this as INVALID_CHAT_HISTORY). Return tool errors as ToolMessage text instead of raising, so the model can recover.

The next lesson shows the prebuilt pieces that do the same thing in a few lines.

Example

from typing import Literal
from langchain.chat_models import init_chat_model
from langchain.tools import tool
from langchain_core.messages import ToolMessage
from langgraph.graph import StateGraph, START, END, MessagesState


@tool
def multiply(a: int, b: int) -> int:
    """Multiply two integers."""
    return a * b


TOOLS = {"multiply": multiply}
model = init_chat_model("claude-sonnet-4-6").bind_tools(list(TOOLS.values()))


def call_model(state: MessagesState) -> dict:
    return {"messages": [model.invoke(state["messages"])]}


def run_tools(state: MessagesState) -> dict:
    calls = state["messages"][-1].tool_calls
    return {"messages": [ToolMessage(str(TOOLS[c["name"]].invoke(c["args"])), tool_call_id=c["id"])
                         for c in calls]}


def should_continue(state: MessagesState) -> Literal["tools", "__end__"]:
    return "tools" if state["messages"][-1].tool_calls else END


b = StateGraph(MessagesState)
b.add_node("model", call_model)
b.add_node("tools", run_tools)
b.add_edge(START, "model")
b.add_conditional_edges("model", should_continue)
b.add_edge("tools", "model")
agent = b.compile()

Remember

  • Loop = model node -> (tool calls?) -> tools node -> back to model
  • bind_tools gives the model the tool schemas
  • Each tool call needs exactly one ToolMessage with the matching tool_call_id
  • Owning the loop lets you add trimming, policies and approvals

Watch out for

  • Dropping a tool call without answering it, leaving an AIMessage with unanswered tool_calls that breaks the next model call.

Check yourself

  1. What decides whether the agent loop ends?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.langchain.com/oss/python/langgraph/workflows-agents