Skip to main content

Command Palette

Search for a command to run...

Adding Multiple Tools to the Agent: SQLite, Web Search, and Live Weather

Updated
8 min read
Adding Multiple Tools to the Agent: SQLite, Web Search, and Live Weather
G

Backend Engineer & AI/ML Developer passionate about building scalable APIs, cloud systems, and LLM-powered applications. Sharing insights on Python, Django, FastAPI, LangChain, and deploying AI in production. I love writing about: Backend Engineering: Python, Django, FastAPI, REST APIs, Celery, PostgreSQL, AWS, Docker AI/ML Applications: LLMs, LangChain, Prompt Engineering, NLP, Vector Databases, MLOps Scaling Products: Payment integration, asynchronous systems, and performance optimization On this blog, I’ll share lessons learned, tutorials, and real-world case studies from my journey building production-ready backends and AI applications. My goal is to make complex concepts practical, actionable, and beginner-friendly — especially for engineers looking to move from theory to real-world deployment.

How the LLM knows what tools exist

Before we look at the tools themselves, it's worth understanding what the LLM actually sees. When you call llm.bind_tools(ALL_TOOLS), LangChain introspects each tool and generates a JSON schema for it , this is a structured description the model can reason about. That schema is built from three main things: the function's name, its type-annotated parameters, and its docstring.

Here's what the schema for events_database_tool looks like to the LLM:

{   
  "name": "events_database_tool",   
  "description": "Query the local SQLite database for events matching a city name.",   
  "parameters": {     
    "type": "object",     
    "properties": {       
      "city": {         
        "type": "string",         
        "description": "Name of the city to search for (partial matches supported)."       
      }     
    },     
    "required": ["city"]   
  } 
}

The LLM uses this schema to decide: does this tool match what the user is asking for? If yes, what argument should I pass? The description and parameter descriptions are the only signal it has. We want to always make the docstring as clear as possible to the LLM, as this is a big deciding factor during a tool call.

A clear description makes it easier for the LLM to pick which tool is appropriate for a particular user action when needed.

Tool 1: The events database

The first tool queries a local SQLite database for events in a given city. It's the most self-contained of the three — no external API, no network dependency, just a file on disk.

# agent/tools.py
import json, os, sqlite3
from langchain_core.tools import tool
from agent.config import DB_PATH

@tool
def events_database_tool(city: str) -> str:
    """Query the local SQLite database for events matching a city name.

    Args:
        city: Name of the city to search for (partial matches supported).

    Returns:
        JSON string with matching events or an error message.
    """
    if not os.path.exists(DB_PATH):
        return json.dumps({"error": f"Database not found at {DB_PATH}"})

    try:
        conn = sqlite3.connect(DB_PATH)
        cur  = conn.cursor()
        cur.execute(
            "SELECT * FROM local_events WHERE city LIKE ? LIMIT 10",
            (f"%{city}%",),
        )
        cols   = [desc[0] for desc in cur.description]
        rows   = cur.fetchall()
        conn.close()
        events = [dict(zip(cols, row)) for row in rows]
        if not events:
            return json.dumps({"message": f"No events found for '{city}'."} )
        return json.dumps({"events": events, "count": len(events)})
    except Exception as exc:
        return json.dumps({"error": str(exc)})

Key decisions :

LIKE with wildcards for partial matching. The query uses WHERE city LIKE ? with the argument wrapped in %{city}%. This means "Lagos" matches "Lagos Island", and "Lagos, Nigeria". The LLM's extracted city name from natural language is rarely a perfectly formatted database key, partial matching makes the tool resilient to that.

LIMIT 10. SQLite will happily return thousands of rows. Dumping thousands of events into the message context is wasteful and potentially breaks context limits. Ten results is enough for the LLM to give a useful answer; if the user needs more, they can refine their query.

Two distinct return shapes. An empty result returns {"message": "No events found..."} rather than an empty events list. This matters because a JSON string like {"events": [], "count": 0} looks like a data response, while the message variant makes it unambiguous to the LLM that nothing was found and it should say so explicitly.

Connection opened and closed per call. There's no persistent connection or connection pool. For a CLI agent making occasional queries, this is fine. In a high-throughput API scenario you'd want to reconsider .

Tool 2: Web search via Tavily

Tavily is a search API, it returns clean, summarised results rather than raw HTML, which means less noise in the context window.

# agent/tools.py

from agent.config import TAVILY_API_KEY

@tool
def search_tool(query: str) -> str:
    """Search the web for information using Tavily.

    Args:
        query: The search query string.

    Returns:
        JSON string with search results or an error message.
    """
    if not TAVILY_API_KEY:
        return json.dumps({"error": "TAVILY_API_KEY not configured in .env"})

    try:
        from tavily import TavilyClient
        client  = TavilyClient(api_key=TAVILY_API_KEY)
        results = client.search(query)
        return json.dumps({"results": results})
    except Exception as exc:
        return json.dumps({"error": str(exc)})

Tavily's client.search(query) does the heavy lifting, it handles result ranking, and content extraction. The tool's job is to check the key exists, call the API, and return the result as a JSON string.

The query is the LLM's output directly. Whatever search string the LLM constructs becomes the query. The LLM is generally good at this. If you ever see off-target search results, the LLM's query formulation is the first place to inspect.

Tool 3: Live weather via OpenWeatherMap

The weather tool uses pyowm, a Python wrapper around the OpenWeatherMap API. It fetches current conditions for a city and returns them as structured JSON:

# agent/tools.py
from agent.config import OPENWEATHERMAP_API_KEY

@tool
def weather_tool(city: str) -> str:
    """Fetch current weather for a city using OpenWeatherMap.
    
    Args:
        city: Name of the city to get weather for.
    
    Returns:
        JSON string with temperature, humidity, wind, and description.
    """
    if not OPENWEATHERMAP_API_KEY:
        return json.dumps({"error": "OPENWEATHERMAP_API_KEY not configured"})
    
    try:
        import pyowm
        owm = pyowm.OWM(OPENWEATHERMAP_API_KEY)
        mgr = owm.weather_manager()
        observation = mgr.weather_at_place(city)
        w = observation.weather
        return json.dumps({"weather": {
            "city": city,
            "status": w.detailed_status,
            "temperature_celsius": w.temperature("celsius"),
            "humidity": w.humidity,
            "wind": w.wind(),
            "clouds": w.clouds,
        }})
    except Exception as exc:
        return json.dumps({"error": str(exc)})

The pyowm library abstracts away OpenWeatherMap's REST API. mgr.weather_at_place(city) does a geocoded lookup , you pass a city name as a string and it handles converting that to coordinates and fetching the weather data.

The return shape is deliberate. Rather than passing w.temperature("celsius") raw (which returns a dict like {"temp": 28.4, "temp_max": 31.0, "temp_min": 26.1}), we return it under a named key inside a structured object. This gives the LLM clear labels to refer to, reducing the chance of it confusing temperature with humidity or wind speed. City name ambiguity.

weather_at_place picks the first geocoding match for a city name. "Lagos" returns Lagos, Nigeria, which is what we want. For a global events agent this is a known limitation; a production version would pass coordinates or a city ID instead of a raw name.

The lazy mport. import pyowm lives inside the try block, so a missing dependency surfaces as a structured error rather than a startup crash, just like we did in the Tavily tool.

Wiring the tools to the graph

The last piece is ALL_TOOLS : how the three tools get connected to both the LLM and the ToolNode. In tools.py, it's just a list at the bottom of the file:

# agent/tools.py
ALL_TOOLS = [events_database_tool, search_tool, weather_tool]

And in graph.py, that list serves two distinct purposes:

# agent/graph.py (excerpt)
from agent.tools import ALL_TOOLS
llm_with_tools = llm.bind_tools(ALL_TOOLS) # 1. LLM learns the tool schemas
builder.add_node("tools", ToolNode(ALL_TOOLS)) # 2. Graph knows how to execute them

bind_tools sends the JSON schemas to the LLM so it can reason about which tool to call and with what arguments. It doesn't execute anything , it just makes the LLM tool-aware.

ToolNode holds the actual function references. When the LLM returns a message with a tool call, ToolNode looks up the function by name, calls it with the provided arguments, and wraps the string result in a ToolMessage before appending it to state.

This separation is why the tool list appears in both places. The LLM needs the schemas. The graph needs the callables. Both get ALL_TOOLS . schema extraction and function reference happen independently.

What good tool design looks like in practice

Looking across all three tools, a few consistent patterns emerge that are worth making explicit:

Every tool returns a string.

 LangGraph's ToolNode expects tools to return strings. Returning a dict directly would cause a serialisation error. Everything gets json.dumps()'d before returning.

Every tool guards its dependencies upfront. 

API key present? Database file exists? These checks happen at the top of each function and return a structured error immediately if they fail. The tool never half-executes.

Every tool catches all exceptions. 

The broad except Exception as exc at the bottom of each tool is intentional. Any unexpected failure like network timeout, malformed response, library bug,  becomes a structured JSON error rather than a traceback that kills the graph.

Docstrings describe behaviour, not implementation. 

The docstring the LLM reads should answer: what does this tool do, what argument does it take, and what does it return? It should not describe how the SQLite query works internally. Write for the model, not the reader of the source code.

What's next

The agent is now fully assembled with the  state, graph, and all three tools. You've seen every line of code that matters, and more importantly you've seen the reasoning behind each decision.

repo link : Here

Part 4 is where put all together and look at what we would do if we are to move this into a production environment, adding logging,monitoring and retries for external API calls, and other deployment cleanups.