LangGraph Basics: Part 5 — Tools, ToolNode & Prebuilt Components

Author Photo
LangGraph Basics Part 5 — Tools, ToolNode & Prebuilt Components

🧠 1. Why LLMs Need External Tools

Language models are surprisingly good at many things — explaining concepts, summarising text, writing code, answering questions. But ask one to calculate the monthly payment on a $20,000 car loan and you might get an answer that sounds right but is quietly off by a few dollars. That might seem harmless, but small calculation errors in finance compound into real problems over a 4-year loan.

This happens because LLMs don't actually compute — they predict text. When an LLM "does math", it's pattern-matching against millions of similar problems it saw during training. Most of the time it's close enough. But close enough isn't acceptable when your users are making financial decisions.

The solution is tools. A tool in LangGraph is simply a Python function that the LLM can call when it needs one. The LLM handles the part it's genuinely good at — understanding the question, reasoning about which function to use, and explaining the result in plain language. Python handles the part it's good at: exact arithmetic. Together, they're far more reliable than either alone.

🔗 What is a Tool? In LangGraph and LangChain, a tool is a Python callable wrapped as a structured object with a name, a description, and a parameter schema — everything the LLM needs to decide when to call it and what arguments to pass. There are three ways to create one: the @tool decorator, StructuredTool.from_function(), or subclassing BaseTool. Section 3 covers all three.
1.1

🔄 The ReAct Loop

The pattern that governs tool-calling agents is called ReAct — short for Reason + Act. It describes a loop the agent runs until it has a final answer:

  • Reason — the LLM reads the conversation and decides what to do next. If it has enough information to answer, it does. If not, it picks a tool.
  • Act — the LLM calls the chosen tool with specific arguments (it doesn't execute the code itself — it just requests the call).
  • Observe — LangGraph's ToolNode runs the Python function and hands the result back to the LLM.
  • Repeat — the LLM reads the tool result and either calls another tool or gives a final answer.

Think of a good chef in a professional kitchen. They read the order (Reason), grab the right knife or pan (Act), taste the dish (Observe), then adjust the seasoning (Reason again). They don't do everything with their bare hands — they use the right tool at each step. A LangGraph tool-calling agent works exactly the same way.

StepWho does itWhat happens
ReasonLLM (agent node)Reads the conversation, decides to use calculate_loan_payment
ActLLM → AIMessageReturns tool_calls with function name and arguments
ObserveToolNodeRuns the Python function, wraps result in ToolMessage
Reason againLLM (agent node)Reads tool result, writes final answer as plain text
1.2

💰 Our Scenario: Personal Finance Assistant

Throughout this post, we'll build a Personal Finance Assistant for Priya — a 28-year-old software engineer who earns $4,500 per month. Priya wants to plan smarter: she's thinking about a car loan, she'd like to start investing, she's about to travel to Europe, and she wants to stop guessing where her salary goes every month.

She asks the assistant questions like:

Q "What would my monthly payment be on a $20,000 car loan at 6% interest for 48 months?"
A Agent calls calculate_loan_payment(20000, 6, 48) → returns $469.70/month
Q "If I invest $5,000 at 7% annual interest, how much will I have after 10 years?"
A Agent calls compound_interest(5000, 7, 10) → returns $9,835.76
Q "How much is 1,000 USD in EUR?"
A Agent calls convert_currency(1000, "USD", "EUR") → returns 925.93 EUR
Q "I earn $4,500/month. Split my budget: 30% rent, 20% food, 20% savings."
A Agent calls split_budget(4500, 30, 20, 20) → returns full breakdown

Each of these requires a different tool. The LLM reads the question, picks the right tool, fills in the arguments, and presents the result clearly. By the end of this post, you'll have built exactly this — a four-tool finance agent powered by Google Gemini and LangGraph.


⚙️ 2. Installation & Setup

This post is part of the LangGraph Basics series. The virtual environment and dependencies are shared across the series — if you've already set them up for a previous post, skip to the project tree.

Step 1 — Check your Python version. Python 3.12 is required.

python --version # Python 3.12.x

Step 2 — Create and activate a virtual environment.

python -m venv langgraph # macOS / Linux source langgraph/bin/activate # Windows langgraph\Scripts\activate

Step 3 — Install dependencies. Create a requirements.txt in the langgraph/ root folder with these exact versions:

langchain==1.2.17 langgraph==1.1.10 langchain-google-genai==4.2.2 python-dotenv==1.2.2 gradio==6.14.0
pip install -r requirements.txt

Step 4 — Get your Gemini API key. Visit Google AI Studio, generate a key, and create a .env file in the langgraph/ root folder:

GEMINI_API_KEY=your_api_key_here GEMINI_MODEL_NAME=gemini-3-flash-preview GEMINI_TEMPERATURE=0.7 GEMINI_MAX_RETRIES=2
⚠️ Never commit your .env file. Add it to .gitignore right away: echo ".env" >> .gitignore

Step 5 — Project structure. All files for this post live inside basics-5-tools-toolnode/. The .env and requirements.txt are shared at the langgraph/ root level.

langgraph/ ├── basics-5-tools-toolnode/ # this post's project folder │ ├── config.py # Config class — loads .env │ ├── llm.py # GeminiLLM — plain LLM wrapper │ ├── state.py # FinanceState — messages-based state │ ├── tools/ # one BaseTool class per file │ │ ├── __init__.py # exports TOOLS list │ │ ├── loan_payment.py # LoanPaymentTool │ │ ├── compound_interest.py # CompoundInterestTool │ │ ├── convert_currency.py # ConvertCurrencyTool │ │ └── split_budget.py # SplitBudgetTool │ ├── nodes.py # FinanceNodes — agent_node with tools │ ├── graph.py # FinanceGraph — builds and compiles │ ├── finance_runner.py # FinanceRunner — entry point + demo │ ├── app.py # Gradio web UI │ ├── prompts/ # all LLM prompt templates │ │ ├── system.txt # system prompt for the agent node │ │ ├── loan_payment.txt # description for LoanPaymentTool │ │ ├── compound_interest.txt # description for CompoundInterestTool │ │ ├── convert_currency.txt # description for ConvertCurrencyTool │ │ └── split_budget.txt # description for SplitBudgetTool │ └── figure/ # auto-generated by graph.py │ ├── graph.mmd # Mermaid source │ └── graph.png # PNG diagram ├── .env # GEMINI_API_KEY (shared, never commit) └── requirements.txt # shared dependencies

Here's how each file maps to the sections in this post: the tools/ package is covered in Section 3, nodes.py in Sections 4–5, graph.py in Section 6, and finance_runner.py + app.py in Section 7.

2.1

🔧 Configuring the LLM

config.py reads the environment variables and exposes them as typed class attributes. Every other file imports from here — nothing reads os.getenv() directly anywhere else.

import os from dotenv import load_dotenv load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), "..", ".env")) class Config: MODEL_NAME = os.getenv("GEMINI_MODEL_NAME", "gemini-3-flash-preview") TEMPERATURE = float(os.getenv("GEMINI_TEMPERATURE", 0.7)) MAX_RETRIES = int(os.getenv("GEMINI_MAX_RETRIES", 2))

llm.py wraps ChatGoogleGenerativeAI in a class. Notice that this file does not call bind_tools() — the plain LLM is returned here, and tool binding happens later in nodes.py. This keeps the LLM class reusable across any graph.

from langchain_google_genai import ChatGoogleGenerativeAI from config import Config class GeminiLLM: def __init__(self): self.llm = ChatGoogleGenerativeAI( model=Config.MODEL_NAME, temperature=Config.TEMPERATURE, max_retries=Config.MAX_RETRIES, ) def get_llm(self): return self.llm

🔧 3. Three Ways to Define Tools

Before the LLM can call a Python function, that function must be wrapped as a LangChain Tool — an object with a name, a description, and a parameter schema. LangChain gives you three ways to do that wrapping. All three produce the same result: a tool the LLM can discover and call through bind_tools().

  • Method 1 — @tool decorator: Minimal setup. Add one decorator; the docstring becomes the description and type annotations build the schema. Best for simple, stateless functions.
  • Method 2 — StructuredTool.from_function(): Explicit name, description, and Pydantic schema. Useful when you want to control each field without a docstring, or reuse an existing function.
  • Method 3 — BaseTool subclass: Full OOP approach. A class with _run() and _arun() methods. Best for complex tools that need internal state, dependency injection, or proper async support — and the most testable and maintainable of the three.
💡 All three work identically with ToolNode and bind_tools. Choose based on how much control you need. For production code, BaseTool subclassing is the most explicit and the easiest to test independently.
3.1

🏷️ Method 1 — The @tool Decorator

The @tool decorator converts a plain Python function into a LangChain tool with one line. Three things are inferred automatically: the name comes from the function name, the description comes from the first line of the docstring (this is what the LLM reads to decide when to call the tool), and the parameter schema is built from type annotations.

Here is the loan payment tool using @tool:

from langchain_core.tools import tool @tool def calculate_loan_payment(principal: float, annual_rate_pct: float, months: int) -> str: """Calculate the fixed monthly loan payment (EMI) for a given loan. Args: principal: Total loan amount in dollars. annual_rate_pct: Annual interest rate as a percentage (e.g., 6 for 6%). months: Loan term in months. """ if annual_rate_pct == 0: monthly_payment = principal / months else: r = annual_rate_pct / 100 / 12 monthly_payment = principal * (r * (1 + r) ** months) / ((1 + r) ** months - 1) total_paid = monthly_payment * months return ( f"Monthly payment: ${monthly_payment:.2f} for {months} months. " f"Total paid: ${total_paid:.2f}." )

You can inspect what the decorator built:

print(calculate_loan_payment.name) # calculate_loan_payment print(calculate_loan_payment.description) # Calculate the fixed monthly loan payment (EMI) for a given loan. print(calculate_loan_payment.args) # {'principal': {'title': 'Principal', 'type': 'number'}, ...}
Best for: Quick, stateless functions where the docstring is already clear. The decorator approach is the fastest way to get a working tool.
3.2

🧩 Method 2 — StructuredTool.from_function()

StructuredTool.from_function() wraps an existing function but lets you set the name and description explicitly — without relying on the docstring format. You also pass a Pydantic BaseModel as the argument schema, which gives you fine-grained control over field validation and per-field descriptions.

Here is the compound interest calculator using StructuredTool:

from langchain_core.tools import StructuredTool from pydantic import BaseModel class CompoundInterestInput(BaseModel): principal: float annual_rate_pct: float years: int def _compound_interest(principal: float, annual_rate_pct: float, years: int) -> str: rate = annual_rate_pct / 100 final_amount = principal * (1 + rate) ** years interest_earned = final_amount - principal return ( f"Final amount after {years} years: ${final_amount:.2f}. " f"Interest earned: ${interest_earned:.2f}." ) compound_interest = StructuredTool.from_function( func=_compound_interest, name="compound_interest", description=( "Calculate the final amount after annual compound interest. " "Use this for savings projections and investment growth questions." ), args_schema=CompoundInterestInput, )

The function _compound_interest is a plain Python function — no decorator needed. The StructuredTool.from_function() call wraps it and attaches an explicit name, description, and schema. This is useful when you're adapting a function you didn't write, or when you want a richer Pydantic schema with field-level validators.

Best for: Wrapping existing functions without modifying them, or when you need explicit Pydantic field descriptions and validators separate from the function docstring.
3.3

🏗️ Method 3 — BaseTool Subclass

Subclassing BaseTool is the most explicit and most powerful approach. Instead of a decorated function, you define a class with two methods: _run() for synchronous calls and _arun() for async. The tool's name, description, and argument schema are class attributes — nothing is inferred from docstrings or function signatures.

Here is the currency converter as a BaseTool subclass:

import os from typing import Type from langchain_core.tools import BaseTool from pydantic import BaseModel _PROMPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "prompts") # Module-level constant — outside the class to avoid Pydantic field validation _RATES_TO_USD: dict[str, float] = { "USD": 1.0, "EUR": 1.08, "GBP": 1.27, "JPY": 0.0067, "BDT": 0.0091, "CAD": 0.74, "AUD": 0.65, "INR": 0.012, } def _load_prompt(filename: str) -> str: with open(os.path.join(_PROMPTS_DIR, filename), "r") as f: return f.read().strip() class ConvertCurrencyInput(BaseModel): amount: float from_currency: str to_currency: str class ConvertCurrencyTool(BaseTool): name: str = "convert_currency" description: str = _load_prompt("convert_currency.txt") args_schema: Type[BaseModel] = ConvertCurrencyInput def _run(self, amount: float, from_currency: str, to_currency: str) -> str: src, dst = from_currency.upper(), to_currency.upper() if src not in _RATES_TO_USD or dst not in _RATES_TO_USD: return f"Unsupported currency. Supported: {', '.join(_RATES_TO_USD)}." converted = (amount * _RATES_TO_USD[src]) / _RATES_TO_USD[dst] return f"{amount:.2f} {src} = {converted:.2f} {dst}." async def _arun(self, amount: float, from_currency: str, to_currency: str) -> str: return self._run(amount, from_currency, to_currency)

Four details are worth noting:

  • name, description, args_schema are typed class attributes. Nothing is inferred — you see exactly what the LLM receives when you read the class definition.
  • Description loaded from prompts/convert_currency.txt_load_prompt() reads the file at class definition time. Keeping descriptions in .txt files lets you tune the LLM's tool-selection behavior without changing Python code.
  • The exchange rate dict is module-level (_RATES_TO_USD). Pydantic would treat class-level dictionaries as model fields and raise a validation error, so constants that aren't tool arguments live outside the class.
  • _arun() delegates to _run(). For I/O-bound tools (API calls, DB queries), you'd implement _arun() with real async/await. For pure-Python arithmetic, delegation is fine.
Best for: Production code. Classes are easy to test in isolation, easy to mock in unit tests, and straightforward to extend with logging, retries, or dependency injection. This is what the source code for this post uses.
3.4

💵 The 4 Finance Tools

The source code uses Method 3 — BaseTool subclassing — for all four tools, organized into a tools/ package with one file per tool. Each tool is its own class in its own file, which keeps it self-contained and independently testable. The package's __init__.py collects all four into a single TOOLS list:

# tools/__init__.py from .compound_interest import CompoundInterestTool from .convert_currency import ConvertCurrencyTool from .loan_payment import LoanPaymentTool from .split_budget import SplitBudgetTool TOOLS = [ LoanPaymentTool(), CompoundInterestTool(), ConvertCurrencyTool(), SplitBudgetTool(), ]

With this in place, from tools import TOOLS works from anywhere in the project — whether tools is a single .py file or a package directory makes no difference to the importer. The four tools cover everything Priya needs:

File Class What It Computes
loan_payment.py LoanPaymentTool Fixed monthly EMI using the compound interest formula
compound_interest.py CompoundInterestTool Final savings amount after annual compounding
convert_currency.py ConvertCurrencyTool Currency conversion via USD as intermediate
split_budget.py SplitBudgetTool Monthly income split into named budget categories

⚡ 4. ToolNode — The Prebuilt Executor

When the LLM decides to call a tool, it doesn't actually run the Python function. Instead, it returns a special AIMessage that contains a tool_calls field — a list of tool names and arguments the LLM has chosen. The LLM's job is to decide what to call; actually running the function is a separate step.

That's exactly what ToolNode does. It's a prebuilt LangGraph node that automatically:

  • Reads the last message in state — it looks for an AIMessage with a non-empty tool_calls field
  • Finds the matching function — it looks up the tool by name in the list you registered
  • Calls the function — it passes the arguments the LLM provided
  • Wraps the result — it wraps the return value in a ToolMessage object
  • Updates state — it returns {"messages": [ToolMessage(...)]}, which the add_messages reducer appends to the conversation

Here's how the message flow looks, step by step:

💬
HumanMessage
User asks: "What's my monthly payment on a $20,000 loan at 6% for 48 months?"
🤖
Agent Node (LLM)
Reads the question and returns an AIMessage with tool_calls: [{"name": "calculate_loan_payment", "args": {"principal": 20000, "annual_rate_pct": 6, "months": 48}}]
⚙️
ToolNode
Finds calculate_loan_payment, calls it with the args, wraps result in ToolMessage("Monthly payment: $469.70 for 48 months. Total paid: $22,545.60.")
🤖
Agent Node (LLM) — second call
Reads the ToolMessage and writes a plain-text answer: "Your monthly payment would be $469.70 for 48 months..."
AIMessage (Final Answer)
No tool_calls — tools_condition routes to END and the answer is returned to the user.

You create a ToolNode by passing it the list of registered tools. It uses the tool names to match the LLM's requests to the right functions:

from langgraph.prebuilt import ToolNode from tools import TOOLS tool_node = ToolNode(TOOLS)
No execution code to write. Before LangGraph's ToolNode existed, developers had to manually parse the LLM's response, dispatch to the right function, handle errors, and format the result. ToolNode handles all of this. You only need to define the tools and register them — the loop runs itself.

🔗 5. Binding Tools to the LLM

By default, a language model knows nothing about your custom functions. It doesn't know calculate_loan_payment exists. Before the LLM can choose to call a tool, it needs to be told what tools are available — and that's exactly what bind_tools() does.

from langchain_google_genai import ChatGoogleGenerativeAI from tools import TOOLS llm = ChatGoogleGenerativeAI(model="gemini-3-flash-preview") # Without binding — LLM knows nothing about your tools llm.invoke("What's my monthly payment on a $20,000 loan?") # → might guess an answer (potentially wrong) # With binding — LLM has the full tool schema injected into its context llm_with_tools = llm.bind_tools(TOOLS) llm_with_tools.invoke("What's my monthly payment on a $20,000 loan?") # → returns an AIMessage with tool_calls, not a guessed text answer

After calling bind_tools(), the LLM's response to any message will be one of two things:

Response typeWhen it happensWhat's insideNext step
AIMessage with tool_callsThe LLM decides a tool is neededTool name + chosen argumentsRoute to ToolNode
AIMessage with textThe LLM has a final answerPlain text the user seesRoute to END

The LLM picks which path to take entirely on its own, based on the tool descriptions and the conversation so far. You don't write any if statements to decide this — that decision is the LLM's job. Your job is to write good tool descriptions and wire up the routing, which we'll do next.

📌 Important: Call bind_tools() before passing the LLM to any node. In our project, this happens inside FinanceNodes.__init__(). If you forget to bind tools, the LLM will never return tool_calls — it'll just answer directly (and possibly incorrectly).

🔀 6. Routing with tools_condition

After the agent node runs and returns an AIMessage, LangGraph needs to decide where to go next: run ToolNode if the LLM wants a tool, or end the graph if the LLM has a final answer. In Part 3 of this series, you wrote your own router functions. For tool-calling agents, LangGraph provides a prebuilt one: tools_condition.

tools_condition inspects the last message in state. If the message is an AIMessage with a non-empty tool_calls list, it returns the string "tools". If tool_calls is empty, it returns END. That's its entire job.

from langgraph.prebuilt import tools_condition # Wire the conditional edge from the agent node graph.add_conditional_edges("agent", tools_condition) # Under the hood, tools_condition does roughly this: def tools_condition(state): last_message = state["messages"][-1] if last_message.tool_calls: # LLM wants a tool return "tools" return END # LLM has a final answer

Combined with a regular edge from "tools" back to "agent", this creates the ReAct loop automatically:

graph.add_edge(START, "agent") # Always start with the agent graph.add_conditional_edges("agent", tools_condition) # Agent → tools or END graph.add_edge("tools", "agent") # After tools, go back to agent

The loop looks like this: the agent runs → if it wants a tool, ToolNode runs it → the result goes back to the agent → the agent either picks another tool or answers the user → if it answers, the graph ends. This is the complete ReAct cycle, and you get it with just three lines of graph wiring.

💡 Why loop back to the agent after tools? The tool result (a ToolMessage) is added to state, but the LLM hasn't read it yet. The loop sends execution back to the agent so the LLM can read the result and decide what to do next — call another tool, or write a final answer for the user.

🏗️ 7. Complete Example: Personal Finance Assistant

Now that we understand tools, ToolNode, bind_tools, and tools_condition separately, let's put them all together into a working agent. This section walks through every file in the project.

7.1

🏛️ Scenario & Architecture

Priya sends a question → the agent node (LLM with tools bound) reads it and either answers directly or requests a tool → if a tool is needed, ToolNode runs the Python function and returns the result → the agent reads the result and writes a final answer → tools_condition sends the graph to END.

🤖

agent node

Calls the tool-bound LLM. Returns either a tool_calls response (needs a tool) or a plain text response (has the answer).

⚙️

tools node (ToolNode)

Executes the requested Python function and adds a ToolMessage with the result to state.

🔀

tools_condition

Reads the last message after the agent runs. Routes to "tools" if tool_calls is present, otherwise routes to END.

💬

FinanceState

Holds the full conversation: HumanMessages, AIMessages, and ToolMessages, all accumulated by the add_messages reducer.

7.2

📦 State Design — state.py

Tool-calling agents need to store the full conversation — including ToolMessage results — so the LLM can read tool outputs on its next turn. We define FinanceState as our own TypedDict rather than using LangGraph's MessagesState shortcut. The key field is messages with the add_messages reducer, which appends every new message (HumanMessage, AIMessage, ToolMessage) rather than replacing the list.

from typing import Annotated from langchain_core.messages import BaseMessage from langgraph.graph.message import add_messages from typing_extensions import TypedDict class FinanceState(TypedDict): # add_messages appends every new message (HumanMessage, AIMessage, ToolMessage) # instead of replacing the list. This gives the agent the full conversation # history — including tool results — on every subsequent turn. messages: Annotated[list[BaseMessage], add_messages]

Using an explicit TypedDict rather than the MessagesState shortcut makes the state definition self-documenting — you can see exactly what field exists, what type it holds, and which reducer runs on it without having to look up the base class. It also leaves room to add extra fields later (like user_name or currency_preference) without changing the graph structure.

7.3

🔧 Tools — tools/ Package

The project uses Method 3 from Section 3 — BaseTool subclassing — with one class per file inside a tools/ package. The __init__.py is the only file the rest of the project needs to import from:

# tools/__init__.py from .compound_interest import CompoundInterestTool from .convert_currency import ConvertCurrencyTool from .loan_payment import LoanPaymentTool from .split_budget import SplitBudgetTool TOOLS = [ LoanPaymentTool(), CompoundInterestTool(), ConvertCurrencyTool(), SplitBudgetTool(), ]

Each tool file is a standalone class. Here is loan_payment.py as a representative example — all four files follow the same pattern:

# tools/loan_payment.py import os from typing import Type from langchain_core.tools import BaseTool from pydantic import BaseModel _PROMPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "prompts") def _load_prompt(filename: str) -> str: with open(os.path.join(_PROMPTS_DIR, filename), "r") as f: return f.read().strip() class LoanPaymentInput(BaseModel): principal: float annual_rate_pct: float months: int class LoanPaymentTool(BaseTool): name: str = "calculate_loan_payment" description: str = _load_prompt("loan_payment.txt") args_schema: Type[BaseModel] = LoanPaymentInput def _run(self, principal: float, annual_rate_pct: float, months: int) -> str: if annual_rate_pct == 0: monthly_payment = principal / months else: r = annual_rate_pct / 100 / 12 monthly_payment = principal * (r * (1 + r) ** months) / ((1 + r) ** months - 1) total_paid = monthly_payment * months return ( f"Monthly payment: ${monthly_payment:.2f} for {months} months. " f"Total paid: ${total_paid:.2f}." ) async def _arun(self, principal: float, annual_rate_pct: float, months: int) -> str: return self._run(principal, annual_rate_pct, months)

The from tools import TOOLS import works identically whether tools is a file or a package — Python's module system handles both the same way. Both nodes.py (for bind_tools) and graph.py (for ToolNode) use this same single import.

7.4

🤖 Agent Node — nodes.py

The agent node has one job: call the LLM (with tools bound) on the current conversation and return the response. The system prompt is loaded from prompts/system.txt — this tells the LLM its role and instructs it always to use tools for calculations, never to guess.

First, the system prompt (prompts/system.txt):

You are a helpful personal finance assistant. You have access to specialized tools that perform accurate financial calculations. Always use the available tools when a calculation is needed — never estimate or guess numerical results. When a user asks about loan payments, savings growth, currency conversion, or budget splits, use the appropriate tool to compute the exact answer. Be friendly, concise, and explain the results in plain language so anyone — regardless of financial background — can understand them.

Now nodes.py:

import os from langchain_core.messages import SystemMessage from llm import GeminiLLM from tools import TOOLS _PROMPTS_DIR = os.path.join(os.path.dirname(__file__), "prompts") def _load_prompt(filename: str) -> str: with open(os.path.join(_PROMPTS_DIR, filename), "r") as f: return f.read() class FinanceNodes: def __init__(self): llm = GeminiLLM().get_llm() self.llm_with_tools = llm.bind_tools(TOOLS) self.system_prompt = _load_prompt("system.txt") def agent_node(self, state: dict) -> dict: messages = [SystemMessage(content=self.system_prompt)] + state["messages"] response = self.llm_with_tools.invoke(messages) return {"messages": [response]}

Let's walk through the key parts:

  • _load_prompt() — a module-level helper that reads a text file from the prompts/ subfolder. Keeping prompts in files (rather than inline strings) lets you tune the system prompt without touching Python code.
  • llm.bind_tools(TOOLS) — called once in __init__, not on every request. Binding is expensive; doing it once and reusing the bound LLM is the right pattern.
  • agent_node(state) — prepends the system prompt to the conversation before invoking the LLM. This ensures every LLM call knows it's a finance assistant, regardless of where in the loop it is.
  • return {"messages": [response]} — returns only the new message. The add_messages reducer in state takes care of appending it to the full conversation list.
7.5

🕸️ Graph Assembly — graph.py

This is where everything comes together. graph.py builds the StateGraph, registers both nodes, wires the edges (including the conditional edge), and compiles the graph. It also saves the graph figure on demand.

import os from langgraph.graph import StateGraph, START from langgraph.prebuilt import ToolNode, tools_condition from state import FinanceState from nodes import FinanceNodes from tools import TOOLS FIGURE_DIR = os.path.join(os.path.dirname(__file__), "figure") class FinanceGraph: def __init__(self): self.nodes = FinanceNodes() self.compiled_graph = self._build() def _build(self): graph = StateGraph(FinanceState) # Register nodes graph.add_node("agent", self.nodes.agent_node) graph.add_node("tools", ToolNode(TOOLS)) # Wire edges — three lines for the full ReAct loop graph.add_edge(START, "agent") graph.add_conditional_edges("agent", tools_condition) graph.add_edge("tools", "agent") return graph.compile() def get_compiled_graph(self): return self.compiled_graph def save_figure(self): os.makedirs(FIGURE_DIR, exist_ok=True) mmd = self.compiled_graph.get_graph().draw_mermaid() mmd_path = os.path.join(FIGURE_DIR, "graph.mmd") with open(mmd_path, "w") as f: f.write(mmd) print(f"Graph saved → {mmd_path}") png_bytes = self.compiled_graph.get_graph().draw_mermaid_png() png_path = os.path.join(FIGURE_DIR, "graph.png") with open(png_path, "wb") as f: f.write(png_bytes) print(f"Graph saved → {png_path}")

The graph wiring is deliberately minimal — three lines is all you need for a complete ReAct agent. Here's what each line does:

  • graph.add_edge(START, "agent") — every invocation starts at the agent node. The agent reads the user's message and decides what to do.
  • graph.add_conditional_edges("agent", tools_condition) — after the agent runs, tools_condition checks whether the response has tool_calls. If yes → "tools". If no → END.
  • graph.add_edge("tools", "agent") — after the tool runs, always go back to the agent. This lets the LLM read the tool result and either call another tool or produce a final answer.
7.6

🚀 Runner & Console Output — finance_runner.py

The runner wraps the compiled graph in a clean chat() method and provides a demo that runs four of Priya's questions:

from langchain_core.messages import HumanMessage from graph import FinanceGraph class FinanceRunner: def __init__(self): self.finance_graph = FinanceGraph() self.app = self.finance_graph.get_compiled_graph() def save_figure(self): self.finance_graph.save_figure() def chat(self, message: str) -> str: result = self.app.invoke({"messages": [HumanMessage(content=message)]}) content = result["messages"][-1].content if isinstance(content, list): return "".join( block.get("text", "") for block in content if isinstance(block, dict) and block.get("type") == "text" ) return content if __name__ == "__main__": SEP = "=" * 60 print(SEP) print(" LangGraph Basics — Personal Finance Assistant Demo") print(SEP) runner = FinanceRunner() print("\n Saving graph architecture...") runner.save_figure() queries = [ "What would my monthly payment be on a $20,000 car loan at 6% interest for 48 months?", "If I invest $5,000 at 7% annual interest, how much will I have after 10 years?", "How much is 1,000 USD in EUR?", "I earn $4,500 per month. Split my budget: 30% rent, 20% food, and 20% savings.", ] print() for query in queries: print(f"💬 Priya: {query}") print(f"🤖 Assistant: {runner.chat(query)}") print("-" * 60) print(SEP)

A few things worth noting in the chat() method:

  • result["messages"][-1] — the last message in state after invoke() is always the final AIMessage (because tools_condition only ends the graph when there are no more tool_calls).
  • The isinstance(content, list) checklangchain-google-genai 4.x returns content as a list of typed blocks rather than a plain string. This guard handles both formats safely.
  • graph.save_figure() first — calling this at the very start of __main__ ensures the figure is always saved before any demo runs.

Run python finance_runner.py from inside basics-5-tools-toolnode/ and you'll see:

============================================================ LangGraph Basics — Personal Finance Assistant Demo ============================================================ Saving graph architecture... Graph saved → /path/to/basics-5-tools-toolnode/figure/graph.mmd Graph saved → /path/to/basics-5-tools-toolnode/figure/graph.png 💬 Priya: What would my monthly payment be on a $20,000 car loan at 6% interest for 48 months? 🤖 Assistant: Your monthly payment would be $469.70 for 48 months, with a total repayment of $22,545.60. That's $2,545.60 in interest over 4 years. ------------------------------------------------------------ 💬 Priya: If I invest $5,000 at 7% annual interest, how much will I have after 10 years? 🤖 Assistant: With 7% annual compound interest, your $5,000 will grow to $9,835.76 after 10 years. You'll earn $4,835.76 in interest — nearly doubling your initial investment! ------------------------------------------------------------ 💬 Priya: How much is 1,000 USD in EUR? 🤖 Assistant: 1,000.00 USD is approximately 925.93 EUR at the current exchange rate. ------------------------------------------------------------ 💬 Priya: I earn $4,500 per month. Split my budget: 30% rent, 20% food, and 20% savings. 🤖 Assistant: Here's your monthly budget breakdown for $4,500: Rent: $1,350.00 (30%), Food: $900.00 (20%), Savings: $900.00 (20%), Discretionary: $1,350.00 (30%). ------------------------------------------------------------ ============================================================

Every answer is exact, because the LLM delegated the math to Python rather than estimating it. Notice that none of these answers required you to write any routing logic — tools_condition handles that automatically based on whether the LLM's response has tool_calls.

7.7

📊 Graph Diagram

Here is the compiled graph structure. Solid arrows are fixed edges; dashed arrows are the two branches of the conditional edge from tools_condition:

%%{init: {'flowchart': {'curve': 'linear'}}}%%
graph TD;
    __start__([

__start__

]):::first agent([agent]) tools([tools]) __end__([

__end__

]):::last __start__ --> agent; agent -.-> tools; agent -.-> __end__; tools --> agent; classDef default fill:#f2f0ff,line-height:1.2 classDef first fill-opacity:0 classDef last fill:#bfb6fc

Figure 1: The compiled Finance Assistant graph. Dashed arrows show the conditional edge from tools_condition — the agent routes to "tools" when it has tool_calls, or to END when it has a final answer. The solid arrow from tools back to agent creates the ReAct loop.

The graph is intentionally simple: two nodes, three edges, one loop. That simplicity is deceptive — inside that loop, the LLM can call as many tools as it needs, in any order, before deciding it has enough information to answer the user.


⚡ 8. Using create_react_agent — The Shortcut

Everything you just built — state, agent node, ToolNode, tools_condition, graph wiring — can be replaced with a single function call. LangGraph provides create_react_agent, a prebuilt helper that assembles the exact same graph for you.

from langgraph.prebuilt import create_react_agent from langchain_google_genai import ChatGoogleGenerativeAI from langchain_core.messages import HumanMessage from tools import TOOLS llm = ChatGoogleGenerativeAI(model="gemini-3-flash-preview") # reads GEMINI_API_KEY from .env system_prompt = ( "You are a helpful personal finance assistant. " "Use the available tools for all calculations — never estimate." ) # One function call — same graph as the manual build above app = create_react_agent( model=llm, # plain LLM — create_react_agent calls bind_tools() internally tools=TOOLS, prompt=system_prompt, ) result = app.invoke({ "messages": [HumanMessage(content="What's my monthly payment on a $15,000 loan at 5% for 36 months?")] }) print(result["messages"][-1].content)

Notice the key difference: you pass the plain LLM to create_react_agent, not llm.bind_tools(). The function handles binding internally. If you pass an already-bound LLM, the tools get bound twice, which can cause unexpected behaviour.

So when should you use the manual approach versus create_react_agent? The table below captures the trade-offs:

SituationManual buildcreate_react_agent
Learning how ReAct works✅ Better — see every piece⬜ Hides the internals
Custom state fields beyond messages✅ Easy — add to TypedDict⬜ Limited support
Extra nodes (e.g. validator, formatter)✅ Just add more nodes❌ Not possible
Custom routing logic✅ Write your own router❌ Fixed ReAct loop only
Quick prototype or demo⬜ More code to write✅ Five lines and done
Standard ReAct agent, no customisation⬜ Unnecessary complexity✅ Perfect fit
Recommendation: Learn the manual approach first (you just did). Once you understand what ToolNode and tools_condition do under the hood, use create_react_agent freely for standard agents. Switch back to the manual approach the moment you need anything the shortcut can't do.

🌐 9. Web Interface

The Gradio web UI wraps the same FinanceRunner from the console demo in a chat interface. Because each question is stateless (no checkpointer — we didn't add one since this post focuses on tools, not memory), each message is a fresh invocation. The UI is intentionally simple: a chat box, a few example questions, and a submit button.

import gradio as gr from finance_runner import FinanceRunner class FinanceApp: def __init__(self): self.runner = FinanceRunner() def respond(self, message: str, _history: list) -> str: if not message.strip(): return "" return self.runner.chat(message) def launch(self): demo = gr.ChatInterface( fn=self.respond, title="💰 Personal Finance Assistant", description=( "Ask me about loan payments, savings growth, currency conversion, " "or budget planning. Powered by LangGraph + Google Gemini." ), examples=[ "What's my monthly payment on a $20,000 car loan at 6% for 48 months?", "If I save $10,000 at 5% annual interest for 15 years, how much will I have?", "Convert 500 EUR to USD.", "I earn $5,000/month. Split my budget: 30% rent, 15% food, 25% savings.", ], theme=gr.themes.Soft(), ) demo.launch() if __name__ == "__main__": FinanceApp().launch()

Run it with python app.py from inside basics-5-tools-toolnode/. Gradio opens a browser window at http://127.0.0.1:7860. Type any finance question in the chat box and the agent will call the right tool and answer with precise numbers.

Personal Finance Assistant Gradio web UI

Figure 2: The Personal Finance Assistant Gradio web UI. The agent uses tools behind the scenes — the user sees only the final, accurate answer.


✅ 10. Conclusion

Tools are how you make an LLM agent reliable for tasks that require precision. Language models are great at reasoning, planning, and communicating — but they're not deterministic calculators. The pattern you've learned in this post — define tools, bind them to the LLM, route with tools_condition, execute with ToolNode — turns a conversational model into a dependable assistant that delegates computation to Python.

Here's a quick recap of everything this post covered:

  • Three ways to define tools@tool decorator (fast, minimal), StructuredTool.from_function() (explicit schema, wraps existing functions), and BaseTool subclass (full OOP, most testable and production-ready).
  • bind_tools() — injects the tool schemas into the LLM's context so it knows what tools are available and when to call them.
  • ToolNode — a prebuilt LangGraph node that reads tool_calls from the last AIMessage, executes the matching Python function, and returns a ToolMessage to state.
  • tools_condition — a prebuilt conditional edge that routes to "tools" if the agent has tool_calls, or to END if it has a final answer.
  • The ReAct loop — START → agent → tools → agent → … → END. Three edges. Infinite potential for multi-step reasoning.
  • create_react_agent — the prebuilt shortcut that builds the same graph in one function call. Best for standard agents; switch to manual when you need customisation.

What's next? In Part 6 — Subgraphs, Interrupt & Human-in-the-Loop, we'll look at how to pause a graph mid-run and wait for a human to review or approve something before continuing. This is essential for any agent that makes decisions with real consequences — think approving a financial transaction before it executes, not just calculating it.


Technical Stacks

Technical Stacks

Python Python
LangGraph LangGraph
LangChain LangChain
Gemini Gemini
Gradio Gradio
Download

Download Source Code

LangGraph Basics — Personal Finance Assistant

View on GitHub
📚

References