Voice-Enabled Geography Research Agent

Build a LangGraph agent that answers geography questions via voice or text.

Introduction

30 Second Summary

Imagine having a research assistant you could ask any geography question, and it would figure out which tools to use, gather real data, and give you a complete answer. That's how modern AI agents work. They reason about problems, pick the right tools, and loop until they have a full answer.

In this project, you will build a voice-enabled geography research agent that listens to your questions, reasons about which APIs to call, fetches real-world data (coordinates, weather, elevation), and delivers answers through a chat interface you can speak to.

What You'll Build

A geography research assistant powered by a LangGraph stateful agent graph, with voice input and a research notebook, all running in a Streamlit chat interface.

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

  • A working LangGraph StateGraph that reasons about geography questions and decides which tools to call using conditional routing.
  • A Streamlit chat interface with voice input that lets you speak questions and see the agent's reasoning process in real time.
  • A research notebook sidebar where you save, organize, and export key findings from your research sessions as markdown.
  • Secret Mission: Add an interactive map that plots all locations researched in your session.

Are there any prerequisites?

You should be comfortable with Python basics (functions, dictionaries, imports). Prior experience with a multi-agent project (like CrewAI) or equivalent LLM tool-calling exposure is helpful. You'll also need a free HuggingFace account with an API token.

Before We Start

To build a geography research agent, you need a foundation: a project folder, the right Python packages, and a working connection to an AI model.

In this step, you will create your project structure, install all dependencies, configure your HuggingFace API token, and verify everything runs before writing any agent logic.

In this step, get ready to:
  • Create the project folder and install all Python dependencies.
  • Configure your HuggingFace token and verify the AI model connection.
  • Launch a placeholder Streamlit app to confirm the UI framework works.
Create your project folder and files

Every Python project needs an isolated folder with its own virtual environment. This keeps your packages separate from other projects on your machine.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal 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 geo-research-agent
cd geo-research-agent
  • Create a Python virtual environment by running:
python3 -m venv venv
  • Activate the virtual environment by running:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the start of your terminal prompt. This confirms you are inside the virtual environment.

What is a virtual environment?

A virtual environment is an isolated Python installation inside your project folder. Packages you install here stay local to this project and do not affect other Python projects on your machine.

  • Create the project files by running:
touch app.py agent.py tools.py .env requirements.txt
  • Confirm the files exist by running:
ls

You should see app.py, agent.py, tools.py, .env, and requirements.txt in the output.

Don't see .env in the list?

Files starting with a dot are hidden by default. Run ls -a to show all files including hidden ones. You should see .env in the output.

help me find my .env file in my geo-research-agent folder

  • Open requirements.txt in VS Code and paste the following dependency list:
langgraph
langchain-huggingface
streamlit
streamlit-mic-recorder
requests
python-dotenv

What are these packages?

  • langgraph builds the stateful agent graph that orchestrates reasoning and tool calls.
  • langchain-huggingface connects LangChain to HuggingFace models.
  • streamlit creates the web-based chat UI.
  • streamlit-mic-recorder adds voice input with free speech-to-text.
  • requests makes HTTP calls to geography APIs.
  • python-dotenv loads environment variables from your .env file.
  • Save requirements.txt.
  • Install all dependencies by running:
pip install -r requirements.txt

This will download and install all six packages plus their dependencies. It may take a minute or two.

Seeing a permission error or install failure?

Make sure you see (venv) at the start of your terminal prompt. If not, re-run the activation command from the platform tab above. Installing without the virtual environment active can cause permission issues.

help me fix my pip install error in my geo-research-agent project

Configure your HuggingFace connection

Your agent needs access to an AI model hosted on HuggingFace Inference Providers. You will store your API token in the .env file so it stays out of your code.

  • Open .env in VS Code.
  • Paste the following line, replacing the placeholder with your actual HuggingFace token:
HF_TOKEN=your_huggingface_token_here

Where do I find my HuggingFace token?

Go to huggingface.co/settings/tokens and create a new token. Make sure your token has the Make calls to Inference Providers permission enabled. Copy the token value and paste it after HF_TOKEN= in your .env file.

  • Save .env.
  • Verify your token works by creating a test script. Open agent.py and paste the following:
import os
from dotenv import load_dotenv
from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint

# Load the HF_TOKEN from .env into the environment
load_dotenv()

# Create an endpoint pointing to a model on HuggingFace
llm = HuggingFaceEndpoint(
    repo_id="mistralai/Mistral-Small-24B-Instruct-2501",
    task="text-generation",
    max_new_tokens=100,
)

# Wrap it for chat-style interaction
chat_model = ChatHuggingFace(llm=llm)

# Send a test message
response = chat_model.invoke("Say hello in one sentence.")
print(response.content)

What does this test script do?

  • load_dotenv() reads your .env file and sets HF_TOKEN as an environment variable.
  • HuggingFaceEndpoint connects to a model hosted on HuggingFace Inference Providers.
  • ChatHuggingFace wraps the endpoint for chat-style message passing.
  • The invoke() call sends a message and returns the model's response.
  • Save agent.py.
  • Run the test script:
python3 agent.py

You should see a short greeting printed to the terminal. This confirms your HuggingFace token is valid and the model is accessible.

Seeing an authentication or 401 error?

Double check that you pasted your token correctly in .env with no extra spaces or quotes around it. The line should look exactly like HF_TOKEN=hf_yourActualToken with no quotation marks.

Also confirm your token has the Make calls to Inference Providers permission at huggingface.co/settings/tokens.

help me fix my HuggingFace authentication error in my geo-research-agent project

  • Now clear out agent.py so it is empty again. You will write the real agent code there in a later step.
  • Save the empty agent.py.
Verify Streamlit runs

Streamlit is the framework that powers your chat UI. A quick test confirms it is installed correctly and can launch a browser window.

  • Open app.py in VS Code and paste the following:
import streamlit as st

st.title("Hello")
  • Save app.py.
  • Launch the Streamlit app by running:
streamlit run app.py

A browser tab should open automatically showing a page with the title "Hello". If the browser does not open, look in the terminal for a URL (usually http://localhost:8501) and open it manually.

Streamlit not opening?

Make sure your virtual environment is still active (you should see (venv) in your prompt). If not, re-activate it and try again.

If you see a "command not found" error for streamlit, re-run pip install -r requirements.txt to make sure all packages are installed.

help me get Streamlit running in my geo-research-agent project

  • Press Ctrl+C in the terminal to stop the Streamlit server once you have confirmed it works.

What about streamlit-mic-recorder?

The streamlit-mic-recorder package was installed alongside Streamlit. It provides voice recording and free speech-to-text via Google's Web Speech API. You will use it in a later step to add voice input to the chat interface.

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is what each file should contain at this point:

HF_TOKEN=your_huggingface_token_here
import streamlit as st

st.title("Hello")
langgraph
langchain-huggingface
streamlit
streamlit-mic-recorder
requests
python-dotenv

agent.py and tools.py should both be empty files.

Your environment is ready. Next up, you will build the geography tools that give your agent real-world data superpowers.

Build the Geography Tools

Your environment is ready and your HuggingFace connection is verified. Now it's time to build the tools your agent will actually use to answer geography questions.

An agent without tools is just a chatbot. The real power comes from giving the LLM access to real-world data sources it can call on demand. In this step, you'll create three tool functions that call free public APIs for geocoding, weather, and elevation data.

In this step, get ready to:
  • Build a geocoding tool that converts place names to coordinates.
  • Build a weather tool that fetches current conditions for any location.
  • Build an elevation tool and register all tools in a shared registry.
Build the geocoding tool

The geocoding tool is the foundation for the other two tools. It converts a human-readable place name (like "Tokyo") into coordinates that the weather and elevation APIs need.

  • Open tools.py in VS Code and write the geocoding function using this skeleton as your guide:
import requests

def geocode_location(location_name: str) -> dict:
    # API endpoint for Open-Meteo Geocoding
    url = "https://geocoding-api.open-meteo.com/v1/search"

    # TODO: Create a params dictionary with:
    # - "name": the location_name
    # - "count": 1 (only get the top match)
    # - "language": "en"
    # - "format": "json"

    # TODO: Make a GET request to the URL with the params
    # TODO: Parse the JSON response

    # TODO: Check if results exist in the data
    # - If not, return {"error": f"Location '{location_name}' not found"}

    # TODO: Extract the first result and return a dict with:
    # name, latitude, longitude, elevation, population, timezone, country
    pass

Hints for your implementation

This function calls the Open-Meteo Geocoding API, a free service that requires no API key.

  • params sends the location name with count: 1 so you only get the top match.
  • The error check handles cases where a location isn't found, returning a helpful message instead of crashing.
  • The function extracts just the fields your agent needs: coordinates, elevation, population, timezone, and country.

Give it a go, then check your solution below.

✔️ I'll try it first

Fill in the skeleton above, then move on to the test command to verify your implementation.

ⓧ Let me check my work

Here's the complete geocoding function:

import requests

def geocode_location(location_name: str) -> dict:
    url = "https://geocoding-api.open-meteo.com/v1/search"
    params = {"name": location_name, "count": 1, "language": "en", "format": "json"}
    response = requests.get(url, params=params)
    data = response.json()
    if not data.get("results"):
        return {"error": f"Location '{location_name}' not found"}
    result = data["results"][0]
    return {
        "name": result.get("name"),
        "latitude": result.get("latitude"),
        "longitude": result.get("longitude"),
        "elevation": result.get("elevation"),
        "population": result.get("population"),
        "timezone": result.get("timezone"),
        "country": result.get("country")
    }
  • Save tools.py.
  • Test the function by running this command in your terminal:
python3 -c "from tools import geocode_location; print(geocode_location('Tokyo'))"

You should see a dictionary with Tokyo's latitude, longitude, population, timezone, and country.

Seeing a ModuleNotFoundError?

  • Make sure you're running the command from inside your geo-research-agent/ folder where tools.py lives.
  • Confirm your virtual environment is activated. You should see (venv) at the start of your terminal prompt.

Help me debug my geocode_location import error.

Build the weather tool

The weather tool takes latitude and longitude coordinates (from the geocoding tool) and returns current weather conditions. It also translates numeric weather codes into human-readable descriptions.

  • In tools.py, write the weather codes dictionary and weather function below your geocode_location function using this skeleton:
WEATHER_CODES = {
    0: "Clear sky", 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
    45: "Fog", 48: "Icy fog", 51: "Light drizzle", 53: "Moderate drizzle",
    55: "Dense drizzle", 61: "Slight rain", 63: "Moderate rain", 65: "Heavy rain",
    71: "Slight snow", 73: "Moderate snow", 75: "Heavy snow", 80: "Slight showers",
    81: "Moderate showers", 82: "Violent showers", 95: "Thunderstorm"
}

def get_weather(latitude: float, longitude: float) -> dict:
    # API endpoint for Open-Meteo Weather
    url = "https://api.open-meteo.com/v1/forecast"

    # TODO: Create a params dictionary with:
    # - "latitude": the latitude argument
    # - "longitude": the longitude argument
    # - "current": "temperature_2m,wind_speed_10m,weather_code"

    # TODO: Make a GET request and parse the JSON response

    # TODO: Extract the "current" dict from the response data
    # TODO: Get the weather_code from current and look it up in WEATHER_CODES

    # TODO: Return a dict with:
    # temperature_celsius, wind_speed_kmh, weather_description
    pass

Hints for your implementation

WEATHER_CODES is a lookup table that maps numeric WMO weather codes (returned by Open-Meteo) to readable descriptions like "Clear sky" or "Heavy rain".

  • The current parameter requests real-time data instead of a full forecast. You ask for temperature, wind speed, and the weather code.
  • The function returns a clean dictionary with the temperature in Celsius, wind speed in km/h, and a human-readable weather description.

Give it a go, then check your solution below.

✔️ I'll try it first

Fill in the skeleton above, then move on to the test command to verify your implementation.

ⓧ Let me check my work

Here's the complete weather function:

WEATHER_CODES = {
    0: "Clear sky", 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
    45: "Fog", 48: "Icy fog", 51: "Light drizzle", 53: "Moderate drizzle",
    55: "Dense drizzle", 61: "Slight rain", 63: "Moderate rain", 65: "Heavy rain",
    71: "Slight snow", 73: "Moderate snow", 75: "Heavy snow", 80: "Slight showers",
    81: "Moderate showers", 82: "Violent showers", 95: "Thunderstorm"
}

def get_weather(latitude: float, longitude: float) -> dict:
    url = "https://api.open-meteo.com/v1/forecast"
    params = {
        "latitude": latitude,
        "longitude": longitude,
        "current": "temperature_2m,wind_speed_10m,weather_code"
    }
    response = requests.get(url, params=params)
    data = response.json()
    current = data.get("current", {})
    weather_code = current.get("weather_code", -1)
    return {
        "temperature_celsius": current.get("temperature_2m"),
        "wind_speed_kmh": current.get("wind_speed_10m"),
        "weather_description": WEATHER_CODES.get(weather_code, "Unknown")
    }
  • Save tools.py.
  • Test the weather function by running:
python3 -c "from tools import get_weather; print(get_weather(35.68, 139.69))"

You should see the current temperature, wind speed, and weather description for Tokyo's coordinates.

Getting None values in the response?

  • Double-check that current in your params string is spelled exactly as temperature_2m,wind_speed_10m,weather_code with no spaces after commas.
  • Ensure you have an active internet connection. The Open-Meteo API is free but requires network access.

Help me figure out why my weather function returns None values.

Build the elevation tool and tool registry

The last tool fetches elevation data for any coordinate pair. After building it, you'll create a registry that maps tool names to their functions, so the agent can look them up dynamically.

  • In tools.py, write the elevation function below your get_weather function using this skeleton:
def get_elevation(latitude: float, longitude: float) -> dict:
    # API endpoint - note the ?json parameter for JSON response
    url = f"https://www.elevation-api.eu/v1/elevation/{latitude}/{longitude}?json"

    # TODO: Make a GET request and parse the JSON response
    # TODO: Return a dict with "elevation_meters" from the response data
    pass

Hints for your implementation

This function calls the Elevation API from elevation-api.eu, which uses ESA Copernicus satellite data. The ?json parameter tells the API to return JSON instead of a plain number.

Like the other tools, it requires no API key. The free plan allows 10 requests per second, which is more than enough for this project.

Give it a go, then check your solution below.

✔️ I'll try it first

Fill in the skeleton above, then move on to the registry section.

ⓧ Let me check my work

Here's the complete elevation function:

def get_elevation(latitude: float, longitude: float) -> dict:
    url = f"https://www.elevation-api.eu/v1/elevation/{latitude}/{longitude}?json"
    response = requests.get(url)
    data = response.json()
    return {"elevation_meters": data.get("elevation")}
  • Now write the tool registry and description string below the get_elevation function using this skeleton:
# TODO: Create a TOOLS_REGISTRY dictionary that maps tool names (strings)
# to their function references:
# - "geocode_location" -> geocode_location
# - "get_weather" -> get_weather
# - "get_elevation" -> get_elevation
TOOLS_REGISTRY = {}

# TODO: Create a TOOLS_DESCRIPTION multi-line string that describes each tool
# for the LLM to read. Include:
# - Function signatures with parameter types and return type
# - Return values for each tool
# - Brief usage description for each
TOOLS_DESCRIPTION = """"""

Why a registry and description string?

TOOLS_REGISTRY maps tool names (as strings) to their actual Python functions. When the agent decides to call "geocode_location", the registry lets you look up and execute the real function dynamically.

TOOLS_DESCRIPTION is the text the LLM reads to understand what tools are available. It includes parameter types and return values so the model knows how to format its tool calls.

Give it a go, then check your solution below.

✔️ I'll try it first

Fill in the registry and description string, then move on to the test command.

ⓧ Let me check my work

Here's the complete registry and description:

TOOLS_REGISTRY = {
    "geocode_location": geocode_location,
    "get_weather": get_weather,
    "get_elevation": get_elevation
}

TOOLS_DESCRIPTION = """
Available tools:
1. geocode_location(location_name: str) -> dict
   Returns: name, latitude, longitude, elevation, population, timezone, country
   Use this to get coordinates and info for any place name.

2. get_weather(latitude: float, longitude: float) -> dict
   Returns: temperature_celsius, wind_speed_kmh, weather_description
   Use this to get current weather at a coordinate.

3. get_elevation(latitude: float, longitude: float) -> dict
   Returns: elevation_meters
   Use this to get the elevation at a coordinate.
"""
  • Save tools.py.
  • Test the elevation function by running:
python3 -c "from tools import get_elevation; print(get_elevation(35.68, 139.69))"

You should see a dictionary with elevation_meters containing a numeric value.

Seeing a connection error?

  • The Elevation API at elevation-api.eu is a small free service. If you get a timeout, wait a moment and try again.
  • Make sure your URL includes ?json at the end. Without it, the API returns plain text instead of JSON, which causes a parsing error.

Help me troubleshoot my elevation API connection.

✔️ Awesome, I've got everything!

Great. Double check you've saved tools.py and all three functions return data when tested.

ⓧ I'd like to double check the full code

Here is the complete tools.py file for comparison:

import requests

def geocode_location(location_name: str) -> dict:
    url = "https://geocoding-api.open-meteo.com/v1/search"
    params = {"name": location_name, "count": 1, "language": "en", "format": "json"}
    response = requests.get(url, params=params)
    data = response.json()
    if not data.get("results"):
        return {"error": f"Location '{location_name}' not found"}
    result = data["results"][0]
    return {
        "name": result.get("name"),
        "latitude": result.get("latitude"),
        "longitude": result.get("longitude"),
        "elevation": result.get("elevation"),
        "population": result.get("population"),
        "timezone": result.get("timezone"),
        "country": result.get("country")
    }

WEATHER_CODES = {
    0: "Clear sky", 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
    45: "Fog", 48: "Icy fog", 51: "Light drizzle", 53: "Moderate drizzle",
    55: "Dense drizzle", 61: "Slight rain", 63: "Moderate rain", 65: "Heavy rain",
    71: "Slight snow", 73: "Moderate snow", 75: "Heavy snow", 80: "Slight showers",
    81: "Moderate showers", 82: "Violent showers", 95: "Thunderstorm"
}

def get_weather(latitude: float, longitude: float) -> dict:
    url = "https://api.open-meteo.com/v1/forecast"
    params = {
        "latitude": latitude,
        "longitude": longitude,
        "current": "temperature_2m,wind_speed_10m,weather_code"
    }
    response = requests.get(url, params=params)
    data = response.json()
    current = data.get("current", {})
    weather_code = current.get("weather_code", -1)
    return {
        "temperature_celsius": current.get("temperature_2m"),
        "wind_speed_kmh": current.get("wind_speed_10m"),
        "weather_description": WEATHER_CODES.get(weather_code, "Unknown")
    }

def get_elevation(latitude: float, longitude: float) -> dict:
    url = f"https://www.elevation-api.eu/v1/elevation/{latitude}/{longitude}?json"
    response = requests.get(url)
    data = response.json()
    return {"elevation_meters": data.get("elevation")}

TOOLS_REGISTRY = {
    "geocode_location": geocode_location,
    "get_weather": get_weather,
    "get_elevation": get_elevation
}

TOOLS_DESCRIPTION = """
Available tools:
1. geocode_location(location_name: str) -> dict
   Returns: name, latitude, longitude, elevation, population, timezone, country
   Use this to get coordinates and info for any place name.

2. get_weather(latitude: float, longitude: float) -> dict
   Returns: temperature_celsius, wind_speed_kmh, weather_description
   Use this to get current weather at a coordinate.

3. get_elevation(latitude: float, longitude: float) -> dict
   Returns: elevation_meters
   Use this to get the elevation at a coordinate.
"""

Why free, keyless APIs?

All three APIs used in this project (Open-Meteo Geocoding, Open-Meteo Weather, and Elevation API) are completely free and require no authentication. This means zero setup friction and no risk of unexpected charges.

In production agent systems, you'd add API key management and rate limiting. For learning how agent tool-calling works, keyless APIs let you focus on the architecture without infrastructure noise.

Your tools are built and tested. Next up, you'll build the LangGraph agent that reasons about questions and decides which tools to call.

Build the LangGraph Agent

Your geography tools are ready to provide real-world data. Now you need an intelligent agent that can decide which tools to call and when.

In this step, you'll build a LangGraph agent that receives a question, reasons about which tools to use, executes them, and loops back to reason again until it has enough information to answer.

In this step, get ready to:
  • Define the agent's state, system prompt, and reasoning logic.
  • Build the tool execution node and routing function.
  • Assemble and test the complete StateGraph.
Understand the ReAct pattern

A single LLM call takes a question and returns an answer. The problem is that one call often isn't enough — the model might need external data (coordinates, weather, elevation) before it can give a useful response.

The ReAct pattern (Reason + Act) solves this by letting the agent loop. On each iteration, the agent reasons about what it knows so far, decides whether to call a tool or deliver a final answer, and then acts on that decision. If it called a tool, the result feeds back into the next reasoning step.

This creates a cycle: Reason, Act, Observe, Reason, Act, Observe, and so on until a Final Answer. The agent keeps looping until it has gathered enough information to respond confidently, or until it hits a safety limit.

LangGraph models this loop as a graph. Each step in the cycle is a node (a Python function that transforms state), and the connections between them are edges. A special type of edge called a conditional edge inspects the current state and decides which node to visit next — this is what creates the loop.

The data that flows between nodes is called the state. In LangGraph, you define state as a Python TypedDict — a dictionary with named, typed fields. Every node receives the current state, does some work, and returns an updated copy.

Why TypedDict for state?

TypedDict gives you the flexibility of a dictionary (easy to pass between functions) with the safety of type hints (your editor can catch mistakes). LangGraph uses the TypedDict schema to validate that every node returns the correct shape of data.

Define the agent state, system prompt, and reasoning node

Your agent's state needs to track five things: the conversation messages, any pending tool calls the LLM has requested, results from tools already executed, the final answer (once produced), and an iteration counter to prevent infinite loops.

The reasoning node is the "brain" of the agent. It takes the full conversation history (including past tool results), sends it to the LLM, and parses the structured JSON response to decide what happens next.

  • Open agent.py in VS Code and write the imports, state definition, system prompt, LLM helper, and reasoning node using this skeleton as your guide:
import json
import os
from typing import TypedDict, List, Optional
from dotenv import load_dotenv
from langgraph.graph import StateGraph, END
from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
from tools import TOOLS_REGISTRY, TOOLS_DESCRIPTION

load_dotenv()

# Define the shape of data passed between graph nodes
class AgentState(TypedDict):
    messages: List
    tool_calls: List[dict]
    tool_results: List[dict]
    final_answer: Optional[str]
    iterations: int

# System prompt instructs the LLM to respond in structured JSON
SYSTEM_PROMPT = f"""You are a geography research assistant with access to real-world tools.
Always respond with valid JSON in one of these two formats:

1. To call a tool:
{{"action": "tool_call", "tool": "tool_name", "args": {{"arg_name": "value"}}}}

2. To give a final answer:
{{"action": "final_answer", "answer": "Your detailed answer here"}}

{TOOLS_DESCRIPTION}

Reason step by step. Use tools to gather data before answering."""

# Safety limit to prevent infinite loops
MAX_ITERATIONS = 10

def get_llm():
    # Connect to HuggingFace Inference Providers
    endpoint = HuggingFaceEndpoint(
        repo_id="mistralai/Mistral-7B-Instruct-v0.3",
        huggingfacehub_api_token=os.getenv("HF_TOKEN"),
        task="text-generation",
        max_new_tokens=512
    )
    return ChatHuggingFace(llm=endpoint)

def reasoning_node(state: AgentState) -> AgentState:
    # TODO: Create the LLM instance using get_llm()

    # TODO: Build the messages list:
    # - Start with SystemMessage(content=SYSTEM_PROMPT)
    # - Append all messages from state["messages"]

    # TODO: If state["tool_results"] is not empty:
    # - Build a tool_context string by joining all previous tool results
    #   Format each as: "Tool '{tool_name}' returned: {json_result}"
    # - Append a HumanMessage with the tool context + "Continue reasoning."

    # TODO: Invoke the LLM with the messages list
    # TODO: Get response.content and strip whitespace

    # TODO: Parse the JSON from the response:
    # - Find the first '{' and last '}' in the content
    # - Use json.loads() on that substring
    # - If parsing fails, default to {"action": "final_answer", "answer": content}

    # TODO: Create new_state as a copy of state
    # TODO: Increment iterations by 1

    # TODO: Check if parsed["action"] == "tool_call":
    # - If yes: set tool_calls to [{"tool": ..., "args": ...}]
    #   and append AIMessage to messages
    # - If no: set tool_calls to [], set final_answer from parsed,
    #   and append AIMessage to messages

    # TODO: Return new_state
    pass

Hints for your implementation

The reasoning node follows a clear pattern: build context, call LLM, parse response, update state.

  • Use json.dumps(r['result']) when building the tool context string so nested dictionaries are formatted properly.
  • The JSON parsing uses content.find("{") and content.rfind("}") to extract JSON even when the LLM adds extra text around it.
  • The fallback in the except block ensures the agent always produces an answer, even if the LLM doesn't return valid JSON.
  • Remember to append AIMessage(content=content) to messages in both branches (tool call and final answer) so the conversation history stays complete.

Give it a go, then check your solution below.

✔️ I'll try it first

Fill in the skeleton above, then move on to the next substep.

ⓧ Let me check my work

Here's the complete reasoning node implementation:

def reasoning_node(state: AgentState) -> AgentState:
    # Send conversation history to the LLM for reasoning
    llm = get_llm()
    messages = [SystemMessage(content=SYSTEM_PROMPT)] + state["messages"]
    # Append prior tool results so the LLM can reason about them
    if state["tool_results"]:
        tool_context = "\n".join([
            f"Tool '{r['tool']}' returned: {json.dumps(r['result'])}"
            for r in state["tool_results"]
        ])
        messages.append(HumanMessage(content=f"Tool results so far:\n{tool_context}\n\nContinue reasoning."))
    response = llm.invoke(messages)
    content = response.content.strip()
    # Parse the JSON response from the LLM
    try:
        start = content.find("{")
        end = content.rfind("}") + 1
        parsed = json.loads(content[start:end])
    except Exception:
        parsed = {"action": "final_answer", "answer": content}
    # Update state based on whether LLM wants a tool call or final answer
    new_state = dict(state)
    new_state["iterations"] = state["iterations"] + 1
    if parsed.get("action") == "tool_call":
        new_state["tool_calls"] = [{"tool": parsed["tool"], "args": parsed.get("args", {})}]
        new_state["messages"] = state["messages"] + [AIMessage(content=content)]
    else:
        new_state["tool_calls"] = []
        new_state["final_answer"] = parsed.get("answer", content)
        new_state["messages"] = state["messages"] + [AIMessage(content=content)]
    return new_state
  • Save agent.py.

You won't see a change yet. The next chunk wires up the tool execution that the reasoning node depends on.

Understand tool execution and routing

When the reasoning node outputs a tool call, something needs to actually execute that tool. This is the "Act" half of the ReAct loop — the agent has made a decision, and now the system carries it out.

The tool execution node reads the pending tool call from state, looks up the matching function in your TOOLS_REGISTRY (from tools.py), executes it with the provided arguments, and stores the result back in state. It then clears the pending tool call so the next reasoning step starts fresh.

After execution, a routing function decides what happens next. LangGraph calls this function after the reasoning node runs, and it must return a string that maps to the next node. In your case, it returns "tool" (go execute the tool) or "end" (stop the graph).

Why a separate routing function?

LangGraph's conditional edges need a pure function that inspects state and returns a string. Keeping the routing logic separate from the reasoning node makes the graph definition clean and testable — you can unit test the routing decision without invoking the LLM.

Build the tool execution and routing logic
  • In agent.py, write the tool execution node and routing function below your reasoning_node function using this skeleton:
def tool_node(state: AgentState) -> AgentState:
    # TODO: Create new_state as a copy of state
    # TODO: If state["tool_calls"] is empty, return new_state immediately

    # TODO: Get the first tool call from state["tool_calls"]
    # TODO: Extract tool_name and tool_args from the call dict

    # TODO: Look up the tool function from TOOLS_REGISTRY using tool_name
    # TODO: If the tool exists, call it with **tool_args
    # TODO: If not, set result to {"error": f"Tool '{tool_name}' not found"}

    # TODO: Append the result to tool_results list:
    #   {"tool": tool_name, "args": tool_args, "result": result}
    # TODO: Clear tool_calls (set to empty list)
    # TODO: Return new_state
    pass

def should_continue(state: AgentState) -> str:
    # TODO: If state["final_answer"] exists OR iterations >= MAX_ITERATIONS:
    #   return "end"
    # TODO: If state["tool_calls"] is not empty:
    #   return "tool"
    # TODO: Otherwise return "end"
    pass

Hints for your implementation

  • tool_node() uses TOOLS_REGISTRY.get(tool_name) which returns None if the tool doesn't exist, letting you handle the error gracefully.
  • The **tool_args syntax unpacks the dictionary as keyword arguments — so {"latitude": 35.68, "longitude": 139.69} becomes get_weather(latitude=35.68, longitude=139.69).
  • should_continue() checks three conditions in order: final answer present, iteration limit hit, or pending tool calls. The order matters — always check stop conditions first.

Give it a go, then check your solution below.

✔️ I'll try it first

Fill in the skeleton above, then move on to the graph assembly.

ⓧ Let me check my work

Here's the complete tool execution and routing implementation:

def tool_node(state: AgentState) -> AgentState:
    # Execute the tool requested by the reasoning node
    new_state = dict(state)
    if not state["tool_calls"]:
        return new_state
    call = state["tool_calls"][0]
    tool_name = call["tool"]
    tool_args = call["args"]
    # Look up the tool function from the registry
    tool_fn = TOOLS_REGISTRY.get(tool_name)
    if tool_fn:
        result = tool_fn(**tool_args)
    else:
        result = {"error": f"Tool '{tool_name}' not found"}
    # Append result and clear pending tool calls
    new_state["tool_results"] = state["tool_results"] + [{"tool": tool_name, "args": tool_args, "result": result}]
    new_state["tool_calls"] = []
    return new_state

def should_continue(state: AgentState) -> str:
    # Route to tools node or end based on current state
    if state["final_answer"] or state["iterations"] >= MAX_ITERATIONS:
        return "end"
    if state["tool_calls"]:
        return "tool"
    return "end"
  • Save agent.py.

You won't see a change yet. The next chunk assembles these pieces into a runnable graph.

Assemble the StateGraph and test the agent

You now have all the individual pieces: a reasoning node that calls the LLM, a tool node that executes functions, and a routing function that decides the next step. The final piece is wiring them into an executable graph.

In LangGraph, building a graph follows a consistent sequence. First, you create a StateGraph instance and pass it your state schema (the TypedDict class). Then you add nodes — each is a name paired with a function. You set an entry point (where execution starts). Then you define edges: a regular edge always routes to the same next node, while a conditional edge calls your routing function and follows the path matching its return value.

Once the graph is defined, you call .compile() to produce a runnable object. This compiled graph can be invoked with an initial state dictionary, and it executes the node sequence until it reaches END. The return value is the final state after all transitions are complete.

What does compile() do?

Compiling validates your graph structure (no dangling edges, all nodes reachable) and produces an optimized runnable. You compile once at module level so the graph object is reused across multiple invocations without rebuilding each time.

  • Write the graph assembly and runner function at the bottom of agent.py using this skeleton:
def build_graph():
    # TODO: Create a StateGraph with AgentState as the schema
    # TODO: Add two nodes:
    #   - "reasoning" -> reasoning_node function
    #   - "tools" -> tool_node function
    # TODO: Set the entry point to "reasoning"
    # TODO: Add a conditional edge from "reasoning" using should_continue:
    #   - Map "tool" -> "tools" node
    #   - Map "end" -> END (imported from langgraph.graph)
    # TODO: Add a regular edge from "tools" -> "reasoning" (always loop back)
    # TODO: Return graph.compile()
    pass

# TODO: Compile the graph once at module level
# agent_graph = build_graph()

def run_agent(question: str) -> dict:
    # TODO: Create an initial AgentState dict with:
    # - messages: [HumanMessage(content=question)]
    # - tool_calls: []
    # - tool_results: []
    # - final_answer: None
    # - iterations: 0
    # TODO: Invoke agent_graph with the initial state
    # TODO: Return the final state
    pass

Hints for your implementation

  • The StateGraph constructor takes the TypedDict class directly: StateGraph(AgentState).
  • Use graph.add_node("name", function) for each node.
  • graph.set_entry_point("reasoning") tells the graph where to start execution.
  • The conditional edge syntax is: graph.add_conditional_edges("reasoning", should_continue, {"tool": "tools", "end": END}).
  • The regular edge from tools back to reasoning is: graph.add_edge("tools", "reasoning").
  • Don't forget to uncomment agent_graph = build_graph() once your implementation is complete.

Give it a go, then check your solution below.

✔️ I'll try it first

Fill in the skeleton above, then move on to the test command to verify your agent works.

ⓧ Let me check my work

Here's the complete graph assembly and runner:

def build_graph():
    graph = StateGraph(AgentState)
    graph.add_node("reasoning", reasoning_node)
    graph.add_node("tools", tool_node)
    graph.set_entry_point("reasoning")
    graph.add_conditional_edges("reasoning", should_continue, {"tool": "tools", "end": END})
    graph.add_edge("tools", "reasoning")
    return graph.compile()

agent_graph = build_graph()

def run_agent(question: str) -> dict:
    initial_state: AgentState = {
        "messages": [HumanMessage(content=question)],
        "tool_calls": [],
        "tool_results": [],
        "final_answer": None,
        "iterations": 0
    }
    final_state = agent_graph.invoke(initial_state)
    return final_state
  • Save agent.py.
  • Test your agent by running this command in the terminal:
python -c "from agent import run_agent; result = run_agent('What is the elevation of Mount Fuji?'); print(result['final_answer'])"

You should see the agent produce a final answer about Mount Fuji's elevation. The response may take 10-20 seconds as it reasons through tool calls.

Not getting a response?

  • If you see an authentication error, double-check that your .env file contains a valid HF_TOKEN and that your token has the "Make calls to Inference Providers" permission enabled on HuggingFace.
  • If you see a JSON parsing error in the output, the LLM may have returned an unexpected format. Run the command again. The structured JSON prompt usually works on the second try.
  • If the command hangs for more than 60 seconds, the HuggingFace endpoint may be cold-starting. Wait a moment and try again.

Help me debug my LangGraph agent not producing output

✔️ Awesome, I've got everything!

Great. Double check you've saved agent.py and confirmed the agent can produce an answer.

ⓧ Show me the complete file for this step

Here's the complete agent.py — compare your implementation against it as a final check before moving on:

import json
import os
from typing import TypedDict, List, Optional
from dotenv import load_dotenv
from langgraph.graph import StateGraph, END
from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
from tools import TOOLS_REGISTRY, TOOLS_DESCRIPTION

load_dotenv()

class AgentState(TypedDict):
    messages: List
    tool_calls: List[dict]
    tool_results: List[dict]
    final_answer: Optional[str]
    iterations: int

SYSTEM_PROMPT = f"""You are a geography research assistant with access to real-world tools.
Always respond with valid JSON in one of these two formats:

1. To call a tool:
{{"action": "tool_call", "tool": "tool_name", "args": {{"arg_name": "value"}}}}

2. To give a final answer:
{{"action": "final_answer", "answer": "Your detailed answer here"}}

{TOOLS_DESCRIPTION}

Reason step by step. Use tools to gather data before answering."""

MAX_ITERATIONS = 10

def get_llm():
    endpoint = HuggingFaceEndpoint(
        repo_id="mistralai/Mistral-7B-Instruct-v0.3",
        huggingfacehub_api_token=os.getenv("HF_TOKEN"),
        task="text-generation",
        max_new_tokens=512
    )
    return ChatHuggingFace(llm=endpoint)

def reasoning_node(state: AgentState) -> AgentState:
    llm = get_llm()
    messages = [SystemMessage(content=SYSTEM_PROMPT)] + state["messages"]
    if state["tool_results"]:
        tool_context = "\n".join([
            f"Tool '{r['tool']}' returned: {json.dumps(r['result'])}"
            for r in state["tool_results"]
        ])
        messages.append(HumanMessage(content=f"Tool results so far:\n{tool_context}\n\nContinue reasoning."))
    response = llm.invoke(messages)
    content = response.content.strip()
    try:
        start = content.find("{")
        end = content.rfind("}") + 1
        parsed = json.loads(content[start:end])
    except Exception:
        parsed = {"action": "final_answer", "answer": content}
    new_state = dict(state)
    new_state["iterations"] = state["iterations"] + 1
    if parsed.get("action") == "tool_call":
        new_state["tool_calls"] = [{"tool": parsed["tool"], "args": parsed.get("args", {})}]
        new_state["messages"] = state["messages"] + [AIMessage(content=content)]
    else:
        new_state["tool_calls"] = []
        new_state["final_answer"] = parsed.get("answer", content)
        new_state["messages"] = state["messages"] + [AIMessage(content=content)]
    return new_state

def tool_node(state: AgentState) -> AgentState:
    new_state = dict(state)
    if not state["tool_calls"]:
        return new_state
    call = state["tool_calls"][0]
    tool_name = call["tool"]
    tool_args = call["args"]
    tool_fn = TOOLS_REGISTRY.get(tool_name)
    if tool_fn:
        result = tool_fn(**tool_args)
    else:
        result = {"error": f"Tool '{tool_name}' not found"}
    new_state["tool_results"] = state["tool_results"] + [{"tool": tool_name, "args": tool_args, "result": result}]
    new_state["tool_calls"] = []
    return new_state

def should_continue(state: AgentState) -> str:
    if state["final_answer"] or state["iterations"] >= MAX_ITERATIONS:
        return "end"
    if state["tool_calls"]:
        return "tool"
    return "end"

def build_graph():
    graph = StateGraph(AgentState)
    graph.add_node("reasoning", reasoning_node)
    graph.add_node("tools", tool_node)
    graph.set_entry_point("reasoning")
    graph.add_conditional_edges("reasoning", should_continue, {"tool": "tools", "end": END})
    graph.add_edge("tools", "reasoning")
    return graph.compile()

agent_graph = build_graph()

def run_agent(question: str) -> dict:
    initial_state: AgentState = {
        "messages": [HumanMessage(content=question)],
        "tool_calls": [],
        "tool_results": [],
        "final_answer": None,
        "iterations": 0
    }
    final_state = agent_graph.invoke(initial_state)
    return final_state

Your LangGraph agent is reasoning, calling tools, and producing answers. Next up, you'll build the Streamlit chat interface so you can interact with this agent through text and voice.

Build the Streamlit Chat UI with Voice and Chatbot Mode

Your agent can reason, call tools, and deliver answers. Now it needs a face.

In this step, you will build a Streamlit chat interface that lets you type or speak geography questions, watch the agent think through its reasoning, and toggle into a direct chatbot mode for open-ended follow-ups.

In this step, get ready to:
  • Set up the Streamlit app skeleton with page config, session state, and a mode toggle.
  • Add voice input using speech-to-text in the sidebar.
  • Integrate the LangGraph agent and a direct chatbot mode for query handling.
Set up the Streamlit app skeleton

Streamlit apps have a unique execution model. Every time a user clicks a button, types in an input, or interacts with any widget, Streamlit reruns your entire Python script from top to bottom. This means variables you declare at the top of the file get reset on every interaction.

To persist data across these reruns, Streamlit provides st.session_state. This is a dictionary-like object that survives reruns. You check if a key exists, and if not, you initialize it. On the next rerun, the key already exists so initialization is skipped and your data survives.

Why session state matters

Without session state, every message the user sends would wipe the entire chat history. The chat would only ever show the latest exchange. Session state is what makes Streamlit behave like a real app instead of a one-shot script.

The pattern is always the same: if "key" not in st.session_state: st.session_state.key = default_value. This runs on the first load and is skipped on every subsequent rerun.

  • Open app.py in VS Code and write the app skeleton using this guide:
import streamlit as st
import os
from dotenv import load_dotenv
from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
from langchain_core.messages import HumanMessage, SystemMessage
from agent import run_agent

load_dotenv()

# TODO: Set page config (must be the FIRST Streamlit command)
# Use st.set_page_config() with:
# - page_title: "Geo Research Agent"
# - page_icon: "🌍"
# - layout: "wide"

# TODO: Initialize session state - check if each key exists first:
# - "chat_history": empty list (stores all messages)
# - "notebook": empty list (stores saved findings)
# - "locations": empty list (stores geocoded locations for map)
# - "mode": "Research Agent" (current interaction mode)

# TODO: Set the page title
# - Use st.title("🌍 Geography Research Agent")

# TODO: Create a sidebar section using "with st.sidebar:"
# Inside the sidebar:
# - Add a header: "Settings"
# - Add a radio button: st.radio("Mode", ["Research Agent", "Chatbot"], index=0)
# - Store the radio result in st.session_state.mode

# TODO: Display existing chat history
# - Loop through st.session_state.chat_history
# - For each msg, use "with st.chat_message(msg["role"]):" as context manager
# - Inside, call st.write(msg["content"])

# TODO: Add a chat input at the bottom
# - Use st.chat_input("Ask a geography question...")
# - Store the result in a variable called "query"

Hints for your implementation

  • st.set_page_config() accepts keyword arguments: page_title, page_icon, and layout.
  • Session state initialization pattern: if "key" not in st.session_state: st.session_state.key = value. Repeat this for each of the four keys.
  • st.chat_message() is a context manager. Use with st.chat_message(role): then call st.write() inside.
  • st.chat_input() returns None when the user hasn't submitted anything, and the text string when they press Enter.

Give it a go, then check your solution below.

✔️ I'll try it first

Fill in the skeleton above, then move on to the test command to verify your app loads.

ⓧ Let me check my work

Here's the complete app skeleton:

import streamlit as st
import os
from dotenv import load_dotenv
from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
from langchain_core.messages import HumanMessage, SystemMessage
from agent import run_agent

load_dotenv()

# Page config must be the first Streamlit command
st.set_page_config(page_title="Geo Research Agent", page_icon="🌍", layout="wide")

# Initialize session state to persist data across reruns
if "chat_history" not in st.session_state:
    st.session_state.chat_history = []
if "notebook" not in st.session_state:
    st.session_state.notebook = []
if "locations" not in st.session_state:
    st.session_state.locations = []
if "mode" not in st.session_state:
    st.session_state.mode = "Research Agent"

st.title("🌍 Geography Research Agent")

# Sidebar with mode selection
with st.sidebar:
    st.header("Settings")
    st.session_state.mode = st.radio("Mode", ["Research Agent", "Chatbot"], index=0)

# Display existing chat history
for msg in st.session_state.chat_history:
    with st.chat_message(msg["role"]):
        st.write(msg["content"])

# Text input at the bottom of the chat
query = st.chat_input("Ask a geography question...")
  • Save app.py.
  • Run the app by executing this command in your terminal:
streamlit run app.py

Your browser should open automatically. You should see the title, a sidebar with a Mode radio toggle, and a chat input bar at the bottom of the page.

Seeing an error about set_page_config?

This means a Streamlit command ran before st.set_page_config. Make sure st.set_page_config(...) is the very first Streamlit call in your script, right after your non-Streamlit imports and load_dotenv().

Help me fix my set_page_config error

Add voice input with speech-to-text

Modern browsers include a built-in speech recognition engine called the Web Speech API. It can transcribe spoken English into text entirely client-side, with no external API calls or keys required.

The streamlit-mic-recorder package wraps this browser API into a Streamlit component. Its speech_to_text() function renders a microphone button. When the user clicks it and speaks, the browser transcribes the audio and returns it as a Python string your code can use like any other text input.

Why just_once=True?

just_once=True means the recorder stops automatically after one phrase. Without it, the recorder keeps listening continuously, which would trigger multiple Streamlit reruns and produce unpredictable behavior.

You also need a last_voice check in session state to prevent the same transcription from being processed on every rerun. Without it, the app would repeatedly send the same voice query each time the page refreshes.

  • In app.py, add this import near the top of the file, above the from agent import run_agent line:
from streamlit_mic_recorder import speech_to_text
  • Inside your with st.sidebar: block, below the mode radio button, write the voice input section using this skeleton:
    # TODO: Add a subheader: "🎤 Voice Input"

    # TODO: Call speech_to_text() and store the result in "voice_text"
    # Parameters to pass:
    # - language: "en"
    # - start_prompt: "Start recording"
    # - stop_prompt: "Stop recording"
    # - just_once: True
    # - key: "voice_input"

    # TODO: If voice_text has content, display it
    # - Use st.info(f"Voice detected: {voice_text}")
  • At the bottom of your file, find the line where you stored the chat input in query. Below it, add the voice-to-query wiring using this skeleton:
# TODO: Override query with voice input if new speech is detected
# - Check two conditions:
#   1. voice_text is not None/empty
#   2. voice_text != st.session_state.get("last_voice", "")
# - If both are true:
#   - Set query = voice_text
#   - Store voice_text in st.session_state.last_voice

Hints for your implementation

  • speech_to_text() returns None when no speech has been detected, and a string when transcription is complete.
  • The voice wiring must come AFTER st.chat_input() because it overrides the query variable. Order matters.
  • The st.session_state.get("last_voice", "") pattern safely returns an empty string if the key doesn't exist yet, avoiding a KeyError.

Give it a go, then check your solution below.

✔️ I'll try it first

Fill in both skeletons above, then move on to verify the voice controls appear.

ⓧ Let me check my work

Here's the complete voice input code. The sidebar section:

    # Voice input using Google Web Speech API
    st.subheader("🎤 Voice Input")
    voice_text = speech_to_text(
        language="en",
        start_prompt="Start recording",
        stop_prompt="Stop recording",
        just_once=True,
        key="voice_input"
    )
    if voice_text:
        st.info(f"Voice detected: {voice_text}")

And the voice-to-query wiring at the bottom:

# Override query with voice input if new speech is detected
if voice_text and voice_text != st.session_state.get("last_voice", ""):
    query = voice_text
    st.session_state.last_voice = voice_text
  • Save app.py and refresh your browser.
  • You should see a microphone section in the sidebar with Start recording and Stop recording buttons.

Don't see the voice input controls?

Make sure from streamlit_mic_recorder import speech_to_text is at the top of your file with the other imports. Check that streamlit-mic-recorder is installed by running pip list | grep streamlit-mic in your terminal.

Help me debug my voice input

Integrate the LangGraph agent and chatbot mode

Your app now has two input methods (typing and speaking) but no way to process the query. You need to connect the input to two different backends depending on the mode the user selected in the sidebar.

In Research Agent mode, the query goes through run_agent() which triggers the full StateGraph loop — reasoning, tool calls, and a final answer. The app also tracks any geocoded locations for the research map and shows the agent's reasoning steps in a collapsible expander.

In Chatbot mode, the query goes directly to the LLM with a geography expert system prompt, bypassing tools entirely. This is useful for follow-up questions or general geography knowledge that doesn't need real-time data from APIs.

Why track geocoded locations in session state?

Every time the agent calls geocode_location, it returns latitude and longitude. By saving these coordinates to st.session_state.locations, you build up a dataset of every place the user has researched. The Secret Mission uses this data to render an interactive map.

  • In app.py, add the query handling code at the very bottom of the file, after the voice wiring section. Use this skeleton:
if query:
    # TODO: Add user message to chat_history and display it
    # - Append {"role": "user", "content": query} to st.session_state.chat_history
    # - Use "with st.chat_message("user"):" and st.write(query)

    if st.session_state.mode == "Research Agent":
        # TODO: Run the LangGraph agent with a loading spinner
        # - Use "with st.spinner("Agent is researching..."):" as context manager
        # - Inside, call run_agent(query) and store in "result"
        # - Extract tool_results: result.get("tool_results", [])
        # - Extract final_answer: result.get("final_answer", "No answer produced.")

        # TODO: Track geocoded locations for the research map
        # - Loop through tool_results
        # - For each tr where tr["tool"] == "geocode_location" and "error" not in tr["result"]:
        #   - Get loc = tr["result"]
        #   - If loc has "latitude" and "longitude":
        #     - Append {"name": loc.get("name", "Unknown"), "lat": loc["latitude"], "lon": loc["longitude"]}
        #     to st.session_state.locations

        # TODO: Show reasoning steps in a collapsible expander
        # - Use "with st.expander("🔍 Agent Reasoning Steps"):"
        # - Inside, loop through tool_results with enumerate
        # - For each step, display: f"**Step {i+1}:** Called `{tr['tool']}` with `{tr['args']}`"
        # - Use st.json(tr["result"]) to show the result data

        # TODO: Display the final answer
        # - Use "with st.chat_message("assistant"):" and st.write(final_answer)
        # - Add a save button: st.button("💾 Save Finding", key=f"save_{len(st.session_state.chat_history)}")
        # - If button clicked:
        #   - from datetime import datetime
        #   - Create entry: f"**[{datetime.now().strftime('%H:%M:%S')}] Q:** {query}\n\n**A:** {final_answer}"
        #   - Append entry to st.session_state.notebook
        #   - Show st.success("Saved to notebook!")

        # TODO: Append assistant message to chat_history
        # - Append {"role": "assistant", "content": final_answer}
        pass
    else:
        # TODO: Chatbot mode - direct LLM conversation without tools
        # - Create endpoint: HuggingFaceEndpoint(
        #     repo_id="mistralai/Mistral-7B-Instruct-v0.3",
        #     huggingfacehub_api_token=os.getenv("HF_TOKEN"),
        #     task="text-generation",
        #     max_new_tokens=512
        #   )
        # - Wrap in: ChatHuggingFace(llm=endpoint)

        # TODO: Build the messages list
        # - Start with SystemMessage(content="You are an expert geography assistant. Answer questions clearly and informatively.")
        # - Loop through st.session_state.chat_history
        # - Append each entry as HumanMessage(content=msg["content"])

        # TODO: Invoke the LLM
        # - Use "with st.spinner("Thinking..."):" as context manager
        # - Call llm.invoke(messages)
        # - Get the answer from response.content

        # TODO: Display the answer and update chat_history
        # - Use "with st.chat_message("assistant"):" and st.write(answer)
        # - Append {"role": "assistant", "content": answer} to chat_history
        pass

Hints for your implementation

  • st.spinner() is a context manager: with st.spinner("text"): — the spinner shows while code inside runs.
  • st.expander() is also a context manager. Content inside only shows when the user clicks to expand it.
  • Use st.json() to render dictionaries as formatted JSON in the UI.
  • The Save Finding button needs a unique key parameter to prevent duplicate widget errors. Use the chat history length: key=f"save_{len(st.session_state.chat_history)}".
  • In chatbot mode, you convert chat_history entries into HumanMessage objects regardless of their original role. This gives the LLM the full conversation context.

Give it a go, then check your solution below.

✔️ I'll try it first

Fill in the skeleton above, then move on to test the full app.

ⓧ Let me check my work

Here's the complete query handling code:

if query:
    # Add user message to history and display it
    st.session_state.chat_history.append({"role": "user", "content": query})
    with st.chat_message("user"):
        st.write(query)

    if st.session_state.mode == "Research Agent":
        # Run the LangGraph agent with tool calling
        with st.spinner("Agent is researching..."):
            result = run_agent(query)
        tool_results = result.get("tool_results", [])
        final_answer = result.get("final_answer", "No answer produced.")
        # Track geocoded locations for the research map
        for tr in tool_results:
            if tr["tool"] == "geocode_location" and "error" not in tr["result"]:
                loc = tr["result"]
                if loc.get("latitude") and loc.get("longitude"):
                    st.session_state.locations.append({
                        "name": loc.get("name", "Unknown"),
                        "lat": loc["latitude"],
                        "lon": loc["longitude"]
                    })
        # Show reasoning steps in a collapsible expander
        with st.expander("🔍 Agent Reasoning Steps"):
            for i, tr in enumerate(tool_results):
                st.write(f"**Step {i+1}:** Called `{tr['tool']}` with `{tr['args']}`")
                st.json(tr["result"])
        # Display final answer with save button
        with st.chat_message("assistant"):
            st.write(final_answer)
            if st.button("💾 Save Finding", key=f"save_{len(st.session_state.chat_history)}"):
                from datetime import datetime
                entry = f"**[{datetime.now().strftime('%H:%M:%S')}] Q:** {query}\n\n**A:** {final_answer}"
                st.session_state.notebook.append(entry)
                st.success("Saved to notebook!")
        st.session_state.chat_history.append({"role": "assistant", "content": final_answer})
    else:
        # Chatbot mode: direct LLM conversation without tools
        endpoint = HuggingFaceEndpoint(
            repo_id="mistralai/Mistral-7B-Instruct-v0.3",
            huggingfacehub_api_token=os.getenv("HF_TOKEN"),
            task="text-generation",
            max_new_tokens=512
        )
        llm = ChatHuggingFace(llm=endpoint)
        messages = [SystemMessage(content="You are an expert geography assistant. Answer questions clearly and informatively.")]
        for msg in st.session_state.chat_history:
            messages.append(HumanMessage(content=msg["content"]) if msg["role"] == "user" else HumanMessage(content=msg["content"]))
        with st.spinner("Thinking..."):
            response = llm.invoke(messages)
        answer = response.content
        with st.chat_message("assistant"):
            st.write(answer)
        st.session_state.chat_history.append({"role": "assistant", "content": answer})
  • Save app.py and refresh your browser.
  • Type a question like What is the elevation of Kathmandu? into the chat input and press Enter.
  • You should see a spinner, then an expandable reasoning section showing tool calls, followed by the agent's final answer.

Agent taking too long or returning an error?

HuggingFace Inference Providers free tier can be slow during peak hours. If the request times out, wait a moment and try again. Check that your HF_TOKEN in .env is valid and has the "Make calls to Inference Providers" permission enabled in your HuggingFace settings.

Help me debug my agent connection

✔️ Awesome, I've got everything!

Great. Double check you have saved app.py and that your app runs without errors.

ⓧ Show me the complete file for this step

Here is the complete app.py as it should look at the end of this step. Compare your work against it:

import streamlit as st
import os
from dotenv import load_dotenv
from streamlit_mic_recorder import speech_to_text
from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
from langchain_core.messages import HumanMessage, SystemMessage
from agent import run_agent

load_dotenv()

st.set_page_config(page_title="Geo Research Agent", page_icon="🌍", layout="wide")

if "chat_history" not in st.session_state:
    st.session_state.chat_history = []
if "notebook" not in st.session_state:
    st.session_state.notebook = []
if "locations" not in st.session_state:
    st.session_state.locations = []
if "mode" not in st.session_state:
    st.session_state.mode = "Research Agent"

st.title("🌍 Geography Research Agent")

with st.sidebar:
    st.header("Settings")
    st.session_state.mode = st.radio("Mode", ["Research Agent", "Chatbot"], index=0)
    st.subheader("🎤 Voice Input")
    voice_text = speech_to_text(
        language="en",
        start_prompt="Start recording",
        stop_prompt="Stop recording",
        just_once=True,
        key="voice_input"
    )
    if voice_text:
        st.info(f"Voice detected: {voice_text}")

for msg in st.session_state.chat_history:
    with st.chat_message(msg["role"]):
        st.write(msg["content"])

query = st.chat_input("Ask a geography question...")
if voice_text and voice_text != st.session_state.get("last_voice", ""):
    query = voice_text
    st.session_state.last_voice = voice_text

if query:
    st.session_state.chat_history.append({"role": "user", "content": query})
    with st.chat_message("user"):
        st.write(query)

    if st.session_state.mode == "Research Agent":
        with st.spinner("Agent is researching..."):
            result = run_agent(query)
        tool_results = result.get("tool_results", [])
        final_answer = result.get("final_answer", "No answer produced.")
        for tr in tool_results:
            if tr["tool"] == "geocode_location" and "error" not in tr["result"]:
                loc = tr["result"]
                if loc.get("latitude") and loc.get("longitude"):
                    st.session_state.locations.append({
                        "name": loc.get("name", "Unknown"),
                        "lat": loc["latitude"],
                        "lon": loc["longitude"]
                    })
        with st.expander("🔍 Agent Reasoning Steps"):
            for i, tr in enumerate(tool_results):
                st.write(f"**Step {i+1}:** Called `{tr['tool']}` with `{tr['args']}`")
                st.json(tr["result"])
        with st.chat_message("assistant"):
            st.write(final_answer)
            if st.button("💾 Save Finding", key=f"save_{len(st.session_state.chat_history)}"):
                from datetime import datetime
                entry = f"**[{datetime.now().strftime('%H:%M:%S')}] Q:** {query}\n\n**A:** {final_answer}"
                st.session_state.notebook.append(entry)
                st.success("Saved to notebook!")
        st.session_state.chat_history.append({"role": "assistant", "content": final_answer})
    else:
        endpoint = HuggingFaceEndpoint(
            repo_id="mistralai/Mistral-7B-Instruct-v0.3",
            huggingfacehub_api_token=os.getenv("HF_TOKEN"),
            task="text-generation",
            max_new_tokens=512
        )
        llm = ChatHuggingFace(llm=endpoint)
        messages = [SystemMessage(content="You are an expert geography assistant. Answer questions clearly and informatively.")]
        for msg in st.session_state.chat_history:
            messages.append(HumanMessage(content=msg["content"]) if msg["role"] == "user" else HumanMessage(content=msg["content"]))
        with st.spinner("Thinking..."):
            response = llm.invoke(messages)
        answer = response.content
        with st.chat_message("assistant"):
            st.write(answer)
        st.session_state.chat_history.append({"role": "assistant", "content": answer})

Your chat interface is live. You can type questions, speak them with the microphone, and toggle between the tool-calling agent and the direct chatbot. Next up, you will add a research notebook so you can save and export your findings.

Add the Research Notebook

Your agent can research locations and deliver answers, but all that valuable information disappears the moment you scroll past it. A research tool without a way to capture findings is like a library without a pen.

In this step, you'll add a persistent Research Notebook to the sidebar where you can write manual notes, save agent findings with one click, and export everything as a downloadable markdown file.

In this step, get ready to:
  • Add a text area and "Save Note" button to the sidebar for manual notes.
  • Display all saved findings as an expandable list in the sidebar.
  • Add an export button that downloads all notes as a markdown file.
Add the export notebook button

Saved notes are useful during a session, but they disappear when you close the browser tab. An export button lets you download everything as a markdown file you can keep, share, or open in any text editor.

  • In app.py, find the st.markdown(entry) line inside the expander loop. Add the export code directly below the for loop (still inside the if st.session_state.notebook: block):
        # Generate a downloadable markdown file from all entries
        notebook_md = "# Geography Research Notebook\n\n" + "\n\n---\n\n".join(st.session_state.notebook)
        st.download_button(
            label="📥 Export Notebook",
            data=notebook_md,
            file_name="research_notebook.md",
            mime="text/markdown"
        )

What does this code do?

The code joins all notebook entries with a horizontal rule separator (---) into one markdown string. It prepends a title heading so the exported file reads like a proper document.

st.download_button() renders a clickable button that triggers a file download. The mime="text/markdown" tells the browser to treat it as a markdown file.

  • Save app.py and refresh your browser.
  • Save a test note, then click the **Export Notebook** button in the sidebar. A file called research_notebook.md should download to your computer.
  • Open the downloaded file in any text editor. You should see a heading followed by your timestamped note entries.

Export button not appearing?

The export button only appears when there's at least one entry in the notebook. Make sure you've clicked **Save Note** at least once first.

Check that the st.download_button() code is indented under if st.session_state.notebook: at the same level as the for loop (two levels of indentation inside the sidebar block).

Help me figure out why my Export Notebook button isn't working in Streamlit.

✔️ Awesome, I've got everything!

Great. Double check you've saved app.py and that the export button downloads a valid markdown file.

ⓧ I'd like to double check the full code

Here's the complete app.py file after all changes in this step:

import streamlit as st
import os
from datetime import datetime
from dotenv import load_dotenv
from streamlit_mic_recorder import speech_to_text
from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
from langchain_core.messages import HumanMessage, SystemMessage
from agent import run_agent

load_dotenv()

st.set_page_config(page_title="Geo Research Agent", page_icon="🌍", layout="wide")

if "chat_history" not in st.session_state:
    st.session_state.chat_history = []
if "notebook" not in st.session_state:
    st.session_state.notebook = []
if "locations" not in st.session_state:
    st.session_state.locations = []
if "mode" not in st.session_state:
    st.session_state.mode = "Research Agent"

st.title("🌍 Geography Research Agent")

with st.sidebar:
    st.header("Settings")
    st.session_state.mode = st.radio("Mode", ["Research Agent", "Chatbot"], index=0)
    st.subheader("🎤 Voice Input")
    voice_text = speech_to_text(
        language="en",
        start_prompt="Start recording",
        stop_prompt="Stop recording",
        just_once=True,
        key="voice_input"
    )
    if voice_text:
        st.info(f"Voice detected: {voice_text}")

    st.divider()
    st.header("📓 Research Notebook")
    manual_note = st.text_area("Write a note:", key="manual_note_input")
    if st.button("Save Note"):
        if manual_note.strip():
            entry = f"**[{datetime.now().strftime('%H:%M:%S')}] Note:** {manual_note.strip()}"
            st.session_state.notebook.append(entry)
            st.success("Note saved!")

    if st.session_state.notebook:
        st.subheader("Saved Findings & Notes")
        for i, entry in enumerate(st.session_state.notebook):
            with st.expander(f"Entry {i+1}"):
                st.markdown(entry)
        notebook_md = "# Geography Research Notebook\n\n" + "\n\n---\n\n".join(st.session_state.notebook)
        st.download_button(
            label="📥 Export Notebook",
            data=notebook_md,
            file_name="research_notebook.md",
            mime="text/markdown"
        )

for msg in st.session_state.chat_history:
    with st.chat_message(msg["role"]):
        st.write(msg["content"])

query = st.chat_input("Ask a geography question...")
if voice_text and voice_text != st.session_state.get("last_voice", ""):
    query = voice_text
    st.session_state.last_voice = voice_text

if query:
    st.session_state.chat_history.append({"role": "user", "content": query})
    with st.chat_message("user"):
        st.write(query)

    if st.session_state.mode == "Research Agent":
        with st.spinner("Agent is researching..."):
            result = run_agent(query)
        tool_results = result.get("tool_results", [])
        final_answer = result.get("final_answer", "No answer produced.")
        for tr in tool_results:
            if tr["tool"] == "geocode_location" and "error" not in tr["result"]:
                loc = tr["result"]
                if loc.get("latitude") and loc.get("longitude"):
                    st.session_state.locations.append({
                        "name": loc.get("name", "Unknown"),
                        "lat": loc["latitude"],
                        "lon": loc["longitude"]
                    })
        with st.expander("🔍 Agent Reasoning Steps"):
            for i, tr in enumerate(tool_results):
                st.write(f"**Step {i+1}:** Called `{tr['tool']}` with `{tr['args']}`")
                st.json(tr["result"])
        with st.chat_message("assistant"):
            st.write(final_answer)
            if st.button("💾 Save Finding", key=f"save_{len(st.session_state.chat_history)}"):
                entry = f"**[{datetime.now().strftime('%H:%M:%S')}] Q:** {query}\n\n**A:** {final_answer}"
                st.session_state.notebook.append(entry)
                st.success("Saved to notebook!")
        st.session_state.chat_history.append({"role": "assistant", "content": final_answer})
    else:
        endpoint = HuggingFaceEndpoint(
            repo_id="mistralai/Mistral-7B-Instruct-v0.3",
            huggingfacehub_api_token=os.getenv("HF_TOKEN"),
            task="text-generation",
            max_new_tokens=512
        )
        llm = ChatHuggingFace(llm=endpoint)
        messages = [SystemMessage(content="You are an expert geography assistant. Answer questions clearly and informatively.")]
        for msg in st.session_state.chat_history:
            messages.append(HumanMessage(content=msg["content"]) if msg["role"] == "user" else HumanMessage(content=msg["content"]))
        with st.spinner("Thinking..."):
            response = llm.invoke(messages)
        answer = response.content
        with st.chat_message("assistant"):
            st.write(answer)
        st.session_state.chat_history.append({"role": "assistant", "content": answer})

Your Research Notebook is fully wired. You can manually jot notes, save agent findings with one click, browse everything in collapsible entries, and export it all as a portable markdown file. Next up, you'll take on the Secret Mission and add an interactive map that plots every location you've researched.

Secret mission

Add an Interactive Research Map

Your agent already tracks every location it researches. What if you could see all those locations plotted on a live map, with session stats showing how much ground you've covered? Add a Research Map tab that visualizes your entire research session geographically.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses only free-tier APIs. There are no ongoing costs or persistent cloud resources to worry about.

Resources you used:

  • Local project folder geo-research-agent/ with all Python files, virtual environment, and .env.
  • HuggingFace API token stored in .env.

Keep everything running

No action needed. Choose this if you plan to keep experimenting with your geography research agent.

  • Your project folder stays on your Desktop.
  • Your HuggingFace free credits replenish monthly, so there is no cost accumulation.
  • The Open-Meteo and Elevation APIs are free with no usage caps that would incur cost.
  • To resume later, open a terminal in the geo-research-agent/ folder, activate your virtual environment, and run streamlit run app.py.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files stay intact for when you return.

  • Stop the Streamlit server by pressing Ctrl+C in the terminal where it is running.
  • Deactivate your virtual environment by running:
deactivate
  • Close VS Code if you are done for now.
  • Your project folder and all files remain on your Desktop. No data is lost.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your code and research notebook data.

  • Stop the Streamlit server by pressing Ctrl+C in the terminal if it is still running.
  • Delete the entire project folder by running:
cd ~/Desktop
rm -rf geo-research-agent
  • On Windows, run this instead:
cd ~\Desktop
rmdir /s /q geo-research-agent

Nice Work!

Nice Work!

You just built a voice-enabled geography research agent from scratch. Your app takes a spoken or typed question, reasons through which tools to call, gathers real-world data, and delivers a researched answer.

You've learned how to:

  • Built a LangGraph StateGraph with conditional routing between a reasoning node and a tool execution node, creating a real ReAct agent loop.
  • Created real-world geography tools calling free APIs (Open-Meteo Geocoding, Open-Meteo Weather, and Elevation API) and wired them into an agent that decides when and how to use each one.
  • Built a Streamlit chat interface with voice input, a dual-mode toggle between agent research and direct chatbot, and a research notebook for saving and exporting findings.
  • Secret Mission: Added an interactive research map that plots every location researched during your session with metric cards and a data table.

Ready to quiz yourself?