Personal Finance Assistant with Claude
Build a CLI finance assistant where Claude calls custom tools via the Anthropic API.
Introduction
30 Second Summary
AI chatbots are great at answering questions, but on their own, they can't run calculations, fetch data, or save anything to a file. Tool use changes this by letting an AI model call real functions you define.
In this project, you will build a command-line Personal Finance Assistant powered by Claude. You will define custom tools for compound interest, currency conversion, and expense logging, then wire them up so Claude can call them automatically based on natural language input.
What You'll Build
A Python command-line app that connects to Claude via the Anthropic API and uses tool use to handle personal finance questions.
By the end of this project, you'll have:
- Three custom tools defined with JSON Schema and connected to Claude's API.
- A working agentic loop that handles the full tool calling lifecycle.
- A multi-tool CLI app where Claude picks the right tool based on natural language input.
- A Secret Mission challenge to add expense analysis and let Claude summarize your spending patterns.
Do I need any prerequisites?
This project assumes you have basic Python knowledge (variables, functions, loops). You will also need an Anthropic API key, which you can get from the Anthropic Console.
Set Up Your Environment
Your finance assistant will communicate with Claude through the Anthropic API. For that to work, your Python environment needs the official SDK installed and your API key configured.
This step gets everything set up so you can jump straight into writing code in the next step.
In this step, get ready to:
- Install the Anthropic Python SDK.
- Set up your API key as an environment variable.
- Create your project directory and main file.
Install the Anthropic Python SDK
The Anthropic Python SDK is the official library for talking to Claude's API from Python. It handles authentication, request formatting, and response parsing for you.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Install the Anthropic Python SDK by running this command:
pip install anthropic
- Confirm the installation succeeded by running this command:
python -c "import anthropic; print(anthropic.__version__)"
You should see a version number like 0.96.0 in the output.
Not seeing a version number?
- If you see command not found: pip, try running pip3 install anthropic instead. Some systems use pip3 for Python 3.
- If you see command not found: python, try python3 instead of python in the verify command.
- If the install fails with a permissions error, try adding --user to the end of the pip command.
- Help me troubleshoot my Anthropic SDK installation
Set your API key as an environment variable
Your API key authenticates your requests to Claude's API. The Anthropic SDK automatically reads it from an environment variable called ANTHROPIC_API_KEY, so you never need to paste it into your code.
Where do I find my API key?
Log in to the Anthropic Console and navigate to API Keys in the sidebar. Create a new key if you do not have one already. Copy the key and keep it handy for the next step.
macOS
- Set your API key by running this command, replacing your-api-key-here with your actual key:
export ANTHROPIC_API_KEY='your-api-key-here'
This sets the key for your current terminal session only. To persist it across sessions, add the same line to your ~/.zshrc file.
Windows
- Set your API key by running this command in PowerShell, replacing your-api-key-here with your actual key:
$env:ANTHROPIC_API_KEY = "your-api-key-here"
This sets the key for your current PowerShell session only. To persist it, add it as a system environment variable through System Properties > Environment Variables.
Pro tip: keep your key out of your code
Storing your API key in an environment variable means it never appears in your source files. If you share your code or push it to GitHub, the key stays safely on your machine.
- Verify your API key is set by running this command:
macOS
echo $ANTHROPIC_API_KEY
Windows
echo $env:ANTHROPIC_API_KEY
You should see your API key printed in the terminal output.
Seeing a blank output?
- Make sure you ran the export (macOS) or $env: (Windows) command in the same terminal session. Opening a new terminal window resets session variables.
- Double-check that you replaced your-api-key-here with your actual API key from the Anthropic Console.
- Help me troubleshoot my API key environment variable
Create your project directory and main file
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir finance-assistant
cd finance-assistant
- Create your main Python file by running this command:
macOS
touch finance_assistant.py
Windows
New-Item finance_assistant.py
- Confirm the file exists by running:
ls
You should see finance_assistant.py in the output.
Don't see your file?
- Make sure you ran cd finance-assistant before creating the file. If you are in the wrong directory, the file will end up somewhere else.
- Check that the file name is exactly finance_assistant.py with an underscore, not a hyphen.
- Help me find or create my finance_assistant.py file
Now open the finance-assistant folder in your text editor. You will start writing code in this file in the next step.
Your environment is ready. Next up, you will make your first API call to Claude and confirm everything works end to end.
Make Your First Claude API Call
Your environment is ready. The Anthropic SDK is installed, your API key is configured, and your project directory is waiting for code.
Before jumping into tool use, you need to confirm that your script can actually talk to Claude. In this step, you'll send a simple test message and print the response.
In this step, get ready to:
- Write a Python script that sends a message to Claude.
- Run the script and confirm Claude responds.
Write the basic script structure
The finance_assistant.py file is where all your code will live throughout this project. Right now, you'll start with the simplest version: import the SDK, create a client, and send one message.
- In your text editor, open finance_assistant.py inside the finance-assistant folder you created in Step 1.
- Copy the following code into the file:
import anthropic
# Create the Anthropic client (reads ANTHROPIC_API_KEY from your environment)
client = anthropic.Anthropic()
MODEL = "claude-sonnet-4-6"
# Send a simple message to verify the connection
response = client.messages.create(
model=MODEL,
max_tokens=1024,
messages=[
{"role": "user", "content": "What is compound interest?"}
]
)
# Print 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.
- MODEL stores the model string claude-sonnet-4-6. You'll reuse this variable throughout the project.
- client.messages.create() sends a message to Claude and returns a response object containing Claude's reply.
- response.content[0].text extracts the text from the first content block in Claude's response.
- Save finance_assistant.py.
Awesome, I've got everything!
Double check you've saved your file before moving on.
I'd like to double check the full code
import anthropic
# Create the Anthropic client (reads ANTHROPIC_API_KEY from your environment)
client = anthropic.Anthropic()
MODEL = "claude-sonnet-4-6"
# Send a simple message to verify the connection
response = client.messages.create(
model=MODEL,
max_tokens=1024,
messages=[
{"role": "user", "content": "What is compound interest?"}
]
)
# Print Claude's response
print(response.content[0].text)
Run the script and verify your connection
Your script is saved. Now you'll run it to confirm that your API key works and Claude is responding.
- Make sure your terminal is inside the finance-assistant folder, then run your script with this command:
python finance_assistant.py
You should see a paragraph or two from Claude explaining what compound interest is. This confirms your API key and SDK are working correctly.
Seeing an error?
- AuthenticationError means your API key is not set or is incorrect. Run echo $ANTHROPIC_API_KEY in your terminal to check if it's set. If it's empty, re-run the export command from Step 1.
- ModuleNotFoundError: No module named 'anthropic' means the SDK isn't installed. Run pip install anthropic again.
- A connection error or timeout means you may not have internet access or Anthropic's API might be temporarily unavailable. Check your connection and try again.
Help me debug my Claude API error.
Claude is listening and responding to your messages. Next up, you'll give it something much more powerful: custom tools that let Claude call your Python functions.
Define Your First Tool
In the previous step, you sent a message to Claude and got a response back. That proves your connection works, but Claude could only respond with text.
Now you are going to teach Claude to do something new: call a custom function you define. Claude's tool use feature lets you describe functions to Claude, and Claude decides when to call them based on the user's question. Your first tool will be a compound interest calculator.
In this step, get ready to:
- Define a compound interest tool using JSON Schema.
- Write a function to execute the tool's logic.
- Test that Claude recognizes when to call your tool.
Define the compound interest tool
For Claude to use a tool, you need to describe it in a format Claude understands. Each tool definition is a Python dictionary with three keys: name, description, and input_schema.
The input_schema uses JSON Schema format to tell Claude exactly what parameters the tool accepts, their types, and which ones are required.
- In finance_assistant.py, replace everything below your MODEL variable with the following tool definition:
# --- Tool Definitions ---
tools = [
{
"name": "calculate_compound_interest",
"description": "Calculate compound interest to find the future value of an investment. Use this when the user asks about investment growth, savings projections, or how much money will grow over time.",
"input_schema": {
"type": "object",
"properties": {
"principal": {
"type": "number",
"description": "The initial investment amount in dollars."
},
"annual_rate": {
"type": "number",
"description": "The annual interest rate as a percentage (e.g., 7 for 7%)."
},
"years": {
"type": "integer",
"description": "The number of years to compound."
},
"compounds_per_year": {
"type": "integer",
"description": "Number of times interest compounds per year. Defaults to 12 (monthly)."
}
},
"required": ["principal", "annual_rate", "years"]
}
}
]
What does this code do?
- tools is a list of tool dictionaries. You will add more tools to this list later.
- name is the unique identifier Claude uses to call this tool.
- description tells Claude when to use this tool. A clear, detailed description helps Claude pick the right tool for the job.
- input_schema defines each parameter's type and purpose. compounds_per_year is not in the required array, so Claude knows it is optional.
Why do descriptions matter so much?
Claude reads the description field to decide whether a tool matches the user's request. Vague descriptions lead to Claude picking the wrong tool or not picking one at all. Think of descriptions as instructions to a coworker who has never seen your code.
- Save the file.
Write the tool implementation
The tool definition tells Claude what parameters to extract from the user's message. You still need a Python function that actually performs the calculation when Claude calls the tool.
- Add the following function below your tools list:
# --- Tool Implementations ---
def calculate_compound_interest(principal, annual_rate, years, compounds_per_year=12):
# Convert percentage to decimal (e.g., 7 becomes 0.07)
rate = annual_rate / 100
# Apply the compound interest formula: A = P(1 + r/n)^(nt)
future_value = principal * (1 + rate / compounds_per_year) ** (compounds_per_year * years)
interest_earned = future_value - principal
return {
"principal": principal,
"annual_rate": annual_rate,
"years": years,
"compounds_per_year": compounds_per_year,
"future_value": round(future_value, 2),
"interest_earned": round(interest_earned, 2)
}
What does this code do?
- The function takes the same parameters defined in your tool's input_schema. Claude extracts these values from the user's message and passes them in.
- compounds_per_year=12 defaults to monthly compounding if the user does not specify.
- The function returns a dictionary with both the calculated future_value and interest_earned. This gives Claude rich data to form a natural language response.
- Save the file.
Now you need a dispatcher function that routes tool calls to the right implementation. When Claude decides to use a tool, it returns the tool's name and inputs. Your dispatcher matches that name to the correct Python function.
- Add the process_tool_call function below calculate_compound_interest:
def process_tool_call(tool_name, tool_input):
if tool_name == "calculate_compound_interest":
return calculate_compound_interest(**tool_input)
What does this code do?
- process_tool_call takes a tool name and the input dictionary Claude extracted from the user's message.
- **tool_input unpacks the dictionary into keyword arguments. For example, {"principal": 5000, "annual_rate": 7, "years": 10} becomes calculate_compound_interest(principal=5000, annual_rate=7, years=10).
- You will add more elif branches here as you add more tools in later steps.
- Save the file.
Test that Claude recognizes your tool
With the tool defined and implemented, you can now pass it to Claude and see what happens. When Claude receives a message that matches a tool's description, it responds with a stop_reason of "tool_use" instead of "end_turn". This is Claude's way of saying "I want to call this tool before I respond."
- Replace the existing API call and print statement at the bottom of your file with the following code:
# --- Test Tool Use ---
response = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=tools,
messages=[{"role": "user", "content": "How much will $5,000 grow in 10 years at 7% annual interest?"}]
)
# Print the stop reason and full content to see Claude's tool call
print(response.stop_reason)
print(response.content)
What changed from your Step 2 API call?
- tools=tools passes your tool definitions to Claude. Claude reads these to decide if any tool matches the user's request.
- The user message is now a finance question instead of a generic greeting. This triggers Claude to choose the compound interest tool.
- You are printing response.stop_reason and response.content to inspect the raw response structure, not just the text.
- Save finance_assistant.py.
- Run your script by executing this command in your terminal:
python finance_assistant.py
You should see tool_use as the stop reason. The content should include a ToolUseBlock with the name calculate_compound_interest and structured input like {"principal": 5000, "annual_rate": 7, "years": 10}.
What is happening here?
Claude read your tool definition, matched it to the user's question, and extracted the right values from natural language. It did not run the function itself. Instead, it paused and asked you to run it by returning stop_reason: "tool_use". This is the core of the tool use protocol: Claude decides WHAT to call, and your code decides HOW to execute it.
Seeing an error instead?
- If you see AuthenticationError, your API key is not set correctly. Run echo $ANTHROPIC_API_KEY in your terminal to check.
- If you see BadRequestError, double check that your input_schema has "type": "object" at the top level. Missing this is a common mistake.
- If the stop_reason is end_turn instead of tool_use, make sure you passed tools=tools in your client.messages.create() call.
Help me debug my tool definition in finance_assistant.py
Awesome, I've got everything!
Great. Double check you have saved finance_assistant.py before moving on.
I'd like to double check the full code
Here is the complete finance_assistant.py file at the end of this step.
import anthropic
client = anthropic.Anthropic()
MODEL = "claude-sonnet-4-6"
# --- Tool Definitions ---
tools = [
{
"name": "calculate_compound_interest",
"description": "Calculate compound interest to find the future value of an investment. Use this when the user asks about investment growth, savings projections, or how much money will grow over time.",
"input_schema": {
"type": "object",
"properties": {
"principal": {
"type": "number",
"description": "The initial investment amount in dollars."
},
"annual_rate": {
"type": "number",
"description": "The annual interest rate as a percentage (e.g., 7 for 7%)."
},
"years": {
"type": "integer",
"description": "The number of years to compound."
},
"compounds_per_year": {
"type": "integer",
"description": "Number of times interest compounds per year. Defaults to 12 (monthly)."
}
},
"required": ["principal", "annual_rate", "years"]
}
}
]
# --- Tool Implementations ---
def calculate_compound_interest(principal, annual_rate, years, compounds_per_year=12):
# Convert percentage to decimal (e.g., 7 becomes 0.07)
rate = annual_rate / 100
# Apply the compound interest formula: A = P(1 + r/n)^(nt)
future_value = principal * (1 + rate / compounds_per_year) ** (compounds_per_year * years)
interest_earned = future_value - principal
return {
"principal": principal,
"annual_rate": annual_rate,
"years": years,
"compounds_per_year": compounds_per_year,
"future_value": round(future_value, 2),
"interest_earned": round(interest_earned, 2)
}
def process_tool_call(tool_name, tool_input):
if tool_name == "calculate_compound_interest":
return calculate_compound_interest(**tool_input)
# --- Test Tool Use ---
response = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=tools,
messages=[{"role": "user", "content": "How much will $5,000 grow in 10 years at 7% annual interest?"}]
)
# Print the stop reason and full content to see Claude's tool call
print(response.stop_reason)
print(response.content)
Claude now knows your tool exists and can extract structured parameters from plain English. The missing piece is that Claude is paused, waiting for you to run the function and send the result back. In the next step, you will build the agentic loop that completes this round trip.
Build the Agentic Loop
In Step 3, you defined a compound interest tool and saw Claude respond with stop_reason: "tool_use". Claude picked the right tool and extracted structured parameters from natural language. But it stopped there, waiting for your code to actually run the calculation.
In this step, you'll close that loop. The agentic loop is the core pattern behind every AI agent. Your code checks if Claude wants to call a tool, executes it, sends the result back, and repeats until Claude has everything it needs to respond.
In this step, get ready to:
- Check stop_reason to detect when Claude wants to call a tool.
- Execute the tool and send the result back as a tool_result message.
- Loop until Claude finishes and returns a natural language response.
Add the JSON import
The agentic loop needs to serialize tool results into JSON strings before sending them back to Claude. You'll use Python's built-in json module for this.
- In finance_assistant.py, find this line at the very top of your file:
import anthropic
- Add import json above it, so the top of your file now looks like this:
import json
import anthropic
Write the agentic loop
Right now your script sends a message and prints the raw response object. You need to replace that with a loop that detects tool calls, executes them, and feeds results back to Claude.
What is the agentic loop?
Every time Claude responds, it sets a stop_reason that tells you why it stopped. When it returns "tool_use", Claude is saying: "I need you to run this tool and give me the result before I can answer."
The agentic loop keeps cycling through this process. Execute the tool, send the result back, and let Claude decide if it needs another tool or if it's ready to respond to the user. When Claude returns "end_turn", the loop exits.
- Scroll to the bottom of finance_assistant.py and find this line:
print(response)
- Delete that line and replace it with the following agentic loop code:
# Agentic loop: keep going while Claude wants to call tools
while response.stop_reason == "tool_use":
# Find the tool_use block in the response
tool_use_block = next(block for block in response.content if block.type == "tool_use")
tool_name = tool_use_block.name
tool_input = tool_use_block.input
# Execute the tool
result = process_tool_call(tool_name, tool_input)
# Send the result back to Claude
messages.append({"role": "assistant", "content": response.content})
messages.append({
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_use_block.id,
"content": json.dumps(result)
}
]
})
# Let Claude process the result
response = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=tools,
messages=messages
)
# Print Claude's final response
for block in response.content:
if block.type == "text":
print(block.text)
What does this code do?
- while response.stop_reason == "tool_use" keeps the loop running as long as Claude wants to call tools. Once Claude has everything it needs, it returns "end_turn" and the loop exits.
- next(block for block in response.content if block.type == "tool_use") scans the response content blocks to find the tool_use block. This block contains the tool name, input, and a unique id.
- process_tool_call(tool_name, tool_input) runs your Python function and returns the calculated result.
- The two messages.append() calls add Claude's response (including the tool_use block) as the assistant message, then add your tool_result as a user message. Claude needs the full conversation history to understand the context.
- json.dumps(result) converts your Python dictionary into a JSON string. The tool_result content field expects a string, not a raw dictionary.
- The final for loop after the while extracts and prints only the text blocks from Claude's final response.
Why does the tool_result go in a user message?
The Anthropic API models tool results as part of the conversation. Claude sends a tool_use block as the assistant, and your code responds with a tool_result block as the user. The tool_use_id links the result to the specific tool call that requested it.
- Save finance_assistant.py.
Run the agentic loop
Previously, running your script printed a raw response object full of metadata. Now the agentic loop handles the tool call behind the scenes, and Claude responds with a human-readable answer.
- Run your script by running this command:
python finance_assistant.py
You should see Claude respond with a natural language answer about compound interest, something like "If you invest $5,000 at 7% annual interest for 10 years, it will grow to approximately $10,048.31." The exact wording varies, but the numbers come from your tool.
Seeing an error instead of a response?
- If you see NameError: name 'json' is not defined, make sure you added import json at the very top of your file.
- If you see StopIteration, Claude might not be returning a tool_use block. Check that your user message in the messages list still asks a compound interest question.
- If the script runs but prints nothing, make sure the final for block in response.content loop is outside the while loop (no indentation under while).
- Something else going wrong? Help me debug my agentic loop.
Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
I'd like to double check the full code
Here is the complete finance_assistant.py file at the end of this step.
import json
import anthropic
client = anthropic.Anthropic()
MODEL = "claude-sonnet-4-6"
# --- Tool Definitions ---
tools = [
{
"name": "calculate_compound_interest",
"description": "Calculate compound interest to find the future value of an investment. Use this when the user asks about investment growth, savings projections, or how much money will grow over time.",
"input_schema": {
"type": "object",
"properties": {
"principal": {
"type": "number",
"description": "The initial investment amount in dollars."
},
"annual_rate": {
"type": "number",
"description": "The annual interest rate as a percentage (e.g., 7 for 7%)."
},
"years": {
"type": "integer",
"description": "The number of years to compound."
},
"compounds_per_year": {
"type": "integer",
"description": "Number of times interest compounds per year. Defaults to 12 (monthly)."
}
},
"required": ["principal", "annual_rate", "years"]
}
}
]
# --- Tool Implementations ---
def calculate_compound_interest(principal, annual_rate, years, compounds_per_year=12):
rate = annual_rate / 100
future_value = principal * (1 + rate / compounds_per_year) ** (compounds_per_year * years)
interest_earned = future_value - principal
return {
"principal": principal,
"annual_rate": annual_rate,
"years": years,
"compounds_per_year": compounds_per_year,
"future_value": round(future_value, 2),
"interest_earned": round(interest_earned, 2)
}
def process_tool_call(tool_name, tool_input):
if tool_name == "calculate_compound_interest":
return calculate_compound_interest(**tool_input)
# Send the initial message
messages = [{"role": "user", "content": "How much will $5,000 grow in 10 years at 7% annual interest?"}]
response = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=tools,
messages=messages
)
# Agentic loop: keep going while Claude wants to call tools
while response.stop_reason == "tool_use":
# Find the tool_use block in the response
tool_use_block = next(block for block in response.content if block.type == "tool_use")
tool_name = tool_use_block.name
tool_input = tool_use_block.input
# Execute the tool
result = process_tool_call(tool_name, tool_input)
# Send the result back to Claude
messages.append({"role": "assistant", "content": response.content})
messages.append({
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_use_block.id,
"content": json.dumps(result)
}
]
})
# Let Claude process the result
response = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=tools,
messages=messages
)
# Print Claude's final response
for block in response.content:
if block.type == "text":
print(block.text)
Your script now handles the full tool use lifecycle automatically. Next up, you'll add more tools and teach the loop to handle errors gracefully.
Add More Tools and Handle Errors
Your finance assistant can calculate compound interest, but a real assistant needs more than one trick. Right now, a question like "Convert 100 USD to EUR" leaves Claude with no tool to call.
In this step, you'll add a currency converter and an expense logger. You'll also make the agentic loop resilient so that errors in your tools don't crash the entire program.
In this step, get ready to:
- Add a currency converter tool with hardcoded exchange rates.
- Add an expense logger tool that saves records to a local JSON file.
- Update the agentic loop to handle tool errors gracefully with is_error.
Add the currency converter tool
Each tool Claude can use needs two things: a definition (a JSON Schema describing the tool's name, purpose, and expected inputs) and an implementation (the Python function that actually does the work).
- In finance_assistant.py, find the closing ] of your tools list.
- Add this new tool definition as the second item in the list, right after your calculate_compound_interest tool:
{
"name": "convert_currency",
"description": "Convert an amount from one currency to another using current exchange rates. Supports USD, EUR, GBP, and JPY.",
"input_schema": {
"type": "object",
"properties": {
"amount": {
"type": "number",
"description": "The amount of money to convert."
},
"from_currency": {
"type": "string",
"description": "The source currency code (e.g., USD, EUR, GBP, JPY)."
},
"to_currency": {
"type": "string",
"description": "The target currency code (e.g., USD, EUR, GBP, JPY)."
}
},
"required": ["amount", "from_currency", "to_currency"]
}
},
What does this definition do?
This dictionary tells Claude what the tool does and what inputs it expects. Claude reads the description to decide when to use the tool, and uses the input_schema to structure its arguments correctly.
All three parameters are required. When a user says "Convert 100 USD to EUR", Claude extracts the amount, source currency, and target currency from the sentence and maps them to these fields.
You won't see a change yet. The definition tells Claude the tool exists, but the tool needs a Python function to actually run.
Instead of calling a live exchange rate API (which would need another API key), you'll use a hardcoded dictionary of rates. This keeps the project self-contained.
- Below your calculate_compound_interest function, add the exchange rates dictionary and the convert_currency function by copying this code:
EXCHANGE_RATES = {
("USD", "EUR"): 0.92,
("USD", "GBP"): 0.79,
("USD", "JPY"): 149.50,
("EUR", "USD"): 1.09,
("EUR", "GBP"): 0.86,
("EUR", "JPY"): 162.50,
("GBP", "USD"): 1.27,
("GBP", "EUR"): 1.16,
("GBP", "JPY"): 189.20,
("JPY", "USD"): 0.0067,
("JPY", "EUR"): 0.0062,
("JPY", "GBP"): 0.0053,
}
def convert_currency(amount, from_currency, to_currency):
# Normalize currency codes to uppercase
from_currency = from_currency.upper()
to_currency = to_currency.upper()
# Handle same-currency conversion
if from_currency == to_currency:
return {"amount": amount, "from": from_currency, "to": to_currency, "result": amount}
# Look up the exchange rate
key = (from_currency, to_currency)
if key not in EXCHANGE_RATES:
raise ValueError(f"Unsupported currency pair: {from_currency} to {to_currency}. Supported currencies: USD, EUR, GBP, JPY.")
rate = EXCHANGE_RATES[key]
result = round(amount * rate, 2)
return {"amount": amount, "from": from_currency, "to": to_currency, "rate": rate, "result": result}
What does this code do?
- EXCHANGE_RATES is a dictionary keyed by tuples like ("USD", "EUR"). This makes lookups simple. Just check if the pair exists.
- The function normalizes inputs to uppercase so that "usd" and "USD" both work.
- If the currency pair is not in the dictionary, the function raises a ValueError. You'll handle this error gracefully later in this step.
The function is defined, but process_tool_call doesn't know about it yet. You need to add a branch so the router can dispatch to it.
- In your process_tool_call function, find this line:
if tool_name == "calculate_compound_interest":
return calculate_compound_interest(**tool_input)
- Add this elif branch directly below it:
elif tool_name == "convert_currency":
return convert_currency(**tool_input)
- Change your user message to test the new tool:
messages = [{"role": "user", "content": "Convert 100 USD to EUR"}]
- Save finance_assistant.py.
- Run the script by running this command:
python finance_assistant.py
Claude should respond with a converted amount, something like "100 USD is approximately 92.00 EUR."
Getting a KeyError or unexpected output?
- Make sure the convert_currency tool definition is inside the tools list (between the opening [ and closing ]), separated by a comma from the previous tool.
- Check that the elif branch in process_tool_call uses the exact string "convert_currency" to match the tool's name field.
- Why is my convert_currency tool not being called by Claude?
Add the expense logger tool
The expense logger is different from your other tools. Instead of calculating and returning a result, it writes data to a file on your computer. This makes it a tool with side effects. It changes the state of your system.
The function needs Python's os and datetime modules to check if the expenses file exists and to timestamp each entry.
- At the top of finance_assistant.py, find your existing imports.
- Find this line:
import json
- Add import os and from datetime import datetime so your imports look like this:
import json
import os
from datetime import datetime
- Add the log_expense tool definition to your tools list, right after the convert_currency tool you just added:
{
"name": "log_expense",
"description": "Log an expense to the local expense tracker. Use this when the user wants to record a purchase or spending.",
"input_schema": {
"type": "object",
"properties": {
"amount": {
"type": "number",
"description": "The expense amount in dollars."
},
"category": {
"type": "string",
"enum": ["food", "transport", "entertainment", "utilities", "other"],
"description": "The expense category."
},
"description": {
"type": "string",
"description": "A brief description of the expense."
}
},
"required": ["amount", "category", "description"]
}
}
What does the enum field do?
The "enum" field restricts the category to a fixed set of values. Claude will pick the closest match from the list instead of inventing a category name. This keeps your expense data consistent.
- Below your convert_currency function, add the EXPENSES_FILE constant and the log_expense function by copying this code:
EXPENSES_FILE = "expenses.json"
def log_expense(amount, category, description):
# Load existing expenses or start with an empty list
expenses = []
if os.path.exists(EXPENSES_FILE):
with open(EXPENSES_FILE, "r") as f:
expenses = json.load(f)
# Create the new expense record with a timestamp
expense = {
"amount": amount,
"category": category,
"description": description,
"timestamp": datetime.now().isoformat()
}
expenses.append(expense)
# Write the updated list back to the file
with open(EXPENSES_FILE, "w") as f:
json.dump(expenses, f, indent=2)
return {"status": "logged", "expense": expense, "total_expenses": len(expenses)}
What does this code do?
- The function first checks if expenses.json already exists. If it does, it loads the existing data. If not, it starts with an empty list.
- Each expense gets a timestamp from datetime.now().isoformat() so you can track when each purchase was logged.
- The function writes the entire list back to the file each time, preserving all previous entries.
- In your process_tool_call function, find this line:
elif tool_name == "convert_currency":
return convert_currency(**tool_input)
- Add the log_expense branch and an else clause directly below it, so the full function looks like this:
def process_tool_call(tool_name, tool_input):
if tool_name == "calculate_compound_interest":
return calculate_compound_interest(**tool_input)
elif tool_name == "convert_currency":
return convert_currency(**tool_input)
elif tool_name == "log_expense":
return log_expense(**tool_input)
else:
raise ValueError(f"Unknown tool: {tool_name}")
Why add an else clause?
The else: raise ValueError catches cases where Claude tries to call a tool name that doesn't have a matching implementation. This is a safety net that surfaces bugs early instead of failing silently.
- Change your user message to test the expense logger:
messages = [{"role": "user", "content": "Log a $45 dinner at the Italian place"}]
- Save finance_assistant.py and run the script:
python finance_assistant.py
Claude should confirm the expense was logged. You should also see a new expenses.json file appear in your finance-assistant folder.
- Confirm the file was created by running:
cat expenses.json
You should see a JSON array with one expense entry containing the amount, category, description, and a timestamp.
No expenses.json file appearing?
- Make sure you added import os and from datetime import datetime at the top of your file. Without these, the function crashes before it can write.
- Check that the log_expense branch is in process_tool_call. If Claude calls the tool but process_tool_call doesn't have a matching branch, your script may crash or return nothing.
- My log_expense tool runs but no expenses.json file is created. What's wrong?
Update the agentic loop to handle errors
Your tools can fail. The currency converter raises a ValueError for unsupported pairs like USD to AUD. Without error handling, that exception crashes your entire program.
The Anthropic API has a built-in pattern for this. When you send a tool_result with is_error set to True, Claude reads the error message and responds gracefully instead of the program crashing.
- Find the inside of your while response.stop_reason == "tool_use" loop.
- Replace the entire body of the while loop with the following code:
# Collect results from all tool calls in this response
tool_results = []
for block in response.content:
if block.type == "tool_use":
try:
result = process_tool_call(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result)
})
except Exception as e:
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(e),
"is_error": True
})
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
)
What changed here?
- The loop now iterates over ALL blocks in response.content with a for loop instead of grabbing the first tool_use block. This handles cases where Claude calls multiple tools in a single response.
- Each tool call is wrapped in a try/except block. If the tool succeeds, the result goes into a normal tool_result. If it raises an exception, the error message goes into a tool_result with "is_error": True.
- Claude reads that is_error flag and the error message. It can then explain the problem to the user ("That currency pair isn't supported") instead of the program crashing.
- Test the error handling by changing your user message to request an unsupported currency pair:
messages = [{"role": "user", "content": "Convert 50 USD to AUD"}]
- Save finance_assistant.py and run the script:
python finance_assistant.py
Instead of crashing, Claude should respond with something like "I'm sorry, but the USD to AUD conversion is not currently supported. The available currencies are USD, EUR, GBP, and JPY."
Script still crashing on unsupported currencies?
- Make sure the try/except wraps the process_tool_call line and the tool_results.append for the success case. The except block should catch Exception (the base class), not just ValueError.
- Check your indentation. The for block in response.content loop must be inside the while loop, and the try/except must be inside the for loop.
- My script crashes with a ValueError when I try an unsupported currency. How do I fix the error handling in my agentic loop?
Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
I'd like to double check the full code
Here is what your finance_assistant.py should look like at the end of this step. The chat() wrapper and interactive loop come in the next step.
import json
import os
from datetime import datetime
import anthropic
client = anthropic.Anthropic()
MODEL = "claude-sonnet-4-6"
# --- Tool Definitions ---
tools = [
{
"name": "calculate_compound_interest",
"description": "Calculate compound interest to find the future value of an investment. Use this when the user asks about investment growth, savings projections, or how much money will grow over time.",
"input_schema": {
"type": "object",
"properties": {
"principal": {
"type": "number",
"description": "The initial investment amount in dollars."
},
"annual_rate": {
"type": "number",
"description": "The annual interest rate as a percentage (e.g., 7 for 7%)."
},
"years": {
"type": "integer",
"description": "The number of years to compound."
},
"compounds_per_year": {
"type": "integer",
"description": "Number of times interest compounds per year. Defaults to 12 (monthly)."
}
},
"required": ["principal", "annual_rate", "years"]
}
},
{
"name": "convert_currency",
"description": "Convert an amount from one currency to another using current exchange rates. Supports USD, EUR, GBP, and JPY.",
"input_schema": {
"type": "object",
"properties": {
"amount": {
"type": "number",
"description": "The amount of money to convert."
},
"from_currency": {
"type": "string",
"description": "The source currency code (e.g., USD, EUR, GBP, JPY)."
},
"to_currency": {
"type": "string",
"description": "The target currency code (e.g., USD, EUR, GBP, JPY)."
}
},
"required": ["amount", "from_currency", "to_currency"]
}
},
{
"name": "log_expense",
"description": "Log an expense to the local expense tracker. Use this when the user wants to record a purchase or spending.",
"input_schema": {
"type": "object",
"properties": {
"amount": {
"type": "number",
"description": "The expense amount in dollars."
},
"category": {
"type": "string",
"enum": ["food", "transport", "entertainment", "utilities", "other"],
"description": "The expense category."
},
"description": {
"type": "string",
"description": "A brief description of the expense."
}
},
"required": ["amount", "category", "description"]
}
}
]
# --- Tool Implementations ---
EXCHANGE_RATES = {
("USD", "EUR"): 0.92,
("USD", "GBP"): 0.79,
("USD", "JPY"): 149.50,
("EUR", "USD"): 1.09,
("EUR", "GBP"): 0.86,
("EUR", "JPY"): 162.50,
("GBP", "USD"): 1.27,
("GBP", "EUR"): 1.16,
("GBP", "JPY"): 189.20,
("JPY", "USD"): 0.0067,
("JPY", "EUR"): 0.0062,
("JPY", "GBP"): 0.0053,
}
EXPENSES_FILE = "expenses.json"
def calculate_compound_interest(principal, annual_rate, years, compounds_per_year=12):
rate = annual_rate / 100
future_value = principal * (1 + rate / compounds_per_year) ** (compounds_per_year * years)
interest_earned = future_value - principal
return {
"principal": principal,
"annual_rate": annual_rate,
"years": years,
"compounds_per_year": compounds_per_year,
"future_value": round(future_value, 2),
"interest_earned": round(interest_earned, 2)
}
def convert_currency(amount, from_currency, to_currency):
# Normalize currency codes to uppercase
from_currency = from_currency.upper()
to_currency = to_currency.upper()
# Handle same-currency conversion
if from_currency == to_currency:
return {"amount": amount, "from": from_currency, "to": to_currency, "result": amount}
# Look up the exchange rate
key = (from_currency, to_currency)
if key not in EXCHANGE_RATES:
raise ValueError(f"Unsupported currency pair: {from_currency} to {to_currency}. Supported currencies: USD, EUR, GBP, JPY.")
rate = EXCHANGE_RATES[key]
result = round(amount * rate, 2)
return {"amount": amount, "from": from_currency, "to": to_currency, "rate": rate, "result": result}
def log_expense(amount, category, description):
# Load existing expenses or start with an empty list
expenses = []
if os.path.exists(EXPENSES_FILE):
with open(EXPENSES_FILE, "r") as f:
expenses = json.load(f)
# Create the new expense record with a timestamp
expense = {
"amount": amount,
"category": category,
"description": description,
"timestamp": datetime.now().isoformat()
}
expenses.append(expense)
# Write the updated list back to the file
with open(EXPENSES_FILE, "w") as f:
json.dump(expenses, f, indent=2)
return {"status": "logged", "expense": expense, "total_expenses": len(expenses)}
def process_tool_call(tool_name, tool_input):
if tool_name == "calculate_compound_interest":
return calculate_compound_interest(**tool_input)
elif tool_name == "convert_currency":
return convert_currency(**tool_input)
elif tool_name == "log_expense":
return log_expense(**tool_input)
else:
raise ValueError(f"Unknown tool: {tool_name}")
# --- Agentic Loop ---
messages = [{"role": "user", "content": "Convert 50 USD to AUD"}]
response = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=tools,
messages=messages
)
while response.stop_reason == "tool_use":
# Collect results from all tool calls in this response
tool_results = []
for block in response.content:
if block.type == "tool_use":
try:
result = process_tool_call(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result)
})
except Exception as e:
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(e),
"is_error": True
})
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
)
# Print the final text response
for block in response.content:
if block.type == "text":
print(block.text)
Your finance assistant now has three tools and handles errors without crashing. Next up, you'll wrap everything in an interactive chat loop so you can have a real conversation with Claude.
Add an Interactive Chat Loop
Your finance assistant now has three tools, an agentic loop, and error handling. But it only processes one hardcoded message per run.
In this step, you will wrap everything in an interactive chat loop so you can have real conversations with Claude. You will also add a system prompt that tells Claude to behave like a personal finance assistant.
In this step, get ready to:
- Create a reusable chat function that wraps the agentic loop.
- Build an interactive loop that lets you send multiple messages.
- Add a system prompt to give Claude a finance assistant personality.
Create a reusable chat function
Right now your agentic loop is inline code that runs once and exits. To support a back-and-forth conversation, you need to wrap it in a function that accepts any user message and returns Claude's final text response.
- In finance_assistant.py, find your existing agentic loop code (everything below the process_tool_call function).
- Replace it with this chat function:
# --- Agentic Loop ---
def chat(user_message, conversation_history):
# Add the user's message to the conversation
conversation_history.append({"role": "user", "content": user_message})
# Send the full conversation to Claude
response = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=tools,
messages=conversation_history
)
# Keep looping while Claude wants to use tools
while response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if block.type == "tool_use":
try:
result = process_tool_call(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result)
})
except Exception as e:
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(e),
"is_error": True
})
# Append assistant response and tool results to conversation
conversation_history.append({"role": "assistant", "content": response.content})
conversation_history.append({"role": "user", "content": tool_results})
# Send updated conversation back to Claude
response = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=tools,
messages=conversation_history
)
# Extract text from the final response
assistant_text = ""
for block in response.content:
if block.type == "text":
assistant_text += block.text
# Save assistant's final response to conversation history
conversation_history.append({"role": "assistant", "content": response.content})
return assistant_text
What does this function do?
- conversation_history is a list that accumulates every message (user, assistant, and tool results). Each API call receives the full history so Claude has context from earlier in the conversation.
- The while response.stop_reason == "tool_use" loop is the same agentic loop from the previous step. It runs tools and feeds results back until Claude is done.
- After the loop exits, the function extracts Claude's final text response and returns it as a plain string.
This function is not runnable on its own yet. The next chunk adds the interactive loop that calls it.
- Below the chat function, add this main function and entry point:
# --- Main ---
def main():
print("Personal Finance Assistant (powered by Claude)")
print("Type 'quit' to exit.\n")
# Start with an empty conversation history
conversation_history = []
while True:
user_input = input("You: ").strip()
# Skip empty inputs
if not user_input:
continue
# Exit on "quit"
if user_input.lower() == "quit":
print("Goodbye!")
break
# Send the message through the agentic loop
response_text = chat(user_input, conversation_history)
print(f"\nAssistant: {response_text}\n")
if __name__ == "__main__":
main()
What does this code do?
- while True keeps the loop running until the user types quit.
- input("You: ") pauses execution and waits for the user to type a message.
- The same conversation_history list is passed to every chat() call. This means Claude remembers everything you said earlier in the session.
- if __name__ == "__main__": ensures main() only runs when you execute the file directly, not when it is imported.
- Save finance_assistant.py.
- Run the script to confirm the interactive prompt appears by running:
python finance_assistant.py
You should see the welcome message and a You: prompt waiting for your input. Type quit and press Enter to exit for now.
Not seeing the prompt?
- Make sure your if __name__ == "__main__": block is at the bottom of the file, outside any function.
- Check that your old inline code (the hardcoded message and print statements from earlier steps) has been replaced by the chat() and main() functions.
- If you see a SyntaxError, check that the f"\nAssistant: {response_text}\n" line uses an f prefix before the opening quote.
Help me debug why my interactive loop is not starting.
Add a system prompt
A system prompt tells Claude how to behave across the entire conversation. Without one, Claude responds as a general-purpose assistant. Adding a finance-specific system prompt keeps its responses focused and concise.
- In finance_assistant.py, find the first client.messages.create() call inside the chat function. It currently looks like this:
response = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=tools,
messages=conversation_history
)
- Add a system parameter so the call now looks like this:
response = client.messages.create(
model=MODEL,
max_tokens=1024,
system="You are a helpful personal finance assistant. You have tools to calculate compound interest, convert currencies, and log expenses. Use them whenever the user's request matches a tool's purpose. Be concise and friendly.",
tools=tools,
messages=conversation_history
)
- Find the second client.messages.create() call inside the while loop (further down in the same function).
- Add the same system parameter to that call too. Both API calls need the system prompt so Claude maintains its personality throughout the tool-calling loop.
Why add the system prompt to both calls?
The chat function calls client.messages.create() twice: once for the initial message and once inside the while loop when processing tool results. Each API call is independent, so the system prompt must be included in every call.
Test the full experience
Your finance assistant is complete. Time to put it through its paces with different types of requests.
- Save finance_assistant.py and run it:
python finance_assistant.py
- Try a compound interest question. Type something like:
How much will $5,000 grow in 10 years at 7% annual interest?
Claude should call the calculate_compound_interest tool and respond with a specific dollar amount.
- Try a currency conversion. Type something like:
Convert 100 USD to EUR
- Try logging an expense. Type something like:
Log a $45 dinner at the Italian place
Claude should call the log_expense tool and confirm the expense was logged. You can verify by checking the expenses.json file that appears in your project folder.
- Try a conversational request that does not match any tool. Type something like:
What's the best way to start saving for retirement?
Claude should respond conversationally without calling any tools.
- Try an error case by requesting an unsupported currency pair. Type something like:
Convert 50 USD to BRL
Claude should receive the error through the is_error flag and explain that BRL is not a supported currency. No crash, no traceback.
- When you are done testing, type quit to exit the assistant.
Claude not calling the right tool?
- Make sure the system parameter was added to both client.messages.create() calls inside the chat function.
- Verify that all three tools are still in the tools list and that each tool's description field clearly describes when to use it.
- If Claude crashes instead of handling an error gracefully, check that the try/except block inside the while loop catches Exception and sets "is_error": True.
Help me debug why my finance assistant is not working correctly.
Awesome, I've got everything!
Make sure you have saved finance_assistant.py with all your changes.
I'd like to double check the full code
import json
import os
from datetime import datetime
import anthropic
client = anthropic.Anthropic()
MODEL = "claude-sonnet-4-6"
# --- Tool Definitions ---
tools = [
{
"name": "calculate_compound_interest",
"description": "Calculate compound interest to find the future value of an investment. Use this when the user asks about investment growth, savings projections, or how much money will grow over time.",
"input_schema": {
"type": "object",
"properties": {
"principal": {
"type": "number",
"description": "The initial investment amount in dollars."
},
"annual_rate": {
"type": "number",
"description": "The annual interest rate as a percentage (e.g., 7 for 7%)."
},
"years": {
"type": "integer",
"description": "The number of years to compound."
},
"compounds_per_year": {
"type": "integer",
"description": "Number of times interest compounds per year. Defaults to 12 (monthly)."
}
},
"required": ["principal", "annual_rate", "years"]
}
},
{
"name": "convert_currency",
"description": "Convert an amount from one currency to another using current exchange rates. Supports USD, EUR, GBP, and JPY.",
"input_schema": {
"type": "object",
"properties": {
"amount": {
"type": "number",
"description": "The amount of money to convert."
},
"from_currency": {
"type": "string",
"description": "The source currency code (e.g., USD, EUR, GBP, JPY)."
},
"to_currency": {
"type": "string",
"description": "The target currency code (e.g., USD, EUR, GBP, JPY)."
}
},
"required": ["amount", "from_currency", "to_currency"]
}
},
{
"name": "log_expense",
"description": "Log an expense to the local expense tracker. Use this when the user wants to record a purchase or spending.",
"input_schema": {
"type": "object",
"properties": {
"amount": {
"type": "number",
"description": "The expense amount in dollars."
},
"category": {
"type": "string",
"enum": ["food", "transport", "entertainment", "utilities", "other"],
"description": "The expense category."
},
"description": {
"type": "string",
"description": "A brief description of the expense."
}
},
"required": ["amount", "category", "description"]
}
}
]
# --- Tool Implementations ---
EXCHANGE_RATES = {
("USD", "EUR"): 0.92,
("USD", "GBP"): 0.79,
("USD", "JPY"): 149.50,
("EUR", "USD"): 1.09,
("EUR", "GBP"): 0.86,
("EUR", "JPY"): 162.50,
("GBP", "USD"): 1.27,
("GBP", "EUR"): 1.16,
("GBP", "JPY"): 189.20,
("JPY", "USD"): 0.0067,
("JPY", "EUR"): 0.0062,
("JPY", "GBP"): 0.0053,
}
EXPENSES_FILE = "expenses.json"
def calculate_compound_interest(principal, annual_rate, years, compounds_per_year=12):
rate = annual_rate / 100
future_value = principal * (1 + rate / compounds_per_year) ** (compounds_per_year * years)
interest_earned = future_value - principal
return {
"principal": principal,
"annual_rate": annual_rate,
"years": years,
"compounds_per_year": compounds_per_year,
"future_value": round(future_value, 2),
"interest_earned": round(interest_earned, 2)
}
def convert_currency(amount, from_currency, to_currency):
from_currency = from_currency.upper()
to_currency = to_currency.upper()
if from_currency == to_currency:
return {"amount": amount, "from": from_currency, "to": to_currency, "result": amount}
key = (from_currency, to_currency)
if key not in EXCHANGE_RATES:
raise ValueError(f"Unsupported currency pair: {from_currency} to {to_currency}. Supported currencies: USD, EUR, GBP, JPY.")
rate = EXCHANGE_RATES[key]
result = round(amount * rate, 2)
return {"amount": amount, "from": from_currency, "to": to_currency, "rate": rate, "result": result}
def log_expense(amount, category, description):
expenses = []
if os.path.exists(EXPENSES_FILE):
with open(EXPENSES_FILE, "r") as f:
expenses = json.load(f)
expense = {
"amount": amount,
"category": category,
"description": description,
"timestamp": datetime.now().isoformat()
}
expenses.append(expense)
with open(EXPENSES_FILE, "w") as f:
json.dump(expenses, f, indent=2)
return {"status": "logged", "expense": expense, "total_expenses": len(expenses)}
def process_tool_call(tool_name, tool_input):
if tool_name == "calculate_compound_interest":
return calculate_compound_interest(**tool_input)
elif tool_name == "convert_currency":
return convert_currency(**tool_input)
elif tool_name == "log_expense":
return log_expense(**tool_input)
else:
raise ValueError(f"Unknown tool: {tool_name}")
# --- Agentic Loop ---
def chat(user_message, conversation_history):
conversation_history.append({"role": "user", "content": user_message})
response = client.messages.create(
model=MODEL,
max_tokens=1024,
system="You are a helpful personal finance assistant. You have tools to calculate compound interest, convert currencies, and log expenses. Use them whenever the user's request matches a tool's purpose. Be concise and friendly.",
tools=tools,
messages=conversation_history
)
while response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if block.type == "tool_use":
try:
result = process_tool_call(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result)
})
except Exception as e:
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(e),
"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,
system="You are a helpful personal finance assistant. You have tools to calculate compound interest, convert currencies, and log expenses. Use them whenever the user's request matches a tool's purpose. Be concise and friendly.",
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
# --- Main ---
def main():
print("Personal Finance 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() == "quit":
print("Goodbye!")
break
response_text = chat(user_input, conversation_history)
print(f"\nAssistant: {response_text}\n")
if __name__ == "__main__":
main()
Secret mission
Add Expense Analysis
Your finance assistant can log expenses, but can it actually analyze them? Build a get_expense_summary tool that reads your expense history and lets Claude give you real spending insights.
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs locally with pay-per-use API calls, so there are no ongoing costs when you are not running the script.
Resources you used:
- finance-assistant/ project directory containing finance_assistant.py.
- expenses.json file created by the expense logging tool.
- Anthropic Python SDK installed via pip.
- ANTHROPIC_API_KEY environment variable (and optionally saved in ~/.zshrc).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep experimenting with your finance assistant.
- Your project files stay in the finance-assistant/ directory.
- Your API key remains set in your environment.
- The Anthropic API only charges you when you run the script, so there is no cost while it sits idle.
Pause - I'll come back to this later
Keep your files but remove the API key from your active environment for safety.
- Remove the API key from your current terminal session by closing the terminal window.
- If you added the export line to your ~/.zshrc file, you can comment it out by adding a # at the beginning of the line.
- Your project directory and expenses.json file will stay on your machine for when you return.
Delete - I don't want to use this again
Remove all project resources and start fresh.
- Delete the project directory by running rm -rf finance-assistant/ from the parent folder.
- Uninstall the Anthropic SDK by running pip uninstall anthropic.
- Remove the ANTHROPIC_API_KEY export line from your ~/.zshrc file if you added it there.
- If you no longer need the API key, you can revoke it from the Anthropic Console under API Keys.
Mission accomplished
That's a Wrap!
Nice work! You just built an AI-powered Personal Finance Assistant that uses Claude's tool use feature to calculate compound interest, convert currencies, and log expenses. This is the same foundational pattern behind every serious AI agent in production today.
Here's what you learned:
- Defined custom tools using JSON Schema and connected them to Claude via the Anthropic API.
- Built the agentic loop: the core pattern of checking stop_reason, executing tools, and returning tool_result blocks until Claude is done.
- Handled errors gracefully with is_error so Claude can recover and respond to the user instead of crashing.
- Added a get_expense_summary tool so Claude can read your spending data and provide insights.
Ready to quiz yourself?