Build a Tourism AI Agent with Claude
Build an AI agent that calls live APIs to answer real-time tourism questions.
Introduction
30 Second Summary
Planning a trip to a new city means juggling weather forecasts, restaurant reviews, and attraction lists across a dozen browser tabs. What if you could ask one assistant and get a personalized answer pulled from live data?
In this project, you will build an AI agent that uses Claude's tool use capability to answer tourism questions with real-time weather and places data from free APIs.
What You'll Build
A terminal-based conversational AI agent that calls live APIs for geocoding, weather forecasts, and places of interest, then synthesizes the results into natural-language travel advice.
By the end of this project, you'll have:
- A Python script implementing a full agent loop that detects when Claude wants to call a tool, executes it, and feeds the result back.
- Three integrated API tools for geocoding, weather forecasting, and places search powered by free services.
- A trip planning mode that chains multiple tool calls into structured daily itineraries based on destination, budget, and interests.
- Secret Mission: Add a currency exchange tool and export itineraries to formatted Markdown files.
Are there any prerequisites?
You should be comfortable writing Python (loops, functions, dictionaries). You will also need an Anthropic API key and a free Geoapify API key, both of which you will set up in Step 1.
Before We Start
You are about to build an AI agent that answers real-time tourism questions by calling live APIs. Unlike a basic chatbot that only draws on its training data, your agent will use tool use to fetch weather forecasts and find restaurants, cafes, and attractions on the fly.
Before you start writing agent code, you need to install the Anthropic Python SDK, grab a free Geoapify API key for places data, and create the project file.
In this step, get ready to:
- Install the Anthropic Python SDK.
- Set up API keys for Claude and Geoapify.
- Create your project file and verify everything works.
Install the Anthropic SDK
The anthropic package gives your Python code access to Claude's API, including the tool-use feature that powers your agent.
- Install the package by running this command:
pip install anthropic
What does this install?
The anthropic package includes the SDK for calling Claude's API. It also bundles httpx as a dependency, which you will use later for making HTTP requests to weather and places APIs.
- Verify the installation by running:
python -c "from anthropic import Anthropic; print('OK')"
You should see OK printed in your terminal.
✔️ I see OK
The SDK is installed and ready to use.
ⓧ I see an error
If you see a ModuleNotFoundError, the package did not install correctly.
- Make sure you are using the same Python environment where you ran pip install anthropic.
- If you use a virtual environment, activate it first, then re-run the install command.
- Try pip3 install anthropic if your system has both Python 2 and Python 3.
Set up your API keys
Your agent needs two API keys: one for Claude (which you already have) and one for Geoapify's places data. The Geoapify free tier gives you 3,000 credits per day, which is more than enough for this project.
First, set your Anthropic API key as an environment variable so the SDK can find it automatically.
macOS
- Set your Anthropic key by running this command (replace the placeholder with your actual key):
export ANTHROPIC_API_KEY=your-anthropic-api-key-here
Windows
- Set your Anthropic key by running this command (replace the placeholder with your actual key):
set ANTHROPIC_API_KEY=your-anthropic-api-key-here
Why an environment variable?
The anthropic SDK reads ANTHROPIC_API_KEY automatically when you create a client. Storing it as an environment variable keeps your key out of your source code.
Next, create a free Geoapify account to get your places API key.
- Open https://myprojects.geoapify.com/register in your browser.
- Create a free account (no credit card required).
- After signing in, click Create a project on the My Projects page.
- Your first API key is generated automatically when the project is created.
- Copy the API key from the API Keys section of your project page.
Now set your Geoapify key as an environment variable.
macOS
- Set your Geoapify key by running this command (replace the placeholder with the key you just copied):
export GEOAPIFY_API_KEY=your-geoapify-api-key-here
Windows
- Set your Geoapify key by running this command (replace the placeholder with the key you just copied):
set GEOAPIFY_API_KEY=your-geoapify-api-key-here
Keep your terminal open
Environment variables set with export or set only last for the current terminal session. If you close and reopen your terminal, you will need to set them again.
Create your project file
With both keys set, you are ready to create the file where all your agent code will live.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir tourism-agent
cd tourism-agent
- Create the project file by running:
touch tourism_agent.py
- Confirm the file exists by running:
ls
You should see tourism_agent.py in the output.
Don't see the file?
Make sure you ran the touch command while inside the tourism-agent folder. Run pwd to check your current directory.
Help me find where my file was created.
Your environment is ready. Next up, you will define the tools that teach Claude how to fetch real-time weather and places data.
Set Up Imports and Initialize the Client
Your packages are installed and your API keys are configured as environment variables. Now it's time to write the foundation code that powers your tourism agent.
Every AI agent needs two things: a connection to its LLM and a set of instructions that define its personality. In this step, you'll set up both by initializing the Anthropic client and writing a system prompt that turns Claude into a travel expert.
In this step, get ready to:
- Import the required libraries and initialize the Anthropic client.
- Read your Geoapify API key from the environment.
- Define a system prompt that specializes Claude as a tourism assistant.
Add the imports and initialize the client
Before your agent can talk to Claude or call any APIs, it needs the right libraries loaded and a client connection ready. The anthropic SDK's client automatically reads your ANTHROPIC_API_KEY environment variable, so you don't need to pass the key directly in code.
- In tourism_agent.py, add the following imports and client setup:
import os
import json
import httpx
from anthropic import Anthropic
# Initialize the Anthropic client (reads ANTHROPIC_API_KEY from environment)
client = Anthropic()
# Read the Geoapify API key for places lookups
GEOAPIFY_KEY = os.environ.get("GEOAPIFY_API_KEY")
What does this code do?
- os and json are standard Python libraries for reading environment variables and working with JSON data from APIs.
- httpx is an HTTP client library that came bundled when you installed the anthropic package. You'll use it to call the weather and places APIs.
- Anthropic() creates a client connection to Claude. It automatically reads your ANTHROPIC_API_KEY from the environment.
- GEOAPIFY_KEY stores your Geoapify key so your places search function can authenticate later.
Define the system prompt
A system prompt is a set of instructions that shapes how Claude behaves throughout a conversation. For your tourism agent, the system prompt tells Claude to always use its tools for real-time data and to structure trip plans in a useful way.
- Add the system prompt below your GEOAPIFY_KEY line in tourism_agent.py:
SYSTEM_PROMPT = """You are an expert tourism AI assistant with access to real-time data tools.
You help travelers with:
- Current weather and forecasts for any destination
- Finding restaurants, cafes, attractions, and points of interest
- Planning complete trip itineraries
IMPORTANT RULES:
- Always use your tools to get real-time data. Never guess weather or make up place names.
- When asked about a city, first get its coordinates, then use those for weather/places lookups.
- For trip planning, gather weather data and search for multiple categories of places (restaurants, cafes, attractions, tourist sights) to build a comprehensive itinerary.
- Organize trip plans by day with morning, afternoon, and evening activities.
- Include practical details: weather expectations, what to wear, estimated walking distances.
- For budget trips, emphasize free attractions and affordable dining. For luxury, suggest premium experiences.
- Be conversational and enthusiastic about travel!"""
What does this system prompt do?
The system prompt acts as Claude's "job description" for every conversation in your agent. It establishes two critical behaviors:
- Always use tools for real-time data. This prevents Claude from hallucinating weather conditions or inventing place names. It forces the agent to call your APIs.
- Structure trip plans clearly. The rules about day-by-day organization and budget awareness ensure Claude produces useful, actionable itineraries.
- Save tourism_agent.py.
- Confirm the script runs without errors by running:
python -c "from anthropic import Anthropic; print('OK')"
You should see OK printed in the terminal with no import errors.
Seeing an import error?
- If you see ModuleNotFoundError: No module named 'anthropic', make sure you ran pip install anthropic in the same Python environment you're using to run the script.
- If you see an error about httpx, try reinstalling with pip install anthropic. The httpx library is bundled as a dependency.
Why isn't my import working?
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
import os
import json
import httpx
from anthropic import Anthropic
client = Anthropic()
GEOAPIFY_KEY = os.environ.get("GEOAPIFY_API_KEY")
SYSTEM_PROMPT = """You are an expert tourism AI assistant with access to real-time data tools.
You help travelers with:
- Current weather and forecasts for any destination
- Finding restaurants, cafes, attractions, and points of interest
- Planning complete trip itineraries
IMPORTANT RULES:
- Always use your tools to get real-time data. Never guess weather or make up place names.
- When asked about a city, first get its coordinates, then use those for weather/places lookups.
- For trip planning, gather weather data and search for multiple categories of places (restaurants, cafes, attractions, tourist sights) to build a comprehensive itinerary.
- Organize trip plans by day with morning, afternoon, and evening activities.
- Include practical details: weather expectations, what to wear, estimated walking distances.
- For budget trips, emphasize free attractions and affordable dining. For luxury, suggest premium experiences.
- Be conversational and enthusiastic about travel!"""
Your agent's foundation is in place. Next up, you'll define the tools that give Claude access to live weather and places data.
Define Tools and Implement API Functions
Your script already has the Anthropic client and system prompt ready to go. Now you need to give Claude the ability to actually do things.
In this step, you will define tool schemas that tell Claude what functions it can call, then implement the Python functions that execute those API calls for real. This is the bridge between Claude's reasoning and live data from the outside world.
In this step, get ready to:
- Define four tool schemas that describe each tool's purpose and parameters to Claude.
- Implement API execution functions for geocoding, weather, places, and trip planning.
- Build a dispatcher function that routes tool calls to the correct execution function.
Define the tool schemas
Claude needs a formal description of each tool before it can use one. These descriptions follow a JSON Schema format that tells Claude the tool's name, what it does, and what parameters it expects.
- In tourism_agent.py, add the following tools list directly below the SYSTEM_PROMPT string:
tools = [
{
"name": "get_coordinates",
"description": "Get the latitude and longitude coordinates for a city or place name. Use this first before calling weather or places tools.",
"input_schema": {
"type": "object",
"properties": {
"city_name": {
"type": "string",
"description": "The name of the city or place to geocode, e.g. 'Paris' or 'Tokyo'"
}
},
"required": ["city_name"]
}
},
{
"name": "get_weather_forecast",
"description": "Get current weather conditions and a 7-day daily forecast for a location. Returns temperature, precipitation, wind speed, and weather conditions.",
"input_schema": {
"type": "object",
"properties": {
"latitude": {
"type": "number",
"description": "Latitude of the location"
},
"longitude": {
"type": "number",
"description": "Longitude of the location"
}
},
"required": ["latitude", "longitude"]
}
},
{
"name": "search_places",
"description": "Search for places and points of interest near a location. Categories include: catering.restaurant, catering.cafe, tourism.attraction, tourism.sights, entertainment, sport, accommodation. You can combine categories with commas.",
"input_schema": {
"type": "object",
"properties": {
"latitude": {
"type": "number",
"description": "Latitude of the search center"
},
"longitude": {
"type": "number",
"description": "Longitude of the search center"
},
"categories": {
"type": "string",
"description": "Comma-separated place categories, e.g. 'catering.restaurant,catering.cafe' or 'tourism.attraction,tourism.sights'"
},
"radius": {
"type": "integer",
"description": "Search radius in meters (default 2000, max 50000)"
},
"limit": {
"type": "integer",
"description": "Maximum number of results to return (default 10, max 20)"
}
},
"required": ["latitude", "longitude", "categories"]
}
},
{
"name": "plan_trip",
"description": "Use this when the user wants a complete trip itinerary. It structures their request so you can then research the destination using other tools.",
"input_schema": {
"type": "object",
"properties": {
"destination": {
"type": "string",
"description": "The travel destination city"
},
"days": {
"type": "integer",
"description": "Number of days for the trip"
},
"budget": {
"type": "string",
"description": "Budget level: 'budget', 'moderate', or 'luxury'"
},
"interests": {
"type": "string",
"description": "Comma-separated interests, e.g. 'food,culture,nature,nightlife,architecture,history'"
}
},
"required": ["destination", "days"]
}
}
]
What does this code do?
Each dictionary in the tools list is a tool definition that Claude reads to understand what it can do. The name field is how Claude refers to the tool, description explains when to use it, and input_schema defines the parameters with their types.
Claude uses these descriptions to decide which tool to call and what arguments to pass. The better your descriptions, the smarter Claude's tool selection becomes.
- Save tourism_agent.py.
- You won't see a change yet. Claude will use these schemas in the next step when we build the agent loop.
Why define schemas separately from functions?
The schemas are what Claude sees. The execution functions (which you will write next) are what actually runs when Claude decides to call a tool. This separation means Claude never executes code directly. It only requests a tool call, and your code decides how to fulfill it.
Implement the API execution functions
Each tool schema needs a matching Python function that performs the real work. These functions call external APIs and return JSON strings that Claude can read and reason about.
- Below the tools list, add the geocoding function that converts city names to coordinates:
def execute_get_coordinates(city_name):
"""Geocode a city name to coordinates using Open-Meteo."""
with httpx.Client() as http:
response = http.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": city_name, "count": 3, "language": "en", "format": "json"}
)
data = response.json()
if "results" not in data or len(data["results"]) == 0:
return json.dumps({"error": f"Could not find coordinates for '{city_name}'"})
results = []
for r in data["results"]:
results.append({
"name": r.get("name"),
"country": r.get("country"),
"latitude": r.get("latitude"),
"longitude": r.get("longitude"),
"timezone": r.get("timezone"),
"population": r.get("population")
})
return json.dumps({"results": results})
What does this code do?
This function calls the Open-Meteo geocoding API at https://geocoding-api.open-meteo.com/v1/search. It takes a city name like "Paris" and returns up to 3 matching locations with their latitude, longitude, country, and timezone.
The httpx.Client() context manager handles HTTP connections cleanly. The function returns a JSON string because that is what Claude expects as a tool result.
- Save tourism_agent.py and test the geocoding function by running this command:
python -c "from tourism_agent import execute_get_coordinates; print(execute_get_coordinates('Paris'))"
- You should see JSON output containing Paris's latitude (approximately 48.85) and longitude (approximately 2.35).
Seeing a ModuleNotFoundError?
Make sure you are running the command from the same directory where tourism_agent.py lives. If you see an httpx import error, confirm you installed the anthropic package in Step 1. The httpx library is bundled as a dependency of the anthropic SDK.
help me debug why my geocoding function is failing
- Next, add the weather forecast function below execute_get_coordinates:
def execute_get_weather_forecast(latitude, longitude):
"""Fetch current weather and 7-day forecast from Open-Meteo."""
with httpx.Client() as http:
response = http.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": latitude,
"longitude": longitude,
"current": "temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m",
"daily": "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,sunrise,sunset",
"timezone": "auto",
"forecast_days": 7
}
)
data = response.json()
weather_codes = {
0: "Clear sky", 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
45: "Fog", 48: "Depositing rime 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 rain showers", 81: "Moderate rain showers", 82: "Violent rain showers",
95: "Thunderstorm", 96: "Thunderstorm with slight hail", 99: "Thunderstorm with heavy hail"
}
current = data.get("current", {})
daily = data.get("daily", {})
result = {
"current": {
"temperature_c": current.get("temperature_2m"),
"feels_like_c": current.get("apparent_temperature"),
"humidity_percent": current.get("relative_humidity_2m"),
"wind_speed_kmh": current.get("wind_speed_10m"),
"condition": weather_codes.get(current.get("weather_code"), "Unknown")
},
"daily_forecast": []
}
if daily.get("time"):
for i in range(len(daily["time"])):
result["daily_forecast"].append({
"date": daily["time"][i],
"condition": weather_codes.get(daily["weather_code"][i], "Unknown"),
"temp_max_c": daily["temperature_2m_max"][i],
"temp_min_c": daily["temperature_2m_min"][i],
"precipitation_mm": daily["precipitation_sum"][i],
"wind_max_kmh": daily["wind_speed_10m_max"][i],
"sunrise": daily["sunrise"][i],
"sunset": daily["sunset"][i]
})
return json.dumps(result)
What does this code do?
This function calls the Open-Meteo forecast API at https://api.open-meteo.com/v1/forecast with latitude and longitude. It requests current conditions (temperature, humidity, wind) and a 7-day daily forecast.
The weather_codes dictionary translates WMO numeric codes (like 0, 61, 95) into human-readable descriptions (like "Clear sky", "Slight rain", "Thunderstorm"). This gives Claude readable data to work with when describing weather to the user.
- Save tourism_agent.py.
- You won't test this one separately. It follows the same pattern as the geocoding function and you will verify it through the dispatcher at the end.
- Below execute_get_weather_forecast, add the places search function:
def execute_search_places(latitude, longitude, categories, radius=2000, limit=10):
"""Search for places using Geoapify Places API."""
limit = min(limit, 20)
radius = min(radius, 50000)
with httpx.Client() as http:
response = http.get(
"https://api.geoapify.com/v2/places",
params={
"categories": categories,
"filter": f"circle:{longitude},{latitude},{radius}",
"limit": limit,
"apiKey": GEOAPIFY_KEY
}
)
data = response.json()
places = []
for feature in data.get("features", []):
props = feature.get("properties", {})
places.append({
"name": props.get("name", "Unnamed"),
"category": props.get("categories", []),
"address": props.get("formatted", ""),
"distance_m": props.get("distance"),
"opening_hours": props.get("opening_hours"),
"website": props.get("website")
})
return json.dumps({"places": places, "total_found": len(places)})
What does this code do?
This function searches the Geoapify Places API for points of interest near a given location. It accepts categories like catering.restaurant or tourism.sights to filter what kind of places to return.
The filter parameter uses the format circle:longitude,latitude,radius. Notice that longitude comes first. This is a common gotcha with the Geoapify API.
- Save tourism_agent.py.
- You won't test this separately yet. The dispatcher test at the end of this step will verify it.
Worried about the GEOAPIFY_KEY variable?
Make sure your GEOAPIFY_API_KEY environment variable is set from Step 1. The function reads it through the GEOAPIFY_KEY variable defined at the top of your file.
help me check that my Geoapify API key is correctly loaded
- Finally, add the trip planning function below execute_search_places:
def execute_plan_trip(destination, days, budget="moderate", interests=""):
"""Return structured trip planning context for the agent."""
plan_context = {
"destination": destination,
"days": days,
"budget": budget,
"interests": interests if interests else "general sightseeing, local food, culture",
"instructions": (
f"Plan a {days}-day trip to {destination}. "
f"Budget level: {budget}. Interests: {interests or 'general sightseeing, local food, culture'}. "
"Now use get_coordinates to find the location, get_weather_forecast to check conditions, "
"and search_places to find relevant attractions, restaurants, and cafes. "
"Then synthesize everything into a day-by-day itinerary."
)
}
return json.dumps(plan_context)
What does this code do?
Unlike the other tools, execute_plan_trip does not call an external API. It returns a structured instruction that Claude reads as a "plan of action." When Claude receives this result, it will follow the instructions and chain multiple calls to the other tools (coordinates, weather, places) on its own.
This is a powerful pattern. Instead of writing complex orchestration code yourself, you give Claude a structured context and let its reasoning handle the multi-step research.
- Save tourism_agent.py.
Build the tool dispatcher
When Claude decides to call a tool, it sends back the tool name and input arguments. You need a single function that routes each tool name to the correct execution function.
- Below execute_plan_trip, add the dispatcher function:
def execute_tool(tool_name, tool_input):
"""Dispatch tool execution to the appropriate function."""
if tool_name == "get_coordinates":
return execute_get_coordinates(tool_input["city_name"])
elif tool_name == "get_weather_forecast":
return execute_get_weather_forecast(tool_input["latitude"], tool_input["longitude"])
elif tool_name == "search_places":
return execute_search_places(
tool_input["latitude"],
tool_input["longitude"],
tool_input["categories"],
tool_input.get("radius", 2000),
tool_input.get("limit", 10)
)
elif tool_name == "plan_trip":
return execute_plan_trip(
tool_input["destination"],
tool_input["days"],
tool_input.get("budget", "moderate"),
tool_input.get("interests", "")
)
else:
return json.dumps({"error": f"Unknown tool: {tool_name}"})
What does this code do?
The execute_tool function is a simple router. It takes the tool name Claude requested and the input parameters Claude provided, then calls the matching execution function. The else clause handles unexpected tool names gracefully by returning an error message.
Notice how tool_input.get("radius", 2000) provides defaults for optional parameters. Claude might omit these, so the dispatcher fills in sensible defaults.
- Save tourism_agent.py and test the full dispatcher by running this command:
python -c "from tourism_agent import execute_tool; print(execute_tool('get_coordinates', {'city_name': 'Tokyo'}))"
- You should see JSON output with Tokyo's coordinates (latitude approximately 35.68, longitude approximately 139.69).
Seeing a NameError or KeyError?
A NameError usually means one of the execution functions is not defined above execute_tool in the file. Make sure all four execute_ functions appear before the dispatcher.
A KeyError means the input dictionary is missing a required key. Double-check that you are passing city_name (not city or name) in the test command.
help me debug my execute_tool dispatcher function
✔️ Awesome, I've got everything!
Great. Double check you have saved tourism_agent.py with all six new code additions (the tools list plus five functions).
ⓧ I'd like to double check the full code
import os
import json
import httpx
from anthropic import Anthropic
client = Anthropic()
GEOAPAFY_KEY = os.environ.get("GEOAPIFY_API_KEY")
SYSTEM_PROMPT = """You are an expert tourism AI assistant with access to real-time data tools.
You help travelers with:
- Current weather and forecasts for any destination
- Finding restaurants, cafes, attractions, and points of interest
- Planning complete trip itineraries
IMPORTANT RULES:
- Always use your tools to get real-time data. Never guess weather or make up place names.
- When asked about a city, first get its coordinates, then use those for weather/places lookups.
- For trip planning, gather weather data and search for multiple categories of places (restaurants, cafes, attractions, tourist sights) to build a comprehensive itinerary.
- Organize trip plans by day with morning, afternoon, and evening activities.
- Include practical details: weather expectations, what to wear, estimated walking distances.
- For budget trips, emphasize free attractions and affordable dining. For luxury, suggest premium experiences.
- Be conversational and enthusiastic about travel!"""
tools = [
{
"name": "get_coordinates",
"description": "Get the latitude and longitude coordinates for a city or place name. Use this first before calling weather or places tools.",
"input_schema": {
"type": "object",
"properties": {
"city_name": {
"type": "string",
"description": "The name of the city or place to geocode, e.g. 'Paris' or 'Tokyo'"
}
},
"required": ["city_name"]
}
},
{
"name": "get_weather_forecast",
"description": "Get current weather conditions and a 7-day daily forecast for a location. Returns temperature, precipitation, wind speed, and weather conditions.",
"input_schema": {
"type": "object",
"properties": {
"latitude": {
"type": "number",
"description": "Latitude of the location"
},
"longitude": {
"type": "number",
"description": "Longitude of the location"
}
},
"required": ["latitude", "longitude"]
}
},
{
"name": "search_places",
"description": "Search for places and points of interest near a location. Categories include: catering.restaurant, catering.cafe, tourism.attraction, tourism.sights, entertainment, sport, accommodation. You can combine categories with commas.",
"input_schema": {
"type": "object",
"properties": {
"latitude": {
"type": "number",
"description": "Latitude of the search center"
},
"longitude": {
"type": "number",
"description": "Longitude of the search center"
},
"categories": {
"type": "string",
"description": "Comma-separated place categories, e.g. 'catering.restaurant,catering.cafe' or 'tourism.attraction,tourism.sights'"
},
"radius": {
"type": "integer",
"description": "Search radius in meters (default 2000, max 50000)"
},
"limit": {
"type": "integer",
"description": "Maximum number of results to return (default 10, max 20)"
}
},
"required": ["latitude", "longitude", "categories"]
}
},
{
"name": "plan_trip",
"description": "Use this when the user wants a complete trip itinerary. It structures their request so you can then research the destination using other tools.",
"input_schema": {
"type": "object",
"properties": {
"destination": {
"type": "string",
"description": "The travel destination city"
},
"days": {
"type": "integer",
"description": "Number of days for the trip"
},
"budget": {
"type": "string",
"description": "Budget level: 'budget', 'moderate', or 'luxury'"
},
"interests": {
"type": "string",
"description": "Comma-separated interests, e.g. 'food,culture,nature,nightlife,architecture,history'"
}
},
"required": ["destination", "days"]
}
}
]
def execute_get_coordinates(city_name):
"""Geocode a city name to coordinates using Open-Meteo."""
with httpx.Client() as http:
response = http.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": city_name, "count": 3, "language": "en", "format": "json"}
)
data = response.json()
if "results" not in data or len(data["results"]) == 0:
return json.dumps({"error": f"Could not find coordinates for '{city_name}'"})
results = []
for r in data["results"]:
results.append({
"name": r.get("name"),
"country": r.get("country"),
"latitude": r.get("latitude"),
"longitude": r.get("longitude"),
"timezone": r.get("timezone"),
"population": r.get("population")
})
return json.dumps({"results": results})
def execute_get_weather_forecast(latitude, longitude):
"""Fetch current weather and 7-day forecast from Open-Meteo."""
with httpx.Client() as http:
response = http.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": latitude,
"longitude": longitude,
"current": "temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m",
"daily": "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,sunrise,sunset",
"timezone": "auto",
"forecast_days": 7
}
)
data = response.json()
weather_codes = {
0: "Clear sky", 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
45: "Fog", 48: "Depositing rime 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 rain showers", 81: "Moderate rain showers", 82: "Violent rain showers",
95: "Thunderstorm", 96: "Thunderstorm with slight hail", 99: "Thunderstorm with heavy hail"
}
current = data.get("current", {})
daily = data.get("daily", {})
result = {
"current": {
"temperature_c": current.get("temperature_2m"),
"feels_like_c": current.get("apparent_temperature"),
"humidity_percent": current.get("relative_humidity_2m"),
"wind_speed_kmh": current.get("wind_speed_10m"),
"condition": weather_codes.get(current.get("weather_code"), "Unknown")
},
"daily_forecast": []
}
if daily.get("time"):
for i in range(len(daily["time"])):
result["daily_forecast"].append({
"date": daily["time"][i],
"condition": weather_codes.get(daily["weather_code"][i], "Unknown"),
"temp_max_c": daily["temperature_2m_max"][i],
"temp_min_c": daily["temperature_2m_min"][i],
"precipitation_mm": daily["precipitation_sum"][i],
"wind_max_kmh": daily["wind_speed_10m_max"][i],
"sunrise": daily["sunrise"][i],
"sunset": daily["sunset"][i]
})
return json.dumps(result)
def execute_search_places(latitude, longitude, categories, radius=2000, limit=10):
"""Search for places using Geoapify Places API."""
limit = min(limit, 20)
radius = min(radius, 50000)
with httpx.Client() as http:
response = http.get(
"https://api.geoapify.com/v2/places",
params={
"categories": categories,
"filter": f"circle:{longitude},{latitude},{radius}",
"limit": limit,
"apiKey": GEOAPIFY_KEY
}
)
data = response.json()
places = []
for feature in data.get("features", []):
props = feature.get("properties", {})
places.append({
"name": props.get("name", "Unnamed"),
"category": props.get("categories", []),
"address": props.get("formatted", ""),
"distance_m": props.get("distance"),
"opening_hours": props.get("opening_hours"),
"website": props.get("website")
})
return json.dumps({"places": places, "total_found": len(places)})
def execute_plan_trip(destination, days, budget="moderate", interests=""):
"""Return structured trip planning context for the agent."""
plan_context = {
"destination": destination,
"days": days,
"budget": budget,
"interests": interests if interests else "general sightseeing, local food, culture",
"instructions": (
f"Plan a {days}-day trip to {destination}. "
f"Budget level: {budget}. Interests: {interests or 'general sightseeing, local food, culture'}. "
"Now use get_coordinates to find the location, get_weather_forecast to check conditions, "
"and search_places to find relevant attractions, restaurants, and cafes. "
"Then synthesize everything into a day-by-day itinerary."
)
}
return json.dumps(plan_context)
def execute_tool(tool_name, tool_input):
"""Dispatch tool execution to the appropriate function."""
if tool_name == "get_coordinates":
return execute_get_coordinates(tool_input["city_name"])
elif tool_name == "get_weather_forecast":
return execute_get_weather_forecast(tool_input["latitude"], tool_input["longitude"])
elif tool_name == "search_places":
return execute_search_places(
tool_input["latitude"],
tool_input["longitude"],
tool_input["categories"],
tool_input.get("radius", 2000),
tool_input.get("limit", 10)
)
elif tool_name == "plan_trip":
return execute_plan_trip(
tool_input["destination"],
tool_input["days"],
tool_input.get("budget", "moderate"),
tool_input.get("interests", "")
)
else:
return json.dumps({"error": f"Unknown tool: {tool_name}"})
Your tools are defined and wired up. Next, you will build the agent loop that actually sends messages to Claude, detects when it wants to call a tool, and feeds the results back until Claude produces a final answer.
Build the Agent Loop and Chat Interface
Your tools are defined and your API functions are ready. Now you need the core logic that actually makes this an AI agent instead of a collection of functions.
The key difference between a chatbot and an agent is autonomy. An agent decides which tools to call, executes them, reads the results, and keeps going until it has enough information to answer. In this step, you will build the loop that gives Claude that autonomy.
In this step, get ready to:
- Understand how the system prompt guides Claude's tool-calling behavior.
- Implement the agentic loop that handles tool calls until Claude produces a final answer.
- Add an interactive chat interface with conversation memory.
Review the system prompt
Your tourism_agent.py already contains a SYSTEM_PROMPT that you defined in the previous step. This prompt is what transforms Claude from a general-purpose model into a focused tourism expert.
What does the system prompt do?
The system prompt sets three important boundaries for Claude:
- It tells Claude to always use tools for real-time data instead of guessing from training knowledge.
- It establishes a workflow: get coordinates first, then use those for weather or places lookups.
- It provides formatting guidance for trip plans (day-by-day, morning/afternoon/evening structure).
Implement the agent loop
The agent loop is the core pattern behind every production AI agent. You send a message to Claude, check whether it wants to call a tool, execute the tool if so, feed the results back, and repeat until Claude responds with plain text.
- In tourism_agent.py, scroll to the bottom of the file (below the execute_tool function) and add the following run_agent_turn function:
def run_agent_turn(messages):
"""Run one full agent turn, handling all tool calls until a final text response."""
while True:
# Send the conversation to Claude with our tools
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=4096,
system=SYSTEM_PROMPT,
tools=tools,
messages=messages
)
# Check if Claude wants to use a tool
if response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if block.type == "tool_use":
print(f" [Calling tool: {block.name}...]")
result = execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result
})
# Add Claude's response and tool results to conversation history
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
else:
# Claude is done calling tools, extract the final text
messages.append({"role": "assistant", "content": response.content})
final_text = ""
for block in response.content:
if hasattr(block, "text"):
final_text += block.text
return final_text
What does this code do?
- The while True loop keeps running until Claude gives a text-only response (no tool calls). This is what makes the agent autonomous.
- When response.stop_reason == "tool_use", Claude is saying "I need to call a tool before I can answer." The code executes the tool and sends the result back.
- Tool results are sent back as a tool_result message with the matching tool_use_id so Claude knows which result belongs to which request.
- Once Claude stops requesting tools, the function extracts the final text response and returns it.
Why a loop instead of a single call?
For a question like "What's the weather in Tokyo?", Claude needs TWO tool calls: first get_coordinates to find Tokyo's latitude/longitude, then get_weather_forecast with those coordinates. Each call is a separate iteration of the loop.
Without the loop, you would need to hard-code these multi-step chains. The loop lets Claude decide its own sequence of calls.
You won't see a change yet. The next function uses run_agent_turn to power the interactive chat.
Add the interactive chat interface
The agent loop handles a single turn of conversation. Now you need a chat interface that keeps the conversation going across multiple questions.
- Below your run_agent_turn function, add the main function and the script entry point:
def main():
"""Main chat loop."""
print("=" * 60)
print(" Tourism AI Agent")
print(" Ask me anything about travel, weather, food, or attractions!")
print(" Type 'plan' to start trip planning, 'quit' to exit.")
print("=" * 60)
print()
# Conversation history persists across turns
messages = []
while True:
user_input = input("You: ").strip()
# Skip empty inputs
if not user_input:
continue
# Handle exit commands
if user_input.lower() in ("quit", "exit", "q"):
print("Safe travels! Goodbye.")
break
# Add user message to history and run the agent
messages.append({"role": "user", "content": user_input})
print()
response_text = run_agent_turn(messages)
print(f"\nAgent: {response_text}\n")
if __name__ == "__main__":
main()
What does this code do?
- The messages list stores the full conversation history. This means Claude remembers what you asked earlier and can reference previous answers.
- Each user input is appended to messages before calling run_agent_turn. The agent turn also appends Claude's responses, so the history grows naturally.
- The if __name__ == "__main__" guard ensures main() only runs when you execute the script directly.
- Save tourism_agent.py.
- Run your agent by executing this command in your terminal:
python tourism_agent.py
- When you see the You: prompt, type:
What's the weather in London?
You should see the agent print tool-call messages like [Calling tool: get_coordinates...] and [Calling tool: get_weather_forecast...], followed by a natural-language response with real weather data for London.
Not getting a response?
- If you see an authentication error, confirm your ANTHROPIC_API_KEY environment variable is set correctly in the same terminal session where you run the script.
- If you see a connection error mentioning Open-Meteo, check your internet connection. The geocoding and weather APIs require network access.
- If the script exits immediately with a syntax error, double-check that run_agent_turn and main are defined at the top level (no extra indentation).
Help me debug my tourism agent - it's not returning a response when I ask about weather.
✔️ Awesome, I've got everything!
Great. Double check you have saved tourism_agent.py before moving on.
ⓧ I'd like to double check the full code
import os
import json
import httpx
from anthropic import Anthropic
client = Anthropic()
GEOAPAFY_KEY = os.environ.get("GEOAPIFY_API_KEY")
SYSTEM_PROMPT = """You are an expert tourism AI assistant with access to real-time data tools.
You help travelers with:
- Current weather and forecasts for any destination
- Finding restaurants, cafes, attractions, and points of interest
- Planning complete trip itineraries
IMPORTANT RULES:
- Always use your tools to get real-time data. Never guess weather or make up place names.
- When asked about a city, first get its coordinates, then use those for weather/places lookups.
- For trip planning, gather weather data and search for multiple categories of places (restaurants, cafes, attractions, tourist sights) to build a comprehensive itinerary.
- Organize trip plans by day with morning, afternoon, and evening activities.
- Include practical details: weather expectations, what to wear, estimated walking distances.
- For budget trips, emphasize free attractions and affordable dining. For luxury, suggest premium experiences.
- Be conversational and enthusiastic about travel!"""
tools = [
{
"name": "get_coordinates",
"description": "Get the latitude and longitude coordinates for a city or place name. Use this first before calling weather or places tools.",
"input_schema": {
"type": "object",
"properties": {
"city_name": {
"type": "string",
"description": "The name of the city or place to geocode, e.g. 'Paris' or 'Tokyo'"
}
},
"required": ["city_name"]
}
},
{
"name": "get_weather_forecast",
"description": "Get current weather conditions and a 7-day daily forecast for a location. Returns temperature, precipitation, wind speed, and weather conditions.",
"input_schema": {
"type": "object",
"properties": {
"latitude": {
"type": "number",
"description": "Latitude of the location"
},
"longitude": {
"type": "number",
"description": "Longitude of the location"
}
},
"required": ["latitude", "longitude"]
}
},
{
"name": "search_places",
"description": "Search for places and points of interest near a location. Categories include: catering.restaurant, catering.cafe, tourism.attraction, tourism.sights, entertainment, sport, accommodation. You can combine categories with commas.",
"input_schema": {
"type": "object",
"properties": {
"latitude": {
"type": "number",
"description": "Latitude of the search center"
},
"longitude": {
"type": "number",
"description": "Longitude of the search center"
},
"categories": {
"type": "string",
"description": "Comma-separated place categories, e.g. 'catering.restaurant,catering.cafe' or 'tourism.attraction,tourism.sights'"
},
"radius": {
"type": "integer",
"description": "Search radius in meters (default 2000, max 50000)"
},
"limit": {
"type": "integer",
"description": "Maximum number of results to return (default 10, max 20)"
}
},
"required": ["latitude", "longitude", "categories"]
}
},
{
"name": "plan_trip",
"description": "Use this when the user wants a complete trip itinerary. It structures their request so you can then research the destination using other tools.",
"input_schema": {
"type": "object",
"properties": {
"destination": {
"type": "string",
"description": "The travel destination city"
},
"days": {
"type": "integer",
"description": "Number of days for the trip"
},
"budget": {
"type": "string",
"description": "Budget level: 'budget', 'moderate', or 'luxury'"
},
"interests": {
"type": "string",
"description": "Comma-separated interests, e.g. 'food,culture,nature,nightlife,architecture,history'"
}
},
"required": ["destination", "days"]
}
}
]
def execute_get_coordinates(city_name):
"""Geocode a city name to coordinates using Open-Meteo."""
with httpx.Client() as http:
response = http.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": city_name, "count": 3, "language": "en", "format": "json"}
)
data = response.json()
if "results" not in data or len(data["results"]) == 0:
return json.dumps({"error": f"Could not find coordinates for '{city_name}'"})
results = []
for r in data["results"]:
results.append({
"name": r.get("name"),
"country": r.get("country"),
"latitude": r.get("latitude"),
"longitude": r.get("longitude"),
"timezone": r.get("timezone"),
"population": r.get("population")
})
return json.dumps({"results": results})
def execute_get_weather_forecast(latitude, longitude):
"""Fetch current weather and 7-day forecast from Open-Meteo."""
with httpx.Client() as http:
response = http.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": latitude,
"longitude": longitude,
"current": "temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m",
"daily": "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,sunrise,sunset",
"timezone": "auto",
"forecast_days": 7
}
)
data = response.json()
weather_codes = {
0: "Clear sky", 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
45: "Fog", 48: "Depositing rime 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 rain showers", 81: "Moderate rain showers", 82: "Violent rain showers",
95: "Thunderstorm", 96: "Thunderstorm with slight hail", 99: "Thunderstorm with heavy hail"
}
current = data.get("current", {})
daily = data.get("daily", {})
result = {
"current": {
"temperature_c": current.get("temperature_2m"),
"feels_like_c": current.get("apparent_temperature"),
"humidity_percent": current.get("relative_humidity_2m"),
"wind_speed_kmh": current.get("wind_speed_10m"),
"condition": weather_codes.get(current.get("weather_code"), "Unknown")
},
"daily_forecast": []
}
if daily.get("time"):
for i in range(len(daily["time"])):
result["daily_forecast"].append({
"date": daily["time"][i],
"condition": weather_codes.get(daily["weather_code"][i], "Unknown"),
"temp_max_c": daily["temperature_2m_max"][i],
"temp_min_c": daily["temperature_2m_min"][i],
"precipitation_mm": daily["precipitation_sum"][i],
"wind_max_kmh": daily["wind_speed_10m_max"][i],
"sunrise": daily["sunrise"][i],
"sunset": daily["sunset"][i]
})
return json.dumps(result)
def execute_search_places(latitude, longitude, categories, radius=2000, limit=10):
"""Search for places using Geoapify Places API."""
limit = min(limit, 20)
radius = min(radius, 50000)
with httpx.Client() as http:
response = http.get(
"https://api.geoapify.com/v2/places",
params={
"categories": categories,
"filter": f"circle:{longitude},{latitude},{radius}",
"limit": limit,
"apiKey": GEOAPIFY_KEY
}
)
data = response.json()
places = []
for feature in data.get("features", []):
props = feature.get("properties", {})
places.append({
"name": props.get("name", "Unnamed"),
"category": props.get("categories", []),
"address": props.get("formatted", ""),
"distance_m": props.get("distance"),
"opening_hours": props.get("opening_hours"),
"website": props.get("website")
})
return json.dumps({"places": places, "total_found": len(places)})
def execute_plan_trip(destination, days, budget="moderate", interests=""):
"""Return structured trip planning context for the agent."""
plan_context = {
"destination": destination,
"days": days,
"budget": budget,
"interests": interests if interests else "general sightseeing, local food, culture",
"instructions": (
f"Plan a {days}-day trip to {destination}. "
f"Budget level: {budget}. Interests: {interests or 'general sightseeing, local food, culture'}. "
"Now use get_coordinates to find the location, get_weather_forecast to check conditions, "
"and search_places to find relevant attractions, restaurants, and cafes. "
"Then synthesize everything into a day-by-day itinerary."
)
}
return json.dumps(plan_context)
def execute_tool(tool_name, tool_input):
"""Dispatch tool execution to the appropriate function."""
if tool_name == "get_coordinates":
return execute_get_coordinates(tool_input["city_name"])
elif tool_name == "get_weather_forecast":
return execute_get_weather_forecast(tool_input["latitude"], tool_input["longitude"])
elif tool_name == "search_places":
return execute_search_places(
tool_input["latitude"],
tool_input["longitude"],
tool_input["categories"],
tool_input.get("radius", 2000),
tool_input.get("limit", 10)
)
elif tool_name == "plan_trip":
return execute_plan_trip(
tool_input["destination"],
tool_input["days"],
tool_input.get("budget", "moderate"),
tool_input.get("interests", "")
)
else:
return json.dumps({"error": f"Unknown tool: {tool_name}"})
def run_agent_turn(messages):
"""Run one full agent turn, handling all tool calls until a final text response."""
while True:
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=4096,
system=SYSTEM_PROMPT,
tools=tools,
messages=messages
)
if response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if block.type == "tool_use":
print(f" [Calling tool: {block.name}...]")
result = execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result
})
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
else:
messages.append({"role": "assistant", "content": response.content})
final_text = ""
for block in response.content:
if hasattr(block, "text"):
final_text += block.text
return final_text
def main():
"""Main chat loop."""
print("=" * 60)
print(" Tourism AI Agent")
print(" Ask me anything about travel, weather, food, or attractions!")
print(" Type 'plan' to start trip planning, 'quit' to exit.")
print("=" * 60)
print()
messages = []
while True:
user_input = input("You: ").strip()
if not user_input:
continue
if user_input.lower() in ("quit", "exit", "q"):
print("Safe travels! Goodbye.")
break
messages.append({"role": "user", "content": user_input})
print()
response_text = run_agent_turn(messages)
print(f"\nAgent: {response_text}\n")
if __name__ == "__main__":
main()
Your agent is alive. It can answer questions about weather in any city using real-time data. Next up, you will add trip planning so the agent chains multiple tool calls into structured daily itineraries.
Secret mission
Add Currency Conversion and Markdown Export
Your tourism agent is already powerful, but real travelers need two more things: knowing how much things cost in their own currency, and a shareable itinerary they can take offline. In this secret mission, you'll add a live currency exchange tool and automatic Markdown file export to make your agent production-ready.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs locally and uses pay-per-call APIs, so there are no ongoing recurring costs.
Resources you used:
- Local project files (tourism_agent.py and any generated itinerary_*.md files).
- Geoapify free account and API key (3,000 credits/day, no charge).
- Anthropic API usage (charged per API call only, typically $0.01-0.05 per conversation with Claude Haiku 4.5).
- Environment variables (ANTHROPIC_API_KEY and GEOAPIFY_API_KEY).
Keep everything running
No action needed. Choose this if you plan to keep experimenting with your tourism agent.
- Your Geoapify account persists at no cost. The free tier gives you 3,000 credits per day.
- Anthropic charges only when you make API calls. No idle charges apply.
- Your local files stay on your machine until you manually delete them.
Pause - I'll come back to this later
No action needed. There are no running processes or recurring charges to pause.
- Your Python script only runs when you explicitly execute it. There is nothing running in the background.
- Geoapify and Anthropic both charge per request only. If you stop making calls, you stop incurring costs.
Delete - I don't want to use this again
Remove all project files and revoke your Geoapify API key.
- Delete your local project files by running this command from the folder containing tourism_agent.py:
- On macOS/Linux:
rm tourism_agent.py itinerary_*.md
- On Windows (PowerShell):
Remove-Item tourism_agent.py, itinerary_*.md
- Revoke your Geoapify API key by going to https://myprojects.geoapify.com. Open the project you created, then delete or regenerate the API key.
- Unset your environment variables for the current terminal session:
unset ANTHROPIC_API_KEY
unset GEOAPIFY_API_KEY
- Your Anthropic API key remains valid for other projects. Only revoke it from the Anthropic console if you no longer need it at all.
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully functional AI tourism agent powered by Claude Haiku 4.5 that answers real-time travel questions using live data from multiple APIs.
You've learned how to:
- Built a fully functional AI agent that autonomously decides which tools to call and chains multiple API calls to synthesize comprehensive travel answers.
- Implemented three live-data tools (geocoding, weather, and places search) using free APIs from Open-Meteo and Geoapify.
- Mastered the agentic loop pattern: send a message, detect tool calls, execute tools, feed results back, and repeat until the model responds with text.
- Created a trip planning system that chains multiple tool calls into structured day-by-day itineraries with real place names and weather context.
- Secret Mission: Added currency conversion and Markdown export for production-quality itinerary output saved to a formatted file.
Ready to quiz yourself?