Build a Weather Assistant with Claude
Build a Python chatbot that uses Claude tool use to answer weather questions.
Introduction
30 Second Summary
When you ask a voice assistant for the weather, it feels like magic. Behind the scenes, the AI is calling external tools to fetch real data, then weaving the results into a natural response.
In this project, you will build a terminal-based weather assistant powered by Claude. You will define custom tools, teach Claude when to call them, and implement the agentic loop pattern that sits at the core of every AI agent.
What You'll Build
A Python chatbot that uses the Anthropic API to autonomously decide which tools to call, execute them, and reason over the results to answer weather questions.
By the end of this project, you'll have:
- Two custom tool definitions with JSON Schema input validation that Claude uses to fetch weather data and convert temperatures.
- A complete agentic loop that handles single-step and multi-step tool calls, the core building block of every production AI agent.
- An interactive chat interface in the terminal where you can ask questions like "What's the weather in Tokyo in Fahrenheit?" and watch Claude chain multiple tool calls to answer.
- Secret Mission: add a third tool and enable parallel tool use so Claude can fetch weather for multiple cities in a single turn.
Are there any prerequisites?
You should be comfortable with basic Python concepts like variables, functions, dictionaries, and loops. No prior experience with the Claude API or tool use is needed.
Before We Start
Before We Start...
Set Up Your Environment
Your weather assistant will be a Python script that communicates with Claude through the Anthropic API.
Before writing any code, you need to install the right tools and get your credentials ready. By the end of this step, you'll have Python, the Anthropic SDK, an API key, and a project directory ready to go.
In this step, get ready to:
- Install Python and the Anthropic SDK.
- Create an Anthropic account and generate your API key.
- Set your API key and create the project file.
Install Python and the Anthropic SDK
Python runs your script. The Anthropic SDK is the library that lets your Python code talk to Claude's API.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check that Python is installed by running this command:
python3 --version
✔️ I see a version number
You're good to go. Make sure the version is 3.9 or higher (e.g. Python 3.11.4).
ⓧ Command not found
Python isn't installed yet. Follow the instructions for your operating system below.
macOS
- Install Python using Homebrew by running this command:
brew install python3
If you don't have Homebrew installed, download Python directly from python.org/downloads instead.
Windows
- Download the latest Python installer from python.org/downloads.
- Run the installer. Check the Add python.exe to PATH checkbox at the bottom of the first screen before clicking Install Now.
- Close and reopen PowerShell, then run python3 --version again. If that doesn't work, try python --version instead.
- Install the Anthropic Python SDK by running this command:
pip3 install anthropic
- You should see output ending with Successfully installed anthropic in your terminal.
Seeing a permission error or pip3 not found?
- If you see a permission error, try running pip3 install --user anthropic instead.
- If pip3 is not found, try python3 -m pip install anthropic.
- help me install the Anthropic SDK
Create an Anthropic account and get your API key
To call Claude from your code, you need an API key. This is a secret string that authenticates your requests to the Anthropic API.
- Open platform.claude.com in your browser.
- Create a free account or sign in if you already have one.
- Click on Account Settings in the sidebar.
- Navigate to the API Keys section.
- Click Create Key to generate a new API key.
- Copy the key immediately. You will not be able to see it again after you close this dialog.
Why can't I see the key again?
For security, Anthropic only shows you the full API key once at creation time. Paste it somewhere safe (like a text file or password manager) before closing the dialog. If you lose it, you can always generate a new one.
You also need prepaid credits so your API calls go through. This project uses Claude Haiku 4.5, which costs very little.
- Navigate to the Billing section in your account settings.
- Add a credit card and purchase at least $5 in credits (the minimum amount).
How much will this project cost?
This project uses Claude Haiku 4.5, the most affordable model at $1 per million input tokens and $5 per million output tokens. Your total spend for this project will be well under $0.10. The $5 minimum purchase leaves you with plenty of credits for future experiments.
Set your API key and create the project file
The Anthropic SDK looks for an environment variable called ANTHROPIC_API_KEY to authenticate your requests. Setting it as an environment variable keeps the key out of your code.
- Set your API key as an environment variable by running this command (replace your-api-key-here with the key you copied):
macOS
export ANTHROPIC_API_KEY='your-api-key-here'
Windows
$env:ANTHROPIC_API_KEY = "your-api-key-here"
- Verify the key is set by running:
macOS
echo $ANTHROPIC_API_KEY
Windows
echo $env:ANTHROPIC_API_KEY
You should see your API key printed in the terminal.
Persist your API key across sessions
The export command only lasts for the current terminal session. To keep it available every time you open a new terminal, add the export line to your ~/.zshrc file (macOS) or your PowerShell profile (Windows).
Now create a project folder to hold your code. All terminal commands for this project will run from this folder.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder, move into it, and create the main Python file by running these commands:
macOS
mkdir weather-assistant
cd weather-assistant
touch weather_assistant.py
Windows
mkdir weather-assistant
cd weather-assistant
New-Item weather_assistant.py
- Confirm the file exists by running:
ls
You should see weather_assistant.py listed in the output.
Don't see the file?
- Make sure you ran cd weather-assistant before running touch or New-Item. If you created the file in the wrong folder, delete it and try again from inside weather-assistant.
- Check that you're inside the weather-assistant folder by running pwd. The output should end with /weather-assistant.
- help me create my project folder and file
Your environment is ready. Next up, you'll define custom tool schemas and write the functions that Claude will call to get weather data.
Define Your Tool Schemas and Simulated Tools
Your environment is set up and your project file is ready. Now it's time to teach Claude about the tools it can work with.
Claude doesn't call your Python functions directly. Instead, you provide tool schemas that describe each tool's name, purpose, and expected inputs. Claude reads these schemas and decides on its own when to call each tool. In this step, you'll define two tool schemas and build the Python functions that back them.
In this step, get ready to:
- Define two tool schemas that describe your weather and temperature conversion tools.
- Write simulated Python functions that return mock weather data and perform real temperature conversions.
- Create a dispatcher function to route Claude's tool calls to the correct function.
Define the tool schemas
Each tool schema is a Python dictionary with three key fields: a name, a description, and an input_schema that follows JSON Schema. Claude reads these schemas to decide when and how to call each tool.
- Open weather_assistant.py in your text editor and copy the following code into it:
import json
import anthropic
# --- Tool Definitions ---
tools = [
{
"name": "get_weather",
"description": (
"Get the current weather conditions for a specific location. "
"Returns the current temperature, weather condition, humidity, "
"and wind speed. Use this tool when a user asks about current "
"weather or conditions in a city or location. The temperature "
"is returned in the unit specified (celsius or fahrenheit)."
),
"input_schema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and country, e.g. 'San Francisco, US' or 'Tokyo, Japan'",
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "The temperature unit to use. Defaults to celsius if not specified.",
},
},
"required": ["location"],
},
"strict": True,
},
{
"name": "convert_temperature",
"description": (
"Convert a temperature value from one unit to another. "
"Supports conversion between celsius and fahrenheit. "
"Use this tool when a user asks to convert a temperature, "
"or when you need to present a temperature in a different "
"unit than what another tool returned."
),
"input_schema": {
"type": "object",
"properties": {
"temperature": {
"type": "number",
"description": "The temperature value to convert.",
},
"from_unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "The unit of the input temperature.",
},
"to_unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "The desired output unit.",
},
},
"required": ["temperature", "from_unit", "to_unit"],
},
"strict": True,
},
]
What does this code do?
You're defining a tools list containing two tool schemas. Each schema tells Claude everything it needs to know to call the tool correctly.
- get_weather takes a required location string and an optional unit string that defaults to celsius.
- convert_temperature takes three required parameters: the temperature number, the from_unit, and the to_unit.
- "strict": True enables strict schema validation, which guarantees Claude's tool call inputs always match your schema exactly.
Why are tool descriptions so important?
The description field is the single most important factor in tool performance. Claude reads descriptions to decide which tool to call and when.
Vague descriptions like "gets weather" lead to incorrect tool calls. Detailed, multi-sentence descriptions that explain the tool's purpose and when to use it produce much better results.
- Save weather_assistant.py.
- Run the script to check for syntax errors by running this command:
python3 weather_assistant.py
You should see no output and no error messages. The script defines your tool schemas but doesn't execute anything yet.
Seeing a SyntaxError?
- Check that every opening { has a matching closing }. Nested dictionaries in the input_schema make it easy to miss one.
- Make sure every dictionary key-value pair and list item ends with a comma, including trailing commas after the last item in each dict and list.
- Verify the parenthesized strings in description are wrapped with () and each line is a separate string ending with a space before the closing quote.
Why is my weather_assistant.py throwing a SyntaxError when I run it?
Write the simulated tool functions
Your tool schemas tell Claude what tools exist. Now you need the actual Python functions that run when Claude calls them.
Why mock data instead of a real weather API?
Using a real weather API would add account signup, a second API key, HTTP request handling, and error management. That doubles the project's complexity without teaching you anything about tool use.
Mock data keeps the focus on the Claude integration. Once you understand the pattern, swapping in a real API is straightforward.
- In weather_assistant.py, add the following get_weather function below the closing ] of your tools list:
# --- Simulated Tool Functions ---
def get_weather(location, unit="celsius"):
"""Return mock weather data for a given location."""
weather_data = {
"San Francisco, US": {"temp_c": 18, "condition": "Foggy", "humidity": 80, "wind_kph": 22},
"Tokyo, Japan": {"temp_c": 26, "condition": "Partly Cloudy", "humidity": 65, "wind_kph": 12},
"London, UK": {"temp_c": 14, "condition": "Rainy", "humidity": 88, "wind_kph": 18},
"Paris, France": {"temp_c": 20, "condition": "Sunny", "humidity": 55, "wind_kph": 10},
"New York, US": {"temp_c": 22, "condition": "Clear", "humidity": 60, "wind_kph": 15},
}
# Find matching city (case-insensitive partial match)
data = None
for city, info in weather_data.items():
if location.lower() in city.lower() or city.lower() in location.lower():
data = info
break
if data is None:
data = {"temp_c": 20, "condition": "Clear", "humidity": 50, "wind_kph": 10}
temp = data["temp_c"]
if unit == "fahrenheit":
temp = round(temp * 9 / 5 + 32, 1)
return json.dumps({
"location": location,
"temperature": temp,
"unit": unit,
"condition": data["condition"],
"humidity": data["humidity"],
"wind_kph": data["wind_kph"],
})
What does this function do?
- weather_data is a dictionary of five preset cities, each with a temperature in Celsius, weather condition, humidity, and wind speed.
- The loop does a case-insensitive partial match so Claude can send "Tokyo" or "Tokyo, Japan" and both will work.
- If the location isn't found, the function returns sensible default values instead of crashing.
- The result is returned as a JSON string with json.dumps() because tool results must be strings that Claude can read.
- Save weather_assistant.py.
You won't see a change yet. The next function adds temperature conversion.
- Add the convert_temperature function below get_weather:
def convert_temperature(temperature, from_unit, to_unit):
"""Convert temperature between celsius and fahrenheit."""
if from_unit == to_unit:
converted = temperature
elif from_unit == "celsius" and to_unit == "fahrenheit":
converted = round(temperature * 9 / 5 + 32, 1)
elif from_unit == "fahrenheit" and to_unit == "celsius":
converted = round((temperature - 32) * 5 / 9, 1)
else:
return json.dumps({"error": f"Unsupported conversion: {from_unit} to {to_unit}"})
return json.dumps({
"original_temperature": temperature,
"original_unit": from_unit,
"converted_temperature": converted,
"converted_unit": to_unit,
})
What does this function do?
- Unlike get_weather, this function does real math. It converts between Celsius and Fahrenheit using the standard formulas.
- If from_unit and to_unit are the same, it returns the original temperature unchanged.
- The result includes both the original and converted values so Claude can reference either in its response.
- Save weather_assistant.py.
You won't see a change yet. The next function ties everything together.
- Add the process_tool_call dispatcher function below convert_temperature:
def process_tool_call(tool_name, tool_input):
"""Route a tool call to the correct function."""
if tool_name == "get_weather":
return get_weather(**tool_input)
elif tool_name == "convert_temperature":
return convert_temperature(**tool_input)
else:
return json.dumps({"error": f"Unknown tool: {tool_name}"})
What does this function do?
process_tool_call is a dispatcher. It takes the tool name and input that Claude sends back, then routes the call to the correct Python function.
The **tool_input syntax unpacks the dictionary into keyword arguments. So if Claude sends {"location": "Tokyo", "unit": "celsius"}, it becomes get_weather(location="Tokyo", unit="celsius").
- Save weather_assistant.py.
- Run the script to verify there are no errors by running this command:
python3 weather_assistant.py
You should see no output and no error messages. The script defines everything but doesn't call any functions yet.
Seeing a NameError or IndentationError?
- A NameError means a function or variable name is misspelled. Double check that your function names match exactly: get_weather, convert_temperature, and process_tool_call.
- An IndentationError means your spacing is inconsistent. Make sure you're using 4 spaces for each level of indentation, not a mix of tabs and spaces.
- Check that each function starts at the top level (no indentation on the def line) and that the body is indented one level in.
Why am I getting an error when I run weather_assistant.py?
✔️ Awesome, I've got everything!
Great. Double check you've saved weather_assistant.py before moving on.
ⓧ I'd like to double check the full code
import json
import anthropic
# --- Tool Definitions ---
tools = [
{
"name": "get_weather",
"description": (
"Get the current weather conditions for a specific location. "
"Returns the current temperature, weather condition, humidity, "
"and wind speed. Use this tool when a user asks about current "
"weather or conditions in a city or location. The temperature "
"is returned in the unit specified (celsius or fahrenheit)."
),
"input_schema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and country, e.g. 'San Francisco, US' or 'Tokyo, Japan'",
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "The temperature unit to use. Defaults to celsius if not specified.",
},
},
"required": ["location"],
},
"strict": True,
},
{
"name": "convert_temperature",
"description": (
"Convert a temperature value from one unit to another. "
"Supports conversion between celsius and fahrenheit. "
"Use this tool when a user asks to convert a temperature, "
"or when you need to present a temperature in a different "
"unit than what another tool returned."
),
"input_schema": {
"type": "object",
"properties": {
"temperature": {
"type": "number",
"description": "The temperature value to convert.",
},
"from_unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "The unit of the input temperature.",
},
"to_unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "The desired output unit.",
},
},
"required": ["temperature", "from_unit", "to_unit"],
},
"strict": True,
},
]
# --- Simulated Tool Functions ---
def get_weather(location, unit="celsius"):
"""Return mock weather data for a given location."""
weather_data = {
"San Francisco, US": {"temp_c": 18, "condition": "Foggy", "humidity": 80, "wind_kph": 22},
"Tokyo, Japan": {"temp_c": 26, "condition": "Partly Cloudy", "humidity": 65, "wind_kph": 12},
"London, UK": {"temp_c": 14, "condition": "Rainy", "humidity": 88, "wind_kph": 18},
"Paris, France": {"temp_c": 20, "condition": "Sunny", "humidity": 55, "wind_kph": 10},
"New York, US": {"temp_c": 22, "condition": "Clear", "humidity": 60, "wind_kph": 15},
}
# Find matching city (case-insensitive partial match)
data = None
for city, info in weather_data.items():
if location.lower() in city.lower() or city.lower() in location.lower():
data = info
break
if data is None:
data = {"temp_c": 20, "condition": "Clear", "humidity": 50, "wind_kph": 10}
temp = data["temp_c"]
if unit == "fahrenheit":
temp = round(temp * 9 / 5 + 32, 1)
return json.dumps({
"location": location,
"temperature": temp,
"unit": unit,
"condition": data["condition"],
"humidity": data["humidity"],
"wind_kph": data["wind_kph"],
})
def convert_temperature(temperature, from_unit, to_unit):
"""Convert temperature between celsius and fahrenheit."""
if from_unit == to_unit:
converted = temperature
elif from_unit == "celsius" and to_unit == "fahrenheit":
converted = round(temperature * 9 / 5 + 32, 1)
elif from_unit == "fahrenheit" and to_unit == "celsius":
converted = round((temperature - 32) * 5 / 9, 1)
else:
return json.dumps({"error": f"Unsupported conversion: {from_unit} to {to_unit}"})
return json.dumps({
"original_temperature": temperature,
"original_unit": from_unit,
"converted_temperature": converted,
"converted_unit": to_unit,
})
def process_tool_call(tool_name, tool_input):
"""Route a tool call to the correct function."""
if tool_name == "get_weather":
return get_weather(**tool_input)
elif tool_name == "convert_temperature":
return convert_temperature(**tool_input)
else:
return json.dumps({"error": f"Unknown tool: {tool_name}"})
Your tools are defined and your functions are ready. Next up, you'll build the agentic loop that connects Claude to these tools so it can call them autonomously.
Build the Agentic Loop
Your tools are defined and your simulated functions are ready to go. The next challenge is connecting these pieces to Claude so it can actually call your tools.
When Claude receives a message along with tool definitions, it can respond with a tool_use block instead of plain text. Your code then executes the requested tool and sends the result back. This back-and-forth is called the agentic loop, and it is the core building block of every AI agent.
In this step, get ready to:
- Make your first API call with tools and see Claude's tool_use response.
- Build the agentic loop that handles tool calls automatically.
- Test multi-step tool calls where Claude chains tools together.
Make your first tool-use API call
Right now your file has tool definitions and functions, but nothing talks to Claude. Let's make a single API call with tools attached to see exactly what Claude sends back.
- At the very bottom of weather_assistant.py (below the process_tool_call function), add this temporary test code:
# --- Quick Test (delete this later) ---
client = anthropic.Anthropic()
# Send a message to Claude with our tools attached
test_response = client.messages.create(
model="claude-haiku-4-5-20241022",
max_tokens=1024,
tools=tools,
messages=[{"role": "user", "content": "What's the weather in San Francisco?"}],
)
# Print the raw response to see what Claude sends back
print(f"Stop reason: {test_response.stop_reason}")
print(f"Content: {test_response.content}")
What does this code do?
- anthropic.Anthropic() creates a client that reads your ANTHROPIC_API_KEY environment variable automatically.
- client.messages.create() sends a message to Claude with your tools list attached.
- The model claude-haiku-4-5-20241022 is the cheapest Claude model that fully supports tool use, costing about $0.001 per call with short messages.
- Save weather_assistant.py.
- Run the script by entering this command in your terminal:
python3 weather_assistant.py
What should I see?
Two lines of output:
- stop_reason is tool_use (not end_turn). This tells you Claude wants to call a tool instead of responding with text.
- The content array contains a ToolUseBlock with a name (which tool Claude chose), input (the parameters Claude filled in), and an id (a unique identifier you will need soon).
Seeing an AuthenticationError?
- Make sure your ANTHROPIC_API_KEY environment variable is set in the same terminal session where you run the script.
- Check that your API key starts with sk-ant- and that you have prepaid credits on your Anthropic account.
- Why is my Anthropic API call failing with an authentication error?
Claude did not answer the question with text. It told your code exactly which tool to call and what parameters to use. Your job now is to build the loop that executes those tool calls and feeds the results back.
Implement the full agentic loop
You have seen that Claude responds with tool_use when it wants to call a tool. The agentic loop handles this automatically: run the tool, send the result back, and repeat until Claude has a final text answer.
- In weather_assistant.py, delete the entire # --- Quick Test (delete this later) --- block you added in the previous substep.
- In the same spot (below the process_tool_call function), add the following run_agent function:
# --- Agentic Loop ---
def run_agent(user_message, messages, client):
"""Run the agentic loop for a single user turn."""
messages.append({"role": "user", "content": user_message})
response = client.messages.create(
model="claude-haiku-4-5-20241022",
max_tokens=1024,
tools=tools,
tool_choice={"type": "auto"},
messages=messages,
)
# Agentic loop: keep going while Claude wants to use tools
while response.stop_reason == "tool_use":
# Extract all tool_use blocks from the response
tool_results = []
for block in response.content:
if block.type == "tool_use":
print(f" \U0001f527 Claude is calling tool: {block.name}")
print(f" Input: {json.dumps(block.input, indent=2)}")
result = process_tool_call(block.name, block.input)
print(f" Result: {result}")
print()
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
# Append assistant response and tool results to messages
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
# Call Claude again with the updated messages
response = client.messages.create(
model="claude-haiku-4-5-20241022",
max_tokens=1024,
tools=tools,
tool_choice={"type": "auto"},
messages=messages,
)
# Extract final text response
final_text = ""
for block in response.content:
if hasattr(block, "text"):
final_text += block.text
messages.append({"role": "assistant", "content": response.content})
return final_text
What does this code do?
This function is the heart of your AI agent. Here is how each section works:
- The function appends the user's message to the conversation history, then makes an API call to Claude with your tools attached.
- while response.stop_reason == "tool_use" is the core of the agentic pattern. It keeps looping as long as Claude wants to call tools.
- Inside the loop, it extracts every tool_use block, calls process_tool_call() to run the matching function, and builds a tool_result dictionary for each call.
- Each tool_result must include a tool_use_id that matches the id from the corresponding tool_use block. This is how Claude matches results to requests.
- Once Claude stops requesting tools, the loop exits and the function extracts the final text answer from the response.
Why is tool_choice set to auto?
tool_choice={"type": "auto"} tells Claude it can choose whether to use a tool or respond with text. This is the default behavior, but making it explicit shows there are other options.
Setting it to "any" forces Claude to call a tool every time. Setting it to "none" prevents tool calls entirely. You will explore these in the next step.
- Save weather_assistant.py.
You will not see a change yet. The next substep adds a test call so you can watch the agentic loop in action.
Test multi-step tool calls
Some questions require Claude to chain multiple tools together. Asking for weather in Fahrenheit when the data is stored in Celsius needs two calls: one to fetch the weather, one to convert the temperature.
- At the very bottom of weather_assistant.py (below the run_agent function), add this temporary test code:
# --- Quick Test (delete this after testing) ---
client = anthropic.Anthropic()
messages = []
# Test a question that requires two tool calls in sequence
result = run_agent("What's the weather in Tokyo in Fahrenheit?", messages, client)
print(f"\nFinal answer: {result}")
What does this test do?
This question is designed to trigger multi-step tool use. Claude should first call get_weather for Tokyo (which returns Celsius by default), then call convert_temperature to convert the result to Fahrenheit, then give you a final text answer.
- Save weather_assistant.py.
- Run the script by entering this command:
python3 weather_assistant.py
You should see Claude make two tool calls in sequence, with the name, input, and result printed for each one. After both calls finish, a final text answer appears summarizing the weather in Fahrenheit.
Only seeing one tool call?
- Claude sometimes answers with a single tool call if it infers Fahrenheit can be passed directly. Try the prompt "Get the weather in Tokyo then convert it to Fahrenheit" to be more explicit.
- Check that the while response.stop_reason == "tool_use" loop is present. Without it, your code only processes the first tool call and stops.
- Why is Claude only making one tool call when I expected two?
- Now delete the entire # --- Quick Test (delete this after testing) --- block from the bottom of your file.
- Save weather_assistant.py.
Your file should now end with the run_agent function and nothing below it.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file.
ⓧ I'd like to double check the full code
Here is the complete weather_assistant.py so far. Make sure your file matches this exactly:
import json
import anthropic
# --- Tool Definitions ---
tools = [
{
"name": "get_weather",
"description": (
"Get the current weather conditions for a specific location. "
"Returns the current temperature, weather condition, humidity, "
"and wind speed. Use this tool when a user asks about current "
"weather or conditions in a city or location. The temperature "
"is returned in the unit specified (celsius or fahrenheit)."
),
"input_schema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and country, e.g. 'San Francisco, US' or 'Tokyo, Japan'",
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "The temperature unit to use. Defaults to celsius if not specified.",
},
},
"required": ["location"],
},
"strict": True,
},
{
"name": "convert_temperature",
"description": (
"Convert a temperature value from one unit to another. "
"Supports conversion between celsius and fahrenheit. "
"Use this tool when a user asks to convert a temperature, "
"or when you need to present a temperature in a different "
"unit than what another tool returned."
),
"input_schema": {
"type": "object",
"properties": {
"temperature": {
"type": "number",
"description": "The temperature value to convert.",
},
"from_unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "The unit of the input temperature.",
},
"to_unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "The desired output unit.",
},
},
"required": ["temperature", "from_unit", "to_unit"],
},
"strict": True,
},
]
# --- Simulated Tool Functions ---
def get_weather(location, unit="celsius"):
"""Return mock weather data for a given location."""
weather_data = {
"San Francisco, US": {"temp_c": 18, "condition": "Foggy", "humidity": 80, "wind_kph": 22},
"Tokyo, Japan": {"temp_c": 26, "condition": "Partly Cloudy", "humidity": 65, "wind_kph": 12},
"London, UK": {"temp_c": 14, "condition": "Rainy", "humidity": 88, "wind_kph": 18},
"Paris, France": {"temp_c": 20, "condition": "Sunny", "humidity": 55, "wind_kph": 10},
"New York, US": {"temp_c": 22, "condition": "Clear", "humidity": 60, "wind_kph": 15},
}
data = None
for city, info in weather_data.items():
if location.lower() in city.lower() or city.lower() in location.lower():
data = info
break
if data is None:
data = {"temp_c": 20, "condition": "Clear", "humidity": 50, "wind_kph": 10}
temp = data["temp_c"]
if unit == "fahrenheit":
temp = round(temp * 9 / 5 + 32, 1)
return json.dumps({
"location": location,
"temperature": temp,
"unit": unit,
"condition": data["condition"],
"humidity": data["humidity"],
"wind_kph": data["wind_kph"],
})
def convert_temperature(temperature, from_unit, to_unit):
"""Convert temperature between celsius and fahrenheit."""
if from_unit == to_unit:
converted = temperature
elif from_unit == "celsius" and to_unit == "fahrenheit":
converted = round(temperature * 9 / 5 + 32, 1)
elif from_unit == "fahrenheit" and to_unit == "celsius":
converted = round((temperature - 32) * 5 / 9, 1)
else:
return json.dumps({"error": f"Unsupported conversion: {from_unit} to {to_unit}"})
return json.dumps({
"original_temperature": temperature,
"original_unit": from_unit,
"converted_temperature": converted,
"converted_unit": to_unit,
})
def process_tool_call(tool_name, tool_input):
"""Route a tool call to the correct function."""
if tool_name == "get_weather":
return get_weather(**tool_input)
elif tool_name == "convert_temperature":
return convert_temperature(**tool_input)
else:
return json.dumps({"error": f"Unknown tool: {tool_name}"})
# --- Agentic Loop ---
def run_agent(user_message, messages, client):
"""Run the agentic loop for a single user turn."""
messages.append({"role": "user", "content": user_message})
response = client.messages.create(
model="claude-haiku-4-5-20241022",
max_tokens=1024,
tools=tools,
tool_choice={"type": "auto"},
messages=messages,
)
# Agentic loop: keep going while Claude wants to use tools
while response.stop_reason == "tool_use":
# Extract all tool_use blocks from the response
tool_results = []
for block in response.content:
if block.type == "tool_use":
print(f" \U0001f527 Claude is calling tool: {block.name}")
print(f" Input: {json.dumps(block.input, indent=2)}")
result = process_tool_call(block.name, block.input)
print(f" Result: {result}")
print()
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
# Append assistant response and tool results to messages
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
# Call Claude again with the updated messages
response = client.messages.create(
model="claude-haiku-4-5-20241022",
max_tokens=1024,
tools=tools,
tool_choice={"type": "auto"},
messages=messages,
)
# Extract final text response
final_text = ""
for block in response.content:
if hasattr(block, "text"):
final_text += block.text
messages.append({"role": "assistant", "content": response.content})
return final_text
Your agentic loop is working. Claude can now autonomously decide which tools to call, chain them together, and reason over the results. Next up, you will wrap this in an interactive chat interface so you can have a real conversation with your weather assistant.
Add an Interactive Chat Interface
Your agentic loop can now handle single-step and multi-step tool calls. The missing piece is a way to actually have a conversation with your assistant.
In this step, you will wrap everything in an interactive chat loop so you can ask weather questions from your terminal. You will also explore the tool_choice and strict parameters that control how Claude interacts with your tools.
In this step, get ready to:
- Build an interactive chat loop that keeps the conversation going across multiple turns.
- Understand the tool_choice and strict parameters that control Claude's behavior.
- Test the assistant with single-step and multi-step weather queries.
Wrap everything in a chat loop
Right now, run_agent() handles a single user turn. You need a main() function that loops forever, collecting user input and feeding it into the agentic loop.
- In weather_assistant.py, scroll to the very bottom of the file, below the run_agent() function.
- Add the interactive chat interface by copying the following code at the end of the file:
# --- Interactive Chat ---
def main():
client = anthropic.Anthropic()
messages = []
print("=" * 50)
print(" Weather Assistant (powered by Claude)")
print(" Type 'quit' to exit")
print("=" * 50)
print()
while True:
user_input = input("You: ").strip()
if not user_input:
continue
if user_input.lower() in ("quit", "exit", "q"):
print("Goodbye!")
break
print()
response_text = run_agent(user_input, messages, client)
print(f"Assistant: {response_text}")
print()
if __name__ == "__main__":
main()
What does this code do?
- anthropic.Anthropic() creates the API client. It automatically reads your ANTHROPIC_API_KEY environment variable, so you don't need to pass the key directly.
- messages = [] holds the full conversation history. Because this list persists across the while True loop, Claude remembers earlier turns and can refer back to them.
- input("You: ").strip() pauses the program and waits for you to type a message. .strip() removes any leading or trailing whitespace.
- if __name__ == "__main__": main() is a Python guard that only runs main() when you execute the file directly. It prevents the chat loop from starting if someone imports the file as a module.
- Save weather_assistant.py.
You won't run the assistant just yet. The next substep walks through important configuration details before you test it.
✔️ Awesome, I've got everything!
Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete weather_assistant.py file. Compare it against yours to spot any differences.
import json
import anthropic
# --- Tool Definitions ---
tools = [
{
"name": "get_weather",
"description": (
"Get the current weather conditions for a specific location. "
"Returns the current temperature, weather condition, humidity, "
"and wind speed. Use this tool when a user asks about current "
"weather or conditions in a city or location. The temperature "
"is returned in the unit specified (celsius or fahrenheit)."
),
"input_schema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and country, e.g. 'San Francisco, US' or 'Tokyo, Japan'",
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "The temperature unit to use. Defaults to celsius if not specified.",
},
},
"required": ["location"],
},
"strict": True,
},
{
"name": "convert_temperature",
"description": (
"Convert a temperature value from one unit to another. "
"Supports conversion between celsius and fahrenheit. "
"Use this tool when a user asks to convert a temperature, "
"or when you need to present a temperature in a different "
"unit than what another tool returned."
),
"input_schema": {
"type": "object",
"properties": {
"temperature": {
"type": "number",
"description": "The temperature value to convert.",
},
"from_unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "The unit of the input temperature.",
},
"to_unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "The desired output unit.",
},
},
"required": ["temperature", "from_unit", "to_unit"],
},
"strict": True,
},
]
# --- Simulated Tool Functions ---
def get_weather(location, unit="celsius"):
"""Return mock weather data for a given location."""
weather_data = {
"San Francisco, US": {"temp_c": 18, "condition": "Foggy", "humidity": 80, "wind_kph": 22},
"Tokyo, Japan": {"temp_c": 26, "condition": "Partly Cloudy", "humidity": 65, "wind_kph": 12},
"London, UK": {"temp_c": 14, "condition": "Rainy", "humidity": 88, "wind_kph": 18},
"Paris, France": {"temp_c": 20, "condition": "Sunny", "humidity": 55, "wind_kph": 10},
"New York, US": {"temp_c": 22, "condition": "Clear", "humidity": 60, "wind_kph": 15},
}
data = None
for city, info in weather_data.items():
if location.lower() in city.lower() or city.lower() in location.lower():
data = info
break
if data is None:
data = {"temp_c": 20, "condition": "Clear", "humidity": 50, "wind_kph": 10}
temp = data["temp_c"]
if unit == "fahrenheit":
temp = round(temp * 9 / 5 + 32, 1)
return json.dumps({
"location": location,
"temperature": temp,
"unit": unit,
"condition": data["condition"],
"humidity": data["humidity"],
"wind_kph": data["wind_kph"],
})
def convert_temperature(temperature, from_unit, to_unit):
"""Convert temperature between celsius and fahrenheit."""
if from_unit == to_unit:
converted = temperature
elif from_unit == "celsius" and to_unit == "fahrenheit":
converted = round(temperature * 9 / 5 + 32, 1)
elif from_unit == "fahrenheit" and to_unit == "celsius":
converted = round((temperature - 32) * 5 / 9, 1)
else:
return json.dumps({"error": f"Unsupported conversion: {from_unit} to {to_unit}"})
return json.dumps({
"original_temperature": temperature,
"original_unit": from_unit,
"converted_temperature": converted,
"converted_unit": to_unit,
})
def process_tool_call(tool_name, tool_input):
"""Route a tool call to the correct function."""
if tool_name == "get_weather":
return get_weather(**tool_input)
elif tool_name == "convert_temperature":
return convert_temperature(**tool_input)
else:
return json.dumps({"error": f"Unknown tool: {tool_name}"})
# --- Agentic Loop ---
def run_agent(user_message, messages, client):
"""Run the agentic loop for a single user turn."""
messages.append({"role": "user", "content": user_message})
response = client.messages.create(
model="claude-haiku-4-5-20241022",
max_tokens=1024,
tools=tools,
tool_choice={"type": "auto"},
messages=messages,
)
# Agentic loop: keep going while Claude wants to use tools
while response.stop_reason == "tool_use":
# Extract all tool_use blocks from the response
tool_results = []
for block in response.content:
if block.type == "tool_use":
print(f" \ud83d\udd27 Claude is calling tool: {block.name}")
print(f" Input: {json.dumps(block.input, indent=2)}")
result = process_tool_call(block.name, block.input)
print(f" Result: {result}")
print()
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
# Append assistant response and tool results to messages
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
# Call Claude again with the updated messages
response = client.messages.create(
model="claude-haiku-4-5-20241022",
max_tokens=1024,
tools=tools,
tool_choice={"type": "auto"},
messages=messages,
)
# Extract final text response
final_text = ""
for block in response.content:
if hasattr(block, "text"):
final_text += block.text
messages.append({"role": "assistant", "content": response.content})
return final_text
# --- Interactive Chat ---
def main():
client = anthropic.Anthropic()
messages = []
print("=" * 50)
print(" Weather Assistant (powered by Claude)")
print(" Type 'quit' to exit")
print("=" * 50)
print()
while True:
user_input = input("You: ").strip()
if not user_input:
continue
if user_input.lower() in ("quit", "exit", "q"):
print("Goodbye!")
break
print()
response_text = run_agent(user_input, messages, client)
print(f"Assistant: {response_text}")
print()
if __name__ == "__main__":
main()
Understand tool_choice and strict, then test your assistant
Before you run the assistant, take a look at two parameters already in your code that control how Claude interacts with your tools.
- In weather_assistant.py, find the run_agent() function and locate this line inside the first client.messages.create() call:
tool_choice={"type": "auto"},
What does tool_choice do?
The tool_choice parameter tells Claude how it should use the tools you provide. There are four options:
- auto (default): Claude decides whether to call a tool or respond with text. This is what your assistant uses.
- any: forces Claude to call at least one tool. Useful when you always need structured output.
- tool: forces Claude to call one specific tool by name.
- none: prevents Claude from calling any tools, even if they are provided.
- Now scroll up to the tools list at the top of the file. Find this line inside the get_weather tool definition:
"strict": True,
What does strict mode do?
When strict is set to True, Claude guarantees that every tool call it makes will match your JSON Schema exactly. No missing parameters, no wrong types.
Without strict mode, Claude could occasionally send a string where you expect a number, or skip an optional field you need. Strict mode eliminates that risk entirely.
Time to take your assistant for a spin. You will test three different queries that exercise different tool-calling patterns.
- Run your weather assistant by running this command:
python3 weather_assistant.py
You should see a welcome banner that says Weather Assistant (powered by Claude) followed by a You: prompt waiting for your input.
Don't see the welcome banner?
- Make sure you saved weather_assistant.py after adding the main() function and the if __name__ guard at the bottom.
- Confirm you are running the command from inside the weather-assistant folder where your file lives.
- If you see AuthenticationError, your ANTHROPIC_API_KEY environment variable is not set in this terminal session. Run the export command from Step 1 again.
Why isn't my weather assistant starting?
- Type this query at the You: prompt and press **Enter**:
What's the weather in London?
You should see Claude call the get_weather tool once, then print a natural language answer that mentions London's rainy conditions and a temperature of 14 degrees Celsius.
- Try a direct conversion query:
Convert 100 degrees Fahrenheit to Celsius
Claude should call convert_temperature and tell you the answer is 37.8 degrees Celsius.
- Now test a multi-step query that forces Claude to chain two tool calls:
What's the weather in Paris in Fahrenheit?
What should I see?
Claude should call get_weather for Paris first (which returns Celsius by default). Then it should call convert_temperature to convert the result to Fahrenheit.
This is the agentic loop in action. Claude autonomously decides it needs two tool calls and chains them together without any help from you.
- Type quit to exit the assistant.
Seeing unexpected results or errors during testing?
- If Claude responds without calling any tools, try rephrasing your question to be more explicit (e.g., "Look up the current weather in London" instead of "How's London?").
- If you see a RateLimitError, wait a few seconds and try again. This can happen if you send many messages in quick succession.
- If the multi-step Paris query only calls one tool, Claude may have called get_weather with unit="fahrenheit" directly. That is also correct behavior. Claude picks the most efficient path.
Help me debug my weather assistant's responses.
Your weather assistant is fully functional. You can ask it about any of the five preset cities, request unit conversions, and watch Claude chain tools together autonomously. Next up, a Secret Mission to push your assistant even further with a third tool and parallel tool calls.
Secret mission
Add a Third Tool and Enable Parallel Tool Use
Your weather assistant can chain tool calls across multiple turns. But what if Claude needs data from two different tools at the same time? Add a forecast tool and discover parallel tool use.
Before you go
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, so there are no ongoing server costs. The only financial resource is the prepaid credits on your Anthropic account.
Cost warning
Your Anthropic account has prepaid credits that can be used by any application with your API key. If your key is exposed or stored in a shared location, someone else could use your credits.
If you are not planning to use the API soon, revoke your key in the Anthropic console to prevent unauthorized usage.
Resources you used:
- weather-assistant project directory with weather_assistant.py.
- anthropic Python SDK installed via pip3.
- ANTHROPIC_API_KEY environment variable (and optional entry in ~/.zshrc).
- Anthropic account with prepaid API credits.
Keep everything running
No action needed. Choose this if you are still actively experimenting with your weather assistant or plan to extend it further.
- Your project files, SDK, and API key stay in place.
- You can re-run weather_assistant.py any time from the weather-assistant directory.
Pause - I'll come back to this later
Revoke your API key to protect your prepaid credits while keeping your project files intact.
- Go to platform.claude.com and navigate to your API key settings.
- Revoke (disable) your current API key. You can generate a new one when you return.
- Your weather-assistant folder and all code files will remain on your Desktop.
- When you come back, generate a new API key and update the ANTHROPIC_API_KEY value in your terminal or ~/.zshrc file.
Delete - I don't want to use this again
Remove all project resources and clean up your environment completely.
- Go to platform.claude.com and revoke your API key in the API key settings.
- Delete the project folder by running this command in your terminal:
rm -rf ~/Desktop/weather-assistant
- Uninstall the Anthropic SDK by running:
pip3 uninstall anthropic
- Remove the ANTHROPIC_API_KEY line from your ~/.zshrc file if you added it there.
- Your prepaid Anthropic credits will remain on your account. You can use them for future projects or leave them as-is.
Mission accomplished
That's a Wrap!
Nice work! You just built a fully functional AI weather assistant that autonomously calls your custom tools to answer questions.
Here's what you learned:
- Defined custom tool schemas with JSON Schema input validation and strict mode for guaranteed schema conformance.
- Implemented the complete agentic loop pattern, where Claude requests tool calls, your code executes them, and Claude reasons over the results to produce a final answer.
- Built a multi-turn interactive chatbot that handles both single-step and multi-step tool calls with tool_choice control.
- Added parallel tool use so Claude can call multiple tools in a single turn to answer complex queries.
Ready to quiz yourself?