LangGraph Advanced: Part 4 โ€” ReAct Agent with Tool Calling

Author Photo
LangGraph Advanced Part 4 โ€” ReAct Agent with Tool Calling

๐Ÿ  1. Introduction

Meet Alex โ€” a first-time homebuyer staring at a $450,000 listing. Before making an offer, Alex has real questions that need real numbers: What's the monthly mortgage payment with 20% down? Can Alex actually afford this on a $95,000 salary with an existing car loan? What does property tax add to the monthly cost? The answers aren't estimates โ€” they're formulas with exact inputs.

Ask a plain LLM for a mortgage payment and it might say something like "approximately $2,200 to $2,500 per month." That range is useless for a financial decision. The LLM isn't being evasive โ€” it genuinely can't reliably execute the amortisation formula. What it can do is reason about what calculation is needed and delegate the execution to a tool.

That's the ReAct (Reasoning + Acting) pattern: the LLM reasons about what to do, acts by calling a tool, observes the precise result, reasons about whether it needs another tool call, and eventually produces a final answer grounded in exact numbers. LangGraph turns this loop into a graph โ€” two nodes, one conditional edge, and the agent runs until it stops calling tools.

What you'll build: HomeBot โ€” an AI Real Estate Advisor backed by five financial tools. Users ask questions in plain English; HomeBot reasons about which tool to call, gets exact calculations, and synthesises a clear, grounded answer. Tools are defined using the production-grade BaseTool subclassing pattern โ€” one class per file, Pydantic input schema, description loaded from a prompt file.

This post is Part 4 of the LangGraph Advanced series. Part 5 of the Basics series introduced tool calling using the @tool decorator and the create_react_agent shortcut. Here the goal is different: build the ReAct loop manually so every component is visible, and use BaseTool subclassing โ€” the production pattern that keeps tool definitions inspectable, testable, and easily prompt-tuned.

๐Ÿ”

ReAct Loop

Agent reasons โ†’ calls a tool โ†’ observes result โ†’ reasons again. Repeats until the LLM produces a final answer with no tool calls.

๐Ÿ”ง

BaseTool Subclasses

Production tool pattern: one class per file, Pydantic args_schema, description loaded from prompts/. Testable and maintainable.

โš™๏ธ

ToolNode + tools_condition

Prebuilt LangGraph components: ToolNode executes function calls; tools_condition routes to tools or END.

๐Ÿก

5 Financial Tools

Mortgage payment, home affordability (28/36 rule), property tax, rental ROI, and buy-vs-rent comparison โ€” all with exact formulas.


โš™๏ธ 2. Installation & Setup

This project uses the same shared dependencies as the rest of the LangGraph series โ€” no new packages needed beyond what's already in requirements.txt.

1. Check your Python version:

python --version # Python 3.12.x

2. Create and activate a virtual environment:

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

3. Install dependencies:

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

4. Create your .env file:

GOOGLE_API_KEY=your_google_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 before your first commit.

5. Project structure:

advanced-4-react-agent-tool-calling/ โ”œโ”€โ”€ config.py # Config class โ€” reads .env settings (Section 2.1) โ”œโ”€โ”€ llm.py # GeminiLLM wrapper (Section 2.1) โ”œโ”€โ”€ state.py # RealEstateState TypedDict (Section 5) โ”œโ”€โ”€ nodes.py # RealEstateNodes โ€” agent_node with bind_tools (Section 6) โ”œโ”€โ”€ graph.py # RealEstateGraph โ€” ReAct loop + MemorySaver (Section 7) โ”œโ”€โ”€ homebot_runner.py # console entry point + 4 demo queries (Section 8) โ”œโ”€โ”€ app.py # Gradio Blocks with New Session button (Section 9) โ”œโ”€โ”€ tools/ # BaseTool subclasses โ€” one file per tool (Section 4) โ”‚ โ”œโ”€โ”€ __init__.py # TOOLS = [MortgageTool(), AffordabilityTool(), ...] โ”‚ โ”œโ”€โ”€ mortgage.py # MortgageTool โ€” amortisation formula โ”‚ โ”œโ”€โ”€ affordability.py # AffordabilityTool โ€” 28/36 rule โ”‚ โ”œโ”€โ”€ property_tax.py # PropertyTaxTool โ€” annual + monthly tax โ”‚ โ”œโ”€โ”€ rental_roi.py # RentalROITool โ€” gross yield + net ROI โ”‚ โ””โ”€โ”€ buy_vs_rent.py # BuyVsRentTool โ€” multi-year cost comparison โ”œโ”€โ”€ prompts/ # LLM prompt files โ€” one per tool + system prompt โ”‚ โ”œโ”€โ”€ system.txt # HomeBot persona and behaviour rules โ”‚ โ”œโ”€โ”€ mortgage.txt # MortgageTool description โ”‚ โ”œโ”€โ”€ affordability.txt # AffordabilityTool description โ”‚ โ”œโ”€โ”€ property_tax.txt # PropertyTaxTool description โ”‚ โ”œโ”€โ”€ rental_roi.txt # RentalROITool description โ”‚ โ””โ”€โ”€ buy_vs_rent.txt # BuyVsRentTool description โ”œโ”€โ”€ figure/ # auto-created by save_figure() at runtime โ”‚ โ”œโ”€โ”€ graph.mmd # Mermaid source diagram โ”‚ โ””โ”€โ”€ graph.png # PNG render of the graph โ””โ”€โ”€ .env # API keys โ€” never commit this file

tools/ contains one BaseTool subclass per file (Section 4). tools/__init__.py collects them into a single TOOLS list that both the agent node and the graph import. nodes.py defines the agent (Section 6) and graph.py wires the ReAct loop (Section 7).

2.1

โš™๏ธ Configuring the LLM โ€” config.py ยท llm.py

config.py reads the three standard settings from .env. No new fields are needed for this post โ€” tool calling uses the same LLM configured in previous parts.

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 is the same thin wrapper used throughout the series:

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. The ReAct Pattern

ReAct stands for Reasoning + Acting. The idea is simple: the LLM doesn't just answer โ€” it reasons about what to do, acts (calls a tool), observes the result, and reasons again. This loop continues until the LLM decides it has everything it needs to give a final answer and produces a response with no tool calls.

In LangGraph this translates directly to a two-node graph. The agent node calls the LLM. If the response contains tool calls, the graph routes to the tools node, which executes them and returns the results as ToolMessages appended to state. The graph then routes back to agent. If the response contains no tool calls, the graph routes to END.

flowchart TD S([START]) --> agent["๐Ÿค– agent\nLLM with bound tools"] agent -- "has tool_calls" --> tools["๐Ÿ”ง tools\nToolNode"] tools -- "returns ToolMessages" --> agent agent -- "no tool_calls" --> E([END]) style S fill:#e8f5e9,stroke:#43a047,color:#1b5e20 style E fill:#fce4ec,stroke:#e53935,color:#b71c1c style agent fill:#e3f2fd,stroke:#1e88e5,color:#0d47a1 style tools fill:#fff3e0,stroke:#fb8c00,color:#e65100

The messages accumulate in state across every hop. By the time the final AIMessage is produced, state contains the complete reasoning trace: the user question, one or more AIMessages with tool call requests, the corresponding ToolMessages with exact results, and the final synthesised answer. This trace is what makes agent behaviour transparent and debuggable.

How does the LLM know which tools exist? bind_tools(TOOLS) serialises each tool's name, description, and Pydantic schema into the LLM's function-calling interface. The LLM reads those schemas to understand what each tool does and what arguments it needs. The description field on each tool is therefore part of the prompt โ€” it directly influences whether the LLM calls the right tool for a given question.

There are two ways a ReAct loop can terminate:

  • Natural stop: The LLM receives tool results and decides they're sufficient to answer the question. It produces an AIMessage with text content and no tool_calls. tools_condition routes to END.
  • Multi-tool reasoning: A complex query may require two or more tool calls before the LLM can synthesise an answer. Each tool result is appended to state as a ToolMessage; the agent reads all of them before forming its response.

For HomeBot, a query like "Can I afford a $450,000 house on $95,000 salary?" might trigger two tool calls in sequence โ€” first calculate_home_affordability to check the price ceiling, then calculate_mortgage_payment to give the exact monthly figure โ€” before the agent synthesises both results into a final answer.


๐Ÿ”ง 4. Defining Tools with BaseTool

LangChain offers several ways to define tools: the @tool decorator, StructuredTool.from_function(), and BaseTool subclassing. The decorator is the fastest to write; BaseTool subclassing is the most explicit, testable, and maintainable. For production code, BaseTool is the right choice โ€” each tool is a proper Python class with a typed schema, a separately managed description, and a clean separation between definition and execution.

4.1

๐Ÿ”ง BaseTool Pattern โ€” tools/mortgage.py

Every tool in HomeBot follows this exact structure. MortgageTool is the canonical example:

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 MortgageInput(BaseModel): principal: float # loan amount in USD (home price minus down payment) annual_rate_pct: float # annual interest rate as a percentage (e.g. 6.5 for 6.5%) years: int # loan term in years class MortgageTool(BaseTool): name: str = "calculate_mortgage_payment" description: str = _load_prompt("mortgage.txt") args_schema: Type[BaseModel] = MortgageInput def _run(self, principal: float, annual_rate_pct: float, years: int) -> str: monthly_rate = annual_rate_pct / 100 / 12 n = years * 12 if monthly_rate == 0: monthly = principal / n else: monthly = ( principal * (monthly_rate * (1 + monthly_rate) ** n) / ((1 + monthly_rate) ** n - 1) ) total = monthly * n interest = total - principal return ( f"Monthly payment: ${monthly:,.2f}\n" f"Total paid over {years} years: ${total:,.2f}\n" f"Total interest paid: ${interest:,.2f}" ) async def _arun(self, principal: float, annual_rate_pct: float, years: int) -> str: return self._run(principal, annual_rate_pct, years)

Let's break down the key design decisions:

  • MortgageInput(BaseModel) โ€” a separate Pydantic model declares every parameter with its type and an inline comment. LangGraph serialises this schema and sends it to the LLM, so the LLM knows exactly which arguments to extract from the user's message.
  • description: str = _load_prompt("mortgage.txt") โ€” the description is loaded at class-definition time from a text file. Editing the description never touches Python code โ€” you just update the prompt file. This separation is what makes the tool "prompt-tunable" without a code change.
  • _PROMPTS_DIR uses ".." โ€” because tool files live in tools/ (a subdirectory), the path must step up one level to reach prompts/. This is the canonical pattern for tools in a tools/ package.
  • _arun delegates to _run โ€” since these tools are synchronous computations, the async method just calls the sync one. In a tool that makes async I/O calls (e.g. API requests), _arun would have its own implementation.
Why not the @tool decorator? The decorator is fine for quick scripts. But it doesn't give you a typed schema class, you can't easily subclass it, and the description lives inline as a docstring โ€” harder to manage as prompts grow. BaseTool is a first-class class you can inspect, mock, test, and extend. That's what production code needs.

All five tools are assembled into a single list in tools/__init__.py. Every other module uses only this list โ€” from tools import TOOLS:

from tools.affordability import AffordabilityTool from tools.buy_vs_rent import BuyVsRentTool from tools.mortgage import MortgageTool from tools.property_tax import PropertyTaxTool from tools.rental_roi import RentalROITool TOOLS = [ MortgageTool(), AffordabilityTool(), PropertyTaxTool(), RentalROITool(), BuyVsRentTool(), ]
4.2

๐Ÿก The Five Tools โ€” tools/

HomeBot's five tools cover the full range of questions a homebuyer asks:

Tool name File What it calculates Key inputs
calculate_mortgage_payment mortgage.py Monthly payment, total paid, total interest โ€” standard amortisation formula principal, annual_rate_pct, years
calculate_home_affordability affordability.py Maximum home price based on the 28/36 rule (front-end and back-end debt ratios) annual_income, monthly_debts, down_payment_pct, annual_rate_pct, years
calculate_property_tax property_tax.py Annual and monthly property tax given assessed value and local tax rate home_value, annual_tax_rate_pct
calculate_rental_roi rental_roi.py Gross yield, net annual income, monthly cash flow, and net ROI percentage purchase_price, annual_rent, annual_expenses
compare_buy_vs_rent buy_vs_rent.py Multi-year cost comparison: net cost of buying (interest โˆ’ appreciation) vs total rent paid, with a verdict home_price, down_payment_pct, annual_rate_pct, years, monthly_rent, annual_appreciation_pct

The affordability tool uses the 28/36 rule: the monthly mortgage payment (PITI) must not exceed 28% of gross monthly income (front-end ratio), and total monthly debts must not exceed 36% (back-end ratio). The lower of the two limits determines the maximum mortgage payment, from which the maximum home price is reverse-engineered.

The buy-vs-rent tool computes net buying cost as: down payment + total interest paid โˆ’ appreciation gain. This accounts for the fact that buying builds equity through home appreciation, making the true financial comparison more nuanced than just mortgage payments vs rent.


๐Ÿ—‚๏ธ 5. Building the State

The state for a ReAct agent is intentionally minimal. All the reasoning โ€” the question, tool calls, tool results, and final answer โ€” lives in the messages list. There's no need for separate fields like question or doc_grade from the RAG pipeline in Part 3. The messages are the state.

from typing import Annotated from langchain_core.messages import BaseMessage from langgraph.graph.message import add_messages from typing_extensions import TypedDict class RealEstateState(TypedDict): # add_messages appends each new message instead of overwriting. # The full conversation โ€” HumanMessage, AIMessage (with tool_calls), # ToolMessage(s), and final AIMessage โ€” all accumulate here. messages: Annotated[list[BaseMessage], add_messages]

The add_messages reducer is what makes the ReAct loop work. Every node returns {"messages": [new_message]}, and add_messages appends it to the existing list rather than overwriting. By the time the graph reaches END, the full exchange is preserved:

Message sequence in a single ReAct turn:
  1. HumanMessage โ€” the user's question
  2. AIMessage(tool_calls=[...]) โ€” the LLM decides which tool to call
  3. ToolMessage โ€” the tool's exact result
  4. AIMessage(content="...") โ€” the LLM's final answer, grounded in the tool result

For a multi-tool query, steps 2 and 3 repeat for each tool call before the final AIMessage is produced. The LLM sees the entire sequence on each pass through the agent node, so it always has full context about what's been calculated so far.


๐Ÿค– 6. Building the Agent Node

The agent node is where the LLM does its reasoning. It reads the full message history, decides whether to call a tool or produce a final answer, and returns whichever AIMessage comes back. The critical line is self.llm.bind_tools(TOOLS) โ€” this is what transforms a plain conversational LLM into a tool-aware agent.

import os from langchain_core.messages import SystemMessage from llm import GeminiLLM from state import RealEstateState 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().strip() def _extract_text(content) -> str: """Normalise langchain-google-genai 4.x content (list or str) to plain str.""" if isinstance(content, str): return content if isinstance(content, list): return "".join( b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text" ) return str(content) class RealEstateNodes: def __init__(self): self.llm = GeminiLLM().get_llm() self.system_prompt = _load_prompt("system.txt") # Bind all tools to the LLM so it can decide which to call. self.llm_with_tools = self.llm.bind_tools(TOOLS) def agent_node(self, state: RealEstateState) -> dict: """Call the LLM. It either returns a final answer or a tool-call request.""" messages = [SystemMessage(content=self.system_prompt)] + list(state["messages"]) response = self.llm_with_tools.invoke(messages) return {"messages": [response]}

bind_tools(TOOLS) serialises each tool's name, description, and Pydantic parameter schema into the format Gemini's function-calling API expects. When the LLM receives a message, it can return either:

  • An AIMessage with tool_calls=[{"name": "...", "args": {...}}] โ€” a request to execute one or more tools.
  • An AIMessage with content="..." and no tool_calls โ€” a final answer ready for the user.

The agent node doesn't decide which path to take โ€” it just returns whatever the LLM produced. The conditional edge (tools_condition) reads the returned message and makes the routing decision.

System prompt as tool guidance: The prompts/system.txt file instructs HomeBot to always use tools for financial calculations rather than estimating. This is important โ€” without explicit instruction, the LLM might skip the tool and guess. The system prompt is the nudge that enforces grounded, exact answers.

๐Ÿ”— 7. Building the Graph

The full ReAct graph is seven lines of wiring. The two-node structure is simpler than either the RAG pipeline (Part 3) or the supervisor architecture (Part 2) โ€” the complexity lives inside the LLM, not in the graph topology.

import os from langgraph.checkpoint.memory import MemorySaver from langgraph.graph import END, START, StateGraph from langgraph.prebuilt import ToolNode, tools_condition from nodes import RealEstateNodes from state import RealEstateState from tools import TOOLS FIGURE_DIR = os.path.join(os.path.dirname(__file__), "figure") class RealEstateGraph: def __init__(self): self.nodes = RealEstateNodes() self.compiled_graph = self._build() def _build(self): graph = StateGraph(RealEstateState) # The agent node: LLM with bound tools. graph.add_node("agent", self.nodes.agent_node) # The tools node: executes whichever tool the LLM requested. graph.add_node("tools", ToolNode(TOOLS)) # Every turn starts at the agent. graph.add_edge(START, "agent") # tools_condition reads the last message: # - if it has tool_calls โ†’ route to "tools" # - if it has no tool_calls (final answer) โ†’ route to END graph.add_conditional_edges("agent", tools_condition) # After the tool executes, return to the agent for the next reasoning step. graph.add_edge("tools", "agent") return graph.compile(checkpointer=MemorySaver()) def save_figure(self): os.makedirs(FIGURE_DIR, exist_ok=True) mmd_path = os.path.join(FIGURE_DIR, "graph.mmd") with open(mmd_path, "w") as f: f.write(self.compiled_graph.get_graph().draw_mermaid()) png_path = os.path.join(FIGURE_DIR, "graph.png") with open(png_path, "wb") as f: f.write(self.compiled_graph.get_graph().draw_mermaid_png()) print(f" Graph saved โ†’ {mmd_path}") print(f" Graph saved โ†’ {png_path}") def get_compiled_graph(self): return self.compiled_graph

Three components do all the work here:

Component Source What it does
ToolNode(TOOLS) langgraph.prebuilt Reads tool_calls from the last AIMessage, finds the matching tool by name, calls _run() with the provided arguments, and returns a ToolMessage with the result.
tools_condition langgraph.prebuilt Inspects the last message in state: if tool_calls is non-empty โ†’ returns "tools"; if empty โ†’ returns END. This is the loop-or-stop decision point.
MemorySaver() langgraph.checkpoint.memory Persists state between invoke() calls using thread_id. Enables multi-turn conversations where the agent remembers previous calculations in the same session.
Why pass TOOLS to both ToolNode and bind_tools? bind_tools(TOOLS) in the agent node tells the LLM which tools exist (their schemas go into the prompt). ToolNode(TOOLS) in the graph tells the graph which Python objects to call when the LLM requests a tool. Both need the same list โ€” from tools import TOOLS ensures they stay in sync automatically.

graph.add_conditional_edges("agent", tools_condition) is called without an explicit routing map. tools_condition already knows it returns either "tools" or END, and LangGraph accepts those return values directly as node names โ€” no mapping needed. This is a special convenience of the prebuilt router.

7.1

๐Ÿ“Š Graph Diagram

The compiled HomeBot graph as generated by save_figure(). Two nodes, one conditional edge, one fixed return edge โ€” the simplest possible loop structure in LangGraph.

flowchart TD S([__start__]) --> agent["agent"] agent -- "has tool_calls" --> tools["tools"] tools --> agent agent -- "no tool_calls" --> E([__end__]) style S fill:#e8f5e9,stroke:#43a047,color:#1b5e20 style E fill:#fce4ec,stroke:#e53935,color:#b71c1c style agent fill:#e3f2fd,stroke:#1e88e5,color:#0d47a1 style tools fill:#fff3e0,stroke:#fb8c00,color:#e65100

โ–ถ๏ธ 8. Running the Demo

HomeBotRunner wraps the compiled graph and exposes a chat(message, thread_id) method. The thread_id parameter connects calls to the same MemorySaver slot โ€” within a session, HomeBot remembers previous calculations and can refer to them in follow-up questions.

from langchain_core.messages import HumanMessage from graph import RealEstateGraph from nodes import _extract_text class HomeBotRunner: def __init__(self): self.real_estate_graph = RealEstateGraph() self.app = self.real_estate_graph.get_compiled_graph() def save_figure(self): self.real_estate_graph.save_figure() def _config(self, thread_id: str) -> dict: return {"configurable": {"thread_id": thread_id}} def chat(self, message: str, thread_id: str) -> str: """Send a message and return HomeBot's final answer for this thread.""" result = self.app.invoke( {"messages": [HumanMessage(content=message)]}, config=self._config(thread_id), ) ai_messages = [m for m in result["messages"] if m.type == "ai" and not m.tool_calls] if not ai_messages: return "I'm sorry, I couldn't generate a response. Please try again." return _extract_text(ai_messages[-1].content)

The filter m.type == "ai" and not m.tool_calls skips intermediate AIMessages that contain tool requests โ€” those are reasoning steps, not the final answer. Only the last AIMessage with text content and no pending tool calls is returned to the user.

cd advanced-4-react-agent-tool-calling && python homebot_runner.py

Expected console output:

============================================================ LangGraph Advanced โ€” AI Real Estate Advisor Demo ============================================================ Saving graph architecture... Graph saved โ†’ /path/to/figure/graph.mmd Graph saved โ†’ /path/to/figure/graph.png โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Demo 1: Mortgage payment calculation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ๐Ÿ™‹ User: What would my monthly mortgage payment be for a $450,000 home with 20% down at 6.5% interest for 30 years? ๐Ÿค– HomeBot: With a $360,000 loan (20% down on $450,000) at 6.5% over 30 years, your monthly payment would be $2,275.18. Over the full term you'll pay $819,064 in total โ€” $459,064 of which is interest... โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Demo 2: Home affordability check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ๐Ÿ™‹ User: I earn $95,000 per year and have $800 in monthly debt payments. How much house can I afford with 10% down at 7% for 30 years? ๐Ÿค– HomeBot: Based on the 28/36 rule, the maximum recommended home price is $376,000. Your 10% down payment would be $37,600, and your maximum monthly mortgage payment is $2,218. The back-end debt ratio (your $800 in existing debts plus the mortgage) is the binding constraint here... โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Demo 3: Rental property ROI โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ๐Ÿ™‹ User: I'm considering a rental property at $320,000. I expect $2,200/month in rent and $800/month in total expenses. What is my ROI? ๐Ÿค– HomeBot: At $2,200/month rent and $800/month expenses, your net annual income is $16,800. The gross yield is 8.25% and the net ROI is 5.25% on the $320,000 purchase price. Monthly cash flow is $1,400... โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Demo 4: Buy vs rent comparison โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ๐Ÿ™‹ User: Should I buy a $380,000 house or keep renting at $2,100/month? 15% down, 6.8% rate, 30-year term, 3% annual appreciation. ๐Ÿค– HomeBot: Over 30 years, buying appears more cost-effective. The net cost of buying (down payment + interest โˆ’ appreciation gain) is approximately $271,000, versus $756,000 in total rent. Appreciation of 3% per year grows the home to $922,000, generating $542,000 in equity gain that offsets the interest... ============================================================

๐ŸŒ 9. Gradio Web UI

The Gradio app uses gr.Blocks with a gr.State for the thread ID โ€” the same pattern as Part 2. A "New Session" button generates a fresh UUID, clearing the MemorySaver context for the next conversation.

import uuid import gradio as gr from homebot_runner import HomeBotRunner class HomeBotApp: def __init__(self): self.runner = HomeBotRunner() def respond(self, message: str, _history: list, thread_id: str): if not message.strip(): yield "" return yield self.runner.chat(message, thread_id) def launch(self): with gr.Blocks(title="๐Ÿ  AI Real Estate Advisor โ€” HomeBot") as demo: thread_state = gr.State(value=str(uuid.uuid4())) chat = gr.ChatInterface( fn=self.respond, title="๐Ÿ  AI Real Estate Advisor โ€” HomeBot", description=( "Ask HomeBot anything about buying, renting, or investing in property. " "HomeBot uses precise financial tools for mortgage payments, " "affordability checks, property tax, rental ROI, and buy-vs-rent analysis." ), additional_inputs=[thread_state], ) gr.ClearButton( [chat.chatbot, chat.textbox], value="๐Ÿ”„ New Session", variant="primary", ).click( fn=lambda: str(uuid.uuid4()), outputs=[thread_state], ) demo.launch(css=".gradio-container { max-width: 860px !important; margin: auto; }") if __name__ == "__main__": HomeBotApp().launch()
cd advanced-4-react-agent-tool-calling && python app.py

The web UI starts at http://127.0.0.1:7860. Within a session (same thread ID), HomeBot remembers previous calculations. Ask "What's the monthly payment on a $400k house with 20% down at 6.5%?", then follow up with "And what would property tax be at 1.2%?" โ€” HomeBot will reference the earlier context naturally.

HomeBot Gradio web UI

HomeBot web UI โ€” asking about mortgage payments triggers the calculate_mortgage_payment tool and returns exact figures.

Extending HomeBot: Adding a new tool is three steps: create a new BaseTool subclass in tools/, add its description to prompts/, and add an instance to the TOOLS list in tools/__init__.py. The graph, agent node, and Gradio app require zero changes โ€” they all import TOOLS and adapt automatically.
9.1

๐Ÿ’ก What to Try

Each query below exercises a different tool. Try them in order โ€” or chain them in the same session to see HomeBot carry context across follow-up questions:

Mortgage payment "What's the monthly payment on a $450,000 home with 15% down at 6.8% over 30 years?"
Triggers calculate_mortgage_payment. HomeBot returns the exact monthly figure and total interest paid over the loan term.
Affordability check "Can I afford a $550,000 house on an $85,000 salary with $1,200/month in existing debts?"
Triggers calculate_home_affordability. HomeBot applies the 28/36 rule and tells you your maximum purchase price and whether $550k is within range.
Property tax "What would annual and monthly property tax be on a $380,000 home at a 1.2% rate?"
Triggers calculate_property_tax. Returns the annual tax bill and monthly equivalent to factor into your budget.
Rental ROI "I'm buying a $320,000 rental property, charging $2,400/month rent with $600/month in expenses. What's the ROI?"
Triggers calculate_rental_roi. HomeBot returns gross yield, net ROI, and annual cash flow.
Buy vs rent "Should I buy a $420,000 home or keep renting at $2,100/month? I have 20% to put down at 7%."
Triggers calculate_buy_vs_rent. Compares total 5-year costs for both scenarios and gives a clear recommendation.
Multi-tool "I earn $110,000/year with $800/month in debts. Can I afford a $480,000 house? If so, what's the monthly payment with 20% down at 6.5%?"
Triggers calculate_home_affordability first, then calculate_mortgage_payment. Watch the agent reason across two tool calls before synthesising a final answer.

โœ… 10. Conclusion

In this post you built HomeBot โ€” an AI Real Estate Advisor that wires a manually constructed ReAct loop around five precise financial tools. Unlike the create_react_agent shortcut from Basics Part 5, every component here is explicit: the agent node binds tools to the LLM, ToolNode executes function calls, and tools_condition decides whether to loop back or stop. You can read the graph wiring and immediately understand every path the agent can take.

The five financial tools cover the core questions a homebuyer asks โ€” monthly payment, affordability ceiling, property tax, rental return, and buy-vs-rent trade-off. Because each tool is a self-contained BaseTool subclass with a Pydantic schema and a file-loaded description, adding a sixth tool means creating one new file. The graph, agent node, and Gradio UI adapt automatically through the shared TOOLS list.

Five key takeaways from this post:

  • Manual ReAct vs prebuilt shortcut: building the loop yourself makes every routing decision visible. Use create_react_agent when speed matters; build manually when you need full control or custom state.
  • BaseTool subclassing is the production pattern: one class per file, Pydantic schema, description from prompts/. Each tool is independently testable and prompt-tunable without touching the agent.
  • Tools need two registrations: bind_tools(TOOLS) tells the LLM which tools exist; ToolNode(TOOLS) tells the graph which Python objects to call. Both reference the same list โ€” from tools import TOOLS keeps them in sync automatically.
  • MemorySaver enables multi-turn tool reasoning: within a session, HomeBot remembers previous calculations and can refer to them in follow-up questions without re-running the tools.
  • Tool descriptions are part of the prompt: the LLM reads each tool's description to decide which tool to call. Clear, specific descriptions in prompts/ directly improve tool selection accuracy.
๐Ÿ”— LangGraph Advanced series: Part 1 covered bounded conversation memory. Part 2 built a multi-agent supervisor architecture. Part 3 implemented a RAG pipeline with conditional routing. This post (Part 4) built a manual ReAct agent with production-grade tool calling. Part 5 takes tool calling further โ€” connecting a LangGraph agent to external tools via the Model Context Protocol (MCP).

๐Ÿ› ๏ธ Tech Stacks Used

Python Python 3.12
LangGraph LangGraph
LangChain LangChain
Google Gemini Google Gemini
Agent ReAct Agent
Gradio Gradio
Download

Source Code

Full project โ€” HomeBot source code, all 5 financial tools, prompt files, and Gradio web UI.

View on GitHub

๐Ÿ“š References