Build a Weather Tool

Su Suriya Ravichandran Updated 10 Nov 2026
8 min read ·Lesson 98 of 200

From Fake Data to a Real Service

The weather tools in earlier lessons returned hard-coded data. This lesson builds a real weather tool that calls a public weather API, and applies everything from the Tool Use and Tool Helpers sections to a realistic tool:

ConcernTechniqueLesson
Clear tool definition@beta_tool with a complete docstring91, 93
Valid inputsType hints, range checks, helpful errors94
External service failuresTimeouts, and errors written for Claude86, 90
Concise, structured resultsSelected fields as JSON82, 86
Repeated identical callsA short-lived cache(this lesson)

The Weather API: Open-Meteo

Open-Meteo provides free weather data without an API key, which makes it convenient for learning. It has two endpoints this tool uses:

EndpointPurposeExample
Geocoding: https://geocoding-api.open-meteo.com/v1/searchTurns a place name into coordinates?name=Chennai&count=1
Forecast: https://api.open-meteo.com/v1/forecastWeather for coordinates?latitude=13.08&longitude=80.27¤t=temperature_2m,weather_code

Weather APIs usually need latitude and longitude, not city names, so the tool first geocodes the city, then requests the weather.

Weather conditions come as numeric WMO weather codes, such as 3 for overcast and 61 for light rain. The tool converts them to words, so Claude does not need to know the code table.

Note: Open-Meteo is free for non-commercial use, with fair-use limits. Commercial applications need a subscription; check the current terms on its website. The same tool design works with any weather provider.


Design Decisions

Inputs

ParameterTypeWhy
citystrThe natural way users ask ("in Chennai")
daysint, default 1, range 1–3Today only by default; short forecasts when asked

A small days range keeps results short and predictable.

Output

The tool returns compact JSON with only what Claude needs: the resolved place name (so Claude can confirm it found the right place), current conditions, and a short daily forecast. Units are part of the field names (temperature_c, wind_kmh), so they cannot be misread.

Failures

FailureMessage for Claude
Place not foundExplains the problem and suggests checking the spelling
Service unreachable or slowSays the service is unavailable, and suggests answering without current weather
Invalid daysStates the allowed range

Every network request has a timeout, so a slow service cannot hang the conversation (Lesson 86).

Caching

Weather changes slowly. A cache that remembers results for 10 minutes avoids repeated calls for the same city, for example when several users ask about Chennai within a few minutes, or Claude asks twice in one run. That makes answers faster and reduces load on the free service.


Practical Example: weather_tool.py

This example uses only Python's standard library for HTTP (urllib), so no extra packages are needed.

"""weather_tool.py: a real weather tool using the free Open-Meteo API."""

import json
import time
import urllib.parse
import urllib.request
from urllib.error import HTTPError, URLError

from anthropic import Anthropic, beta_tool

GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
TIMEOUT_SECONDS = 8
CACHE_SECONDS = 600
_cache: dict[str, tuple[float, str]] = {}

WEATHER_CODES = {
    0: "clear sky", 1: "mainly clear", 2: "partly cloudy", 3: "overcast", 45: "fog", 48: "fog",
    51: "light drizzle", 53: "drizzle", 55: "heavy drizzle", 61: "light rain", 63: "rain", 65: "heavy rain",
    71: "light snow", 73: "snow", 75: "heavy snow", 80: "light showers", 81: "showers", 82: "heavy showers",
    95: "thunderstorm", 96: "thunderstorm with hail", 99: "thunderstorm with hail",
}


class WeatherError(Exception):
    """A weather problem, with a message written for Claude."""


def get_json(url: str, params: dict) -> dict:
    full_url = f"{url}?{urllib.parse.urlencode(params)}"
    request = urllib.request.Request(full_url, headers={"User-Agent": "gocourse-tutorial/1.0"})
    try:
        with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response:
            return json.load(response)
    except (HTTPError, URLError, TimeoutError) as error:
        raise WeatherError("The weather service is not reachable right now. "
                           "Try again later, or answer without current weather.") from error


def describe(code: int) -> str:
    return WEATHER_CODES.get(code, f"weather code {code}")


def find_place(city: str) -> dict:
    data = get_json(GEOCODING_URL, {"name": city, "count": 1, "language": "en", "format": "json"})
    results = data.get("results") or []
    if not results:
        raise WeatherError(f"No place found for {city!r}. Check the spelling, or try a nearby larger city.")
    return results[0]


def fetch_weather(city: str, days: int) -> dict:
    place = find_place(city)
    data = get_json(FORECAST_URL, {
        "latitude": place["latitude"],
        "longitude": place["longitude"],
        "current": "temperature_2m,relative_humidity_2m,precipitation,weather_code,wind_speed_10m",
        "daily": "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max",
        "timezone": "auto",
        "forecast_days": days,
    })
    current, daily = data["current"], data["daily"]
    return {
        "place": ", ".join(p for p in (place.get("name"), place.get("admin1"), place.get("country")) if p),
        "current": {
            "time": current["time"],
            "temperature_c": current["temperature_2m"],
            "humidity_percent": current["relative_humidity_2m"],
            "precipitation_mm": current["precipitation"],
            "conditions": describe(current["weather_code"]),
            "wind_kmh": current["wind_speed_10m"],
        },
        "forecast": [
            {"date": day, "min_c": low, "max_c": high, "rain_chance_percent": rain, "conditions": describe(code)}
            for day, low, high, rain, code in zip(
                daily["time"], daily["temperature_2m_min"], daily["temperature_2m_max"],
                daily["precipitation_probability_max"], daily["weather_code"])
        ],
    }


@beta_tool
def get_weather(city: str, days: int = 1) -> str:
    """Get current weather and a short daily forecast for a city.

    Use it when the user asks about current conditions, today's or the next few days' weather,
    or whether the weather suits an activity. Do not use it for climate averages or historical weather.
    Returns JSON with the resolved place name, current conditions (Celsius, km/h, mm), and a
    daily forecast with min/max temperature, rain chance, and conditions.

    Args:
        city: City or town name, e.g. 'Chennai' or 'Bengaluru'
        days: Number of forecast days, from 1 (today only) to 3; defaults to 1
    """
    if not 1 <= days <= 3:
        raise WeatherError(f"days must be between 1 and 3, not {days}.")
    key = f"{city.strip().lower()}|{days}"
    cached = _cache.get(key)
    if cached and time.monotonic() - cached[0] < CACHE_SECONDS:
        return cached[1]
    result = json.dumps(fetch_weather(city.strip(), days), ensure_ascii=False)
    _cache[key] = (time.monotonic(), result)
    return result


if __name__ == "__main__":
    runner = Anthropic().beta.messages.tool_runner(
        model="claude-opus-5-5",
        max_tokens=4096,
        max_iterations=5,
        system="You are a helpful assistant. Use the weather tool for weather questions. Be brief.",
        tools=[get_weather],
        messages=[{"role": "user", "content":
                   "I'm choosing between Chennai and Bengaluru for an outdoor workshop tomorrow evening. "
                   "Which looks better?"}],
    )
    final = runner.until_done()
    print("".join(b.text for b in final.content if b.type == "text"))

Example output (the answer depends on the actual weather when you run it):

Bengaluru looks better. Tomorrow it's forecast at 19–27°C with partly cloudy skies and a 20% chance of rain, while Chennai is expected to reach 32°C with a 70% chance of rain. If the workshop is outdoors, Bengaluru is the safer choice.

How It Works

  1. get_json() builds the URL with urllib.parse.urlencode(), which escapes values safely, sends the request with a timeout, and parses the JSON response. Any network problem, HTTP error, or timeout becomes a WeatherError with a message written for Claude. from error keeps the original exception attached for your logs.
  2. find_place() geocodes the city with the first (best) match. When nothing is found, it raises a clear error, so Claude can ask the user to check the spelling.
  3. fetch_weather() requests only the fields the tool needs: current conditions, plus a daily forecast for days days, with timezone=auto, so dates are in the place's local time. It reshapes the API response into a smaller structure with readable field names, units in the names, and conditions converted from WMO codes to words by describe().
  4. zip(...) combines Open-Meteo's parallel daily lists, one list per variable, into one dictionary per day.
  5. get_weather() is the tool Claude sees. It validates days, checks the cache, and calls fetch_weather(). Cached results expire after 10 minutes, measured with time.monotonic() (Lesson 78).
  6. The runner lets Claude call get_weather for both cities, usually in parallel with days=2 to include tomorrow, then compare the results.
  7. Place names in the result, such as "Chennai, Tamil Nadu, India", let Claude confirm that the geocoder found the intended place, which matters for names that exist in several countries.

Note: this tool runs synchronous network requests. In async applications, use an async HTTP client with @beta_async_tool (Lesson 91), so weather requests do not block the event loop.


Common Mistakes

Network Calls Without Timeouts

What happens: a slow weather service hangs the whole conversation.

Fix: set a timeout on every request.

Returning the Raw API Response

What happens: large, unfamiliar JSON with numeric weather codes and unlabeled units.

Fix: reshape the response into a small, clear structure.

Silent Geocoding Mistakes

What happens: "Paris" resolves to a different Paris than the user meant, and nobody notices.

Fix: return the resolved place name with region and country.

Exposing Technical Errors

What happens: Claude receives URLError: [Errno -3], and may repeat it to the user.

Fix: convert service failures into clear, safe messages, and keep the details in your logs.

No Caching for Slow-Changing Data

What happens: unnecessary requests, slower answers, and possible rate limiting by the provider.

Fix: cache results briefly.


Best Practices

  • Wrap external APIs behind a small, well-described tool.
  • Use timeouts on every network call.
  • Return concise, labeled results, with units in field names.
  • Convert codes into words before returning them.
  • Write service errors for Claude, and log technical details separately.
  • Cache slow-changing data for a short time.
  • Respect the provider's terms and usage limits.

Exercise

  1. Call fetch_weather("Chennai", 2) directly and print the result with json.dumps(..., indent=2).
  2. Add an optional units parameter with Literal["metric", "imperial"], and pass Open-Meteo's temperature_unit=fahrenheit and wind_speed_unit=mph when imperial is chosen. Update the field names accordingly.
  3. Simulate a network failure by changing FORECAST_URL to an invalid host, and ask the runner about the weather. How does Claude respond?
  4. Explain why the cache key includes days.
Show answers for steps 2 and 4

Step 2: add the parameter and adjust the request:

@beta_tool
def get_weather(city: str, days: int = 1, units: Literal["metric", "imperial"] = "metric") -> str:
    ...

# In fetch_weather(), when units == "imperial":
params["temperature_unit"] = "fahrenheit"
params["wind_speed_unit"] = "mph"
params["precipitation_unit"] = "inch"

Rename the result fields to match, for example temperature_f and wind_mph, and include units in the cache key. Field names that state units prevent Claude from misreporting 77 °F as 77 °C.

Step 4: a result for days=1 contains only today's forecast. If the cache key were just the city, a later request for days=3 could receive the cached one-day result and miss two days of forecast. Every input that changes the result must be part of the cache key.

0 Comments

Reviewed before they appear

No comments yet.

Anthropic SDK Using Python
Anthropic SDK Intro Claude API Intro SDK Requirements Install Anthropic SDK Upgrade the SDK Create an API Key Set API Keys Use Environment Variables Import Anthropic Create a Client Send Your First Message Choose a Claude Model Set Max Tokens Read a Response Print Response Text Understand Message Blocks Use System Prompts Send User Messages Set Temperature Handle Stop Reasons Messages API Intro Create Messages Use Message Roles Send Conversation History Continue a Conversation Build a Chat Loop Reset Chat History Use Multiple Turns Inspect Message IDs Read Usage Data Write Clear Prompts Set a System Prompt Add Context to Prompts Use Prompt Templates Add Examples to Prompts Request JSON Output Use XML Tags Control Response Length Improve Prompt Quality Avoid Prompt Injection Read Text Blocks Read Content Blocks Check Stop Reasons Count Input Tokens Count Output Tokens Count Tokens Before Sending Limit Output Tokens Use Stop Sequences Read Request IDs Parse API Responses Streaming Intro Stream Text Output Use Stream Events Use Stream Helpers Get Final Messages Stream Async Responses Handle Stream Errors Build a Live Chat Stream Long Responses Close Streams Safely Build a Chatbot Keep Chat History Manage Chat Sessions Add Chat Memory Build a CLI Chatbot Build a Web Chatbot Use Flask with Claude Use FastAPI with Claude Return JSON from an API Add Chat Error Messages Async SDK Intro Create an Async Client Send Async Messages Use asyncio Run Parallel Requests Stream Async Text Handle Async Errors Set Async Timeouts Close Async Clients Choose Sync or Async Tool Use Intro Define a Tool Create Tool Schemas Send Tools to Claude Read Tool Calls Run Python Tools Return Tool Results Continue After Tool Use Control Tool Choice Handle Tool Errors Use the beta_tool Decorator Create a Tool Runner Add Tool Docstrings Validate Tool Inputs Use Multiple Tools Limit Tool Calls Log Tool Activity Build a Weather Tool Build a Search Tool Build a Calculator Tool Structured Output Intro Request JSON Data Validate JSON Output Use Pydantic Models Handle Invalid JSON Extract Fields from Text Generate Typed Data Use Image URLs Send Base64 Images Read PDFs with Claude Use Output Schemas Build Data Extraction Test Structured Responses Send Image Inputs Upload Files Use File IDs Process Text Files Summarize Documents Extract Table Data Handle File Errors Read Cache Usage Reduce Repeated Input Costs Debug Cache Misses Prompt Caching Intro Enable Prompt Caching Cache System Prompts Cache Long Context Test Cache Performance Use Cache Breakpoints Cache Best Practices Message Batches Intro Create a Message Batch Set Batch IDs Check Batch Status List Message Batches Get Batch Results Process Batch Outputs Handle Failed Batch Items Use Batches for Bulk Tasks Compare Batches and Requests Handle API Errors Handle Rate Limits Retry Failed Requests Set Request Timeouts Handle Network Errors Use Exponential Backoff Build Reliable API Calls Use Extended Thinking Log API Errors Track Request IDs Paginate API Results Set Thinking Budgets Read Thinking Blocks Use Tool Search Use Web Search Tools Use Computer Tools Use Citations Use Token Counting Read Raw Responses Set Custom Headers Use Claude on AWS Bedrock Use Claude on Vertex AI Use Claude on Microsoft Foundry Configure Cloud Credentials Choose a Cloud Provider Compare API Integrations Configure Provider Clients Handle Cloud Errors Use Cloud SDK Extras Deploy Cloud Apps Protect API Keys Use a .env File Ignore Secrets in Git Validate User Input Reduce Prompt Injection Limit Tool Permissions Avoid Unsafe Tool Calls Sanitize Tool Results Set Usage Limits Review Security Logs Test SDK Functions Mock API Responses Test Error Handling Test Streaming Code Measure Token Usage Track API Costs Configure Logging Deploy with Docker Deploy a FastAPI App Monitor Production Apps Build a Claude Chatbot Build a Text Summarizer Build a Code Explainer Build a JSON Extractor Build a PDF Assistant Build a Streaming Chat App Build a Tool-Calling Agent Build a Batch Processor Build a Research Assistant Build a Claude API Dashboard
Ask about this post
AI Ask about this post

Ask questions about Build a Weather Tool and get answers drawn from it.

Signed-in readers only.