Personal Assistant with Claude Tool Use

Build a CLI assistant that uses Claude tool use to answer real questions.

Introduction

30 Second Summary

When you ask a voice assistant for the weather or tell a chatbot to book a meeting, something interesting happens behind the scenes. The AI doesn't magically know the answer. It decides to call a specific tool, your code runs that tool, and the AI uses the result to respond naturally.

In this project, you will build a command-line personal assistant powered by Claude that can call custom tools you define. You will write the exact pattern that production AI agents use to connect language models to real-world capabilities.

What You'll Build

A Python CLI assistant that uses Claude's tool use feature to call functions you create, execute them, and return natural language answers.

By the end of this project, you'll have:

  • A working Claude-powered assistant that decides which tools to call based on your questions.
  • Three custom tool definitions using JSON Schema for weather, math, and time lookups.
  • A complete agentic loop that sends tools to Claude, detects tool requests, executes them, and repeats until Claude has a final answer.
  • Secret Mission: Add conversation memory so your assistant remembers context between messages.

Are there any prerequisites?

You need basic Python knowledge (variables, functions, loops). No prior AI or LLM experience is needed. The project walks you through everything from scratch.

Before We Start

Before We Start...

Set Up Your Environment

Before you can build your personal assistant, you need a working Python environment with the Anthropic SDK installed.

In this step, you will verify that Python is ready on your machine, create a dedicated project folder, and connect to the Claude API with an API key.

In this step, get ready to:
  • Verify Python 3.9+ and create your project folder.
  • Install the Anthropic Python SDK.
  • Configure your API key for Claude access.
Install Python and create your project

Python is the language you will use to talk to Claude's API. The project requires version 3.9 or higher.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Check your Python version by running this command:
python3 --version

✔️ I see version 3.9 or higher

You are good to go. Any version number at 3.9 or above (like 3.11, 3.12, 3.14) works perfectly.

ⓧ I see a version lower than 3.9

You have Python installed, but the version is too old for the Anthropic SDK.

  • Visit python.org/downloads and download the latest Python 3 installer for your operating system.
  • Run the installer. On Windows, make sure to check Add Python to PATH before clicking Install Now.
  • Close and reopen your terminal, then run python3 --version again to confirm the new version.

macOS users with an old system Python

If python3 --version still shows an old version after installing, the system may still point to the old binary. Run the new install using its full path (e.g. /Library/Frameworks/Python.framework/Versions/3.14/bin/python3) or use a version manager like pyenv.

ⓧ Command not found

Python is not installed on your machine yet.

  • Visit python.org/downloads and download the latest Python 3 installer for your operating system.
  • Run the installer. On Windows, make sure to check Add Python to PATH before clicking Install Now.
  • Close and reopen your terminal, then run python3 --version again.

Windows users

On Windows, you may need to use python --version (without the 3) instead. If that shows 3.9 or higher, use python in place of python3 throughout this project.

  • Move to your Desktop by running this command:
cd ~/Desktop

Windows users

Use cd %USERPROFILE%\Desktop in Command Prompt, or cd ~/Desktop works in PowerShell.

  • Create your project folder and move into it by running these commands:
mkdir claude-assistant
cd claude-assistant
  • Confirm you are inside the new folder by running:
ls

You should see an empty output since the folder is brand new.

  • Create a virtual environment by running this command:
python3 -m venv venv

What is a virtual environment?

A virtual environment is an isolated space where Python packages are installed separately from your system Python. This prevents version conflicts between projects.

  • Activate the virtual environment by running this command:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate
  • Check that activation worked. Your terminal prompt should now start with (venv).

Don't see (venv) in your prompt?

Make sure you ran the activate command from inside the claude-assistant folder (the one containing the venv directory).

On Windows, if you see an execution policy error, run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned in PowerShell and try again.

Help me activate my virtual environment.

Install the Anthropic SDK

The Anthropic SDK is the Python library that lets your code communicate with Claude's API.

  • Install the Anthropic package by running:
pip install anthropic

What does this command do?

This uses pip (Python's package manager) to download and install the anthropic library into your virtual environment. Because you activated the venv first, the package is isolated to this project.

  • Verify the installation succeeded by running:
python3 -c "import anthropic; print(anthropic.__version__)"

You should see a version number printed (like 0.103.0 or similar).

Seeing ModuleNotFoundError?

This usually means the virtual environment is not active. Check that your prompt starts with (venv). If not, re-run the activate command from the previous substep.

If you are on Windows and used python instead of python3 earlier, use python -c "import anthropic; print(anthropic.__version__)" here as well.

Help me fix my Anthropic SDK installation.

Set up your API key

Claude needs an API key to verify that requests come from your account. You will create one in the Anthropic Console and save it as an environment variable.

  • Open your browser and go to console.anthropic.com.
  • Create an account or sign in if you already have one.
  • In the left sidebar, click API Keys.
  • Click Create Key.
  • Give it a name like claude-assistant and copy the key value. Store it somewhere safe because you will not be able to see it again.

What is an API key?

An API key is a secret token that identifies your account when your code sends requests to Claude. It works like a password. Never share it publicly or commit it to version control.

  • Back in your terminal (with the venv still active), set the API key as an environment variable by running this command:

macOS

export ANTHROPIC_API_KEY='your-api-key-here'

Windows

$env:ANTHROPIC_API_KEY='your-api-key-here'
  • Replace your-api-key-here with the actual key you copied from the console.

Why an environment variable?

The Anthropic SDK automatically reads the ANTHROPIC_API_KEY environment variable when you create a client. This keeps your key out of your source code.

  • To keep the key available across terminal sessions (macOS/Linux only), add it to your shell profile by running:
echo "export ANTHROPIC_API_KEY='your-api-key-here'" >> ~/.zshrc
  • Replace your-api-key-here with your actual API key before running this command.

Windows persistence

On Windows, open Settings, search for Environment Variables, and add a new user variable named ANTHROPIC_API_KEY with your key as the value.

  • Verify your key is set correctly by running:
python3 -c "import anthropic; client = anthropic.Anthropic(); print('Client ready!')"

You should see Client ready! printed. If the key was missing or invalid, you would see an authentication error instead.

Seeing an authentication error?

Double-check that you pasted the full key without extra spaces. Run echo $ANTHROPIC_API_KEY (macOS) or echo $env:ANTHROPIC_API_KEY (Windows PowerShell) to see what value is currently set.

If the output is empty, the export command did not persist. Re-run the export command in the same terminal session where you will run the assistant.

Help me fix my API key setup.

Your environment is ready. Next up, you will define the tools that Claude can call.

Define Your Tools

Your environment is ready. Now it is time to teach Claude what it can do.

In this step, you will create the assistant.py file and define three tools that Claude can call. Each tool is described using JSON Schema so Claude knows what parameters to send. You will also write the Python functions that execute when Claude calls each tool.

In this step, get ready to:
  • Define three tool schemas that tell Claude what tools are available.
  • Write Python functions that execute each tool.
  • Build a dispatcher that routes Claude's tool requests to the correct function.
Create assistant.py and set up the tool definitions

Claude's tool use feature works by sending a list of tool definitions alongside your message. Each definition uses JSON Schema to describe the tool's name, purpose, and expected inputs. Claude reads these schemas and decides which tool to call based on the user's question.

  • Create a new file called assistant.py in your claude-assistant folder and add the imports and client initialization by pasting this code:
import anthropic
import json
from datetime import datetime

# Initialize the Anthropic client (reads ANTHROPIC_API_KEY from environment)
client = anthropic.Anthropic()

What does this code do?

  • anthropic is the SDK that communicates with the Claude API.
  • json lets your tool functions return structured data that Claude can read.
  • datetime powers the time tool you will build shortly.
  • client = anthropic.Anthropic() automatically reads your ANTHROPIC_API_KEY environment variable to authenticate.
  • Save assistant.py.
  • Confirm the file exists by running:
ls

You should see assistant.py in the output.

Now you will define the three tools Claude can use. Each tool has a name, a description, and an input_schema that tells Claude what parameters it accepts.

  • In assistant.py, add the tool definitions below your client initialization:
# Define the tools Claude can use
tools = [
    {
        "name": "get_weather",
        "description": "Get the current weather for a given location. Returns temperature, conditions, and humidity.",
        "input_schema": {
            "type": "object",
            "properties": {
                "location": {
                    "type": "string",
                    "description": "The city and country, e.g. 'London, UK' or 'San Francisco, US'"
                }
            },
            "required": ["location"]
        }
    },
    {
        "name": "calculate",
        "description": "Perform a mathematical calculation. Supports basic arithmetic operations.",
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "The mathematical expression to evaluate, e.g. '2 + 2' or '(10 * 5) / 3'"
                }
            },
            "required": ["expression"]
        }
    },
    {
        "name": "get_current_time",
        "description": "Get the current date and time for a given timezone.",
        "input_schema": {
            "type": "object",
            "properties": {
                "timezone": {
                    "type": "string",
                    "description": "The timezone, e.g. 'US/Eastern', 'Europe/London', 'Asia/Tokyo'. Defaults to UTC if not specified."
                }
            },
            "required": ["timezone"]
        }
    }
]

What does this code do?

The tools list is what you send to Claude with every message. Claude reads the description fields to understand what each tool does, then uses input_schema to know what parameters to provide when calling one.

  • get_weather accepts a location string (city and country).
  • calculate accepts an expression string (a math problem).
  • get_current_time accepts a timezone string.
  • Save assistant.py.
  • Verify the file has no syntax errors by running:
python3 -m py_compile assistant.py

If there is no output, your code is valid. If you see an error, check for missing commas or brackets in the tools list.

Seeing a syntax error?

  • JSON-style dictionaries in Python need commas between every key-value pair and between each dictionary in the list. Check that every } that closes a tool definition has a trailing comma (except the last one).
  • Make sure every opening { has a matching } and every opening [ has a matching ].

help me fix a syntax error in my Python tool definitions list

Write the tool execution functions

The tool definitions tell Claude what tools exist. Now you need the actual Python functions that run when Claude requests a tool. Each function receives the parameters Claude sends and returns a JSON string with the result.

  • In assistant.py, add the get_weather function below your tools list:
# Tool execution functions
def get_weather(location):
    """Simulated weather data - in a real app, you'd call a weather API."""
    weather_data = {
        "location": location,
        "temperature": "72°F (22°C)",
        "conditions": "Partly cloudy",
        "humidity": "45%",
        "wind": "10 mph NW"
    }
    return json.dumps(weather_data)

What does this code do?

This function returns simulated weather data as a JSON string. In a production app, you would replace this with a call to a real weather API. The simulated data lets you test the full tool use flow without needing API keys for a weather service.

  • Add the calculate function below get_weather:
def calculate(expression):
    """Safely evaluate a mathematical expression."""
    try:
        # Only allow numbers and basic math operators
        allowed_chars = set("0123456789+-*/.() ")
        if not all(c in allowed_chars for c in expression):
            return json.dumps({"error": "Invalid characters in expression. Only numbers and basic operators (+, -, *, /, parentheses) are allowed."})
        result = eval(expression)
        return json.dumps({"expression": expression, "result": result})
    except Exception as e:
        return json.dumps({"error": f"Could not evaluate expression: {str(e)}"})

What does this code do?

The calculator uses Python's eval() to compute the result, but first checks that the expression only contains safe characters (digits and basic operators). This character allowlist prevents malicious code execution.

In a production app, you would use a dedicated expression parser instead of eval(). For this learning project, the allowlist is sufficient.

  • Add the get_current_time function below calculate:
def get_current_time(timezone):
    """Get the current time."""
    now = datetime.now()
    return json.dumps({
        "timezone": timezone,
        "datetime": now.strftime("%Y-%m-%d %H:%M:%S"),
        "note": "This is the server's local time. In production, use a timezone library for accurate timezone conversion."
    })

What does this code do?

This returns the current server time as a formatted string. In production you would use a timezone library like pytz to handle actual timezone conversion. For this project, the local time is enough to demonstrate the pattern.

  • Save assistant.py.
  • You won't see a change yet. The next chunk connects these functions to the tool names Claude sends.
Create a tool dispatcher

When Claude calls a tool, it sends back the tool's name as a string (like "get_weather"). You need a way to look up that string and call the matching Python function. A dispatcher handles this routing.

  • In assistant.py, add the dispatcher below your tool functions:
# Map tool names to their functions
tool_functions = {
    "get_weather": get_weather,
    "calculate": calculate,
    "get_current_time": get_current_time,
}


def execute_tool(tool_name, tool_input):
    """Execute a tool by name with the given input."""
    if tool_name in tool_functions:
        func = tool_functions[tool_name]
        return func(**tool_input)
    return json.dumps({"error": f"Unknown tool: {tool_name}"})

What does this code do?

  • tool_functions is a dictionary that maps each tool name string to its Python function.
  • execute_tool takes the tool name and input that Claude provides, looks up the correct function, and calls it with the parameters unpacked using **tool_input.
  • If Claude sends an unknown tool name, the function returns an error message instead of crashing.
  • Save assistant.py.
  • Test the complete file by running:
python3 -c "import assistant; print(assistant.execute_tool('calculate', {'expression': '2 + 2'}))"

You should see {"expression": "2 + 2", "result": 4} printed in your terminal. This confirms your tool definitions, functions, and dispatcher are all working together.

Seeing an import error or no output?

  • Make sure your virtual environment is still active. You should see (venv) at the start of your terminal prompt.
  • If you see a ModuleNotFoundError for anthropic, re-run pip install anthropic inside your virtual environment.
  • Confirm you are running the command from inside the claude-assistant folder where assistant.py lives.

help me fix an import or module error when testing my assistant.py file

✔️ Awesome, I've got everything!

Great. Double check you have saved assistant.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete assistant.py so far:

import anthropic
import json
from datetime import datetime

client = anthropic.Anthropic()

# Define the tools Claude can use
tools = [
    {
        "name": "get_weather",
        "description": "Get the current weather for a given location. Returns temperature, conditions, and humidity.",
        "input_schema": {
            "type": "object",
            "properties": {
                "location": {
                    "type": "string",
                    "description": "The city and country, e.g. 'London, UK' or 'San Francisco, US'"
                }
            },
            "required": ["location"]
        }
    },
    {
        "name": "calculate",
        "description": "Perform a mathematical calculation. Supports basic arithmetic operations.",
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "The mathematical expression to evaluate, e.g. '2 + 2' or '(10 * 5) / 3'"
                }
            },
            "required": ["expression"]
        }
    },
    {
        "name": "get_current_time",
        "description": "Get the current date and time for a given timezone.",
        "input_schema": {
            "type": "object",
            "properties": {
                "timezone": {
                    "type": "string",
                    "description": "The timezone, e.g. 'US/Eastern', 'Europe/London', 'Asia/Tokyo'. Defaults to UTC if not specified."
                }
            },
            "required": ["timezone"]
        }
    }
]


# Tool execution functions
def get_weather(location):
    """Simulated weather data - in a real app, you'd call a weather API."""
    weather_data = {
        "location": location,
        "temperature": "72°F (22°C)",
        "conditions": "Partly cloudy",
        "humidity": "45%",
        "wind": "10 mph NW"
    }
    return json.dumps(weather_data)


def calculate(expression):
    """Safely evaluate a mathematical expression."""
    try:
        allowed_chars = set("0123456789+-*/.() ")
        if not all(c in allowed_chars for c in expression):
            return json.dumps({"error": "Invalid characters in expression. Only numbers and basic operators (+, -, *, /, parentheses) are allowed."})
        result = eval(expression)
        return json.dumps({"expression": expression, "result": result})
    except Exception as e:
        return json.dumps({"error": f"Could not evaluate expression: {str(e)}"})


def get_current_time(timezone):
    """Get the current time."""
    now = datetime.now()
    return json.dumps({
        "timezone": timezone,
        "datetime": now.strftime("%Y-%m-%d %H:%M:%S"),
        "note": "This is the server's local time. In production, use a timezone library for accurate timezone conversion."
    })


# Map tool names to their functions
tool_functions = {
    "get_weather": get_weather,
    "calculate": calculate,
    "get_current_time": get_current_time,
}


def execute_tool(tool_name, tool_input):
    """Execute a tool by name with the given input."""
    if tool_name in tool_functions:
        func = tool_functions[tool_name]
        return func(**tool_input)
    return json.dumps({"error": f"Unknown tool: {tool_name}"})

Your tools are defined and working. Next up, you will build the agentic loop that sends these tools to Claude and handles the back-and-forth conversation when Claude decides to call one.

Build the Agentic Loop

You have tools defined and a dispatcher ready to execute them. The missing piece is the logic that connects Claude to those tools in a conversation.

An agentic loop is a pattern where your code sends a message to Claude, checks if Claude wants to call a tool, executes that tool, sends the result back, and repeats until Claude has a final answer. This loop is the foundation of every AI agent that interacts with the real world.

In this step, get ready to:
  • Send user messages to Claude along with your tool definitions.
  • Detect when Claude wants to use a tool and execute it.
  • Loop until Claude produces a final text answer.
Implement the core chat_with_tools function

The chat_with_tools function is the heart of your assistant. It takes a user message, sends it to Claude with your tool definitions, and manages the entire back-and-forth until Claude has a final response.

  • In assistant.py, add the following function below the execute_tool function:
def chat_with_tools(user_message):
    """Send a message to Claude with tools and handle the agentic loop."""
    messages = [{"role": "user", "content": user_message}]
    print(f"\nYou: {user_message}")
    print("-" * 40)

    # Agentic loop: keep going until Claude produces a final answer
    while True:
        response = client.messages.create(
            model="claude-haiku-4-5",
            max_tokens=1024,
            tools=tools,
            messages=messages,
        )

        # Check if Claude wants to use a tool
        if response.stop_reason == "tool_use":
            # Process all tool_use blocks in the response
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    tool_name = block.name
                    tool_input = block.input
                    print(f"\U0001f527 Using tool: {tool_name}")
                    print(f"   Input: {json.dumps(tool_input, indent=2)}")

                    # Execute the tool
                    result = execute_tool(tool_name, tool_input)
                    print(f"   Result: {result}")

                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": result,
                    })

            # Add the assistant response and tool results to conversation
            messages.append({"role": "assistant", "content": response.content})
            messages.append({"role": "user", "content": tool_results})
        else:
            # Claude is done - extract and display the final text
            final_response = ""
            for block in response.content:
                if hasattr(block, "text"):
                    final_response += block.text

            print(f"\nClaude: {final_response}")
            return final_response

What does this function do at a high level?

This function implements a loop that keeps running until Claude gives a final text answer. Each iteration sends the conversation to Claude, checks whether Claude wants to call a tool, and if so, executes the tool and feeds the result back.

The next two substeps walk through the two key sections of this function in detail.

  • Save assistant.py.
  • You won't run this yet because the assistant needs a main() function to accept input. The next step adds that.

Seeing a syntax error on save?

  • Make sure the function is indented consistently. Every line inside the function body should be indented by 4 spaces.
  • Check that you placed this function below the execute_tool function, not inside it.
  • Verify the f-strings use matching quotes. Each print(f"...") line must have balanced double quotes.

Why am I getting a syntax error in my chat_with_tools function?

Handle the tool use response

The most important section of your function is the if response.stop_reason == "tool_use" branch. This is where Claude tells your code it wants to call a tool.

        # Check if Claude wants to use a tool
        if response.stop_reason == "tool_use":
            # Process all tool_use blocks in the response
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    tool_name = block.name
                    tool_input = block.input

What is happening in this section?

  • When Claude responds with stop_reason: "tool_use", it means Claude has decided it needs to call one of your tools to answer the question.
  • Claude can request multiple tools in a single response. The loop through response.content finds every tool_use block and extracts the tool name and input arguments.
  • Each tool_use block has a unique block.id. Your code passes that ID back as tool_use_id in the result so Claude can match each result to the correct tool call.

Why are tool results sent as a user message?

The Claude Messages API requires strict alternation between user and assistant roles. Tool results go in a user message because they represent information coming from your system (not from Claude) back into the conversation.

Complete the loop

The else branch handles the case where Claude is done calling tools and has a final answer for the user.

        else:
            # Claude is done - extract and display the final text
            final_response = ""
            for block in response.content:
                if hasattr(block, "text"):
                    final_response += block.text

            print(f"\nClaude: {final_response}")
            return final_response

How does the loop know when to stop?

  • The while True loop keeps sending messages to Claude until stop_reason is no longer "tool_use". At that point, Claude has all the information it needs.
  • Claude's final response might contain multiple content blocks (like a text block). The loop through response.content collects all text into one string.
  • The return statement breaks out of the while True and gives the final answer back to whatever called this function.

What if Claude calls multiple tools in one turn?

If you ask something like "What time is it in London and what's the weather there?", Claude may call both get_current_time and get_weather in a single response. Your loop handles this because it processes ALL tool_use blocks before sending results back.

✔️ Awesome, I've got everything!

Great. Double check you have saved assistant.py.

ⓧ I'd like to double check the full code

Here is the full assistant.py file as it should look at the end of this step.

import anthropic
import json
from datetime import datetime

client = anthropic.Anthropic()

# Define the tools Claude can use
tools = [
    {
        "name": "get_weather",
        "description": "Get the current weather for a given location. Returns temperature, conditions, and humidity.",
        "input_schema": {
            "type": "object",
            "properties": {
                "location": {
                    "type": "string",
                    "description": "The city and country, e.g. 'London, UK' or 'San Francisco, US'"
                }
            },
            "required": ["location"]
        }
    },
    {
        "name": "calculate",
        "description": "Perform a mathematical calculation. Supports basic arithmetic operations.",
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "The mathematical expression to evaluate, e.g. '2 + 2' or '(10 * 5) / 3'"
                }
            },
            "required": ["expression"]
        }
    },
    {
        "name": "get_current_time",
        "description": "Get the current date and time for a given timezone.",
        "input_schema": {
            "type": "object",
            "properties": {
                "timezone": {
                    "type": "string",
                    "description": "The timezone, e.g. 'US/Eastern', 'Europe/London', 'Asia/Tokyo'. Defaults to UTC if not specified."
                }
            },
            "required": ["timezone"]
        }
    }
]


# Tool execution functions
def get_weather(location):
    """Simulated weather data - in a real app, you'd call a weather API."""
    weather_data = {
        "location": location,
        "temperature": "72°F (22°C)",
        "conditions": "Partly cloudy",
        "humidity": "45%",
        "wind": "10 mph NW"
    }
    return json.dumps(weather_data)


def calculate(expression):
    """Safely evaluate a mathematical expression."""
    try:
        allowed_chars = set("0123456789+-*/.() ")
        if not all(c in allowed_chars for c in expression):
            return json.dumps({"error": "Invalid characters in expression. Only numbers and basic operators (+, -, *, /, parentheses) are allowed."})
        result = eval(expression)
        return json.dumps({"expression": expression, "result": result})
    except Exception as e:
        return json.dumps({"error": f"Could not evaluate expression: {str(e)}"})


def get_current_time(timezone):
    """Get the current time."""
    now = datetime.now()
    return json.dumps({
        "timezone": timezone,
        "datetime": now.strftime("%Y-%m-%d %H:%M:%S"),
        "note": "This is the server's local time. In production, use a timezone library for accurate timezone conversion."
    })


# Map tool names to their functions
tool_functions = {
    "get_weather": get_weather,
    "calculate": calculate,
    "get_current_time": get_current_time,
}


def execute_tool(tool_name, tool_input):
    """Execute a tool by name with the given input."""
    if tool_name in tool_functions:
        func = tool_functions[tool_name]
        return func(**tool_input)
    return json.dumps({"error": f"Unknown tool: {tool_name}"})


def chat_with_tools(user_message):
    """Send a message to Claude with tools and handle the agentic loop."""
    messages = [{"role": "user", "content": user_message}]
    print(f"\nYou: {user_message}")
    print("-" * 40)

    # Agentic loop: keep going until Claude produces a final answer
    while True:
        response = client.messages.create(
            model="claude-haiku-4-5",
            max_tokens=1024,
            tools=tools,
            messages=messages,
        )

        # Check if Claude wants to use a tool
        if response.stop_reason == "tool_use":
            # Process all tool_use blocks in the response
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    tool_name = block.name
                    tool_input = block.input
                    print(f"\U0001f527 Using tool: {tool_name}")
                    print(f"   Input: {json.dumps(tool_input, indent=2)}")

                    # Execute the tool
                    result = execute_tool(tool_name, tool_input)
                    print(f"   Result: {result}")

                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": result,
                    })

            # Add the assistant response and tool results to conversation
            messages.append({"role": "assistant", "content": response.content})
            messages.append({"role": "user", "content": tool_results})
        else:
            # Claude is done - extract and display the final text
            final_response = ""
            for block in response.content:
                if hasattr(block, "text"):
                    final_response += block.text

            print(f"\nClaude: {final_response}")
            return final_response

Your agentic loop is complete. Next up, you'll wrap this in an interactive chat interface so you can actually talk to your assistant.

Create the Interactive Chat Interface

Your agentic loop handles the entire tool use cycle. Claude receives messages, requests tools, your code executes them, and Claude weaves the results into a natural response.

Now you need a way to actually talk to your assistant. In this step, you'll build an interactive command-line interface that lets you type messages and see Claude's tool-powered responses in real time.

In this step, get ready to:
  • Build the main() function with a welcome banner and input loop.
  • Run your assistant and test it with weather, math, and multi-tool queries.
Build the main() function

The main() function is the entry point of your assistant. It prints a welcome message, then sits in a loop waiting for the user to type something.

  • In assistant.py, scroll to the very bottom of your file (below the chat_with_tools function) and add the following code:
def main():
    """Run the personal assistant in a loop."""
    print("=" * 50)
    print("🤖 Claude Personal Assistant with Tool Use")
    print("=" * 50)
    print("\nI can help you with:")
    print("  🌤️  Weather information (simulated)")
    print("  🧮  Math calculations")
    print("  🕐  Current time")
    print("\nType 'quit' to exit.\n")

    while True:
        user_input = input("You: ").strip()
        if user_input.lower() in ("quit", "exit", "q"):
            print("\nGoodbye! 👋")
            break
        if not user_input:
            continue
        chat_with_tools(user_input)
        print()


if __name__ == "__main__":
    main()

What does this code do?

The welcome banner prints a header and lists the three capabilities so the user knows what to ask about.

The while True loop keeps the assistant running until the user types quit, exit, or q. Empty inputs are skipped with continue so they don't waste an API call.

The if __name__ == "__main__": guard means main() only runs when you execute the file directly. It won't run if you import the file as a module from somewhere else.

  • Save assistant.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on to testing.

ⓧ I'd like to double check the full code

Here is the complete assistant.py file. Compare yours against this:

import anthropic
import json
from datetime import datetime

client = anthropic.Anthropic()

# Define the tools Claude can use
tools = [
    {
        "name": "get_weather",
        "description": "Get the current weather for a given location. Returns temperature, conditions, and humidity.",
        "input_schema": {
            "type": "object",
            "properties": {
                "location": {
                    "type": "string",
                    "description": "The city and country, e.g. 'London, UK' or 'San Francisco, US'"
                }
            },
            "required": ["location"]
        }
    },
    {
        "name": "calculate",
        "description": "Perform a mathematical calculation. Supports basic arithmetic operations.",
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "The mathematical expression to evaluate, e.g. '2 + 2' or '(10 * 5) / 3'"
                }
            },
            "required": ["expression"]
        }
    },
    {
        "name": "get_current_time",
        "description": "Get the current date and time for a given timezone.",
        "input_schema": {
            "type": "object",
            "properties": {
                "timezone": {
                    "type": "string",
                    "description": "The timezone, e.g. 'US/Eastern', 'Europe/London', 'Asia/Tokyo'. Defaults to UTC if not specified."
                }
            },
            "required": ["timezone"]
        }
    }
]


# Tool execution functions
def get_weather(location):
    """Simulated weather data - in a real app, you'd call a weather API."""
    weather_data = {
        "location": location,
        "temperature": "72°F (22°C)",
        "conditions": "Partly cloudy",
        "humidity": "45%",
        "wind": "10 mph NW"
    }
    return json.dumps(weather_data)


def calculate(expression):
    """Safely evaluate a mathematical expression."""
    try:
        allowed_chars = set("0123456789+-*/.() ")
        if not all(c in allowed_chars for c in expression):
            return json.dumps({"error": "Invalid characters in expression. Only numbers and basic operators (+, -, *, /, parentheses) are allowed."})
        result = eval(expression)
        return json.dumps({"expression": expression, "result": result})
    except Exception as e:
        return json.dumps({"error": f"Could not evaluate expression: {str(e)}"})


def get_current_time(timezone):
    """Get the current time."""
    now = datetime.now()
    return json.dumps({
        "timezone": timezone,
        "datetime": now.strftime("%Y-%m-%d %H:%M:%S"),
        "note": "This is the server's local time. In production, use a timezone library for accurate timezone conversion."
    })


# Map tool names to their functions
tool_functions = {
    "get_weather": get_weather,
    "calculate": calculate,
    "get_current_time": get_current_time,
}


def execute_tool(tool_name, tool_input):
    """Execute a tool by name with the given input."""
    if tool_name in tool_functions:
        func = tool_functions[tool_name]
        return func(**tool_input)
    return json.dumps({"error": f"Unknown tool: {tool_name}"})


def chat_with_tools(user_message):
    """Send a message to Claude with tools and handle the agentic loop."""
    messages = [{"role": "user", "content": user_message}]
    print(f"\nYou: {user_message}")
    print("-" * 40)

    # Agentic loop: keep going until Claude produces a final answer
    while True:
        response = client.messages.create(
            model="claude-haiku-4-5",
            max_tokens=1024,
            tools=tools,
            messages=messages,
        )

        # Check if Claude wants to use a tool
        if response.stop_reason == "tool_use":
            # Process all tool_use blocks in the response
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    tool_name = block.name
                    tool_input = block.input
                    print(f"🔧 Using tool: {tool_name}")
                    print(f"   Input: {json.dumps(tool_input, indent=2)}")

                    # Execute the tool
                    result = execute_tool(tool_name, tool_input)
                    print(f"   Result: {result}")

                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": result,
                    })

            # Add the assistant response and tool results to conversation
            messages.append({"role": "assistant", "content": response.content})
            messages.append({"role": "user", "content": tool_results})
        else:
            # Claude is done - extract and display the final text
            final_response = ""
            for block in response.content:
                if hasattr(block, "text"):
                    final_response += block.text

            print(f"\nClaude: {final_response}")
            return final_response


def main():
    """Run the personal assistant in a loop."""
    print("=" * 50)
    print("🤖 Claude Personal Assistant with Tool Use")
    print("=" * 50)
    print("\nI can help you with:")
    print("  🌤️  Weather information (simulated)")
    print("  🧮  Math calculations")
    print("  🕐  Current time")
    print("\nType 'quit' to exit.\n")

    while True:
        user_input = input("You: ").strip()
        if user_input.lower() in ("quit", "exit", "q"):
            print("\nGoodbye! 👋")
            break
        if not user_input:
            continue
        chat_with_tools(user_input)
        print()


if __name__ == "__main__":
    main()
Run your assistant and test it

Your assistant is complete. Time to see it in action and confirm the full tool use cycle works end to end.

  • Start your assistant by running:
python3 assistant.py

You should see the welcome banner listing the three capabilities, followed by a You: prompt waiting for your input.

Seeing an error when you run the script?

  • If you see a ModuleNotFoundError for anthropic, make sure your virtual environment is activated. Run source venv/bin/activate from inside your claude-assistant folder.
  • If you see an authentication error, confirm your ANTHROPIC_API_KEY environment variable is set in your current terminal session.
  • If you see a SyntaxError, check your indentation. Python requires consistent spacing throughout the file.

Help me debug my assistant startup error.

  • Test a single tool call by typing this at the prompt:
What's the weather in Tokyo?

You should see Claude call the get_weather tool, print the simulated weather data, and then give you a natural language answer using that data.

  • Test the calculator by typing:
What's 247 * 38 + 15?

Claude should call the calculate tool and return the correct result.

  • Test multi-tool use in a single turn by typing:
What time is it in London and what's the weather there?

This time, Claude should call both get_current_time and get_weather in the same response. Your agentic loop handles both tool calls before Claude produces its final answer.

Why can Claude call multiple tools at once?

When Claude determines that a question requires information from multiple sources, it includes several tool_use blocks in a single response. Your loop iterates over all of them, executes each one, and sends all results back together.

This parallel tool calling is what makes the assistant feel intelligent. It gathers everything it needs in one round trip instead of asking you multiple follow-up questions.

  • When you're done testing, type quit to exit the assistant.

Claude not calling the right tool?

  • If Claude answers without calling any tool, your tools list might not be getting passed to the API. Check that tools=tools is included in your client.messages.create() call.
  • If you see a JSON parsing error, check that all your tool definitions have valid input_schema structures with no trailing commas.

Help me figure out why Claude isn't using my tools.

Secret mission

Add Conversation Memory

Your assistant currently forgets everything after each message. Give it the ability to remember previous messages so you can have multi-turn conversations with context.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with API calls to Claude. There are no ongoing costs from leaving your files on disk. The only resource that could incur future charges is your Anthropic API key, and only if something actively sends requests with it.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The claude-assistant project folder on your Desktop (contains assistant.py and the venv virtual environment).
  • The ANTHROPIC_API_KEY environment variable (and the line in your ~/.zshrc or ~/.bashrc file).
  • Your Anthropic API key on the Claude Console.

Keep everything running

No action needed. Choose this if you plan to keep building on your assistant or experimenting with Claude's tool use feature.

  • Your project files stay on your Desktop.
  • Your API key remains active for future use.
  • Your environment variable stays set so you can run assistant.py any time.

Pause - I'll come back to this later

Free up terminal resources and deactivate the virtual environment. Your files and API key stay intact for next time.

  • If your virtual environment is still active, deactivate it by running:
deactivate
  • Close any terminal windows you had open for this project.
  • Your project folder and API key remain ready for when you return.

Delete - I don't want to use this again

Remove all project resources and revoke your API key so nothing remains from this project.

  • Deactivate your virtual environment if it is still active:
deactivate
  • Delete the project folder by running:

macOS

cd ~/Desktop
rm -rf claude-assistant

Windows

cd ~\Desktop
rmdir /s /q claude-assistant
  • Remove the ANTHROPIC_API_KEY line from your shell profile. Open ~/.zshrc (macOS) or ~/.bashrc (Linux) in a text editor and delete the line that says export ANTHROPIC_API_KEY='...'.
  • Unset the variable in your current terminal session:
unset ANTHROPIC_API_KEY
  • Revoke your API key on the Claude Console. Go to console.anthropic.com, navigate to Settings, then API Keys. Find the key you created for this project and disable or delete it.

Nice Work!

Nice Work!

You just built a working personal assistant powered by Claude that can call custom tools you define. This is the exact same pattern used in production AI applications to connect LLMs to real-world capabilities.

You learned how to:

  • Defined custom tools using JSON Schema that Claude can understand and call.
  • Implemented the complete agentic loop pattern: send tools, detect tool_use, execute, return results, and repeat until Claude has a final answer.
  • Built an interactive CLI that demonstrates multi-tool conversations in a single turn.
  • Added conversation memory so your assistant remembers context between messages.

Ready to quiz yourself?