Build a Research Assistant with Claude
Build a Python chatbot that uses Claude tool use to answer questions.
Introduction
30 Second Summary
AI chatbots are great at answering questions from memory, but they can't do math, look up words, or check today's date on their own. To make an AI truly useful, you need to give it tools it can reach for when it needs real information.
In this project, you will build a command-line research assistant powered by Claude that can call custom tools you define. You will teach Claude when to use a calculator, a dictionary, and a date/time lookup to answer complex questions in a single response.
What You'll Build
A Python command-line chatbot that uses the Claude API tool use protocol to call custom functions and synthesize results into natural language answers.
By the end of this project, you'll have:
- A working research assistant with three custom tools: a calculator, a dictionary lookup, and a date/time checker.
- A complete agentic loop that keeps calling tools until Claude has everything it needs to answer your question.
- Hands-on experience with the tool_use / tool_result protocol, including parallel tool calls and graceful error handling.
- Secret Mission: add a live news headlines tool that makes your assistant truly useful for daily research.
Are there any prerequisites?
You should be comfortable writing basic Python (variables, functions, loops). You also need an Anthropic API key, which you can get from the Anthropic Console.
Before We Start
Before We Start...
Set Up Your Project and Test the API
Before you can build a research assistant, you need a working connection to Claude. That means installing the Anthropic Python SDK and making sure your API key is set up correctly.
In this step, you'll create a project folder, install the SDK, and write a quick test script to confirm everything connects.
In this step, get ready to:
- Create a project folder and install the Anthropic Python SDK.
- Set your API key as an environment variable.
- Write and run a basic Claude API call to verify everything works.
Create your project folder and install the SDK
You need a dedicated folder for your project files and the Anthropic SDK installed inside it.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop and create your project folder by running these commands:
cd ~/Desktop
mkdir research-assistant
cd research-assistant
- Confirm you're in the right folder by running:
pwd
You should see a path ending in research-assistant.
- Install the Anthropic Python SDK by running:
pip install anthropic==0.97.0
What is the Anthropic SDK?
The Anthropic SDK is a Python library that handles all the details of sending requests to Claude's API. It manages authentication, request formatting, and response parsing so you can focus on building your assistant.
The SDK automatically reads your API key from an environment variable called ANTHROPIC_API_KEY. You'll set that up next.
- Check your terminal output for a line that says Successfully installed anthropic-0.97.0 (or similar).
Seeing a pip error?
- If your system says command not found: pip, try pip3 install anthropic==0.97.0 instead.
- If you see a permissions error, try adding --user at the end of the command.
- Make sure you have Python 3.9 or newer installed. Check with python --version or python3 --version.
Still stuck? Help me fix this pip install error
Now set your API key so the SDK can authenticate your requests to Claude.
macOS
- Set your API key by running this command (replace your-api-key-here with your actual Anthropic API key):
export ANTHROPIC_API_KEY="your-api-key-here"
Windows
- Set your API key by running this command (replace your-api-key-here with your actual Anthropic API key):
$env:ANTHROPIC_API_KEY = "your-api-key-here"
This key only lasts for your current session
The export (or $env:) command sets the variable for this terminal session only. If you close the terminal and reopen it, you'll need to run the command again.
Write a basic Claude API call
Before adding any tools, you want to confirm that Claude responds to a simple message. A quick test script catches setup issues early.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code (or the name of your code editor) and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the research-assistant folder, then click Open.
- Create a new file called assistant.py by clicking the new file icon in the Explorer sidebar (or press Cmd+N on macOS, Ctrl+N on Windows).
- Add the following code to assistant.py:
import anthropic
# Create a client that reads your API key from the environment
client = anthropic.Anthropic()
MODEL = "claude-haiku-4-5-20251001"
# Send a simple test message to Claude
response = client.messages.create(
model=MODEL,
max_tokens=1024,
messages=[{"role": "user", "content": "Hello! Can you introduce yourself in one sentence?"}],
)
# Print just the text from Claude's response
print(response.content[0].text)
What does this code do?
- anthropic.Anthropic() creates a client that automatically reads your ANTHROPIC_API_KEY environment variable. No need to pass the key directly in code.
- client.messages.create() sends a message to Claude and returns a response object. The messages list follows a chat format where each message has a role and content.
- response.content[0].text pulls the text out of Claude's response. Claude returns a list of content blocks, and [0].text grabs the first one.
- Save assistant.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Why Claude Haiku 4.5?
Claude Haiku 4.5 (claude-haiku-4-5-20251001) is Anthropic's fastest and cheapest model at $1 per million input tokens. For a learning project with simple tool calls, Haiku is more than capable. The entire project costs a few cents at most.
✔️ Awesome, I've got everything!
Make sure you've saved assistant.py before moving on.
ⓧ I'd like to double check the full code
Here's the complete assistant.py so far. Compare it against yours:
import anthropic
client = anthropic.Anthropic()
MODEL = "claude-haiku-4-5-20251001"
response = client.messages.create(
model=MODEL,
max_tokens=1024,
messages=[{"role": "user", "content": "Hello! Can you introduce yourself in one sentence?"}],
)
print(response.content[0].text)
Run the script and verify it works
Time to see if your setup is working. This test confirms your API key, SDK, and model access are all connected.
- Back in your terminal (the same session where you set your API key), run your script with:
python assistant.py
After a moment, you should see a one-sentence response from Claude printed in your terminal. Something like: "I'm Claude, an AI assistant made by Anthropic to be helpful, harmless, and honest."
Not seeing a response?
- If you see command not found: python, try python3 assistant.py instead.
- If you see AuthenticationError, your API key is missing or incorrect. Re-run the export command from the previous substep with the correct key.
- If you see ModuleNotFoundError: No module named 'anthropic', the SDK is not installed in the Python version you're running. Make sure you used the same pip/python pair (e.g. pip3 and python3).
Still stuck? Help me debug my Claude API connection
Your environment is ready and Claude is responding. Next, you'll define your first tool and teach Claude how to call it.
Define Your First Tool and Handle the Tool Use Response
Your script can send messages to Claude and get text back. That's a good start, but Claude can do more than generate text.
Tool use (also called function calling) lets Claude call functions you define to perform real actions like math, lookups, or API calls. In this step, you'll define a calculator tool, pass it to Claude, and handle the back-and-forth protocol that powers every tool-using AI agent.
In this step, get ready to:
- Define a calculator tool with a JSON Schema that tells Claude what the tool does and what inputs it expects.
- Send a math question to Claude along with the tool definition.
- Execute the calculator when Claude asks to use it, and send the result back for a final answer.
Define a calculator tool with JSON Schema
Tool definitions are JSON objects that describe what a tool does and what inputs it expects. Claude reads these definitions and decides when a tool is relevant to the user's question.
- In assistant.py, find this line at the very top of the file:
import anthropic
- Add import json above it, so the top of your file looks like this:
import json
import anthropic
- Now find the MODEL = "claude-haiku-4-5-20251001" line and copy the following code below it:
# --- Tool definitions (JSON Schema) ---
tools = [
{
"name": "calculator",
"description": "Evaluate a mathematical expression. Use this for any arithmetic, algebra, or math calculations. The expression should be a valid Python math expression using numbers and operators like +, -, *, /, **, (, ), %. Example expressions: '1547 / 23', '2**10', '(50 + 30) * 0.15'.",
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "A mathematical expression to evaluate, e.g. '2 + 2' or '100 * 0.15'"
}
},
"required": ["expression"]
}
}
]
What does this code do?
Each tool definition has three parts that Claude uses to decide when and how to call it:
- name is the identifier Claude uses to request this tool.
- description is natural language that tells Claude what the tool does and when to use it. The more specific the description, the better Claude's decisions.
- input_schema uses JSON Schema to define the parameters the tool accepts. Here, the calculator takes a single expression string.
- Save assistant.py.
You won't see a change yet. The next substep uses this tools list to give Claude something to call.
Send a math question with the tools list
When you pass a tools list to the API, Claude evaluates whether any tool would help answer the question. If Claude decides to use a tool, the response comes back with stop_reason: "tool_use" instead of "end_turn".
- In assistant.py, find and delete everything from response = client.messages.create( down to print(response.content[0].text) at the bottom of your file.
This is the old test code from Step 1. You're replacing it with a math question that triggers tool use.
- Copy the following code at the bottom of your file, below the tools list:
# --- Send a message with tools ---
user_message = "What is 1547 divided by 23?"
messages = [{"role": "user", "content": user_message}]
response = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=tools,
messages=messages,
)
What does this code do?
- user_message is a math question that should trigger the calculator tool.
- messages is the conversation history list. The Anthropic Messages API is stateless, so you must send the full history with every request.
- The tools=tools parameter passes your tool definitions to Claude. Claude reads these and decides whether to call one.
- Save assistant.py.
You won't see output yet. The next substep adds the code that actually runs the calculator and returns the result to Claude.
Execute the calculator and return results to Claude
When Claude responds with a tool_use block, your code needs to do three things: run the tool, package the result as a tool_result block with the matching tool_use_id, and send it back to Claude for a final answer.
First, you need a Python function that actually performs the calculation. Then you'll add the response handling code that ties it all together.
- In assistant.py, find the closing ] of the tools list and copy the following function below it, above the user_message line:
# --- Tool execution functions ---
def run_calculator(expression):
# Only allow safe math characters
allowed_chars = set("0123456789+-*/.() %e")
if not all(c in allowed_chars for c in expression.replace(" ", "")):
raise ValueError(f"Invalid characters in expression: {expression}")
result = eval(expression)
return {"result": result, "expression": expression}
What does this code do?
- allowed_chars is a basic safety check that blocks anything other than numbers and math operators before calling eval().
- eval() evaluates the math expression as Python code. This works for a learning project, but a production app would use a proper math parser.
- The function returns a dictionary with both the result and the original expression. Claude uses this to form a natural language response.
You won't see a change yet. The next chunk wires this function into the tool use response.
- Now add the response handling code at the very bottom of assistant.py, below the response = client.messages.create(...) call:
# --- Handle tool use response ---
if response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if block.type == "tool_use":
# Log which tool Claude wants to call
print(f" [Tool call: {block.name}({json.dumps(block.input)})]")
# Execute the calculator with Claude's input
result = run_calculator(block.input["expression"])
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result),
})
# Add Claude's response and tool results to conversation history
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
# Send results back to Claude for a final answer
response = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=tools,
messages=messages,
)
# Print Claude's final text response
for block in response.content:
if block.type == "text":
print(block.text)
What's happening here?
This is the core of the tool use protocol. It follows a specific pattern:
- response.stop_reason == "tool_use" tells you Claude wants to call a tool instead of giving a final answer.
- Each tool_use block in response.content contains the tool name, input (the parameters Claude chose), and a unique id.
- Each tool_result you send back must include the matching tool_use_id so Claude knows which tool call the result belongs to.
- Both the assistant response and tool results get appended to messages before the second API call. The API is stateless, so you always send the full conversation.
- Save assistant.py.
- Run your script by running this command:
python assistant.py
You should see a tool call log line like [Tool call: calculator({"expression": "1547 / 23"})] followed by Claude's natural language answer (something like "1547 divided by 23 is 67.26").
Not seeing the expected output?
- If you see an AuthenticationError, make sure your ANTHROPIC_API_KEY environment variable is still set. Run echo $ANTHROPIC_API_KEY to check.
- If you see a NameError for run_calculator, make sure the function is defined above the user_message line, not below the response handling code.
- If you see import json errors, make sure the import is at the very top of the file.
Help me debug my tool use response in assistant.py.
Why does tool use need two API calls?
Claude cannot execute code on its own. It can only request that your code runs a tool. The first API call is Claude saying "I need the calculator." The second call is you handing Claude the answer so it can write a final response.
This two-step handshake is the foundation of every tool-using AI agent. Your code is always in control of what actually runs.
✔️ Awesome, I've got everything!
Great. Double check you've saved assistant.py before moving on.
ⓧ I'd like to double check the full code
Here is the complete assistant.py file at the end of this step. Compare it against yours:
import json
import anthropic
client = anthropic.Anthropic()
MODEL = "claude-haiku-4-5-20251001"
tools = [
{
"name": "calculator",
"description": "Evaluate a mathematical expression. Use this for any arithmetic, algebra, or math calculations. The expression should be a valid Python math expression using numbers and operators like +, -, *, /, **, (, ), %. Example expressions: '1547 / 23', '2**10', '(50 + 30) * 0.15'.",
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "A mathematical expression to evaluate, e.g. '2 + 2' or '100 * 0.15'"
}
},
"required": ["expression"]
}
}
]
def run_calculator(expression):
allowed_chars = set("0123456789+-*/.() %e")
if not all(c in allowed_chars for c in expression.replace(" ", "")):
raise ValueError(f"Invalid characters in expression: {expression}")
result = eval(expression)
return {"result": result, "expression": expression}
user_message = "What is 1547 divided by 23?"
messages = [{"role": "user", "content": user_message}]
response = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=tools,
messages=messages,
)
if response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if block.type == "tool_use":
print(f" [Tool call: {block.name}({json.dumps(block.input)})]")
result = run_calculator(block.input["expression"])
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result),
})
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
response = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=tools,
messages=messages,
)
for block in response.content:
if block.type == "text":
print(block.text)
Your assistant can now call a calculator tool and return a real answer. In the next step, you'll add two more tools and build an agentic loop that handles multiple tool calls automatically.
Build the Agentic Loop with Multiple Tools
In the previous step, you taught Claude to use a single calculator tool. Your code handled one tool call and stopped. Real questions are rarely that simple.
In this step, you'll add two more tools and build an agentic loop. This loop keeps sending requests to Claude until it has everything it needs for a final answer. It is the core pattern behind every tool-using AI agent.
In this step, get ready to:
- Add dictionary lookup and date/time tools so Claude can answer more types of questions.
- Build the agentic while loop that keeps running tools until Claude has a final answer.
- Test a multi-tool query where Claude calls two tools in a single response.
Add two more tool definitions and their execution functions
Right now Claude only has a calculator. You'll give it two more tools: a dictionary lookup that fetches real definitions from the Free Dictionary API, and a date/time tool that returns the current moment.
The new tools need two new Python libraries. Your file currently starts with two imports. You need to add three more.
- In assistant.py, find these lines at the very top of the file:
import json
import anthropic
- Replace those two lines with the following, which adds datetime, urllib.request, and urllib.error:
import json
import datetime
import urllib.request
import urllib.error
import anthropic
Why the blank line between imports?
Python convention groups standard library imports (json, datetime, urllib) together, then separates third-party packages (anthropic) with a blank line. This makes it easy to see which imports come from Python itself versus packages you installed.
Now you'll add the two new tool definitions to the tools list. Each definition is a JSON Schema that tells Claude what the tool does, what inputs it expects, and when to use it.
- In assistant.py, scroll down to your tools list. Find the closing ] of the list (right after the calculator definition ends).
- Add a comma after the calculator's closing }, then paste the following two new tool definitions before the closing ]:
{
"name": "dictionary_lookup",
"description": "Look up the definition of an English word. Returns the word's meanings, parts of speech, and example usage. Use this when the user asks what a word means, asks for a definition, or wants to understand a word.",
"input_schema": {
"type": "object",
"properties": {
"word": {
"type": "string",
"description": "The English word to look up"
}
},
"required": ["word"]
}
},
{
"name": "get_current_datetime",
"description": "Get the current date and time. Use this when the user asks what time it is, what today's date is, or needs current date/time information.",
"input_schema": {
"type": "object",
"properties": {},
"required": []
}
}
What do these tool definitions do?
Each tool definition has three fields that Claude reads before deciding whether to call the tool.
- name is the unique identifier Claude uses when it calls the tool.
- description tells Claude WHEN to use the tool. The more specific your description, the better Claude's decisions.
- input_schema defines what inputs the tool accepts. Notice that get_current_datetime has an empty schema because it needs no input at all.
You won't see a change yet. Next, you'll write the Python functions that actually execute these tools.
The tool definitions tell Claude what tools exist. Now you need the actual Python functions that run when Claude calls them.
- In assistant.py, find the run_calculator function. Add the following two new functions directly below it (after the return {"result": result, "expression": expression} line):
def run_dictionary_lookup(word):
# Fetch the definition from the Free Dictionary API
url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}"
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=10) as response:
data = json.loads(response.read().decode())
# Parse the first entry's meanings and definitions
entry = data[0]
meanings = []
for meaning in entry.get("meanings", []):
part_of_speech = meaning.get("partOfSpeech", "")
definitions = []
for defn in meaning.get("definitions", [])[:2]:
d = {"definition": defn["definition"]}
if "example" in defn:
d["example"] = defn["example"]
definitions.append(d)
meanings.append({"partOfSpeech": part_of_speech, "definitions": definitions})
return {
"word": entry.get("word", word),
"phonetic": entry.get("phonetic", ""),
"meanings": meanings
}
def run_get_current_datetime():
# Return the current date, time, and day of week
now = datetime.datetime.now()
return {
"date": now.strftime("%Y-%m-%d"),
"time": now.strftime("%H:%M:%S"),
"day_of_week": now.strftime("%A"),
"timezone": "local"
}
What do these functions do?
- run_dictionary_lookup sends a request to the Free Dictionary API and parses the JSON response. It grabs up to 2 definitions per part of speech, keeping the result compact for Claude.
- run_get_current_datetime uses Python's datetime module to return the current date, time, and day of the week as a dictionary.
- Save assistant.py.
- Run the script to confirm there are no syntax or import errors by running:
python assistant.py
You should see the same calculator output as before (the answer to 1547 divided by 23). The new tools aren't wired up yet, but no errors means your imports and functions are correct.
Seeing a NameError or ImportError?
- Check that import datetime and import urllib.request are at the top of the file, before import anthropic.
- Make sure the new functions are outside the tools list, not accidentally indented inside it.
Why is my script crashing after adding the new imports?
Build the agentic while loop
Your current code handles exactly one tool call and stops. If Claude needs to call two tools in the same response, or needs to call a tool and then call another based on the result, the single-turn approach breaks down.
The agentic loop solves this. It keeps sending requests to Claude as long as Claude says "I need more tools." When Claude finally has everything it needs, the loop ends and you get the final answer.
First, you'll replace the old single-turn code with reusable functions. The old code starts at the line user_message = "What is 1547 divided by 23?" and runs all the way to the end of the file.
- In assistant.py, find this line:
user_message = "What is 1547 divided by 23?"
- Select everything from that line down to the very end of the file and delete it.
Now you'll add three things in the space you just cleared: a tool dispatcher, the agentic loop function, and a test call.
- Add the tool dispatcher function at the bottom of assistant.py (where the old code used to be) by pasting the following:
# --- Tool dispatcher ---
def execute_tool(name, tool_input):
if name == "calculator":
return run_calculator(tool_input["expression"])
elif name == "dictionary_lookup":
return run_dictionary_lookup(tool_input["word"])
elif name == "get_current_datetime":
return run_get_current_datetime()
else:
raise ValueError(f"Unknown tool: {name}")
What does the dispatcher do?
When Claude calls a tool, it gives you a tool name and some input. The execute_tool function routes that call to the right Python function. This keeps the agentic loop clean because it only needs to call one function instead of checking the tool name itself.
- Add the agentic loop function directly below execute_tool by pasting the following:
# --- Agentic loop ---
def run_agent(user_message, conversation_history):
conversation_history.append({"role": "user", "content": user_message})
response = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=tools,
messages=conversation_history,
)
while response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if block.type == "tool_use":
print(f" [Tool call: {block.name}({json.dumps(block.input)})]")
result = execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result),
})
conversation_history.append({"role": "assistant", "content": response.content})
conversation_history.append({"role": "user", "content": tool_results})
response = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=tools,
messages=conversation_history,
)
assistant_text = ""
for block in response.content:
if block.type == "text":
assistant_text += block.text
conversation_history.append({"role": "assistant", "content": response.content})
return assistant_text
What does the agentic loop do?
This function is the heart of your assistant. Here is how it works step by step.
- It sends the user's message to Claude along with the list of available tools.
- If Claude's stop_reason is "tool_use", that means Claude wants to call one or more tools before answering.
- The loop processes ALL tool_use blocks in the response. Claude can call multiple tools in parallel, and you must send all results back together in one user message.
- It appends the assistant's response and the tool results to conversation_history, then calls the API again. This repeats until Claude's stop_reason is no longer "tool_use".
- Once the loop ends, it extracts Claude's final text response and returns it.
Why append the raw response.content?
The Claude Messages API is stateless. It does not remember previous turns. You must send the full conversation history with every request, including assistant messages that contain tool_use blocks. That is why the code appends response.content directly, not just the text.
You won't see a change yet. The next substep adds a test call so you can watch the loop in action.
Test with a multi-tool query
Time to see the agentic loop in action. You'll send Claude a question that requires two different tools at once: a dictionary lookup and a calculation.
- Add the following test code at the very bottom of assistant.py, below the run_agent function:
# Test multi-tool query
history = []
result = run_agent("What does 'ephemeral' mean, and what's the square root of 144?", history)
print(result)
What does this test do?
This question is deliberately multi-tool. "What does 'ephemeral' mean" triggers the dictionary lookup. "What's the square root of 144" triggers the calculator. Claude should call both tools, get both results, and then write one combined answer.
- Save assistant.py.
- Run the script by running:
python assistant.py
You should see two [Tool call: ...] lines printed, one for dictionary_lookup and one for calculator. After those, Claude's final response combines both answers into one natural paragraph.
Only seeing one tool call?
Claude sometimes calls tools sequentially instead of in parallel. This is normal behavior. As long as you see both a dictionary result and a calculation result in the final answer, the agentic loop is working correctly. The loop runs as many iterations as Claude needs.
If you see an error instead, check that the execute_tool function's tool names match the names in your tools list exactly.
Help me debug my agentic loop. I'm not getting the expected multi-tool output.
✔️ Awesome, I've got everything!
Great. Double check you've saved assistant.py before moving on.
ⓧ I'd like to double check the full code
Here is the complete assistant.py file as it should look at the end of this step.
import json
import datetime
import urllib.request
import urllib.error
import anthropic
client = anthropic.Anthropic()
MODEL = "claude-haiku-4-5-20251001"
tools = [
{
"name": "calculator",
"description": "Evaluate a mathematical expression. Use this for any arithmetic, algebra, or math calculations. The expression should be a valid Python math expression using numbers and operators like +, -, *, /, **, (, ), %. Example expressions: '1547 / 23', '2**10', '(50 + 30) * 0.15'.",
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "A mathematical expression to evaluate, e.g. '2 + 2' or '100 * 0.15'"
}
},
"required": ["expression"]
}
},
{
"name": "dictionary_lookup",
"description": "Look up the definition of an English word. Returns the word's meanings, parts of speech, and example usage. Use this when the user asks what a word means, asks for a definition, or wants to understand a word.",
"input_schema": {
"type": "object",
"properties": {
"word": {
"type": "string",
"description": "The English word to look up"
}
},
"required": ["word"]
}
},
{
"name": "get_current_datetime",
"description": "Get the current date and time. Use this when the user asks what time it is, what today's date is, or needs current date/time information.",
"input_schema": {
"type": "object",
"properties": {},
"required": []
}
}
]
def run_calculator(expression):
allowed_chars = set("0123456789+-*/.() %e")
if not all(c in allowed_chars for c in expression.replace(" ", "")):
raise ValueError(f"Invalid characters in expression: {expression}")
result = eval(expression)
return {"result": result, "expression": expression}
def run_dictionary_lookup(word):
url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}"
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=10) as response:
data = json.loads(response.read().decode())
entry = data[0]
meanings = []
for meaning in entry.get("meanings", []):
part_of_speech = meaning.get("partOfSpeech", "")
definitions = []
for defn in meaning.get("definitions", [])[:2]:
d = {"definition": defn["definition"]}
if "example" in defn:
d["example"] = defn["example"]
definitions.append(d)
meanings.append({"partOfSpeech": part_of_speech, "definitions": definitions})
return {
"word": entry.get("word", word),
"phonetic": entry.get("phonetic", ""),
"meanings": meanings
}
def run_get_current_datetime():
now = datetime.datetime.now()
return {
"date": now.strftime("%Y-%m-%d"),
"time": now.strftime("%H:%M:%S"),
"day_of_week": now.strftime("%A"),
"timezone": "local"
}
def execute_tool(name, tool_input):
if name == "calculator":
return run_calculator(tool_input["expression"])
elif name == "dictionary_lookup":
return run_dictionary_lookup(tool_input["word"])
elif name == "get_current_datetime":
return run_get_current_datetime()
else:
raise ValueError(f"Unknown tool: {name}")
def run_agent(user_message, conversation_history):
conversation_history.append({"role": "user", "content": user_message})
response = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=tools,
messages=conversation_history,
)
while response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if block.type == "tool_use":
print(f" [Tool call: {block.name}({json.dumps(block.input)})]")
result = execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result),
})
conversation_history.append({"role": "assistant", "content": response.content})
conversation_history.append({"role": "user", "content": tool_results})
response = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=tools,
messages=conversation_history,
)
assistant_text = ""
for block in response.content:
if block.type == "text":
assistant_text += block.text
conversation_history.append({"role": "assistant", "content": response.content})
return assistant_text
# Test multi-tool query
history = []
result = run_agent("What does 'ephemeral' mean, and what's the square root of 144?", history)
print(result)
Your assistant can now call multiple tools and loop until it has a complete answer. There is one problem though: if a tool crashes, the whole script crashes. In the next step, you'll add error handling and turn this into an interactive chatbot.
Add Error Handling and an Interactive Chat Loop
Your agentic loop can call multiple tools and feed results back to Claude. There is one problem though. If any tool throws an error right now, the entire script crashes.
In this step, you will add error handling so Claude can recover gracefully when a tool fails. You will also replace the test code with an interactive chat loop so you can have real conversations with your assistant.
In this step, get ready to:
- Add try/except error handling so failed tools report errors instead of crashing.
- Build an interactive chat loop with multi-turn conversation history.
- Test error recovery and multi-turn conversations.
Wrap tool execution in error handling
Right now, if the dictionary API cannot find a word or the calculator gets an invalid expression, Python raises an exception and your script dies. A production agent needs to catch those errors and send them back to Claude so it can respond intelligently.
- In assistant.py, scroll to the run_agent function and find the for block in response.content: loop inside the while loop.
- Find these lines inside the if block.type == "tool_use": block:
result = execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result),
})
- Replace those lines with the following code, which wraps the execution in a try/except block:
try:
result = execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result),
})
except Exception as exc:
# Send the error back to Claude instead of crashing
print(f" [Tool error: {exc}]")
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(exc),
"is_error": True,
})
What is is_error?
The is_error field is part of the Claude tool use protocol. When you set it to True in a tool_result block, Claude knows the tool failed.
Instead of crashing or hallucinating an answer, Claude reads the error message and responds gracefully. For example, if the dictionary can't find a word, Claude might say "I couldn't find that word in the dictionary" instead of your script dying.
Replace the test code with an interactive chat loop
The hardcoded test query at the bottom of your file served its purpose. Now it is time to replace it with a proper interactive loop so you can type questions and have a real conversation.
- Scroll to the bottom of assistant.py and find the test code:
# Test multi-tool query
history = []
result = run_agent("What does 'ephemeral' mean, and what's the square root of 144?", history)
print(result)
- Delete those four lines entirely.
- In their place, add the following main() function and entry point:
# --- Interactive chat loop ---
def main():
print("Research Assistant (powered by Claude)")
print("Type 'quit' to exit.\n")
# Each conversation starts with a fresh history
conversation_history = []
while True:
user_input = input("You: ").strip()
if not user_input:
continue
if user_input.lower() in ("quit", "exit", "q"):
print("Goodbye!")
break
# Run the agentic loop and print Claude's response
response_text = run_agent(user_input, conversation_history)
print(f"\nAssistant: {response_text}\n")
if __name__ == "__main__":
main()
Why does conversation_history persist across turns?
The Claude Messages API is stateless. Every request must include the full conversation history, including all previous user messages, assistant responses, and tool results.
By keeping conversation_history as a list that grows with each turn, Claude has full context from the entire conversation. This means you can ask follow-up questions like "What about that second word?" and Claude knows what you are referring to.
- Save assistant.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete assistant.py file. Compare your code against this to make sure everything matches.
import json
import datetime
import urllib.request
import urllib.error
import anthropic
# --- Claude client setup ---
client = anthropic.Anthropic()
MODEL = "claude-haiku-4-5-20251001"
# --- Tool definitions (JSON Schema) ---
tools = [
{
"name": "calculator",
"description": "Evaluate a mathematical expression. Use this for any arithmetic, algebra, or math calculations. The expression should be a valid Python math expression using numbers and operators like +, -, *, /, **, (, ), %. Example expressions: '1547 / 23', '2**10', '(50 + 30) * 0.15'.",
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "A mathematical expression to evaluate, e.g. '2 + 2' or '100 * 0.15'"
}
},
"required": ["expression"]
}
},
{
"name": "dictionary_lookup",
"description": "Look up the definition of an English word. Returns the word's meanings, parts of speech, and example usage. Use this when the user asks what a word means, asks for a definition, or wants to understand a word.",
"input_schema": {
"type": "object",
"properties": {
"word": {
"type": "string",
"description": "The English word to look up"
}
},
"required": ["word"]
}
},
{
"name": "get_current_datetime",
"description": "Get the current date and time. Use this when the user asks what time it is, what today's date is, or needs current date/time information.",
"input_schema": {
"type": "object",
"properties": {},
"required": []
}
}
]
# --- Tool execution functions ---
def run_calculator(expression):
allowed_chars = set("0123456789+-*/.() %e")
if not all(c in allowed_chars for c in expression.replace(" ", "")):
raise ValueError(f"Invalid characters in expression: {expression}")
result = eval(expression)
return {"result": result, "expression": expression}
def run_dictionary_lookup(word):
url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}"
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=10) as response:
data = json.loads(response.read().decode())
entry = data[0]
meanings = []
for meaning in entry.get("meanings", []):
part_of_speech = meaning.get("partOfSpeech", "")
definitions = []
for defn in meaning.get("definitions", [])[:2]:
d = {"definition": defn["definition"]}
if "example" in defn:
d["example"] = defn["example"]
definitions.append(d)
meanings.append({"partOfSpeech": part_of_speech, "definitions": definitions})
return {
"word": entry.get("word", word),
"phonetic": entry.get("phonetic", ""),
"meanings": meanings
}
def run_get_current_datetime():
now = datetime.datetime.now()
return {
"date": now.strftime("%Y-%m-%d"),
"time": now.strftime("%H:%M:%S"),
"day_of_week": now.strftime("%A"),
"timezone": "local"
}
# --- Tool dispatcher ---
def execute_tool(name, tool_input):
if name == "calculator":
return run_calculator(tool_input["expression"])
elif name == "dictionary_lookup":
return run_dictionary_lookup(tool_input["word"])
elif name == "get_current_datetime":
return run_get_current_datetime()
else:
raise ValueError(f"Unknown tool: {name}")
# --- Agentic loop ---
def run_agent(user_message, conversation_history):
conversation_history.append({"role": "user", "content": user_message})
response = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=tools,
messages=conversation_history,
)
while response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if block.type == "tool_use":
print(f" [Tool call: {block.name}({json.dumps(block.input)})]")
try:
result = execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result),
})
except Exception as exc:
print(f" [Tool error: {exc}]")
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(exc),
"is_error": True,
})
conversation_history.append({"role": "assistant", "content": response.content})
conversation_history.append({"role": "user", "content": tool_results})
response = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=tools,
messages=conversation_history,
)
assistant_text = ""
for block in response.content:
if block.type == "text":
assistant_text += block.text
conversation_history.append({"role": "assistant", "content": response.content})
return assistant_text
# --- Interactive chat loop ---
def main():
print("Research Assistant (powered by Claude)")
print("Type 'quit' to exit.\n")
conversation_history = []
while True:
user_input = input("You: ").strip()
if not user_input:
continue
if user_input.lower() in ("quit", "exit", "q"):
print("Goodbye!")
break
response_text = run_agent(user_input, conversation_history)
print(f"\nAssistant: {response_text}\n")
if __name__ == "__main__":
main()
Test error handling with a made-up word
Time to see error handling in action. You will ask about a word that does not exist in any dictionary, which forces the dictionary API to return an error.
- Run your assistant by running this command:
python assistant.py
You should see the welcome message: Research Assistant (powered by Claude) followed by a You: prompt waiting for your input.
- Type the following and press Enter:
What does 'xyzzyplugh' mean?
You should see a [Tool call: dictionary_lookup(...)] line followed by a [Tool error: ...] line. Claude then responds with a natural message explaining it could not find that word.
What should I see?
The key thing to notice is that your script did not crash. Instead, the error was sent back to Claude as a tool_result with is_error: True. Claude read the error and gave you a helpful response instead of the whole program dying.
Test multi-turn conversation
Conversation history lets Claude remember what you talked about in previous turns. Try a follow-up question to see it in action.
- At the You: prompt, type a question that uses a tool:
What does 'ephemeral' mean?
- After Claude responds with the definition, ask a follow-up that references the previous answer:
Can you use that word in a sentence?
Claude should use "ephemeral" in a sentence without you needing to repeat the word. This confirms that conversation history is working correctly.
- Type quit and press Enter to exit the assistant.
Script crashes instead of showing an error message?
Make sure the try/except block wraps the execute_tool call AND the success tool_results.append. Both lines need to be inside the try: block.
Check that the except Exception as exc: line is at the same indentation level as the try: line. Python indentation errors can silently break your error handling.
help me debug why my assistant crashes when a tool fails
Claude does not remember previous messages?
Make sure conversation_history is defined outside the while True loop (before it, not inside it). If it is created inside the loop, it resets to an empty list on every turn.
help me debug why Claude does not remember my previous messages
Secret mission
Add a Live News Headlines Tool
Your assistant knows math, definitions, and the current time. But what about what's happening in the world right now? In this mission, you'll add a live news headlines tool that fetches real data from the internet. The best part: your agentic loop already handles everything. Adding a new capability is just a schema and a function away.
Before you go
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs from idle resources. You only pay for Claude API calls when you actively use the assistant (a few cents per session at most).
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Project folder on your Desktop containing assistant.py.
- The anthropic Python package installed via pip.
- Your Anthropic API key set as the ANTHROPIC_API_KEY environment variable.
- Your Anthropic account (used to generate the API key).
Keep everything running
No action needed. Choose this if you plan to keep experimenting with your research assistant or want to add more tools.
- Your project folder and assistant.py stay on your Desktop.
- Your ANTHROPIC_API_KEY environment variable remains available in your terminal session.
- The anthropic package stays installed in your Python environment.
- You only incur costs when you actively run the assistant and make API calls.
Pause - I'll come back to this later
Remove your API key from the current terminal session to prevent accidental usage. Your code and package stay intact for when you return.
- Unset your API key from the current terminal session by running:
unset ANTHROPIC_API_KEY
- This removes the key from your active session only. Your project files and installed packages remain untouched.
- When you come back, re-export the key in your terminal before running the assistant again.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your project files and uninstalls the package.
- Unset your API key from the current terminal session by running:
unset ANTHROPIC_API_KEY
- Delete your project folder by running:
cd ~/Desktop
rm -rf research-assistant
- Uninstall the Anthropic SDK by running:
pip uninstall anthropic -y
- If you want to revoke your API key entirely, log in to the Anthropic Console and delete the key from your account settings.
Mission accomplished
Nice Work!
You just built a fully functional research assistant powered by Claude tool use. From defining tools with JSON Schema to handling parallel calls and errors, you now understand the core protocol behind every Claude-based agent.
Here's what you accomplished:
- Defined custom tools using JSON Schema and connected them to the Claude tool use protocol, teaching the model when and how to call each one.
- Built a complete agentic loop that processes parallel tool calls and keeps running until Claude produces a final answer.
- Implemented error handling with is_error so your assistant recovers gracefully instead of crashing, and wrapped it all in an interactive chat loop with multi-turn conversation history.
- Extended your assistant with a live news headlines tool, proving that adding new capabilities is just a schema and a function away.
Ready to quiz yourself?