Build a Personal Assistant with Claude
Build a terminal AI assistant using Claude's tool use and agentic loops.
Introduction
30 Second Summary
Think about how useful it would be to have a personal assistant that can check the weather, do quick math, and manage your to-do list all in one conversation. That's exactly what AI agents do by combining a language model's understanding with the ability to take real actions.
In this project, you'll build a terminal-based personal assistant powered by Claude that can autonomously decide which tools to call and execute them in sequence. You'll learn the exact pattern that powers every production AI agent built on large language models.
What You'll Build
A Python chatbot that connects to the Claude API and uses tool use to call external functions for weather lookups, calculations, and to-do list management.
By the end of this project, you'll have:
- A working connection to the Claude API with tool definitions that Claude can understand and invoke.
- Three tool implementations (weather, calculator, to-do list) that Claude calls autonomously based on your questions.
- An agentic loop that handles multiple sequential tool calls in a single conversation turn and recovers from errors gracefully.
- Secret Mission: A smart search tool that lets Claude filter and summarize your to-do list by keyword and date.
Are there any prerequisites?
You'll need basic Python knowledge (variables, functions, dictionaries, and loops). No prior AI or LLM experience is required.
Before We Start
Before We Start...
Set Up Your Environment
Before you can build your personal assistant, you need the right tools in place. Python will run your code, the Anthropic SDK will connect you to Claude, and an API key will authenticate your requests.
In this step, you will install Python, create a project folder with a virtual environment, install the required packages, and securely store your API key.
In this step, get ready to:
- Install and verify Python 3.9+.
- Install the Anthropic Python SDK and python-dotenv.
- Create an Anthropic API account and store your key in a .env file.
Install Python and create your project folder
This project requires Python 3.9 or higher. Let's check whether you already have it installed.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or cmd (Windows) and press Enter to open it.
- Check your Python version by running this command:
macOS
python3 --version
Windows
python --version
✔️ I see version 3.9 or higher
You're all set. Continue to the next instruction below to create your project folder.
ⓧ I see an older version
You have Python installed, but it's below 3.9. You need to install a newer version.
- Visit python.org/downloads and download the latest Python 3 installer for your operating system.
- Run the installer. On Windows, make sure to check Add Python to PATH before clicking Install Now.
- Close and reopen your terminal, then run the version check command again to confirm the update.
macOS users with an older system Python
macOS includes a system-managed Python that may be older than 3.9. After installing from python.org, you may need to use python3.12 (or whichever specific version you installed) instead of python3 if the system version still takes precedence.
Close and reopen your terminal after installing. The python.org installer should update your PATH so that python3 points to the new version.
ⓧ Command not found
Python is not installed on your machine yet.
- Visit python.org/downloads and download the latest Python 3 installer for your operating system.
- Run the installer. On Windows, make sure to check Add Python to PATH before clicking Install Now.
- Close and reopen your terminal, then run the version check command again to confirm the installation.
- 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
- Create a virtual environment by running this command:
macOS
python3 -m venv venv
Windows
python -m venv venv
What is a virtual environment?
A virtual environment is an isolated space for your project's packages. It keeps this project's dependencies separate from other Python projects on your machine.
- Activate the virtual environment by running:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.
Don't see (venv) in your prompt?
- Make sure you ran the activation command for your operating system (they differ between macOS and Windows).
- On Windows, if you see an execution policy error, run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned in PowerShell first, then try activating again.
- Confirm you are inside the claude-assistant folder by running ls (macOS) or dir (Windows). You should see a venv folder listed.
help me activate my Python virtual environment
Install the Anthropic Python SDK
The Anthropic SDK is the official Python library for communicating with Claude's API. It handles authentication, request formatting, and response parsing for you.
- Install the Anthropic SDK by running this command:
pip install anthropic
What does this command do?
pip install anthropic downloads the official Anthropic Python SDK from PyPI and installs it into your virtual environment. This gives you access to the Anthropic client class that you will use to send messages to Claude.
- Verify the installation by opening a Python shell and importing the package:
macOS
python3 -c "from anthropic import Anthropic; print('Anthropic SDK installed successfully')"
Windows
python -c "from anthropic import Anthropic; print('Anthropic SDK installed successfully')"
You should see Anthropic SDK installed successfully printed in your terminal.
Seeing a ModuleNotFoundError?
- Make sure your virtual environment is active. You should see (venv) in your terminal prompt.
- If you accidentally installed the package outside the virtual environment, activate it first, then run pip install anthropic again.
help me fix the ModuleNotFoundError for the anthropic package
Create your Anthropic API account and store your key
To call Claude from your code, you need an API key. This key authenticates your requests so Anthropic knows who is making the call.
- Open your browser and navigate to console.anthropic.com.
- Create an account or sign in if you already have one.
- Navigate to the API keys section of your account settings.
- Click the button to create a new API key.
- Copy the key immediately. You will not be able to see it again after you close the dialog.
Keep your API key safe
Your API key is like a password. Never share it publicly, commit it to version control, or paste it directly into your code. You will store it in a .env file that stays local to your machine.
- Install the python-dotenv package so your code can load the key securely:
pip install python-dotenv
What is python-dotenv?
python-dotenv reads key-value pairs from a .env file and loads them as environment variables. The Anthropic SDK automatically looks for the ANTHROPIC_API_KEY environment variable when you create a client.
- Create a .env file in your claude-assistant folder by running:
macOS
touch .env
Windows
New-Item .env
- Open .env in a text editor and paste the following, replacing your-api-key-here with the key you copied from the Anthropic console:
ANTHROPIC_API_KEY=your-api-key-here
- Save the .env file.
Why not use a variable placeholder for the API key?
API keys are sensitive credentials. You paste your real key directly in place of your-api-key-here so it never gets stored or displayed anywhere except your local .env file.
✔️ Awesome, I've got everything!
Great. Double check you have saved your .env file in the claude-assistant folder.
ⓧ I'd like to double check the full code
Your .env file should contain exactly this (with your real key in place of the placeholder):
ANTHROPIC_API_KEY=your-api-key-here
- Verify everything works by running this quick test:
macOS
python3 -c "from dotenv import load_dotenv; load_dotenv(); from anthropic import Anthropic; print('All set!')"
Windows
python -c "from dotenv import load_dotenv; load_dotenv(); from anthropic import Anthropic; print('All set!')"
You should see All set! printed to your terminal. This confirms both packages are installed and your .env file is readable.
Seeing an error about dotenv or anthropic?
- Make sure (venv) appears in your terminal prompt. Both packages were installed into the virtual environment.
- If you see an import error for dotenv, run pip install python-dotenv again while the virtual environment is active.
- Make sure your .env file is in the same folder where you are running the command (claude-assistant).
help me troubleshoot my Python package imports
Your environment is ready to go. Next up, you will create your assistant script and teach Claude about the tools it can use.
Define Your Tools and Make Your First Tool Call
Before Claude can use any tool, you need to describe that tool in a format it understands. Claude uses JSON Schema definitions to learn what tools are available, what inputs they expect, and when to use them.
In this step, you will define three tool schemas, send a test message to see Claude request a tool, and then implement the actual Python functions that execute when Claude calls them.
In this step, get ready to:
- Define three tool schemas that tell Claude what tools are available.
- Send a test message and observe Claude's tool_use response.
- Implement the Python functions that execute each tool.
Create assistant.py and define tool schemas
Tool schemas are JSON descriptions that tell Claude what each tool does, what parameters it accepts, and which parameters are required. Claude reads these schemas and decides on its own when to call a tool based on the user's message.
- Create a new file called assistant.py in your project folder.
- Add the imports and client setup by pasting this code:
import json
from datetime import datetime
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv()
client = Anthropic()
# In-memory to-do list
todos = []
What does this code do?
- load_dotenv() reads your .env file and loads ANTHROPIC_API_KEY into your environment.
- Anthropic() creates the API client. It automatically picks up the key from your environment variables.
- todos is a simple Python list that will store to-do items in memory while the script runs.
- Below the todos list, add the tool definitions that describe your three tools to Claude:
# Tool definitions for Claude
tools = [
{
"name": "get_weather",
"description": "Get the current weather for a given location. Returns temperature and conditions. Use this when the user asks about weather, temperature, or conditions in a specific place.",
"input_schema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and optionally country, e.g. 'London, UK' or 'Tokyo'",
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "The temperature unit to use. Defaults to celsius.",
},
},
"required": ["location"],
},
},
{
"name": "calculate",
"description": "Evaluate a mathematical expression and return the result. Use this for any arithmetic, calculations, or math problems the user asks about. Supports +, -, *, /, and parentheses.",
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "The math expression to evaluate, e.g. '(15 * 7) + 3'",
},
},
"required": ["expression"],
},
},
{
"name": "manage_todo",
"description": "Manage the user's to-do list. Can add items, list all items, or remove items. Use this when the user wants to track tasks, add reminders, or manage their to-do list.",
"input_schema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["add", "list", "remove"],
"description": "The action to perform on the to-do list.",
},
"item": {
"type": "string",
"description": "The to-do item text. Required for 'add' and 'remove' actions.",
},
},
"required": ["action"],
},
},
]
What does this code do?
Each tool definition has three parts:
- name is the identifier Claude uses to request the tool. It must match the regex ^[a-zA-Z0-9_-]{1,64}$.
- description tells Claude when and how to use the tool. Detailed descriptions produce better tool-calling accuracy.
- input_schema defines what parameters the tool accepts using JSON Schema. The required array lists which parameters Claude must always provide.
Claude reads these schemas and autonomously decides which tool to call based on the user's message. You never need to hardcode "if the user says weather, call weather."
- Save assistant.py.
Seeing a syntax error?
The tools list uses nested dictionaries with many brackets. Check that every opening { has a matching } and every opening [ has a matching ]. Also confirm that each dictionary entry ends with a comma except the last one before a closing bracket.
help me fix a syntax error in my tool definitions list
Send a test message to Claude
Now that Claude knows what tools exist, you can send it a message that should trigger a tool call. The response will contain a tool_use content block instead of (or alongside) regular text.
- Add this temporary test code at the bottom of assistant.py (below the tools list):
# Temporary test: send a message that should trigger tool use
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=1024,
tools=tools,
messages=[{"role": "user", "content": "What's the weather in London?"}],
)
print(f"Stop reason: {response.stop_reason}")
for block in response.content:
print(f"Block type: {block.type}")
if block.type == "tool_use":
print(f" Tool: {block.name}")
print(f" Input: {block.input}")
- Save assistant.py and run it:
python assistant.py
You should see output similar to this:
Stop reason: tool_use
Block type: text
Block type: tool_use
Tool: get_weather
Input: {'location': 'London'}
What does this output mean?
- stop_reason: tool_use means Claude stopped generating because it wants to call a tool. It is waiting for you to execute the tool and return the result.
- The response contains two content blocks: a text block (Claude saying something like "I'll check the weather for you") and a tool_use block with the tool name and input it chose.
- Claude decided on its own that get_weather was the right tool and extracted London as the location from your message.
Getting an authentication error?
Make sure your .env file is in the same folder as assistant.py and contains ANTHROPIC_API_KEY= followed by your actual key. Also verify your virtual environment is activated (you should see the environment name in your terminal prompt).
help me fix an authentication error with the Anthropic API
Implement the tool functions
Claude can now request tools, but those tools do not do anything yet. You need to write the actual Python functions that run when Claude calls each tool.
- In assistant.py, delete the temporary test code you added at the bottom (everything below the tools list).
- Add the three tool implementation functions between the todos = [] line and the # Tool definitions for Claude comment. Paste this code:
# Tool implementations
def get_weather(location, unit="celsius"):
"""Simulated weather lookup."""
weather_data = {
"london": {"temp": 14, "condition": "Cloudy"},
"tokyo": {"temp": 22, "condition": "Sunny"},
"paris": {"temp": 18, "condition": "Partly cloudy"},
"new york": {"temp": 20, "condition": "Clear"},
}
city = location.lower().split(",")[0].strip()
data = weather_data.get(city, {"temp": 16, "condition": "Unknown"})
temp = data["temp"]
if unit == "fahrenheit":
temp = round(temp * 9 / 5 + 32)
return json.dumps({
"location": location,
"temperature": temp,
"unit": unit,
"condition": data["condition"],
})
def calculate(expression):
"""Safely evaluate a math expression."""
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
raise ValueError(f"Invalid characters in expression: {expression}")
result = eval(expression)
return json.dumps({"expression": expression, "result": result})
def manage_todo(action, item=None):
"""Manage a to-do list."""
if action == "add" and item:
todos.append({"item": item, "added_at": datetime.now().isoformat()})
return json.dumps({"status": "added", "item": item, "total_items": len(todos)})
elif action == "list":
return json.dumps({"items": todos, "total_items": len(todos)})
elif action == "remove" and item:
todos[:] = [t for t in todos if t["item"].lower() != item.lower()]
return json.dumps({"status": "removed", "item": item, "total_items": len(todos)})
else:
raise ValueError(f"Invalid action '{action}' or missing item")
What does this code do?
- get_weather uses a dictionary of simulated weather data. It normalizes the city name to lowercase, looks it up, and returns a JSON string with temperature and conditions.
- calculate validates that the expression contains only safe characters (digits and math operators), then uses eval() to compute the result.
- manage_todo manipulates the in-memory todos list. It can add items (with a timestamp), list all items, or remove items by name.
Each function returns a JSON string because Claude expects tool results as text content it can read and summarize.
- Now add the mapping dictionary that connects tool names to their functions. Paste this below the tools list (after the closing ]):
# Map tool names to functions
tool_functions = {
"get_weather": lambda input: get_weather(input["location"], input.get("unit", "celsius")),
"calculate": lambda input: calculate(input["expression"]),
"manage_todo": lambda input: manage_todo(input["action"], input.get("item")),
}
What does this code do?
The tool_functions dictionary maps each tool name (the same string Claude sends back in its tool_use block) to a lambda that unpacks the input dictionary and calls the matching function.
This pattern makes it easy to dispatch tool calls. When Claude says "call get_weather with {location: London}", your code can look up tool_functions["get_weather"] and invoke it directly.
Each lambda uses .get() for optional parameters (like unit and item) so the code handles cases where Claude does not include them.
- Save assistant.py.
- Verify your file loads without errors by running:
python -c "import assistant; print('All good!')"
You should see All good! printed with no errors.
Seeing an ImportError or NameError?
Make sure the three functions (get_weather, calculate, manage_todo) are placed above the tools list in your file. Python reads files top to bottom, so the tool_functions lambdas need the functions to be defined before they reference them.
help me fix an import or name error in my assistant.py
✔️ Awesome, I've got everything!
Great. Double check you have saved assistant.py and the import test passed without errors.
ⓧ I'd like to double check the full code
Here is the complete assistant.py as it should look at the end of this step:
import json
from datetime import datetime
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv()
client = Anthropic()
# In-memory to-do list
todos = []
# Tool implementations
def get_weather(location, unit="celsius"):
"""Simulated weather lookup."""
weather_data = {
"london": {"temp": 14, "condition": "Cloudy"},
"tokyo": {"temp": 22, "condition": "Sunny"},
"paris": {"temp": 18, "condition": "Partly cloudy"},
"new york": {"temp": 20, "condition": "Clear"},
}
city = location.lower().split(",")[0].strip()
data = weather_data.get(city, {"temp": 16, "condition": "Unknown"})
temp = data["temp"]
if unit == "fahrenheit":
temp = round(temp * 9 / 5 + 32)
return json.dumps({
"location": location,
"temperature": temp,
"unit": unit,
"condition": data["condition"],
})
def calculate(expression):
"""Safely evaluate a math expression."""
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
raise ValueError(f"Invalid characters in expression: {expression}")
result = eval(expression)
return json.dumps({"expression": expression, "result": result})
def manage_todo(action, item=None):
"""Manage a to-do list."""
if action == "add" and item:
todos.append({"item": item, "added_at": datetime.now().isoformat()})
return json.dumps({"status": "added", "item": item, "total_items": len(todos)})
elif action == "list":
return json.dumps({"items": todos, "total_items": len(todos)})
elif action == "remove" and item:
todos[:] = [t for t in todos if t["item"].lower() != item.lower()]
return json.dumps({"status": "removed", "item": item, "total_items": len(todos)})
else:
raise ValueError(f"Invalid action '{action}' or missing item")
# Tool definitions for Claude
tools = [
{
"name": "get_weather",
"description": "Get the current weather for a given location. Returns temperature and conditions. Use this when the user asks about weather, temperature, or conditions in a specific place.",
"input_schema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and optionally country, e.g. 'London, UK' or 'Tokyo'",
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "The temperature unit to use. Defaults to celsius.",
},
},
"required": ["location"],
},
},
{
"name": "calculate",
"description": "Evaluate a mathematical expression and return the result. Use this for any arithmetic, calculations, or math problems the user asks about. Supports +, -, *, /, and parentheses.",
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "The math expression to evaluate, e.g. '(15 * 7) + 3'",
},
},
"required": ["expression"],
},
},
{
"name": "manage_todo",
"description": "Manage the user's to-do list. Can add items, list all items, or remove items. Use this when the user wants to track tasks, add reminders, or manage their to-do list.",
"input_schema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["add", "list", "remove"],
"description": "The action to perform on the to-do list.",
},
"item": {
"type": "string",
"description": "The to-do item text. Required for 'add' and 'remove' actions.",
},
},
"required": ["action"],
},
},
]
# Map tool names to functions
tool_functions = {
"get_weather": lambda input: get_weather(input["location"], input.get("unit", "celsius")),
"calculate": lambda input: calculate(input["expression"]),
"manage_todo": lambda input: manage_todo(input["action"], input.get("item")),
}
Why simulated weather data?
This project focuses on the tool use pattern, not on integrating third-party APIs. Simulated data lets you see the full request-execute-respond loop clearly without dealing with API keys, rate limits, or network errors from a weather service.
The pattern you are learning transfers directly to real APIs. Once you understand the loop, swapping in a live weather service is as simple as replacing the dictionary lookup with an HTTP request.
Your tools are defined and implemented. Next up, you will build the agentic loop that automatically executes tools and feeds results back to Claude.
Build the Agentic Loop
In the previous step, you sent Claude a message and saw it respond with a tool_use block. Claude told you which tool it wants to call and what inputs to pass. But nothing actually ran the tool.
The missing piece is an agentic loop. This is a pattern where your code keeps calling Claude, executing any tool requests, and feeding results back until Claude has everything it needs to answer. This is the exact architecture behind every production AI agent.
In this step, get ready to:
- Create a function that executes tool calls and formats the results.
- Build a loop that keeps calling Claude until it finishes responding.
- Test the loop with a multi-tool prompt.
Create the process_tool_call function
When Claude returns a tool_use block, your code needs to read which tool was requested, run it, and package the result in a specific format that Claude expects back.
- In assistant.py, find the tool_functions dictionary at the bottom of your file.
- Add the following function below it:
def process_tool_call(tool_use_block):
"""Execute a tool and return the result."""
tool_name = tool_use_block.name
tool_input = tool_use_block.input
try:
result = tool_functions[tool_name](tool_input)
return {
"type": "tool_result",
"tool_use_id": tool_use_block.id,
"content": result,
}
except Exception as e:
return {
"type": "tool_result",
"tool_use_id": tool_use_block.id,
"content": f"Error: {str(e)}",
"is_error": True,
}
What does this code do?
This function is the bridge between Claude's tool requests and your actual Python functions.
- tool_use_block.name and tool_use_block.input extract what Claude wants to call and with what arguments.
- The try/except block catches errors gracefully. If a tool fails, it returns is_error: True so Claude can tell the user what went wrong instead of crashing.
- The tool_use_id ties the result back to the specific tool request. Claude uses this to match results to requests.
- Save assistant.py.
Seeing a syntax error?
- Make sure you left a blank line between the tool_functions dictionary and the new function definition.
- Check that the try: and except Exception as e: lines are indented one level inside the function.
Help me debug my process_tool_call function
Build the agentic loop
Right now you can process one tool call. But Claude might need to call multiple tools before it can give a final answer. The agentic loop keeps calling Claude until it signals it is done by returning a stop_reason of "end_turn".
- Add the following chat function below process_tool_call in assistant.py:
def chat(user_message, conversation_history):
"""Send a message and handle the agentic tool-use loop."""
conversation_history.append({"role": "user", "content": user_message})
while True:
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=1024,
system="You are a helpful personal assistant. You have access to tools for checking weather, doing calculations, and managing a to-do list. Use them when appropriate to help the user.",
tools=tools,
messages=conversation_history,
)
# Add assistant response to history
conversation_history.append({"role": "assistant", "content": response.content})
# If Claude is done (no more tool calls), return the text
if response.stop_reason == "end_turn":
text_parts = [block.text for block in response.content if block.type == "text"]
return "\n".join(text_parts)
# Process all tool calls in the response
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = process_tool_call(block)
tool_results.append(result)
# Send tool results back to Claude
conversation_history.append({"role": "user", "content": tool_results})
What does this code do?
This is the core of your AI agent. Here is how the loop works:
- The user's message gets appended to conversation_history, which tracks the full conversation.
- Inside the while True loop, Claude is called with the full message history and tool definitions.
- If stop_reason is "end_turn", Claude is finished. The function extracts text blocks and returns them.
- If stop_reason is "tool_use", it processes every tool call, appends the results as a user message, and loops back to call Claude again with the new information.
Why are tool results sent as a user message?
Claude's API uses a strict alternating message format: user, assistant, user, assistant. Tool results are always sent in the user role because they come from your code (the "user" side), not from Claude. The tool_result type inside the content tells Claude these are tool outputs, not human text.
- Save assistant.py.
Seeing an indentation error?
- The entire while True: block must be indented one level inside the chat function.
- The if response.stop_reason == "end_turn": check must be indented two levels (inside the function and inside the loop).
Help me fix my chat function indentation
Test with a multi-tool prompt
Time to see the loop in action. You will add a few temporary lines at the bottom of your file to test the chat function, then remove them after confirming everything works.
- Add the following test code at the very bottom of assistant.py:
# Temporary test code - remove after testing
history = []
result = chat("What's 15 * 7 and also check the weather in Paris?", history)
print(result)
- Save assistant.py.
- Run the script by running:
python assistant.py
You should see Claude's response that includes both the calculation result (105) and the weather in Paris (18 degrees, partly cloudy). Claude called both tools sequentially and produced a single unified answer.
What just happened behind the scenes?
- Your code sent the message to Claude.
- Claude responded with stop_reason: "tool_use" and one or more tool_use blocks.
- Your loop executed the tools, sent the results back, and called Claude again.
- Claude combined all the tool results into a natural language response and returned stop_reason: "end_turn".
- Delete the temporary test code you added (the last 4 lines starting with # Temporary test code).
- Save assistant.py.
Not seeing a response?
- Check that your .env file contains a valid API key and is in the same folder as assistant.py.
- Make sure you did not accidentally delete the load_dotenv() call at the top of the file.
- If you see a timeout error, try running the script again. The API occasionally takes a moment on the first request.
Help me debug my agentic loop
✔️ Awesome, I've got everything!
Great. Double check you have saved assistant.py and removed the temporary test code.
ⓧ I'd like to double check the full code
Here is the full assistant.py at this point:
import json
from datetime import datetime
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv()
client = Anthropic()
# In-memory to-do list
todos = []
# Tool implementations
def get_weather(location, unit="celsius"):
"""Simulated weather lookup."""
weather_data = {
"london": {"temp": 14, "condition": "Cloudy"},
"tokyo": {"temp": 22, "condition": "Sunny"},
"paris": {"temp": 18, "condition": "Partly cloudy"},
"new york": {"temp": 20, "condition": "Clear"},
}
city = location.lower().split(",")[0].strip()
data = weather_data.get(city, {"temp": 16, "condition": "Unknown"})
temp = data["temp"]
if unit == "fahrenheit":
temp = round(temp * 9 / 5 + 32)
return json.dumps({
"location": location,
"temperature": temp,
"unit": unit,
"condition": data["condition"],
})
def calculate(expression):
"""Safely evaluate a math expression."""
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
raise ValueError(f"Invalid characters in expression: {expression}")
result = eval(expression)
return json.dumps({"expression": expression, "result": result})
def manage_todo(action, item=None):
"""Manage a to-do list."""
if action == "add" and item:
todos.append({"item": item, "added_at": datetime.now().isoformat()})
return json.dumps({"status": "added", "item": item, "total_items": len(todos)})
elif action == "list":
return json.dumps({"items": todos, "total_items": len(todos)})
elif action == "remove" and item:
todos[:] = [t for t in todos if t["item"].lower() != item.lower()]
return json.dumps({"status": "removed", "item": item, "total_items": len(todos)})
else:
raise ValueError(f"Invalid action '{action}' or missing item")
# Tool definitions for Claude
tools = [
{
"name": "get_weather",
"description": "Get the current weather for a given location. Returns temperature and conditions. Use this when the user asks about weather, temperature, or conditions in a specific place.",
"input_schema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and optionally country, e.g. 'London, UK' or 'Tokyo'",
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "The temperature unit to use. Defaults to celsius.",
},
},
"required": ["location"],
},
},
{
"name": "calculate",
"description": "Evaluate a mathematical expression and return the result. Use this for any arithmetic, calculations, or math problems the user asks about. Supports +, -, *, /, and parentheses.",
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "The math expression to evaluate, e.g. '(15 * 7) + 3'",
},
},
"required": ["expression"],
},
},
{
"name": "manage_todo",
"description": "Manage the user's to-do list. Can add items, list all items, or remove items. Use this when the user wants to track tasks, add reminders, or manage their to-do list.",
"input_schema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["add", "list", "remove"],
"description": "The action to perform on the to-do list.",
},
"item": {
"type": "string",
"description": "The to-do item text. Required for 'add' and 'remove' actions.",
},
},
"required": ["action"],
},
},
]
# Map tool names to functions
tool_functions = {
"get_weather": lambda input: get_weather(input["location"], input.get("unit", "celsius")),
"calculate": lambda input: calculate(input["expression"]),
"manage_todo": lambda input: manage_todo(input["action"], input.get("item")),
}
def process_tool_call(tool_use_block):
"""Execute a tool and return the result."""
tool_name = tool_use_block.name
tool_input = tool_use_block.input
try:
result = tool_functions[tool_name](tool_input)
return {
"type": "tool_result",
"tool_use_id": tool_use_block.id,
"content": result,
}
except Exception as e:
return {
"type": "tool_result",
"tool_use_id": tool_use_block.id,
"content": f"Error: {str(e)}",
"is_error": True,
}
def chat(user_message, conversation_history):
"""Send a message and handle the agentic tool-use loop."""
conversation_history.append({"role": "user", "content": user_message})
while True:
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=1024,
system="You are a helpful personal assistant. You have access to tools for checking weather, doing calculations, and managing a to-do list. Use them when appropriate to help the user.",
tools=tools,
messages=conversation_history,
)
# Add assistant response to history
conversation_history.append({"role": "assistant", "content": response.content})
# If Claude is done (no more tool calls), return the text
if response.stop_reason == "end_turn":
text_parts = [block.text for block in response.content if block.type == "text"]
return "\n".join(text_parts)
# Process all tool calls in the response
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = process_tool_call(block)
tool_results.append(result)
# Send tool results back to Claude
conversation_history.append({"role": "user", "content": tool_results})
Your agentic loop is working. Next up, you will wrap everything in a multi-turn conversation interface so you can chat back and forth with your assistant.
Add Multi-Turn Conversation and Error Handling
Your agentic loop can handle multi-tool requests in a single turn. But right now, you'd need to restart the script for every new question.
In this step, you'll wrap everything in an interactive conversation loop so you can chat continuously with your assistant. You'll also see how your existing error handling lets Claude recover gracefully when a tool fails.
In this step, get ready to:
- Build an interactive terminal loop for multi-turn conversation.
- Understand how error handling lets Claude recover from tool failures.
- Test your complete assistant with a realistic multi-turn session.
Add the interactive conversation loop
Your chat() function handles a single user message through the full agentic loop. To make this feel like a real assistant, you need a main() function that continuously reads input and keeps the conversation going.
- In assistant.py, scroll to the very bottom of the file (after the chat() function) and add the following code:
def main():
"""Run the interactive chat loop."""
print("=" * 50)
print(" Personal Assistant (powered by Claude)")
print(" Type 'quit' to exit")
print("=" * 50)
print()
conversation_history = []
while True:
user_input = input("You: ").strip()
if not user_input:
continue
if user_input.lower() == "quit":
print("Goodbye!")
break
response = chat(user_input, conversation_history)
print(f"\nAssistant: {response}\n")
if __name__ == "__main__":
main()
What does this code do?
- main() prints a welcome banner so the user knows how to exit, then creates an empty conversation_history list that persists across turns.
- The while True loop reads input, skips empty lines, and breaks on quit. Every other message passes through chat() which appends it to the shared history.
- Because conversation_history is the same list object on every call, Claude sees all prior messages. This is how it remembers context across turns.
- The if __name__ == "__main__": guard ensures main() only runs when you execute the script directly, not when it's imported as a module.
- Save assistant.py.
Seeing an indentation error?
Make sure main() is defined at the top level (no indentation), not nested inside chat(). There should be two blank lines between the end of chat() and the start of def main():.
help me fix an indentation error in my assistant.py file
✔️ 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 what your complete assistant.py should look like at this point:
import json
from datetime import datetime
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv()
client = Anthropic()
# In-memory to-do list
todos = []
# Tool implementations
def get_weather(location, unit="celsius"):
"""Simulated weather lookup."""
weather_data = {
"london": {"temp": 14, "condition": "Cloudy"},
"tokyo": {"temp": 22, "condition": "Sunny"},
"paris": {"temp": 18, "condition": "Partly cloudy"},
"new york": {"temp": 20, "condition": "Clear"},
}
city = location.lower().split(",")[0].strip()
data = weather_data.get(city, {"temp": 16, "condition": "Unknown"})
temp = data["temp"]
if unit == "fahrenheit":
temp = round(temp * 9 / 5 + 32)
return json.dumps({
"location": location,
"temperature": temp,
"unit": unit,
"condition": data["condition"],
})
def calculate(expression):
"""Safely evaluate a math expression."""
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
raise ValueError(f"Invalid characters in expression: {expression}")
result = eval(expression)
return json.dumps({"expression": expression, "result": result})
def manage_todo(action, item=None):
"""Manage a to-do list."""
if action == "add" and item:
todos.append({"item": item, "added_at": datetime.now().isoformat()})
return json.dumps({"status": "added", "item": item, "total_items": len(todos)})
elif action == "list":
return json.dumps({"items": todos, "total_items": len(todos)})
elif action == "remove" and item:
todos[:] = [t for t in todos if t["item"].lower() != item.lower()]
return json.dumps({"status": "removed", "item": item, "total_items": len(todos)})
else:
raise ValueError(f"Invalid action '{action}' or missing item")
# Tool definitions for Claude
tools = [
{
"name": "get_weather",
"description": "Get the current weather for a given location. Returns temperature and conditions. Use this when the user asks about weather, temperature, or conditions in a specific place.",
"input_schema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and optionally country, e.g. 'London, UK' or 'Tokyo'",
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "The temperature unit to use. Defaults to celsius.",
},
},
"required": ["location"],
},
},
{
"name": "calculate",
"description": "Evaluate a mathematical expression and return the result. Use this for any arithmetic, calculations, or math problems the user asks about. Supports +, -, *, /, and parentheses.",
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "The math expression to evaluate, e.g. '(15 * 7) + 3'",
},
},
"required": ["expression"],
},
},
{
"name": "manage_todo",
"description": "Manage the user's to-do list. Can add items, list all items, or remove items. Use this when the user wants to track tasks, add reminders, or manage their to-do list.",
"input_schema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["add", "list", "remove"],
"description": "The action to perform on the to-do list.",
},
"item": {
"type": "string",
"description": "The to-do item text. Required for 'add' and 'remove' actions.",
},
},
"required": ["action"],
},
},
]
# Map tool names to functions
tool_functions = {
"get_weather": lambda input: get_weather(input["location"], input.get("unit", "celsius")),
"calculate": lambda input: calculate(input["expression"]),
"manage_todo": lambda input: manage_todo(input["action"], input.get("item")),
}
def process_tool_call(tool_use_block):
"""Execute a tool and return the result."""
tool_name = tool_use_block.name
tool_input = tool_use_block.input
try:
result = tool_functions[tool_name](tool_input)
return {
"type": "tool_result",
"tool_use_id": tool_use_block.id,
"content": result,
}
except Exception as e:
return {
"type": "tool_result",
"tool_use_id": tool_use_block.id,
"content": f"Error: {str(e)}",
"is_error": True,
}
def chat(user_message, conversation_history):
"""Send a message and handle the agentic tool-use loop."""
conversation_history.append({"role": "user", "content": user_message})
while True:
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=1024,
system="You are a helpful personal assistant. You have access to tools for checking weather, doing calculations, and managing a to-do list. Use them when appropriate to help the user.",
tools=tools,
messages=conversation_history,
)
# Add assistant response to history
conversation_history.append({"role": "assistant", "content": response.content})
# If Claude is done (no more tool calls), return the text
if response.stop_reason == "end_turn":
text_parts = [block.text for block in response.content if block.type == "text"]
return "\n".join(text_parts)
# Process all tool calls in the response
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = process_tool_call(block)
tool_results.append(result)
# Send tool results back to Claude
conversation_history.append({"role": "user", "content": tool_results})
def main():
"""Run the interactive chat loop."""
print("=" * 50)
print(" Personal Assistant (powered by Claude)")
print(" Type 'quit' to exit")
print("=" * 50)
print()
conversation_history = []
while True:
user_input = input("You: ").strip()
if not user_input:
continue
if user_input.lower() == "quit":
print("Goodbye!")
break
response = chat(user_input, conversation_history)
print(f"\nAssistant: {response}\n")
if __name__ == "__main__":
main()
Understand your error handling
Your process_tool_call function already includes error handling that you built in the previous step. This is the safety net that prevents your assistant from crashing when a tool fails.
- In assistant.py, find the process_tool_call function and review the except block:
except Exception as e:
return {
"type": "tool_result",
"tool_use_id": tool_use_block.id,
"content": f"Error: {str(e)}",
"is_error": True,
}
Why does is_error matter?
When a tool fails, you have two choices: crash the entire program, or tell Claude what went wrong and let it respond gracefully. Setting is_error to True signals to Claude that the tool call failed.
Claude reads the error message and crafts a helpful response instead of breaking. For example, if the calculator receives an invalid expression, Claude will apologize and ask the user to rephrase rather than silently failing.
Test the complete assistant with a multi-turn conversation
Everything is in place. Your assistant can handle tool calls, recover from errors, and maintain conversation context across turns.
- Run your assistant by executing this command in your terminal:
python assistant.py
You should see the welcome banner appear:
==================================================
Personal Assistant (powered by Claude)
Type 'quit' to exit
==================================================
You:
- Try the following conversation to test all three tools and multi-turn context. Type each message at the You: prompt:
- Type What's the weather in Tokyo? and press Enter. Claude should respond with the simulated weather data (22 degrees, Sunny).
- Type Add 'buy umbrella' to my to-do list and press Enter. Claude should confirm the item was added.
- Type What's 15 * 7? and press Enter. Claude should return 105.
- Type What's on my to-do list? and press Enter. Claude should list the umbrella item you added earlier. This confirms multi-turn memory is working.
- Type quit to exit the assistant.
Why does multi-turn context work?
When you asked "What's on my to-do list?", Claude remembered the item you added in a previous turn. This works because conversation_history is a single list that grows with every exchange. Each API call sends the full history, so Claude always has the complete context.
Assistant not responding or crashing?
- If you see an authentication error, double check that your .env file contains your actual API key (not the placeholder your-api-key-here).
- If the script exits immediately without showing the banner, make sure the if __name__ == "__main__": line is at the very bottom of your file with no indentation.
- If you see a rate limit error, wait a few seconds and try again. The Haiku model processes requests quickly but your account may have a low initial rate limit.
help me debug why my assistant isn't responding
Secret mission
Add a Smart To-Do Search Tool
Your assistant can add and list to-do items, but what happens when the list grows? In this bonus challenge, you'll add a fourth tool that lets Claude search and filter your to-do list by keyword or date. This teaches you how to design tools with optional parameters and more complex logic.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally and only costs money when you actively run the script (each API call to Claude Haiku 4.5 costs fractions of a cent). There are no background processes or always-on cloud resources to worry about.
Decide whether to keep your project files, pause by deactivating your virtual environment, or delete everything entirely.
Resources you used:
- Project folder on your Desktop (contains assistant.py, .env, and your virtual environment).
- Anthropic API key (stored in your Anthropic Console account at console.anthropic.com).
Keep everything running
No action needed. Choose this if you plan to keep building on your assistant or want to experiment with new tools and prompts.
- Your project folder stays on your Desktop, ready to use any time.
- Your API key remains active. You only pay when you run the script.
- Your virtual environment stays in place with all packages installed.
Pause - I'll come back to this later
Free up terminal resources while keeping your files intact for later.
- If the script is running, press Ctrl+C to stop it.
- Deactivate your virtual environment by running:
deactivate
- Your project folder and .env file remain on your Desktop for next time.
- To resume later, navigate back to your project folder, reactivate the virtual environment, and run python assistant.py.
Delete - I don't want to use this again
Remove all project files and revoke your API key so nothing is left behind.
- Delete your project folder by running this command (macOS/Linux):
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]
- On Windows, run this instead:
cd ~/Desktop
rmdir /s /q [[PROJECT_FOLDER="your project folder name"]]
- Revoke your API key to prevent any future charges. Go to console.anthropic.com, navigate to your API keys settings, and delete the key you created for this project.
Nice Work!
Nice Work!
You've built your own AI agent from scratch. Your terminal-based personal assistant can check weather, do math, and manage a to-do list, all powered by Claude's tool use feature.
You've learned how to:
- Define tool schemas using JSON Schema that Claude can understand and invoke autonomously.
- Implement an agentic loop that handles multiple sequential tool calls in a single conversation turn.
- Create a multi-turn conversational interface with graceful error recovery using is_error tool results.
- Added a smart search tool with date filtering and keyword queries to your assistant.
Ready to quiz yourself?