Build a Tool-Calling Agent with LangGraph

Build a LangGraph agent that routes queries to tools using conditional edges.

Introduction

30 Second Summary

When you ask a smart assistant a question, it doesn't just guess an answer. It picks the right tool, runs it, and reports back.

In this project, you will build a research assistant agent powered by LangGraph and Google Gemini that autonomously decides which tools to call based on user queries. Your agent will route between a calculator, a word counter, and a tech dictionary using a stateful graph with conditional edges.

What You'll Build

A Python-based research assistant agent that uses a StateGraph with typed state, a model node, a tool node, and conditional routing to autonomously select and execute tools based on natural language queries.

By the end of this project, you'll have:

  • A complete LangGraph agent with three custom tools: a calculator, a word counter, and a tech dictionary.
  • A StateGraph with typed state, conditional edges, and an agentic loop that runs until the LLM has a final answer.
  • A chat function that demonstrates multi-tool routing with visible reasoning and tool call counts.
  • Secret Mission: Add conversation memory so your agent remembers previous messages across turns.

Are there any prerequisites?

You should be comfortable writing Python, including type annotations. Familiarity with async concepts is helpful but not required.

Set Up Your Environment

Before you can build an AI agent, you need the right tools installed and configured on your machine.

In this step, you will install the LangGraph framework and the Google Gemini integration package, then grab a free API key so your agent can talk to the Gemini model.

In this step, get ready to:
  • Install LangGraph and the Google Gemini integration packages.
  • Get a free Google Gemini API key from Google AI Studio.
  • Set the API key as an environment variable so your code can access it.
Install LangGraph and the Google Gemini integration
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir langgraph-agent
cd langgraph-agent
  • Confirm the folder exists by running:
ls

You should see the langgraph-agent folder listed in the output (on macOS) or an empty directory listing since you are now inside it (on Windows).

  • Create and activate a virtual environment by running these commands:

macOS

python3 -m venv venv
source venv/bin/activate

Windows

python -m venv venv
venv\Scripts\activate

What is a virtual environment?

A virtual environment is an isolated Python workspace. Packages you install inside it stay separate from your system Python, so different projects never conflict with each other.

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

Make sure you ran the activate command (not just the create command). On macOS, double-check you typed source venv/bin/activate with a forward slash. On Windows, use a backslash: venv\Scripts\activate.

help me activate my Python virtual environment

  • Install the project dependencies by running:
pip install langgraph langchain-google-genai

This installs LangGraph (the agent framework) and the LangChain Google Gemini integration (which connects LangGraph to the Gemini API).

  • Verify the installation succeeded by running:
python -c "import langgraph; print(langgraph.__version__)"

✔️ I see a version number

You should see something like 0.x.x printed. The exact number does not matter as long as it prints without errors. LangGraph is ready to go.

ⓧ I see a ModuleNotFoundError

This means the package did not install into your active environment. Check the following:

  • Confirm (venv) appears in your terminal prompt. If not, re-run the activate command above.
  • Run pip install langgraph langchain-google-genai again inside the activated environment.
  • On macOS, make sure you use python (not python3) when running the verification. Inside a venv, python always points to the correct interpreter.

help me fix a ModuleNotFoundError for langgraph after pip install

Get a free Google Gemini API key

Your agent needs an API key to authenticate with Google's Gemini model. The Gemini API has a free tier that requires no billing account.

  • Open your browser and go to aistudio.google.com.
  • Sign in with your Google account.
  • In the left-hand navigation panel, click Get API key.
  • Click Create API key.
  • Copy the generated key and save it somewhere safe (you will need it in a moment).

Why is this free?

Google's Gemini API provides free input and output tokens on the free tier. No billing account or credit card is required. Your usage in this project will stay well within the free tier limits.

Seeing a permission denied error?

If the Create API key button is greyed out or you see a permission error, try opening the page in an incognito window. Make sure you are signed in with a personal Google account (not a work or school account that may have restrictions).

help me create a Gemini API key in Google AI Studio

Set the environment variable

Your code reads the API key from an environment variable called GOOGLE_API_KEY. This keeps the key out of your source code.

macOS

  • In the same terminal where your venv is active, set the variable by running:
export GOOGLE_API_KEY=your-api-key-here
  • Replace your-api-key-here with the key you copied from Google AI Studio.

Windows

  • Press the Windows key and search for Environment Variables.
  • Click Edit the system environment variables.
  • In the System Properties dialog, click Environment Variables.
  • Under User variables, click New....
  • Set Variable name to GOOGLE_API_KEY.
  • Set Variable value to the API key you copied from Google AI Studio.
  • Click OK on all dialogs to save.
  • Close your current terminal and open a new one so the variable loads.
  • Reactivate your virtual environment in the new terminal by navigating back to your project folder and running:
cd ~/Desktop/langgraph-agent
venv\Scripts\activate

Why an environment variable?

Storing secrets in environment variables keeps them out of your code files. If you ever share your code or push it to GitHub, the key stays private on your machine.

  • Verify the variable is set by running:
python -c "import os; print('Key set!' if os.environ.get('GOOGLE_API_KEY') else 'Not found')"

You should see Key set! printed in your terminal.

Seeing 'Not found' instead?

On macOS, the export command only lasts for the current terminal session. If you opened a new terminal window, run the export command again.

On Windows, make sure you opened a brand new terminal after setting the variable in System Properties. The old terminal does not pick up changes.

help me set the GOOGLE_API_KEY environment variable

Your environment is ready. Next up, you will create the agent file and define the tools that your AI agent can call.

Define Tools and Connect the LLM

Your environment is ready. Now it is time to give your agent its capabilities.

An LLM on its own can only generate text. To make it useful as an agent, you give it tools that it can call when a user asks a question that requires action. In this step, you will define three tools, connect them to Google Gemini, and set up the typed state that tracks your agent's conversation.

In this step, get ready to:
  • Create three custom tools: a calculator, a word counter, and a tech dictionary.
  • Connect Google Gemini to your tools using tool binding.
  • Define the typed state that tracks messages and tool call count.
Create agent.py and define three tools

The @tool decorator from LangGraph turns a regular Python function into something an LLM can discover and call. The LLM reads the function's docstring to understand when and how to use it.

  • In your project folder, create a new file called agent.py.
  • Open agent.py in your code editor and add these imports:
import operator
import os
from typing import Literal

from typing_extensions import TypedDict, Annotated
from langchain.tools import tool
from langchain.messages import AnyMessage, HumanMessage, SystemMessage, ToolMessage
from langchain_google_genai import ChatGoogleGenerativeAI
from langgraph.graph import StateGraph, START, END

What do these imports do?

  • langchain.tools gives you the @tool decorator to define tools the LLM can call.
  • langchain.messages provides message types (human, system, tool) that the agent uses to track conversation.
  • langchain_google_genai connects to the Google Gemini API.
  • langgraph.graph provides the graph-building primitives you will use in the next step.
  • typing_extensions and operator are used to define typed state with message appending.
  • Save agent.py.
  • Verify the imports work by running this in your terminal:
python agent.py

You should see no output and no errors. A clean return to the prompt means all packages imported successfully.

Seeing a ModuleNotFoundError?

Make sure your virtual environment is active. You should see the environment name in parentheses at the start of your terminal prompt.

If the error mentions langgraph or langchain_google_genai, re-run the install command from Step 1 with your virtual environment active.

Help me debug my ModuleNotFoundError

Now define the first tool. This is a calculator that safely evaluates math expressions.

  • In agent.py, add the following code below your imports:


# --- Tools ---

@tool
def calculate(expression: str) -> str:
    """Evaluate a mathematical expression and return the result.

    Args:
        expression: A mathematical expression to evaluate, e.g. '2 + 3 * 4'
    """
    try:
        allowed_chars = set("0123456789+-*/.(). ")
        if not all(c in allowed_chars for c in expression):
            return "Error: Invalid characters in expression. Only numbers and +, -, *, /, (, ) are allowed."
        result = eval(expression)
        return str(result)
    except Exception as e:
        return f"Error evaluating expression: {e}"

What does this code do?

The @tool decorator registers this function as a tool the LLM can call. The docstring with the Args section is critical. The LLM reads it to understand what the tool does and what arguments to pass.

The allowed_chars check ensures only numbers and math operators are evaluated. This prevents arbitrary code execution through eval().

You won't see a change yet. The next chunk adds the remaining tools.

  • Add the word counter tool below the calculate function:


@tool
def get_word_count(text: str) -> str:
    """Count the number of words and characters in a given text.

    Args:
        text: The text to analyze
    """
    words = len(text.split())
    characters = len(text)
    return f"Word count: {words}, Character count: {characters}"

What does this code do?

This tool splits text by whitespace to count words and uses len() to count characters. The docstring tells the LLM this tool is for text analysis.

You won't see a change yet. The next chunk adds the final tool.

  • Add the tech dictionary lookup tool below get_word_count:


@tool
def lookup_definition(term: str) -> str:
    """Look up the definition of a programming or technology term.

    Args:
        term: The term to look up
    """
    definitions = {
        "api": "Application Programming Interface - a set of rules that allows different software applications to communicate with each other.",
        "rest": "Representational State Transfer - an architectural style for designing networked applications using HTTP methods.",
        "sdk": "Software Development Kit - a collection of tools, libraries, and documentation for building applications on a specific platform.",
        "llm": "Large Language Model - a type of AI model trained on vast amounts of text data to understand and generate human-like text.",
        "agent": "In AI, an agent is a system that can perceive its environment, make decisions, and take actions to achieve specific goals.",
        "graph": "A data structure consisting of nodes (vertices) connected by edges, used to represent relationships between objects.",
        "state": "The current condition or data stored in a system at a particular point in time.",
        "node": "A fundamental unit in a graph or tree data structure that contains data and connections to other nodes.",
    }
    term_lower = term.lower().strip()
    if term_lower in definitions:
        return f"{term.upper()}: {definitions[term_lower]}"
    return f"Definition not found for '{term}'. Available terms: {', '.join(definitions.keys())}"

What does this code do?

This tool uses a hardcoded dictionary of tech terms. The LLM will call it whenever a user asks for a definition. If the term is not found, the tool returns a helpful message listing available terms.

All three tools follow the same pattern: a function decorated with @tool, a descriptive docstring with Args, and a return value as a string. The LLM uses the docstrings to decide which tool matches a user's question.

  • Save agent.py and verify all three tools are defined correctly by running:
python agent.py

You should see no output and no errors. The functions are defined but not yet called.

Seeing a SyntaxError?

Check that each function's docstring uses triple quotes (""") and that the indentation is consistent. Python requires 4 spaces per indent level inside functions.

Also verify that the definitions dictionary in lookup_definition has a comma after every entry, including the last one.

Help me fix my SyntaxError in agent.py

Set up the LLM with tool binding

Your tools are defined, but the LLM does not know about them yet. Tool binding gives the model a structured description of each tool so it can decide which one to call.

  • Add the LLM setup below your lookup_definition function in agent.py:


# --- LLM Setup ---

llm = ChatGoogleGenerativeAI(
    model="gemini-2.5-flash",
    temperature=0,
)

tools = [calculate, get_word_count, lookup_definition]
tools_by_name = {t.name: t for t in tools}
llm_with_tools = llm.bind_tools(tools)

What does this code do?

  • ChatGoogleGenerativeAI creates a connection to the Gemini API. It reads your GOOGLE_API_KEY environment variable automatically.
  • temperature=0 makes the model's responses deterministic. This is ideal for tool-calling agents that need reliable routing.
  • tools_by_name creates a dictionary mapping each tool's name string to its function. The agent uses this later to look up which function to execute.
  • llm.bind_tools(tools) attaches the tool definitions to the model. Now the LLM knows it can call these tools and understands their parameters.

Why bind tools instead of just describing them in a prompt?

Tool binding passes structured JSON schemas to the model's function-calling API. This is far more reliable than describing tools in plain text. The model returns structured tool call objects with exact argument names and values, instead of free-form text you would have to parse.

  • Save agent.py and run it again:
python agent.py

You should still see no output and no errors. The LLM connection is established but nothing is being invoked yet.

Seeing an authentication error?

This means your GOOGLE_API_KEY environment variable is not being found. Make sure you opened a new terminal session after setting it in Step 1.

Verify the key is loaded by running:

python -c "import os; print('Key set!' if os.environ.get('GOOGLE_API_KEY') else 'Not found')"

If it prints Not found, revisit the environment variable setup in Step 1.

Help me fix my Google API key authentication error

Define the typed state

Every LangGraph agent needs a state object that holds data as it flows through the graph. Your state will track the conversation messages and count how many times the LLM is called.

  • Add the state definition below your LLM setup in agent.py:


# --- State ---

class AgentState(TypedDict):
    messages: Annotated[list[AnyMessage], operator.add]
    tool_call_count: int

What does this code do?

  • AgentState inherits from TypedDict to define a typed dictionary that LangGraph uses as the graph's state.
  • messages holds the full conversation history. The Annotated[list[AnyMessage], operator.add] pattern tells LangGraph to append new messages to the list instead of replacing it.
  • tool_call_count is a plain integer tracking how many LLM calls the agent makes. This shows that state can hold more than just messages.

Why use operator.add for messages?

Without the Annotated reducer, returning {"messages": [new_message]} from a node would overwrite the entire message history. With operator.add as the reducer, returning a new message appends it to the existing list. This is the standard LangGraph pattern for building up conversation history.

  • Save agent.py and confirm the file still runs without errors:
python agent.py

You should see no output and no errors. Your state is defined and ready for the graph to use.

Seeing a NameError or TypeError?

Check that TypedDict and Annotated are imported from typing_extensions (not from typing). Also verify that import operator is at the top of your file.

Help me fix my NameError in the AgentState class

✔️ Awesome, I've got everything!

Great. Double check you have saved agent.py and that python agent.py returns cleanly with no errors.

ⓧ I'd like to double check the full code

Here is the complete agent.py file so far. Compare it against yours:

import operator
import os
from typing import Literal

from typing_extensions import TypedDict, Annotated
from langchain.tools import tool
from langchain.messages import AnyMessage, HumanMessage, SystemMessage, ToolMessage
from langchain_google_genai import ChatGoogleGenerativeAI
from langgraph.graph import StateGraph, START, END


# --- Tools ---

@tool
def calculate(expression: str) -> str:
    """Evaluate a mathematical expression and return the result.

    Args:
        expression: A mathematical expression to evaluate, e.g. '2 + 3 * 4'
    """
    try:
        allowed_chars = set("0123456789+-*/.(). ")
        if not all(c in allowed_chars for c in expression):
            return "Error: Invalid characters in expression. Only numbers and +, -, *, /, (, ) are allowed."
        result = eval(expression)
        return str(result)
    except Exception as e:
        return f"Error evaluating expression: {e}"


@tool
def get_word_count(text: str) -> str:
    """Count the number of words and characters in a given text.

    Args:
        text: The text to analyze
    """
    words = len(text.split())
    characters = len(text)
    return f"Word count: {words}, Character count: {characters}"


@tool
def lookup_definition(term: str) -> str:
    """Look up the definition of a programming or technology term.

    Args:
        term: The term to look up
    """
    definitions = {
        "api": "Application Programming Interface - a set of rules that allows different software applications to communicate with each other.",
        "rest": "Representational State Transfer - an architectural style for designing networked applications using HTTP methods.",
        "sdk": "Software Development Kit - a collection of tools, libraries, and documentation for building applications on a specific platform.",
        "llm": "Large Language Model - a type of AI model trained on vast amounts of text data to understand and generate human-like text.",
        "agent": "In AI, an agent is a system that can perceive its environment, make decisions, and take actions to achieve specific goals.",
        "graph": "A data structure consisting of nodes (vertices) connected by edges, used to represent relationships between objects.",
        "state": "The current condition or data stored in a system at a particular point in time.",
        "node": "A fundamental unit in a graph or tree data structure that contains data and connections to other nodes.",
    }
    term_lower = term.lower().strip()
    if term_lower in definitions:
        return f"{term.upper()}: {definitions[term_lower]}"
    return f"Definition not found for '{term}'. Available terms: {', '.join(definitions.keys())}"


# --- LLM Setup ---

llm = ChatGoogleGenerativeAI(
    model="gemini-2.5-flash",
    temperature=0,
)

tools = [calculate, get_word_count, lookup_definition]
tools_by_name = {t.name: t for t in tools}
llm_with_tools = llm.bind_tools(tools)


# --- State ---

class AgentState(TypedDict):
    messages: Annotated[list[AnyMessage], operator.add]
    tool_call_count: int

Your tools are defined, the LLM is connected, and the state is ready. Next up, you will wire everything together into a graph that routes between the model and tools automatically.

Build the Graph

You have tools that can calculate, count words, and look up definitions. You also have an LLM that knows those tools exist. The missing piece is the orchestration layer that ties everything together.

In this step, you will build the StateGraph that controls your agent's decision-making loop. The graph decides when the LLM should call a tool, executes that tool, and sends the result back to the LLM until it has a final answer.

In this step, get ready to:
  • Create model and tool node functions that process state.
  • Wire up routing logic and compile the graph.
  • Run the agent and see it route queries to the correct tools.
Create the model node and tool node functions

A node in LangGraph is a Python function that receives the current state, does some work, and returns updates to that state. Your agent needs two nodes: one that asks the LLM what to do, and one that executes whatever tool the LLM chose.

  • In agent.py, find the AgentState class at the bottom of your file.
  • Below it, add the model node function by copying this code:
# --- Nodes ---

def model_node(state: AgentState) -> dict:
    """Call the LLM to decide whether to use a tool or respond directly."""
    response = llm_with_tools.invoke(
        [
            SystemMessage(
                content=(
                    "You are a helpful research assistant. You have access to tools for "
                    "calculations, word counting, and looking up tech definitions. "
                    "Use the appropriate tool when the user's question requires it. "
                    "If you can answer directly without a tool, do so."
                )
            )
        ]
        + state["messages"]
    )
    return {
        "messages": [response],
        "tool_call_count": state.get("tool_call_count", 0) + 1,
    }

What does this code do?

The model_node function is the "brain" of your agent. It sends a system prompt plus all conversation messages to the LLM.

The LLM then decides whether to call a tool or respond directly. The function also increments tool_call_count so you can track how many times the LLM was invoked per query.

  • Directly below the model_node function, add the tool node function:
def tool_node(state: AgentState) -> dict:
    """Execute the tool calls from the last LLM message."""
    results = []
    for tool_call in state["messages"][-1].tool_calls:
        tool_fn = tools_by_name[tool_call["name"]]
        observation = tool_fn.invoke(tool_call["args"])
        results.append(
            ToolMessage(content=str(observation), tool_call_id=tool_call["id"])
        )
    return {"messages": results}

What does this code do?

The tool_node function is the "hands" of your agent. It reads the tool calls from the last LLM message, looks up each tool by name in tools_by_name, and executes it.

Each result gets wrapped in a ToolMessage with the matching tool_call_id so the LLM can match answers back to its requests.

  • Save agent.py.

You won't see a change yet. The next chunk wires these nodes into the graph so the agent can actually use them.

Create the routing function and build the graph

The routing function is what makes your agent agentic. Instead of following a fixed sequence, the graph asks: "Did the LLM request a tool call?" If yes, route to the tool node. If no, the conversation is done.

  • Below the tool_node function, add the routing function:
# --- Routing ---

def should_continue(state: AgentState) -> Literal["tool_node", "__end__"]:
    """Route to tool_node if the LLM made tool calls, otherwise end."""
    last_message = state["messages"][-1]
    if last_message.tool_calls:
        return "tool_node"
    return END

What does this code do?

The should_continue function inspects the last message in state. If the LLM included tool_calls, it returns "tool_node" to continue the loop. Otherwise it returns END to stop the graph.

This is the conditional edge that creates the agentic loop. The LLM keeps calling tools and receiving results until it decides it has enough information to answer directly.

  • Below the should_continue function, add the graph construction code:
# --- Build Graph ---

builder = StateGraph(AgentState)
builder.add_node("model_node", model_node)
builder.add_node("tool_node", tool_node)

builder.add_edge(START, "model_node")
builder.add_conditional_edges("model_node", should_continue, ["tool_node", END])
builder.add_edge("tool_node", "model_node")

agent = builder.compile()

What does this code do?

This is where you assemble the graph structure. First you create a StateGraph with your AgentState schema, then register both nodes.

The edges define the flow: START sends every query to model_node first. A conditional edge routes from model_node to either tool_node or END. The loop edge from tool_node back to model_node completes the cycle.

Finally, builder.compile() turns the graph definition into a runnable agent.

Why a graph instead of a simple loop?

You could write a while-loop that calls the LLM and executes tools. The graph approach makes the orchestration explicit and visible. Each node, edge, and routing decision is a named, testable piece.

This is the exact pattern production systems use because it supports features like persistence, streaming, and human-in-the-loop without rewriting the control flow.

  • Save agent.py.

The graph is compiled and ready. The next chunk adds a way to actually send messages to it.

Create the chat helper and run the agent

The graph is compiled, but you need a way to send messages in and see what comes out. The chat() function handles invoking the agent and printing the results in a readable format.

  • Below the agent = builder.compile() line, add the chat function and main block:
# --- Run ---

def chat(user_message: str) -> None:
    """Send a message to the agent and print the response."""
    print(f"\n{'='*50}")
    print(f"User: {user_message}")
    print(f"{'='*50}")

    result = agent.invoke(
        {"messages": [HumanMessage(content=user_message)], "tool_call_count": 0}
    )

    for message in result["messages"]:
        if hasattr(message, "tool_calls") and message.tool_calls:
            print(f"\n[Agent calling tools: {[tc['name'] for tc in message.tool_calls]}]")
        elif hasattr(message, "content") and message.type == "tool":
            print(f"[Tool result: {message.content}]")
        elif hasattr(message, "content") and message.type == "ai" and not message.tool_calls:
            print(f"\nAssistant: {message.content}")

    print(f"\nTotal LLM calls: {result['tool_call_count']}")


if __name__ == "__main__":
    chat("What is 25 * 4 + 10?")
    chat("What does API stand for?")
    chat("Count the words in: The quick brown fox jumps over the lazy dog")
    chat("What is the definition of LLM and also calculate 100 / 4?")

What does this code do?

The chat() function sends a HumanMessage into the compiled graph via agent.invoke(). It then loops through the result messages, printing tool calls, tool results, and the final AI response.

The if __name__ == "__main__" block tests four queries: a math calculation, a definition lookup, a word count, and a multi-tool query that requires both a definition and a calculation.

  • Save agent.py.
  • Run the agent by executing this command in your terminal:
python agent.py

You should see output for each query showing which tools the agent calls and the final answers. The math query routes to calculate, the definition query routes to lookup_definition, and the word count query routes to get_word_count.

Seeing an error when you run the script?

  • If you see an authentication error, make sure your GOOGLE_API_KEY environment variable is set. Open a new terminal window if you recently added it.
  • If you see a NameError, check that your functions are defined in the correct order: tools first, then LLM setup, then state, then nodes, then routing, then graph build, then run.
  • If you see an indentation error, double-check the spacing inside your functions matches the code above exactly.

Help me debug my LangGraph agent script

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete agent.py file as it should look at the end of this step:

import operator
import os
from typing import Literal

from typing_extensions import TypedDict, Annotated
from langchain.tools import tool
from langchain.messages import AnyMessage, HumanMessage, SystemMessage, ToolMessage
from langchain_google_genai import ChatGoogleGenerativeAI
from langgraph.graph import StateGraph, START, END


# --- Tools ---

@tool
def calculate(expression: str) -> str:
    """Evaluate a mathematical expression and return the result.

    Args:
        expression: A mathematical expression to evaluate, e.g. '2 + 3 * 4'
    """
    try:
        allowed_chars = set("0123456789+-*/.(). ")
        if not all(c in allowed_chars for c in expression):
            return "Error: Invalid characters in expression. Only numbers and +, -, *, /, (, ) are allowed."
        result = eval(expression)
        return str(result)
    except Exception as e:
        return f"Error evaluating expression: {e}"


@tool
def get_word_count(text: str) -> str:
    """Count the number of words and characters in a given text.

    Args:
        text: The text to analyze
    """
    words = len(text.split())
    characters = len(text)
    return f"Word count: {words}, Character count: {characters}"


@tool
def lookup_definition(term: str) -> str:
    """Look up the definition of a programming or technology term.

    Args:
        term: The term to look up
    """
    definitions = {
        "api": "Application Programming Interface - a set of rules that allows different software applications to communicate with each other.",
        "rest": "Representational State Transfer - an architectural style for designing networked applications using HTTP methods.",
        "sdk": "Software Development Kit - a collection of tools, libraries, and documentation for building applications on a specific platform.",
        "llm": "Large Language Model - a type of AI model trained on vast amounts of text data to understand and generate human-like text.",
        "agent": "In AI, an agent is a system that can perceive its environment, make decisions, and take actions to achieve specific goals.",
        "graph": "A data structure consisting of nodes (vertices) connected by edges, used to represent relationships between objects.",
        "state": "The current condition or data stored in a system at a particular point in time.",
        "node": "A fundamental unit in a graph or tree data structure that contains data and connections to other nodes.",
    }
    term_lower = term.lower().strip()
    if term_lower in definitions:
        return f"{term.upper()}: {definitions[term_lower]}"
    return f"Definition not found for '{term}'. Available terms: {', '.join(definitions.keys())}"


# --- LLM Setup ---

llm = ChatGoogleGenerativeAI(
    model="gemini-2.5-flash",
    temperature=0,
)

tools = [calculate, get_word_count, lookup_definition]
tools_by_name = {t.name: t for t in tools}
llm_with_tools = llm.bind_tools(tools)


# --- State ---

class AgentState(TypedDict):
    messages: Annotated[list[AnyMessage], operator.add]
    tool_call_count: int


# --- Nodes ---

def model_node(state: AgentState) -> dict:
    """Call the LLM to decide whether to use a tool or respond directly."""
    response = llm_with_tools.invoke(
        [
            SystemMessage(
                content=(
                    "You are a helpful research assistant. You have access to tools for "
                    "calculations, word counting, and looking up tech definitions. "
                    "Use the appropriate tool when the user's question requires it. "
                    "If you can answer directly without a tool, do so."
                )
            )
        ]
        + state["messages"]
    )
    return {
        "messages": [response],
        "tool_call_count": state.get("tool_call_count", 0) + 1,
    }


def tool_node(state: AgentState) -> dict:
    """Execute the tool calls from the last LLM message."""
    results = []
    for tool_call in state["messages"][-1].tool_calls:
        tool_fn = tools_by_name[tool_call["name"]]
        observation = tool_fn.invoke(tool_call["args"])
        results.append(
            ToolMessage(content=str(observation), tool_call_id=tool_call["id"])
        )
    return {"messages": results}


# --- Routing ---

def should_continue(state: AgentState) -> Literal["tool_node", "__end__"]:
    """Route to tool_node if the LLM made tool calls, otherwise end."""
    last_message = state["messages"][-1]
    if last_message.tool_calls:
        return "tool_node"
    return END


# --- Build Graph ---

builder = StateGraph(AgentState)
builder.add_node("model_node", model_node)
builder.add_node("tool_node", tool_node)

builder.add_edge(START, "model_node")
builder.add_conditional_edges("model_node", should_continue, ["tool_node", END])
builder.add_edge("tool_node", "model_node")

agent = builder.compile()


# --- Run ---

def chat(user_message: str) -> None:
    """Send a message to the agent and print the response."""
    print(f"\n{'='*50}")
    print(f"User: {user_message}")
    print(f"{'='*50}")

    result = agent.invoke(
        {"messages": [HumanMessage(content=user_message)], "tool_call_count": 0}
    )

    for message in result["messages"]:
        if hasattr(message, "tool_calls") and message.tool_calls:
            print(f"\n[Agent calling tools: {[tc['name'] for tc in message.tool_calls]}]")
        elif hasattr(message, "content") and message.type == "tool":
            print(f"[Tool result: {message.content}]")
        elif hasattr(message, "content") and message.type == "ai" and not message.tool_calls:
            print(f"\nAssistant: {message.content}")

    print(f"\nTotal LLM calls: {result['tool_call_count']}")


if __name__ == "__main__":
    chat("What is 25 * 4 + 10?")
    chat("What does API stand for?")
    chat("Count the words in: The quick brown fox jumps over the lazy dog")
    chat("What is the definition of LLM and also calculate 100 / 4?")

Your agent is alive. It receives a question, decides which tool to call, executes it, and loops back until it has a complete answer. Next up, you will test multi-tool routing and see how the agent handles queries that need different tools or no tools at all.

Test Multi-Tool Routing

In the previous step, you built a complete StateGraph with nodes, edges, and conditional routing. You even ran the agent and saw it answer a few queries.

Now it is time to stress-test the routing logic. You will send the agent queries that exercise each tool individually, a query that needs no tools at all, and a complex query that triggers multiple tool calls in a single turn.

In this step, get ready to:
  • Confirm that the agent routes to the correct tool for math, definitions, and word counts.
  • Verify that the agent responds directly when no tool is needed.
  • Test a multi-tool query and observe how the agentic loop handles it.
Test the agent with queries that require different tools

The should_continue routing function checks whether the LLM's response contains tool calls. If it does, the graph loops through the tool node and back to the model. If it does not, the graph ends.

You will now swap in fresh queries to observe how the agent picks the right tool each time.

  • In agent.py, scroll to the if __name__ == "__main__": block at the bottom of the file.
  • Find these lines:
if __name__ == "__main__":
    chat("What is 25 * 4 + 10?")
    chat("What does API stand for?")
    chat("Count the words in: The quick brown fox jumps over the lazy dog")
    chat("What is the definition of LLM and also calculate 100 / 4?")
  • Replace the contents of the if __name__ block with these new test queries:
if __name__ == "__main__":
    chat("What is 144 / 12 + 3 * 7?")
    chat("What is an LLM?")
    chat("Count the words in: LangGraph is a framework for building stateful agents")

What are these queries testing?

Each query targets a different tool. The math expression should route to calculate. The definition question should route to lookup_definition. The word count request should route to get_word_count.

The LLM reads each query, decides which tool fits based on the tool docstrings, and emits a tool call. The should_continue function sees the tool call and routes the graph through the tool node.

  • Save agent.py.
  • Run the script by running this command:
python agent.py
  • Watch the output for each query. You should see lines like [Agent calling tools: ['calculate']] followed by the tool result and a final assistant response with the correct answer.

Seeing unexpected tool choices?

If the agent calls the wrong tool (e.g., calculate for a definition query), make sure your tool docstrings match the Complete Code exactly. The LLM relies on docstrings to choose which tool to call.

If you see an API error, confirm that your GOOGLE_API_KEY environment variable is still set by opening a new terminal and running the verification command from Step 1.

Why is my agent calling the wrong tool?

Test a query that requires no tools

A well-built agent does not force tool calls when a simple response will do. The system message tells the LLM to answer directly if no tool is needed. This is where the conditional edge proves its value.

  • In agent.py, add this line inside the if __name__ == "__main__": block below your existing queries:
    chat("Hello, how are you?")

What should happen with this query?

A greeting has nothing to do with math, word counts, or tech definitions. The LLM should respond directly without emitting any tool calls.

When should_continue checks the response, it finds no tool_calls on the message and returns END. The graph finishes in a single pass through model_node with a tool_call_count of 1.

  • Save agent.py and run the script again:
python agent.py
  • Scroll to the output for the greeting query. You should see Total LLM calls: 1 and no [Agent calling tools: ...] line. The agent responded directly without looping through the tool node.

Why is tool_call_count 1 instead of 0?

The counter increments every time model_node runs. Even for a direct response, the LLM still runs once to decide that no tools are needed. A count of 1 means one LLM call with no tool loop. A count of 2 means the LLM called a tool, got the result back, and ran a second time to produce the final answer.

Test a complex query that triggers multiple tool calls

The real power of the agentic loop is that it keeps cycling until the LLM is satisfied. A query that asks for two things at once should trigger multiple tool calls in one turn.

  • Add one more test query to your if __name__ == "__main__": block:
    chat("What is the definition of SDK and also calculate 256 / 8?")

What does this test?

This query needs two tools: lookup_definition for the SDK definition and calculate for the math. The LLM might call both tools in a single response, or it might chain them across separate loop iterations.

Either way, the tool_call_count will be higher than the greeting query because the graph loops back through model_node after each batch of tool results.

  • Save agent.py and run the script one more time:
python agent.py
  • Scroll to the output for the multi-tool query. You should see [Agent calling tools: ['lookup_definition', 'calculate']] (or two separate tool call lines). The final answer combines both results.
  • Compare the Total LLM calls printed for this query against the greeting query. The multi-tool query requires at least 2 LLM calls (one to decide on tools, one to produce the final answer after receiving results).

Agent only calling one tool for the multi-tool query?

LLMs can sometimes miss the second part of a compound question. This is normal behavior. If it only calls one tool, try rephrasing the query to be more explicit (e.g., start each request on its own clause).

The important thing to confirm is that the loop works. When the agent does call both tools, it demonstrates that the tool_node -> model_node edge correctly feeds results back for the LLM to synthesize.

Why is my agent only calling one tool when the query needs two?

  • Now that you have confirmed the routing works, restore the original test queries in your if __name__ == "__main__": block so your file matches the reference below:
if __name__ == "__main__":
    chat("What is 25 * 4 + 10?")
    chat("What does API stand for?")
    chat("Count the words in: The quick brown fox jumps over the lazy dog")
    chat("What is the definition of LLM and also calculate 100 / 4?")
  • Save agent.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file.

ⓧ I'd like to double check the full code

import operator
import os
from typing import Literal

from typing_extensions import TypedDict, Annotated
from langchain.tools import tool
from langchain.messages import AnyMessage, HumanMessage, SystemMessage, ToolMessage
from langchain_google_genai import ChatGoogleGenerativeAI
from langgraph.graph import StateGraph, START, END


# --- Tools ---

@tool
def calculate(expression: str) -> str:
    """Evaluate a mathematical expression and return the result.

    Args:
        expression: A mathematical expression to evaluate, e.g. '2 + 3 * 4'
    """
    try:
        allowed_chars = set("0123456789+-*/.(). ")
        if not all(c in allowed_chars for c in expression):
            return "Error: Invalid characters in expression. Only numbers and +, -, *, /, (, ) are allowed."
        result = eval(expression)
        return str(result)
    except Exception as e:
        return f"Error evaluating expression: {e}"


@tool
def get_word_count(text: str) -> str:
    """Count the number of words and characters in a given text.

    Args:
        text: The text to analyze
    """
    words = len(text.split())
    characters = len(text)
    return f"Word count: {words}, Character count: {characters}"


@tool
def lookup_definition(term: str) -> str:
    """Look up the definition of a programming or technology term.

    Args:
        term: The term to look up
    """
    definitions = {
        "api": "Application Programming Interface - a set of rules that allows different software applications to communicate with each other.",
        "rest": "Representational State Transfer - an architectural style for designing networked applications using HTTP methods.",
        "sdk": "Software Development Kit - a collection of tools, libraries, and documentation for building applications on a specific platform.",
        "llm": "Large Language Model - a type of AI model trained on vast amounts of text data to understand and generate human-like text.",
        "agent": "In AI, an agent is a system that can perceive its environment, make decisions, and take actions to achieve specific goals.",
        "graph": "A data structure consisting of nodes (vertices) connected by edges, used to represent relationships between objects.",
        "state": "The current condition or data stored in a system at a particular point in time.",
        "node": "A fundamental unit in a graph or tree data structure that contains data and connections to other nodes.",
    }
    term_lower = term.lower().strip()
    if term_lower in definitions:
        return f"{term.upper()}: {definitions[term_lower]}"
    return f"Definition not found for '{term}'. Available terms: {', '.join(definitions.keys())}"


# --- LLM Setup ---

llm = ChatGoogleGenerativeAI(
    model="gemini-2.5-flash",
    temperature=0,
)

tools = [calculate, get_word_count, lookup_definition]
tools_by_name = {t.name: t for t in tools}
llm_with_tools = llm.bind_tools(tools)


# --- State ---

class AgentState(TypedDict):
    messages: Annotated[list[AnyMessage], operator.add]
    tool_call_count: int


# --- Nodes ---

def model_node(state: AgentState) -> dict:
    """Call the LLM to decide whether to use a tool or respond directly."""
    response = llm_with_tools.invoke(
        [
            SystemMessage(
                content=(
                    "You are a helpful research assistant. You have access to tools for "
                    "calculations, word counting, and looking up tech definitions. "
                    "Use the appropriate tool when the user's question requires it. "
                    "If you can answer directly without a tool, do so."
                )
            )
        ]
        + state["messages"]
    )
    return {
        "messages": [response],
        "tool_call_count": state.get("tool_call_count", 0) + 1,
    }


def tool_node(state: AgentState) -> dict:
    """Execute the tool calls from the last LLM message."""
    results = []
    for tool_call in state["messages"][-1].tool_calls:
        tool_fn = tools_by_name[tool_call["name"]]
        observation = tool_fn.invoke(tool_call["args"])
        results.append(
            ToolMessage(content=str(observation), tool_call_id=tool_call["id"])
        )
    return {"messages": results}


# --- Routing ---

def should_continue(state: AgentState) -> Literal["tool_node", "__end__"]:
    """Route to tool_node if the LLM made tool calls, otherwise end."""
    last_message = state["messages"][-1]
    if last_message.tool_calls:
        return "tool_node"
    return END


# --- Build Graph ---

builder = StateGraph(AgentState)
builder.add_node("model_node", model_node)
builder.add_node("tool_node", tool_node)

builder.add_edge(START, "model_node")
builder.add_conditional_edges("model_node", should_continue, ["tool_node", END])
builder.add_edge("tool_node", "model_node")

agent = builder.compile()


# --- Run ---

def chat(user_message: str) -> None:
    """Send a message to the agent and print the response."""
    print(f"\n{'='*50}")
    print(f"User: {user_message}")
    print(f"{'='*50}")

    result = agent.invoke(
        {"messages": [HumanMessage(content=user_message)], "tool_call_count": 0}
    )

    for message in result["messages"]:
        if hasattr(message, "tool_calls") and message.tool_calls:
            print(f"\n[Agent calling tools: {[tc['name'] for tc in message.tool_calls]}]")
        elif hasattr(message, "content") and message.type == "tool":
            print(f"[Tool result: {message.content}]")
        elif hasattr(message, "content") and message.type == "ai" and not message.tool_calls:
            print(f"\nAssistant: {message.content}")

    print(f"\nTotal LLM calls: {result['tool_call_count']}")


if __name__ == "__main__":
    chat("What is 25 * 4 + 10?")
    chat("What does API stand for?")
    chat("Count the words in: The quick brown fox jumps over the lazy dog")
    chat("What is the definition of LLM and also calculate 100 / 4?")

Your agent now handles single-tool routing, direct responses, and multi-tool queries. You have a production-ready pattern: the LLM decides, the graph routes, and the loop continues until the answer is complete.

Secret mission

Add Conversation Memory

Your agent works great for one-off questions. But what if you want to have a real conversation? Right now, every call to chat() starts fresh. The agent has no idea what you said 10 seconds ago. In this secret mission, you'll give your agent memory so it can recall previous messages within a conversation thread.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine and uses the Gemini API free tier, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Project folder on your Desktop (contains agent.py, virtual environment, and installed packages).
  • GOOGLE_API_KEY environment variable.
  • Google AI Studio API key.

Keep everything running

No action needed. Choose this if you plan to keep building on this agent or experimenting with LangGraph.

  • Your project folder stays on your Desktop.
  • Your GOOGLE_API_KEY environment variable remains set for future projects that use the Gemini API.
  • Your Google AI Studio API key remains active (free tier, no charges).

Pause - I'll come back to this later

Shut down any running Python processes to free up memory. Your files and API key stay intact for when you return.

  • Close any terminal windows running your agent script.
  • Deactivate your virtual environment by running:
deactivate
  • Your project folder, environment variable, and API key all remain in place for next time.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to clean up completely.

  • Delete your project folder from your Desktop.

On macOS or Linux, run:

cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]

On Windows, run:

cd ~\Desktop
rmdir /s /q [[PROJECT_FOLDER="your project folder name"]]
  • Remove the GOOGLE_API_KEY environment variable.

On Windows, search for "Environment Variables" in the Windows search bar. Click Environment Variables in the System Properties dialog. Under User variables, find GOOGLE_API_KEY, select it, and click Delete. Click OK to save.

On macOS or Linux, open your shell config file (~/.zshrc or ~/.bashrc) and remove the line that exports GOOGLE_API_KEY.

  • Delete your API key from the Google Cloud API Credentials page.

Open the Google Cloud API Credentials page. Find your API key and click the Actions icon. Select Delete API key. In the confirmation modal, select Delete.

Nice Work!

Nice Work!

Nice work! You just built a fully functional tool-calling agent using LangGraph and Google Gemini. Your agent autonomously decides which tools to call, executes them, and loops until it has a final answer.

You've learned how to:

  • Build a complete LangGraph agent with three custom tools (calculator, word counter, and tech dictionary) using the @tool decorator.
  • Define typed state using TypedDict with the Annotated message append pattern, and wire up a StateGraph with conditional routing edges.
  • Test the agentic loop pattern where the LLM decides which tool to call, executes it, and loops back until it can deliver a final response.
  • Secret Mission: Added conversation memory using InMemorySaver and thread-based persistence so the agent remembers previous messages across turns.

Ready to quiz yourself?