Build a Weather Tool
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:
| Concern | Technique | Lesson |
|---|---|---|
| Clear tool definition | @beta_tool with a complete docstring | 91, 93 |
| Valid inputs | Type hints, range checks, helpful errors | 94 |
| External service failures | Timeouts, and errors written for Claude | 86, 90 |
| Concise, structured results | Selected fields as JSON | 82, 86 |
| Repeated identical calls | A 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:
| Endpoint | Purpose | Example |
|---|---|---|
Geocoding: https://geocoding-api.open-meteo.com/v1/search | Turns a place name into coordinates | ?name=Chennai&count=1 |
Forecast: https://api.open-meteo.com/v1/forecast | Weather 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
| Parameter | Type | Why |
|---|---|---|
city | str | The natural way users ask ("in Chennai") |
days | int, default 1, range 1–3 | Today 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
| Failure | Message for Claude |
|---|---|
| Place not found | Explains the problem and suggests checking the spelling |
| Service unreachable or slow | Says the service is unavailable, and suggests answering without current weather |
Invalid days | States 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
get_json()builds the URL withurllib.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 aWeatherErrorwith a message written for Claude.from errorkeeps the original exception attached for your logs.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.fetch_weather()requests only the fields the tool needs: current conditions, plus a daily forecast fordaysdays, withtimezone=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 bydescribe().zip(...)combines Open-Meteo's parallel daily lists, one list per variable, into one dictionary per day.get_weather()is the tool Claude sees. It validatesdays, checks the cache, and callsfetch_weather(). Cached results expire after 10 minutes, measured withtime.monotonic()(Lesson 78).- The runner lets Claude call
get_weatherfor both cities, usually in parallel withdays=2to include tomorrow, then compare the results. - 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
- Call
fetch_weather("Chennai", 2)directly and print the result withjson.dumps(..., indent=2). - Add an optional
unitsparameter withLiteral["metric", "imperial"], and pass Open-Meteo'stemperature_unit=fahrenheitandwind_speed_unit=mphwhenimperialis chosen. Update the field names accordingly. - Simulate a network failure by changing
FORECAST_URLto an invalid host, and ask the runner about the weather. How does Claude respond? - Explain why the cache key includes
days.
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.