Build a Tool-Using AI Assistant
Build a Python AI assistant that calls real functions using Claude's tool-use API.
Introduction
30 Second Summary
Ever noticed how AI chatbots sometimes confidently give you wrong answers? The best AI-powered tools solve this by calling real functions to get accurate data instead of guessing.
In this project, you will build a command-line AI assistant powered by Claude that uses tool calling to execute Python functions and return accurate results.
What You'll Build
Your assistant answers weather questions and performs unit conversions by calling Python functions you define, using the same pattern that powers production AI agents and copilots.
By the end of this project, you'll have:
- A Python script that connects to the Claude API using the official SDK.
- Tool definitions that teach Claude what functions are available.
- A tool-use loop that executes functions and feeds results back to Claude for a natural language answer.
- Secret Mission: Add a live data tool that fetches real weather from a public API.
Are there any prerequisites?
You need basic Python knowledge (variables, functions, dictionaries, and loops). No prior AI or machine learning experience is needed.
Before We Start
Before We Start...
Set Up Your Environment
Before you can build your AI assistant, you need a working development environment. This means having Python installed, a code editor ready, and a connection to the Claude API.
In this step, you will install the required tools, create your project folder, and set up your credentials so your code can talk to Claude.
In this step, get ready to:
- Install Python, VS Code, and create your project folder with a virtual environment.
- Install the Anthropic Python SDK and python-dotenv.
- Create an Anthropic Console account, generate an API key, and store it safely.
Set up Python, VS Code, and your project folder
Python is the language you will use to build your assistant. VS Code is the code editor where you will write and run your scripts.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
- Check your Python version by running this command:
macOS
python3 --version
Windows
python --version
✔️ I see version 3.9 or higher
Your Python version is ready. Move on to checking VS Code below.
ⓧ I see an older version
You need Python 3.9 or higher for this project.
- Download the latest Python installer from python.org/downloads.
- Run the installer. On Windows, check the box that says Add Python to PATH before clicking Install Now.
- Close and reopen your terminal, then run the version check command again.
macOS ships an older Python
macOS often comes with Python 3.9 pre-installed, but if yours shows an older version, download the latest from python.org. After installing, use python3 --version to confirm the new version is active.
ⓧ Command not found
Python is not installed on your machine yet.
- Download the latest Python installer from python.org/downloads.
- Run the installer. On Windows, check the box that says Add Python to PATH before clicking Install Now.
- Close and reopen your terminal after installation completes.
- Run the version check command again to confirm Python is installed.
- Check if VS Code is installed by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Visual Studio Code, and pressing Enter.
✔️ VS Code opens
You are good to go. Move on to creating your project folder below.
ⓧ Nothing happens
- Download VS Code from code.visualstudio.com.
- Run the installer and follow the prompts.
- Once installed, open VS Code using the search method above.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir claude-assistant
cd claude-assistant
- Confirm the folder exists by running:
ls
You should see the claude-assistant folder listed in the output.
A virtual environment keeps your project's packages isolated from other Python projects on your machine.
- Create a virtual environment by running this command:
macOS
python3 -m venv venv
Windows
python -m venv venv
- Activate the virtual environment:
macOS
source venv/bin/activate
Windows
.\venv\Scripts\activate
You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.
Don't see (venv) in your prompt?
- Make sure you are inside the claude-assistant folder. Run pwd (macOS) or cd (Windows) to check your current location.
- On Windows, if you see an execution policy error, run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned and try the activate command again.
Need more help? Help me activate my Python virtual environment
Install the Anthropic SDK and python-dotenv
The Anthropic Python SDK lets your code communicate with the Claude API. The python-dotenv package loads your API key from a file so you never hard-code secrets.
- Install both packages by running this command:
pip install anthropic python-dotenv
What does this command do?
- pip install anthropic downloads the official Anthropic Python SDK, which gives you a ready-made client for calling the Claude API.
- pip install python-dotenv downloads a utility that reads environment variables from a .env file. This keeps your API key out of your code.
- Verify the installation succeeded by running:
pip show anthropic
You should see package details including a version number and the install location. This confirms the SDK is ready to use.
Seeing a 'pip not found' error?
- Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt.
- On macOS, try pip3 install anthropic python-dotenv instead.
Still stuck? Help me install the Anthropic SDK
Create your Anthropic account and API key
To call the Claude API, you need an API key. This key authenticates your requests so Anthropic knows they come from your account.
- Open your browser and go to console.anthropic.com.
- Click Sign up to create a new account (or log in if you already have one).
- Verify your email address and phone number when prompted.
- Once logged in, click Settings in the left sidebar.
- Click API Keys.
- Click Create Key.
- Give your key a name like claude-assistant and confirm.
- Copy the key immediately. You will not be able to see it again after closing the dialog.
What about cost?
New Anthropic accounts receive free credits to test the API. This entire project costs less than $0.05, so you will stay well within the free tier.
Now you will store your key in a .env file so your Python code can read it without you pasting it directly in your script.
- Open the claude-assistant folder in VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Visual Studio Code, and pressing Enter.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the claude-assistant folder, then click Open.
- Create a new file by clicking File then New File.
- Save the file immediately as .env by pressing Cmd+S (macOS) or Ctrl+S (Windows) and typing .env as the filename.
- Paste the following into your .env file, replacing your-api-key-here with the key you just copied:
ANTHROPIC_API_KEY=your-api-key-here
What does this file do?
The .env file stores your API key as an environment variable called ANTHROPIC_API_KEY. When your Python script runs, the python-dotenv package reads this file and makes the key available to your code automatically.
This approach keeps your secret out of your source code. If you ever share your project, the .env file stays on your machine.
- Save .env.
- Create one more file. Click File then New File.
- Save this file as assistant.py by pressing Cmd+S (macOS) or Ctrl+S (Windows) and typing assistant.py as the filename.
This is the file where all of your assistant code will live. Leave it empty for now.
- Check the Explorer sidebar in VS Code. You should see both .env and assistant.py listed under your claude-assistant folder.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Your .env file should contain exactly this (with your real key replacing the placeholder):
ANTHROPIC_API_KEY=your-api-key-here
Your assistant.py file should be empty at this point.
Seeing issues with your API key?
- Make sure there are no spaces around the = sign in your .env file.
- Make sure the file is named exactly .env (with the dot at the start, no file extension after it).
- Confirm you copied the full API key from the Anthropic Console. It should start with sk-ant-.
Still having trouble? Help me set up my Anthropic API key
Your environment is fully set up. You have Python, the Anthropic SDK, and a secure API key ready to go. Next up, you will write your first API call and get Claude to respond.
Make Your First API Call
Your environment is ready with Python, the SDK, and your API key stored safely. Now it's time to write code that actually talks to Claude.
The Messages API is how your Python code sends prompts and receives responses from Claude. In this step, you'll send your first message, read what comes back, and customize Claude's personality with a system prompt.
In this step, get ready to:
- Send a message to Claude using the Messages API.
- Inspect the response object to understand its structure.
- Add a system prompt to customize Claude's behavior.
Write a basic script that connects to the Claude API
Your assistant.py file is currently empty. You'll build it up in chunks, starting with the imports and client setup.
- Open assistant.py in VS Code and add the following imports and client setup:
import json
import os
from dotenv import load_dotenv
from anthropic import Anthropic
# Load your API key from the .env file
load_dotenv()
# Create the Anthropic client (reads ANTHROPIC_API_KEY automatically)
client = Anthropic()
What does this code do?
The first two imports (json and os) will be used in later steps when you handle tool results. You include them now so your imports stay in one place.
load_dotenv() reads your .env file and loads ANTHROPIC_API_KEY into your environment. The Anthropic() client automatically picks up that key, so you never have to paste it into your code.
Now that the client is ready, you can make your first API call. The Messages API takes a model name, a token limit, and a list of messages.
- Add the following code below the client = Anthropic() line:
# Send a message to Claude
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[
{"role": "user", "content": "Hello! What can you help me with?"}
]
)
# Print Claude's response and metadata
print(response.content[0].text)
print("Model:", response.model)
print("Usage:", response.usage)
What does this code do?
client.messages.create() sends a request to Claude's Messages API. You specify claude-sonnet-4-6 as the model, which is Claude's best balance of speed, quality, and cost.
max_tokens=1024 limits how long Claude's response can be. The messages list contains the conversation so far. Right now it's just a single user message.
- Save assistant.py.
Seeing a red underline on the imports?
Make sure your virtual environment is activated. VS Code sometimes selects the wrong Python interpreter. Click the Python version in the bottom-left status bar and select the interpreter from your project's virtual environment.
Help me fix import errors in VS Code
Run the script and inspect the response
Your script is ready to call the Claude API. The three print statements will show you the response text, which model handled your request, and how many tokens were used.
- Run your script by executing this command in your terminal:
python assistant.py
- You should see Claude's response printed to your terminal, followed by the model name and a usage summary showing input and output token counts.
What is the response object?
The response from client.messages.create() contains several fields. response.content is a list of content blocks. Each block has a type (like text) and the actual text.
response.model confirms which model handled your request. response.usage shows how many input tokens (your prompt) and output tokens (Claude's reply) were consumed. This is how billing works for the API.
Getting an authentication error?
Double check that your .env file contains ANTHROPIC_API_KEY= followed by your actual key (no spaces around the = sign). Make sure the .env file is in the same folder as assistant.py.
Help me fix my Anthropic API authentication error
Add a system prompt to shape Claude's behavior
A system prompt is a special instruction that tells Claude how to behave. It sets the tone, defines capabilities, and gives Claude context about its role before the user ever sends a message.
- In assistant.py, find the client.messages.create() call. Locate this line:
max_tokens=1024,
messages=[
- Add a system parameter between max_tokens and messages, so those lines now look like this:
max_tokens=1024,
system="You are a helpful assistant with access to weather data and unit conversion tools. Use your tools when the user asks about weather or needs a conversion. Be concise and friendly.",
messages=[
Why set up the system prompt now?
This system prompt tells Claude it has access to weather and unit conversion tools. Claude doesn't have those tools yet, but defining the persona early means Claude will already know its role when you add tools in the next step.
System prompts are invisible to the user but shape every response Claude gives. They're the primary way developers control AI behavior in production.
- Save assistant.py and run it again:
python assistant.py
- Claude's response should now mention weather and unit conversion capabilities. The system prompt has changed how Claude introduces itself.
Claude's response looks the same as before?
Make sure the system parameter is inside the client.messages.create() call, not outside it. It should be on its own line between max_tokens and messages, with a trailing comma.
Help me fix my system prompt placement
✔️ Awesome, I've got everything!
Great. Make sure you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here's the complete assistant.py at this point:
import json
import os
from dotenv import load_dotenv
from anthropic import Anthropic
load_dotenv()
client = Anthropic()
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system="You are a helpful assistant with access to weather data and unit conversion tools. Use your tools when the user asks about weather or needs a conversion. Be concise and friendly.",
messages=[
{"role": "user", "content": "Hello! What can you help me with?"}
]
)
print(response.content[0].text)
print("Model:", response.model)
print("Usage:", response.usage)
You've sent your first message to Claude and shaped its personality with a system prompt. Next up, you'll teach Claude about the tools it can use by defining them as structured schemas.
Define Your Tools
Right now, Claude can only respond with text. If you ask it for the weather, it has to guess or refuse. Tool use changes that by letting Claude call functions you define and use real data in its answers.
In this step, you will teach Claude about two tools: a weather lookup and a unit converter. You will define these tools as JSON Schema objects, write the Python functions that power them, and then watch Claude decide to call a tool instead of generating a text response.
In this step, get ready to:
- Create tool definitions that describe your functions to Claude.
- Write Python functions for weather lookup and unit conversion.
- Pass the tools to the API and observe Claude's tool_use response.
Create tool definitions for weather and unit conversion
Claude does not magically know what functions exist in your code. You need to describe each tool using a structured schema so Claude understands what it can call, what parameters to provide, and when to use it.
- In assistant.py, find the line that says client = Anthropic().
- Delete everything below that line (the entire response = client.messages.create(...) block and the print statements).
- Add the following tool definitions below client = Anthropic():
TOOLS = [
{
"name": "get_weather",
"description": "Get the current weather for a given city. Returns temperature in Celsius and a short description of conditions.",
"input_schema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name, e.g. 'London' or 'New York'"
}
},
"required": ["city"]
}
},
{
"name": "convert_units",
"description": "Convert a value from one unit to another. Supports temperature (celsius/fahrenheit), distance (miles/kilometers), and weight (pounds/kilograms).",
"input_schema": {
"type": "object",
"properties": {
"value": {
"type": "number",
"description": "The numeric value to convert"
},
"from_unit": {
"type": "string",
"description": "The unit to convert from, e.g. 'celsius', 'miles', 'pounds'"
},
"to_unit": {
"type": "string",
"description": "The unit to convert to, e.g. 'fahrenheit', 'kilometers', 'kilograms'"
}
},
"required": ["value", "from_unit", "to_unit"]
}
}
]
What does this code do?
TOOLS is a list of dictionaries that describe your functions to Claude. Each tool has a name, a description (which Claude reads to decide when to use the tool), and an input_schema that tells Claude what parameters to provide.
The schema uses JSON Schema format. Claude reads these definitions like an instruction manual for your functions.
Why are descriptions so important?
Claude uses the description field to decide which tool to call. A vague description means Claude might pick the wrong tool or skip using tools entirely. Detailed descriptions with examples produce much better results.
Write the weather lookup function
Tool definitions tell Claude what it can call, but you still need the actual Python functions that run when Claude makes a call. You will start with simulated weather data so you can focus on the tool-use pattern without worrying about external APIs.
- Add the weather data dictionary and the get_weather function below the TOOLS list by copying this code:
WEATHER_DATA = {
"london": {"temp": 14, "condition": "Cloudy with light rain"},
"new york": {"temp": 22, "condition": "Sunny and clear"},
"tokyo": {"temp": 26, "condition": "Humid and partly cloudy"},
"paris": {"temp": 18, "condition": "Overcast"},
"sydney": {"temp": 12, "condition": "Cool and windy"},
}
def get_weather(city):
# Look up the city in our simulated data
city_lower = city.lower()
if city_lower in WEATHER_DATA:
data = WEATHER_DATA[city_lower]
return json.dumps({
"city": city,
"temperature_celsius": data["temp"],
"condition": data["condition"]
})
# Return an error message if the city isn't in our data
return json.dumps({
"city": city,
"error": f"Weather data not available for {city}. Available cities: {', '.join(WEATHER_DATA.keys())}"
})
What does this code do?
WEATHER_DATA is a dictionary of cities mapped to temperature and conditions. The get_weather function looks up a city and returns the data as a JSON string.
Tool functions must return strings (not Python dictionaries). Claude receives this string as the tool result and uses it to craft a natural language answer.
- Now add the convert_units function below get_weather by copying this code:
def convert_units(value, from_unit, to_unit):
# Normalize units to lowercase for matching
from_unit = from_unit.lower()
to_unit = to_unit.lower()
# Define conversion formulas as lambda functions
conversions = {
("celsius", "fahrenheit"): lambda v: v * 9 / 5 + 32,
("fahrenheit", "celsius"): lambda v: (v - 32) * 5 / 9,
("miles", "kilometers"): lambda v: v * 1.60934,
("kilometers", "miles"): lambda v: v / 1.60934,
("pounds", "kilograms"): lambda v: v * 0.453592,
("kilograms", "pounds"): lambda v: v / 0.453592,
}
key = (from_unit, to_unit)
if key in conversions:
result = conversions[key](value)
return json.dumps({
"value": value,
"from_unit": from_unit,
"to_unit": to_unit,
"result": round(result, 2)
})
# Return an error if the conversion pair is not supported
return json.dumps({
"error": f"Cannot convert from {from_unit} to {to_unit}. Supported: celsius/fahrenheit, miles/kilometers, pounds/kilograms"
})
What does this code do?
The conversions dictionary maps pairs of units to lambda functions that perform the math. The function looks up the pair, runs the conversion, and returns the result as a JSON string.
If someone asks for a conversion that is not supported (like liters to gallons), the function returns a helpful error message. Claude will use that error to inform the user.
- Save assistant.py.
Seeing a syntax error?
Make sure the TOOLS list, WEATHER_DATA dictionary, and both functions are all at the top level of the file (no extra indentation). Check that you have matching brackets for the nested dictionaries in TOOLS.
help me fix a syntax error in my tool definitions
Pass tools to the API and observe the response
With your tool definitions and functions ready, you can now pass the tools to Claude and see what happens when you ask a weather question. Instead of generating a text answer, Claude will respond with a tool_use block requesting that your code execute the function.
- Add the following test API call at the bottom of assistant.py, below the convert_units function:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system="You are a helpful assistant with access to weather data and unit conversion tools. Use your tools when the user asks about weather or needs a conversion. Be concise and friendly.",
tools=TOOLS,
messages=[
{"role": "user", "content": "What's the weather like in London?"}
]
)
print(response.content)
print("Stop reason:", response.stop_reason)
What's different about this API call?
The key addition is tools=TOOLS. This tells Claude what functions are available. You also changed the user message to ask a weather question so Claude has a reason to use the get_weather tool.
You are printing response.content (the full content list) instead of response.content[0].text because the response now contains a tool_use block instead of a plain text block.
- Save assistant.py.
- Run your script by entering this command in the terminal:
python assistant.py
- Look at the output. You should see a stop_reason of tool_use and a content block with type='tool_use' containing the name get_weather and an input of {"city": "London"}.
What just happened?
Claude read your question, looked at the available tools, and decided that get_weather is the right one to call. Instead of generating a final answer, it paused and asked your code to run the function. The stop_reason of tool_use is Claude's way of saying "I need you to execute this tool before I can finish my response."
In the next step, you will execute the function with the arguments Claude provided and send the result back so Claude can give a natural language answer.
Seeing an authentication error?
Make sure your .env file contains your real API key (not the placeholder) and that load_dotenv() is still at the top of your script. The Anthropic SDK automatically reads the ANTHROPIC_API_KEY environment variable.
help me fix an authentication error when calling the Claude API
✔️ Awesome, I've got everything!
Great. Double check you have saved assistant.py before moving on.
ⓧ I'd like to double check the full code
Here is the full assistant.py file at this point. Compare yours against it:
import json
import os
from dotenv import load_dotenv
from anthropic import Anthropic
load_dotenv()
client = Anthropic()
TOOLS = [
{
"name": "get_weather",
"description": "Get the current weather for a given city. Returns temperature in Celsius and a short description of conditions.",
"input_schema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name, e.g. 'London' or 'New York'"
}
},
"required": ["city"]
}
},
{
"name": "convert_units",
"description": "Convert a value from one unit to another. Supports temperature (celsius/fahrenheit), distance (miles/kilometers), and weight (pounds/kilograms).",
"input_schema": {
"type": "object",
"properties": {
"value": {
"type": "number",
"description": "The numeric value to convert"
},
"from_unit": {
"type": "string",
"description": "The unit to convert from, e.g. 'celsius', 'miles', 'pounds'"
},
"to_unit": {
"type": "string",
"description": "The unit to convert to, e.g. 'fahrenheit', 'kilometers', 'kilograms'"
}
},
"required": ["value", "from_unit", "to_unit"]
}
}
]
WEATHER_DATA = {
"london": {"temp": 14, "condition": "Cloudy with light rain"},
"new york": {"temp": 22, "condition": "Sunny and clear"},
"tokyo": {"temp": 26, "condition": "Humid and partly cloudy"},
"paris": {"temp": 18, "condition": "Overcast"},
"sydney": {"temp": 12, "condition": "Cool and windy"},
}
def get_weather(city):
city_lower = city.lower()
if city_lower in WEATHER_DATA:
data = WEATHER_DATA[city_lower]
return json.dumps({
"city": city,
"temperature_celsius": data["temp"],
"condition": data["condition"]
})
return json.dumps({
"city": city,
"error": f"Weather data not available for {city}. Available cities: {', '.join(WEATHER_DATA.keys())}"
})
def convert_units(value, from_unit, to_unit):
from_unit = from_unit.lower()
to_unit = to_unit.lower()
conversions = {
("celsius", "fahrenheit"): lambda v: v * 9 / 5 + 32,
("fahrenheit", "celsius"): lambda v: (v - 32) * 5 / 9,
("miles", "kilometers"): lambda v: v * 1.60934,
("kilometers", "miles"): lambda v: v / 1.60934,
("pounds", "kilograms"): lambda v: v * 0.453592,
("kilograms", "pounds"): lambda v: v / 0.453592,
}
key = (from_unit, to_unit)
if key in conversions:
result = conversions[key](value)
return json.dumps({
"value": value,
"from_unit": from_unit,
"to_unit": to_unit,
"result": round(result, 2)
})
return json.dumps({
"error": f"Cannot convert from {from_unit} to {to_unit}. Supported: celsius/fahrenheit, miles/kilometers, pounds/kilograms"
})
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system="You are a helpful assistant with access to weather data and unit conversion tools. Use your tools when the user asks about weather or needs a conversion. Be concise and friendly.",
tools=TOOLS,
messages=[
{"role": "user", "content": "What's the weather like in London?"}
]
)
print(response.content)
print("Stop reason:", response.stop_reason)
Claude now knows your tools exist and is asking your code to run them. Next up, you will build the handler that executes the function and sends the result back so Claude can give you a real answer.
Handle Tool Calls and Return Results
In the previous step, you saw Claude respond with a tool_use block instead of plain text. Claude is saying "I want to call this function with these arguments." Your code needs to actually run the function and give Claude the result.
This is the core loop of every AI agent in production: the model decides which tool to call, your code executes it, and the model uses the result to form a natural language answer. You will build this loop now.
In this step, get ready to:
- Create a dispatcher function that routes tool calls to the correct Python function.
- Build a chat function that handles the full tool-use loop with the Claude API.
- Test the loop end-to-end and see Claude return a natural language answer after calling a tool.
Create the tool execution dispatcher
When Claude responds with a tool call, the response contains the function name and the arguments it wants to pass. Your code needs a single entry point that looks at the function name and routes the call to the correct Python function.
- In assistant.py, find the test code at the bottom of your file. It starts with:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
- Delete everything from that response = client.messages.create( line down to and including the two print() lines at the very end of the file.
- In the space where the test code was, add the execute_tool function by pasting this code:
def execute_tool(tool_name, tool_input):
if tool_name == "get_weather":
return get_weather(tool_input["city"])
elif tool_name == "convert_units":
return convert_units(
tool_input["value"],
tool_input["from_unit"],
tool_input["to_unit"]
)
return json.dumps({"error": f"Unknown tool: {tool_name}"})
What does this code do?
This function is a dispatcher. It takes the tool name and arguments that Claude requested and routes them to the correct Python function.
- tool_name is the string Claude chose from your tool definitions (like "get_weather").
- tool_input is a dictionary of arguments Claude filled in based on the JSON schema you defined.
- The fallback returns a JSON error string for any unknown tool name, preventing crashes if something unexpected happens.
- Save assistant.py.
Seeing a red underline in VS Code?
Make sure the execute_tool function is at the same indentation level as your other functions (not indented inside convert_units). It should start at column 0 with no leading spaces.
help me fix an indentation error in my execute_tool function
Build the chat function
The chat function is where the full tool-use loop lives. It sends the user's message to Claude, checks if Claude wants to call a tool, executes the tool, sends the result back, and repeats until Claude gives a final text answer.
- Below your execute_tool function, add the chat function by pasting this code:
def chat(user_message, conversation_history):
# Add the user's message to the conversation
conversation_history.append({
"role": "user",
"content": user_message
})
# Send the conversation to Claude with tool definitions
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system="You are a helpful assistant with access to weather data and unit conversion tools. Use your tools when the user asks about weather or needs a conversion. Be concise and friendly.",
tools=TOOLS,
messages=conversation_history
)
# Loop while Claude wants to use tools
while response.stop_reason == "tool_use":
# Find the tool_use block in Claude's response
tool_use_block = next(
block for block in response.content if block.type == "tool_use"
)
# Execute the requested tool
tool_result = execute_tool(tool_use_block.name, tool_use_block.input)
# Add Claude's response and the tool result to conversation history
conversation_history.append({
"role": "assistant",
"content": response.content
})
conversation_history.append({
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_use_block.id,
"content": tool_result
}
]
})
# Call Claude again with the tool result
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system="You are a helpful assistant with access to weather data and unit conversion tools. Use your tools when the user asks about weather or needs a conversion. Be concise and friendly.",
tools=TOOLS,
messages=conversation_history
)
# Extract the final text response
assistant_message = ""
for block in response.content:
if hasattr(block, "text"):
assistant_message += block.text
# Save Claude's final response to history
conversation_history.append({
"role": "assistant",
"content": response.content
})
return assistant_message
What does this code do?
This function implements the complete tool-use loop. Here is what each section does:
- conversation_history.append() adds the user's message to a running list that Claude can reference for context.
- The while response.stop_reason == "tool_use" loop keeps running as long as Claude wants to call tools. Claude might call multiple tools before giving a final answer.
- The tool_result message is sent with the tool_use_id so Claude knows which tool call this result belongs to.
- After the loop ends, the function extracts the text from Claude's final response and returns it as a string.
Why does the tool result use role "user"?
The Claude API requires tool results to be sent as a user message with a tool_result content block. Think of it as your code reporting back to Claude: "Here is what happened when I ran that function for you."
- Save assistant.py.
Seeing a syntax error?
Check that you fully deleted the old test code from the previous step. If any lines from the old response = client.messages.create( block remain, they will conflict with the new function.
Also confirm the chat function starts at column 0 (no leading spaces) and is placed below execute_tool, not inside it.
help me fix a syntax error in my chat function
Test the complete tool-use loop
Your tool-use loop is complete. Time to see it work end-to-end. You will add a few temporary test lines, run the script, and confirm Claude returns a natural language answer after calling your weather tool.
- At the very bottom of assistant.py (below the chat function), add these temporary test lines:
# Temporary test - delete after verifying
conversation_history = []
result = chat("What's the weather like in London?", conversation_history)
print(result)
- Save assistant.py.
- Run the script by entering this command in your terminal:
python assistant.py
You should see Claude respond with a natural language sentence about London's weather, something like "The weather in London is currently 14°C and cloudy with light rain."
What just happened behind the scenes?
- Your code sent "What's the weather like in London?" to Claude.
- Claude saw the available tools and responded with a tool_use block requesting get_weather with {"city": "London"}.
- Your execute_tool function ran get_weather("London") and returned the JSON result.
- Your code sent that result back to Claude as a tool_result message.
- Claude read the raw data and composed a friendly answer in plain English.
- Now delete the four temporary test lines you added (the comment, conversation_history = [], result = chat(...), and print(result)). You will build a proper conversation interface in the next step.
- Save assistant.py.
Seeing an API error or no output?
If you see an authentication error, make sure your .env file contains a valid API key and that load_dotenv() is still at the top of your file.
If the script hangs or takes a long time, this is normal for the first API call. It may take 5-10 seconds depending on your connection.
help me debug my tool-use loop
✔️ Awesome, I've got everything!
Great. Double check you have saved your file with the test lines removed.
ⓧ I'd like to double check the full code
Here is the complete assistant.py at this point (with the test lines already removed):
import json
import os
from dotenv import load_dotenv
from anthropic import Anthropic
load_dotenv()
client = Anthropic()
TOOLS = [
{
"name": "get_weather",
"description": "Get the current weather for a given city. Returns temperature in Celsius and a short description of conditions.",
"input_schema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name, e.g. 'London' or 'New York'"
}
},
"required": ["city"]
}
},
{
"name": "convert_units",
"description": "Convert a value from one unit to another. Supports temperature (celsius/fahrenheit), distance (miles/kilometers), and weight (pounds/kilograms).",
"input_schema": {
"type": "object",
"properties": {
"value": {
"type": "number",
"description": "The numeric value to convert"
},
"from_unit": {
"type": "string",
"description": "The unit to convert from, e.g. 'celsius', 'miles', 'pounds'"
},
"to_unit": {
"type": "string",
"description": "The unit to convert to, e.g. 'fahrenheit', 'kilometers', 'kilograms'"
}
},
"required": ["value", "from_unit", "to_unit"]
}
}
]
WEATHER_DATA = {
"london": {"temp": 14, "condition": "Cloudy with light rain"},
"new york": {"temp": 22, "condition": "Sunny and clear"},
"tokyo": {"temp": 26, "condition": "Humid and partly cloudy"},
"paris": {"temp": 18, "condition": "Overcast"},
"sydney": {"temp": 12, "condition": "Cool and windy"},
}
def get_weather(city):
city_lower = city.lower()
if city_lower in WEATHER_DATA:
data = WEATHER_DATA[city_lower]
return json.dumps({
"city": city,
"temperature_celsius": data["temp"],
"condition": data["condition"]
})
return json.dumps({
"city": city,
"error": f"Weather data not available for {city}. Available cities: {', '.join(WEATHER_DATA.keys())}"
})
def convert_units(value, from_unit, to_unit):
from_unit = from_unit.lower()
to_unit = to_unit.lower()
conversions = {
("celsius", "fahrenheit"): lambda v: v * 9 / 5 + 32,
("fahrenheit", "celsius"): lambda v: (v - 32) * 5 / 9,
("miles", "kilometers"): lambda v: v * 1.60934,
("kilometers", "miles"): lambda v: v / 1.60934,
("pounds", "kilograms"): lambda v: v * 0.453592,
("kilograms", "pounds"): lambda v: v / 0.453592,
}
key = (from_unit, to_unit)
if key in conversions:
result = conversions[key](value)
return json.dumps({
"value": value,
"from_unit": from_unit,
"to_unit": to_unit,
"result": round(result, 2)
})
return json.dumps({
"error": f"Cannot convert from {from_unit} to {to_unit}. Supported: celsius/fahrenheit, miles/kilometers, pounds/kilograms"
})
def execute_tool(tool_name, tool_input):
if tool_name == "get_weather":
return get_weather(tool_input["city"])
elif tool_name == "convert_units":
return convert_units(
tool_input["value"],
tool_input["from_unit"],
tool_input["to_unit"]
)
return json.dumps({"error": f"Unknown tool: {tool_name}"})
def chat(user_message, conversation_history):
conversation_history.append({
"role": "user",
"content": user_message
})
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system="You are a helpful assistant with access to weather data and unit conversion tools. Use your tools when the user asks about weather or needs a conversion. Be concise and friendly.",
tools=TOOLS,
messages=conversation_history
)
while response.stop_reason == "tool_use":
tool_use_block = next(
block for block in response.content if block.type == "tool_use"
)
tool_result = execute_tool(tool_use_block.name, tool_use_block.input)
conversation_history.append({
"role": "assistant",
"content": response.content
})
conversation_history.append({
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_use_block.id,
"content": tool_result
}
]
})
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system="You are a helpful assistant with access to weather data and unit conversion tools. Use your tools when the user asks about weather or needs a conversion. Be concise and friendly.",
tools=TOOLS,
messages=conversation_history
)
assistant_message = ""
for block in response.content:
if hasattr(block, "text"):
assistant_message += block.text
conversation_history.append({
"role": "assistant",
"content": response.content
})
return assistant_message
Your assistant can now call tools and return real answers. In the next step, you will wrap everything in a conversation loop so you can chat back and forth with your assistant in real time.
Build the Conversation Loop
Your chat() function handles the entire tool-use loop. It sends a message, detects tool calls, executes functions, and returns Claude's final answer.
But right now, there is no way to actually talk to your assistant. In this step, you will wrap everything in an interactive loop so you can type questions, get answers, and hold a real conversation with memory across turns.
In this step, get ready to:
- Write a main() function with an interactive input loop.
- Test your assistant with weather and unit conversion questions.
- Verify that Claude remembers context across multiple turns.
Write the main function
Your chat() function handles one exchange at a time. To hold a real conversation, you need a loop that repeatedly prompts for input, passes it to chat(), and prints the response.
The key detail is the conversation_history list. Because you pass the same list into every call, Claude sees the full conversation so far and can reference earlier messages.
- In assistant.py, scroll to the very bottom of the file (after the chat() function) and add the following code:
def main():
# Keep a single list that grows with each exchange
conversation_history = []
print("AI Assistant (type 'quit' to exit)")
print("-" * 40)
while True:
user_input = input("\nYou: ").strip()
# Let the user exit gracefully
if user_input.lower() in ("quit", "exit", "q"):
print("Goodbye!")
break
# Skip empty messages
if not user_input:
continue
try:
response = chat(user_input, conversation_history)
print(f"\nAssistant: {response}")
except Exception as e:
print(f"\nError: {e}")
print("Something went wrong. Please try again.")
if __name__ == "__main__":
main()
What does this code do?
- conversation_history = [] creates an empty list that accumulates every message. This is how Claude "remembers" earlier turns.
- The while True loop keeps the conversation going until the user types quit, exit, or q.
- The try/except block catches API errors (network issues, rate limits) so the loop does not crash on a single failed request.
- if __name__ == "__main__": main() makes the script runnable from the terminal. Python only calls main() when you execute the file directly.
- Save assistant.py.
- Run your assistant by executing this command in your terminal:
python assistant.py
You should see the header print out, followed by a You: prompt waiting for your input.
Not seeing the prompt?
- Make sure you saved assistant.py after adding the new code.
- Check that the if __name__ == "__main__": line is at the very bottom of the file, with no indentation.
- Confirm your virtual environment is still active (you should see (venv) in your terminal prompt).
Why isn't my assistant.py running?
Test a multi-turn conversation
Your assistant is running. Time to put it through its paces and confirm it can handle tool calls and remember context across turns.
- Type a weather question at the You: prompt. Try something like:
What's the weather in Tokyo?
Claude should respond with the temperature (26 degrees Celsius) and conditions (humid and partly cloudy) from your simulated data.
- Now test a follow-up that relies on memory. Type:
Convert that temperature to Fahrenheit
Claude remembers that "that temperature" means 26 Celsius from the previous turn. It should call the convert_units tool and respond with 78.8 Fahrenheit.
Why does this work without repeating the number?
The conversation_history list contains every previous message, including tool results. When Claude sees "that temperature", it looks back through the history and finds the 26 Celsius value from the weather result.
This is the power of multi-turn context. Without the history list, each message would be completely isolated.
- Try one more question to test a regular (non-tool) response:
What's the capital of France?
Claude answers from its own knowledge without calling any tools. The assistant knows when a tool is needed and when it can answer directly.
- Type quit to exit the assistant.
Getting an API error?
- If you see an authentication error, double-check that your .env file contains a valid key and is in the same folder as assistant.py.
- If you see a rate limit error, wait a minute and try again. The error handling keeps the loop alive so you can retry without restarting.
Help me debug my Claude API error
✔️ Awesome, I've got everything!
Great. Double check that you have saved assistant.py and that your conversation test worked as expected.
ⓧ I'd like to double check the full code
Here is the complete assistant.py file. Make sure your code matches this exactly.
import json
import os
from dotenv import load_dotenv
from anthropic import Anthropic
load_dotenv()
client = Anthropic()
TOOLS = [
{
"name": "get_weather",
"description": "Get the current weather for a given city. Returns temperature in Celsius and a short description of conditions.",
"input_schema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name, e.g. 'London' or 'New York'"
}
},
"required": ["city"]
}
},
{
"name": "convert_units",
"description": "Convert a value from one unit to another. Supports temperature (celsius/fahrenheit), distance (miles/kilometers), and weight (pounds/kilograms).",
"input_schema": {
"type": "object",
"properties": {
"value": {
"type": "number",
"description": "The numeric value to convert"
},
"from_unit": {
"type": "string",
"description": "The unit to convert from, e.g. 'celsius', 'miles', 'pounds'"
},
"to_unit": {
"type": "string",
"description": "The unit to convert to, e.g. 'fahrenheit', 'kilometers', 'kilograms'"
}
},
"required": ["value", "from_unit", "to_unit"]
}
}
]
WEATHER_DATA = {
"london": {"temp": 14, "condition": "Cloudy with light rain"},
"new york": {"temp": 22, "condition": "Sunny and clear"},
"tokyo": {"temp": 26, "condition": "Humid and partly cloudy"},
"paris": {"temp": 18, "condition": "Overcast"},
"sydney": {"temp": 12, "condition": "Cool and windy"},
}
def get_weather(city):
city_lower = city.lower()
if city_lower in WEATHER_DATA:
data = WEATHER_DATA[city_lower]
return json.dumps({
"city": city,
"temperature_celsius": data["temp"],
"condition": data["condition"]
})
return json.dumps({
"city": city,
"error": f"Weather data not available for {city}. Available cities: {', '.join(WEATHER_DATA.keys())}"
})
def convert_units(value, from_unit, to_unit):
from_unit = from_unit.lower()
to_unit = to_unit.lower()
conversions = {
("celsius", "fahrenheit"): lambda v: v * 9 / 5 + 32,
("fahrenheit", "celsius"): lambda v: (v - 32) * 5 / 9,
("miles", "kilometers"): lambda v: v * 1.60934,
("kilometers", "miles"): lambda v: v / 1.60934,
("pounds", "kilograms"): lambda v: v * 0.453592,
("kilograms", "pounds"): lambda v: v / 0.453592,
}
key = (from_unit, to_unit)
if key in conversions:
result = conversions[key](value)
return json.dumps({
"value": value,
"from_unit": from_unit,
"to_unit": to_unit,
"result": round(result, 2)
})
return json.dumps({
"error": f"Cannot convert from {from_unit} to {to_unit}. Supported: celsius/fahrenheit, miles/kilometers, pounds/kilograms"
})
def execute_tool(tool_name, tool_input):
if tool_name == "get_weather":
return get_weather(tool_input["city"])
elif tool_name == "convert_units":
return convert_units(
tool_input["value"],
tool_input["from_unit"],
tool_input["to_unit"]
)
return json.dumps({"error": f"Unknown tool: {tool_name}"})
def chat(user_message, conversation_history):
conversation_history.append({
"role": "user",
"content": user_message
})
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system="You are a helpful assistant with access to weather data and unit conversion tools. Use your tools when the user asks about weather or needs a conversion. Be concise and friendly.",
tools=TOOLS,
messages=conversation_history
)
while response.stop_reason == "tool_use":
tool_use_block = next(
block for block in response.content if block.type == "tool_use"
)
tool_result = execute_tool(tool_use_block.name, tool_use_block.input)
conversation_history.append({
"role": "assistant",
"content": response.content
})
conversation_history.append({
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_use_block.id,
"content": tool_result
}
]
})
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system="You are a helpful assistant with access to weather data and unit conversion tools. Use your tools when the user asks about weather or needs a conversion. Be concise and friendly.",
tools=TOOLS,
messages=conversation_history
)
assistant_message = ""
for block in response.content:
if hasattr(block, "text"):
assistant_message += block.text
conversation_history.append({
"role": "assistant",
"content": response.content
})
return assistant_message
def main():
conversation_history = []
print("AI Assistant (type 'quit' to exit)")
print("-" * 40)
while True:
user_input = input("\nYou: ").strip()
if user_input.lower() in ("quit", "exit", "q"):
print("Goodbye!")
break
if not user_input:
continue
try:
response = chat(user_input, conversation_history)
print(f"\nAssistant: {response}")
except Exception as e:
print(f"\nError: {e}")
print("Something went wrong. Please try again.")
if __name__ == "__main__":
main()
Your AI assistant is fully functional. You have a working tool-use loop, multi-turn memory, and graceful error handling. Next up, the Secret Mission challenges you to connect your assistant to live data from the real world.
Secret mission
Add a Live Data Tool
Your assistant currently uses fake weather data for just five cities. In this challenge, you'll connect it to a real API so it can fetch live conditions for any location in the world.
Clean Up Your Resources
Clean Up Your Resources
This project uses the Claude API, which charges per token. You are only charged when you make API calls, so there are no ongoing costs once you stop chatting with your assistant.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Anthropic API key (stored in the Anthropic Console).
- Local project files (assistant.py, .env, virtual environment folder).
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your assistant.
- Your API key remains active in the Anthropic Console.
- You are only charged when you make API calls. Normal usage for this project costs less than $0.05 total.
- Your Anthropic Console account is free and has no ongoing charges.
Pause - I'll come back to this later
No infrastructure to shut down. You are only charged when you make API calls, so leaving everything in place costs nothing.
- Your project files stay on your Desktop until you are ready to return.
- Your API key remains active but generates no charges while idle.
- Deactivate your virtual environment by running:
deactivate
This frees your terminal session from the virtual environment without deleting anything.
Delete - I don't want to use this again
Remove all project resources and revoke API access.
- Delete your API key in the Anthropic Console under Settings > API Keys. Click the key you created for this project and select the delete option.
- Delete your local project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/ai-assistant
- On Windows, run this instead:
Remove-Item -Recurse -Force ~\Desktop\ai-assistant
- If you exported ANTHROPIC_API_KEY in your shell profile (~/.zshrc or ~/.bashrc), open that file and remove the export line.
Nice Work!
Nice Work!
You built a tool-using AI assistant from scratch. This is the exact pattern behind every AI agent, copilot, and intelligent feature in production today.
You've learned how to:
- Connected to the Claude API using the official Python SDK and crafted system prompts to shape Claude's behavior.
- Defined tools with JSON schemas so Claude knows what functions are available and how to call them.
- Implemented the complete tool-use loop: the model decides to call a tool, your code executes it, and the model uses the result to form a natural language answer.
- Secret Mission: Added a live data integration with a public API, making your assistant return real-world data instead of simulated responses.
Ready to quiz yourself?