Build a Tool-Using AI Assistant

Build a CLI assistant where Claude calls custom tools to calculate, tell time, and define words.

Introduction

30 Second Summary

AI assistants like Claude are incredibly knowledgeable, but they can't do things in the real world on their own. They can't check the time, perform precise calculations, or look up live data unless you give them the tools to do so.

In this project, you will build a command-line AI assistant powered by Claude that can call custom tools you define. You'll teach Claude to perform calculations, tell the time in any timezone, and look up word definitions from a real API.

What You'll Build

A Python CLI chatbot that connects to the Claude API and gives it access to 3 custom tools, with a full agentic loop that handles tool execution automatically.

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

  • A working Python script with 3 custom tool definitions (calculator, time, and dictionary).
  • A complete agentic loop that detects when Claude wants to call a tool, executes it, and returns the result.
  • An interactive chat interface where you can have multi-turn conversations with your tool-equipped assistant.
  • Secret Mission: Add a weather tool and teach Claude to chain multiple tools together in a single response.

Are there any prerequisites?

You'll need basic Python knowledge (variables, functions, loops, and dictionaries). No prior experience with AI APIs is required.

Set Up Your Python Environment and API Key

Before you can build your AI assistant, you need a few things in place. Python runs the code, the Anthropic SDK connects you to Claude, and an API key proves your identity to Anthropic's servers.

In this step, you will install all the tools and configure the credentials that the rest of the project depends on.

In this step, get ready to:
  • Install and verify Python 3.10+.
  • Install the Anthropic Python SDK and the requests library.
  • Create an Anthropic API key and set it as an environment variable.
Install and verify Python

This project requires Python 3.10 or higher because it uses the zoneinfo module from the standard library for timezone handling.

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

✔️ I see Python 3.10 or higher

You are good to go. Move on to the next substep.

ⓧ Command not found or version is below 3.10

You need to install Python 3.10 or higher.

  • Go to python.org/downloads and download the latest version for your operating system.
  • Run the installer and follow the prompts.
  • On Windows, make sure to check the box that says Add Python to PATH during installation.
  • After installation, close and reopen your terminal.
  • Run python3 --version again to confirm it shows 3.10 or higher.

Still seeing an old version?

On macOS, the system Python might be outdated. Try python3.12 --version to see if a newer version was installed alongside the old one.

On Windows, try py --version which uses the Python launcher to find the newest version.

Help me troubleshoot my Python installation.

Install the Anthropic SDK and requests library

The anthropic package is the official Python SDK for talking to Claude. The requests library handles HTTP calls to external APIs, which you will use later for the dictionary tool.

  • Install both packages by running this command:
pip3 install anthropic requests
  • You should see output showing packages being downloaded and installed, ending with Successfully installed followed by the package names.

Seeing a permission error?

If you get a permissions error on macOS or Linux, try adding --user to the end of the command to install into your user directory instead of the system directory.

If you see pip3: command not found, try python3 -m pip install anthropic requests instead.

Help me fix my pip installation issue.

Create your Anthropic API key

An API key is a secret credential that identifies you when your code sends requests to Claude. Without it, Anthropic's servers will reject your calls.

  • Open console.anthropic.com in your browser.
  • Sign up for an account if you do not have one already, or log in.
  • Click API Keys in the left sidebar.
  • Click Create Key.
  • Give your key a name like tool-use-project and click Create Key.
  • Copy the key that appears. This is the only time you will see it, so do not close this page yet.

What about cost?

New Anthropic accounts receive free API credits. This project uses very little API time, so it should cost around $0.05 or less. You will not be charged beyond your free credits unless you explicitly add a payment method.

Now set the key as an environment variable so the SDK can find it automatically.

  • In your terminal, set the environment variable by running this command (replace the placeholder with your actual key):
export ANTHROPIC_API_KEY="your-api-key-here"

What does this command do?

The export command creates an environment variable that lasts for the current terminal session. The Anthropic SDK automatically looks for a variable named ANTHROPIC_API_KEY when you initialize the client, so you never have to paste your key into code.

macOS

The export command above works in macOS Terminal and zsh. This variable only lasts for your current terminal session. If you close the terminal, you will need to run the export command again.

Windows

If you are using PowerShell on Windows, use this command instead:

$env:ANTHROPIC_API_KEY="your-api-key-here"

If you are using Command Prompt (cmd), use this:

set ANTHROPIC_API_KEY=your-api-key-here
  • Verify the variable is set correctly by running:
echo $ANTHROPIC_API_KEY
  • You should see your API key printed in the terminal. If you see a blank line, run the export command again.

Seeing a blank line instead of your key?

Make sure you included the quotes around your key in the export command. Check that there are no spaces around the = sign.

On Windows PowerShell, use echo $env:ANTHROPIC_API_KEY instead of echo $ANTHROPIC_API_KEY.

Help me set my Anthropic API key as an environment variable.

📣 Keep this terminal open

Your environment variable only exists in this terminal session. If you close the terminal, you will need to run the export command again before running your Python script. Keep this terminal window open for the rest of the project.

Your environment is ready. Next up, you will create your first tool definition and make your first API call to Claude.

Define Your First Tool and Make a Basic API Call

Your environment is ready with Python, the Anthropic SDK, and your API key. Now it's time to create the actual assistant and teach Claude how to use a tool.

Out of the box, Claude can't perform live calculations or look up real-time data. Tool use changes that. You define a tool as a JSON Schema description, and Claude decides when to call it based on the conversation.

In this step, get ready to:
  • Create your assistant.py file and initialize the Claude client.
  • Define a calculator tool with a JSON Schema that tells Claude what the tool does.
  • Send a test message and see Claude's raw tool_use response.
Create assistant.py and initialize the client

Every Claude application starts the same way: import the SDK, create a client object, and choose a model. The client reads your ANTHROPIC_API_KEY environment variable automatically.

  • Create a new file called assistant.py in your project folder.
  • Add the following imports and client setup:
import json
import anthropic
import requests
from datetime import datetime
from zoneinfo import ZoneInfo

# Initialize the Anthropic client
client = anthropic.Anthropic()
MODEL_NAME = "claude-sonnet-4-5"

What does this code do?

  • anthropic is the official Python SDK for talking to Claude's API.
  • requests is for making HTTP calls to external APIs (you'll use it for the dictionary tool later).
  • datetime and ZoneInfo handle timezone-aware time lookups (you'll need them in a future step).
  • anthropic.Anthropic() creates a client that automatically reads your ANTHROPIC_API_KEY environment variable.
  • MODEL_NAME stores the model ID so you can change it in one place if needed.
  • Save assistant.py.

You won't see a change yet. The next chunk adds the tool definition that makes this file do something interesting.

Seeing a red underline on an import?

If your editor flags anthropic or requests as unresolved, make sure you installed them with pip3 install anthropic requests in the same Python environment your editor is using.

help me fix an import error in my Python file

Define the calculate tool schema

A tool definition tells Claude what the tool does, when to use it, and what inputs it needs. Claude never sees your Python code. It only reads the JSON Schema you provide.

  • In assistant.py, add the following tool definition below your MODEL_NAME line:
# Define tools
tools = [
    {
        "name": "calculate",
        "description": "Evaluate a mathematical expression and return the result. Use this tool when the user asks you to perform any kind of math calculation, arithmetic, or numeric computation. Supports basic arithmetic (+, -, *, /), exponents (**), parentheses, and common math 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"]
        }
    }
]

What does this tool definition do?

This is the schema Claude reads to understand when and how to use your tool.

  • name is the identifier Claude uses when it decides to call this tool.
  • description tells Claude WHEN to use the tool. The more specific your description, the better Claude's decisions.
  • input_schema defines the parameters using JSON Schema. Here, expression is a required string.

Now you need the actual Python function that runs when Claude calls this tool.

  • Add the calculate function below your tools list:
def calculate(expression):
    """Safely evaluate a math expression."""
    try:
        # Only allow safe math characters
        allowed_chars = set("0123456789+-*/.() ")
        if not all(c in allowed_chars for c in expression):
            return f"Error: Expression contains invalid characters. Only numbers and +-*/.() are allowed."
        result = eval(expression)
        return str(result)
    except Exception as e:
        return f"Error evaluating expression: {e}"

What does this function do?

  • allowed_chars restricts input to digits and math operators. This prevents running arbitrary Python code through eval().
  • eval(expression) evaluates the math string and returns the numeric result.
  • The function always returns a string, because tool results are sent back to Claude as text.

💡 Why is eval() okay here?

The character filter only allows digits and basic math symbols, blocking anything dangerous. In a production app, you would use a proper math parser like sympy. For a learning project, the filtered eval() keeps things simple.

  • Save assistant.py.

You won't see output yet. The next chunk sends a test message so Claude can actually try calling your tool.

Syntax error on the tools list?

The nested braces and brackets can be tricky. Make sure every opening { has a matching }, and that the outer list uses square brackets [ ] while each tool object uses curly braces { }.

help me fix my tools list syntax

Send a test message and observe the tool_use response

When you send a message to Claude with tools attached, Claude reads the conversation and decides whether to respond directly or call a tool. If it chooses a tool, it returns a special tool_use content block instead of plain text.

  • Add the following test call at the bottom of assistant.py:
# Test call
response = client.messages.create(
    model=MODEL_NAME,
    max_tokens=1024,
    tools=tools,
    messages=[{"role": "user", "content": "What is 25 * 4 + 10?"}]
)
print(response)

What does this code do?

  • client.messages.create() sends a message to Claude's API.
  • tools=tools tells Claude about your available tools. Claude's default behavior (tool_choice: auto) lets it decide whether to use a tool or respond directly.
  • The message asks a math question, so Claude should choose to call your calculate tool.
  • Save assistant.py.
  • Run the script by running:
python3 assistant.py

You should see a large response object printed to your terminal. Look for stop_reason='tool_use' and a content block with type='tool_use' containing the tool name and input.

What should I see?

The response shows Claude wants to call your tool but hasn't received the result yet. You'll see something like:

  • stop_reason='tool_use' means Claude paused to request a tool call.
  • A ToolUseBlock with name='calculate' and input={'expression': '25 * 4 + 10'} tells you exactly what Claude wants computed.
  • A unique id field on the block identifies this specific tool call. You'll use it in the next step to send the result back.

🙋‍♀️ Seeing an authentication error?

If you see AuthenticationError, your API key isn't being found. Make sure you exported it in the same terminal session where you're running the script.

Run echo $ANTHROPIC_API_KEY to confirm the variable is set. If it's empty, re-run your export command from Step 1.

help me fix my Anthropic API key authentication

✔️ 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's the full assistant.py file as it should look at the end of this step:

import json
import anthropic
import requests
from datetime import datetime
from zoneinfo import ZoneInfo

# Initialize the Anthropic client
client = anthropic.Anthropic()
MODEL_NAME = "claude-sonnet-4-5"

# Define tools
tools = [
    {
        "name": "calculate",
        "description": "Evaluate a mathematical expression and return the result. Use this tool when the user asks you to perform any kind of math calculation, arithmetic, or numeric computation. Supports basic arithmetic (+, -, *, /), exponents (**), parentheses, and common math 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"]
        }
    }
]


def calculate(expression):
    """Safely evaluate a math expression."""
    try:
        allowed_chars = set("0123456789+-*/.() ")
        if not all(c in allowed_chars for c in expression):
            return f"Error: Expression contains invalid characters. Only numbers and +-*/.() are allowed."
        result = eval(expression)
        return str(result)
    except Exception as e:
        return f"Error evaluating expression: {e}"


# Test call
response = client.messages.create(
    model=MODEL_NAME,
    max_tokens=1024,
    tools=tools,
    messages=[{"role": "user", "content": "What is 25 * 4 + 10?"}]
)
print(response)

Claude now knows your tool exists and asks to use it. But notice how the response just stops. Claude is waiting for the result. In the next step, you'll build the agentic loop that executes the tool, sends the result back, and lets Claude finish its answer.

Build the Agentic Loop

In the previous step, you defined your first tool and saw Claude respond with a tool_use block instead of plain text.

Right now, that tool call just sits there. Claude asked to use your calculator, but nobody actually ran the math and sent back the answer. The agentic loop is the piece that closes this gap. It detects tool calls, executes the function, returns the result to Claude, and repeats until Claude has everything it needs to respond naturally.

In this step, get ready to:
  • Build a tool router that executes the right function when Claude requests it.
  • Implement the agentic loop that keeps calling Claude until it finishes responding.
  • Test the full flow by asking Claude to calculate something and getting a natural language answer.
Implement the tool router

When Claude returns a tool_use block, it includes the tool name and the input it wants to pass. You need a function that takes those two pieces and routes them to the correct Python function.

  • In assistant.py, find the test call at the bottom of your file:
# Test call
response = client.messages.create(
    model=MODEL_NAME,
    max_tokens=1024,
    tools=tools,
    messages=[{"role": "user", "content": "What is 25 * 4 + 10?"}]
)
print(response)
  • Delete this entire test call block. You will replace it with the tool router function.
  • Add the following process_tool_call function below your calculate function:
def process_tool_call(tool_name, tool_input):
    """Route a tool call to the correct function."""
    if tool_name == "calculate":
        return calculate(tool_input["expression"])
    else:
        return f"Unknown tool: {tool_name}"

What does this code do?

This function acts as a switchboard. When Claude asks to use a tool, you pass the tool name and input here, and it calls the matching Python function.

Right now it only handles the calculator. You will add more routes here as you define more tools in the next step.

  • Save assistant.py.
Build the agentic loop

The agentic loop is a while True loop that keeps calling the Claude API until Claude signals it is done. Each iteration checks the stop_reason field on the response to decide what to do next.

  • Add the following chat function below your process_tool_call function:
def chat(user_message, messages):
    """Send a message and handle the full agentic loop."""
    messages.append({"role": "user", "content": user_message})

    while True:
        response = client.messages.create(
            model=MODEL_NAME,
            max_tokens=1024,
            tools=tools,
            messages=messages
        )

        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason == "end_turn":
            text_parts = []
            for block in response.content:
                if hasattr(block, "text"):
                    text_parts.append(block.text)
            return "\n".join(text_parts)

        if response.stop_reason == "tool_use":
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    tool_name = block.name
                    tool_input = block.input
                    print(f"  [Calling tool: {tool_name}({json.dumps(tool_input)})]")
                    result = process_tool_call(tool_name, tool_input)
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": result
                    })

            messages.append({"role": "user", "content": tool_results})

What does this code do?

This function implements the full tool-use conversation cycle. Here is what each section handles:

  • messages.append({"role": "user", ...}) adds your message to the conversation history before sending it to Claude.
  • The while True loop calls the API repeatedly. Each response gets appended to the conversation so Claude remembers what happened.
  • If stop_reason == "end_turn", Claude is done talking. The function extracts any text blocks and returns them.
  • If stop_reason == "tool_use", Claude wants to call a tool. The code executes each requested tool, collects the results, and sends them back as a user message with matching tool_use_id values.

💡 Why send tool results as a user message?

Claude's API uses a strict alternating message pattern: user, assistant, user, assistant. Tool results must be sent as a "user" role message so the pattern stays valid. The tool_use_id field links each result back to the specific tool call Claude made.

  • Save assistant.py.
Add the interactive chat and test it

The loop needs a way to receive user input and display Claude's responses. You will add a simple interactive prompt that keeps the conversation going until you type quit.

  • Add the following main function and entry point below your chat function:
def main():
    """Run the interactive chat loop."""
    print("=" * 50)
    print("  Claude Tool-Use Assistant")
    print("  Type 'quit' or 'exit' to end the conversation")
    print("=" * 50)
    print()

    messages = []

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

        response_text = chat(user_input, messages)
        print(f"\nClaude: {response_text}\n")


if __name__ == "__main__":
    main()

What does this code do?

  • messages = [] creates an empty conversation history. This list grows with each exchange and gives Claude full context of the conversation.
  • The while True loop reads input, skips empty lines, and exits on "quit" or "exit".
  • if __name__ == "__main__" ensures main() only runs when you execute the script directly, not when importing it.
  • Save assistant.py.
  • Run your assistant by executing this command in your terminal:
python3 assistant.py
  • When you see the You: prompt, type a math question like What is 25 * 4 + 10? and press Enter.

You should see output similar to this:

==================================================
  Claude Tool-Use Assistant
  Type 'quit' or 'exit' to end the conversation
==================================================

You: What is 25 * 4 + 10?
  [Calling tool: calculate({"expression": "25 * 4 + 10"})]

Claude: 25 * 4 + 10 = 110.

The [Calling tool: ...] line confirms your agentic loop detected the tool call, ran the calculation, and sent the result back. Claude then used that result to form a natural language answer.

  • Type quit to exit the assistant.

Not seeing a response from Claude?

  • Make sure you deleted the entire test call block from the previous step. If it is still there, the script will run that code first and may error or exit before reaching main().
  • Check that your ANTHROPIC_API_KEY environment variable is still set in your current terminal session. If you opened a new terminal, you need to export it again.
  • Verify that if __name__ == "__main__": is at the very bottom of the file with no indentation.

My assistant isn't responding when I type a message. Help me debug.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete assistant.py at this point:

import json
import anthropic
import requests
from datetime import datetime
from zoneinfo import ZoneInfo

# Initialize the Anthropic client
client = anthropic.Anthropic()
MODEL_NAME = "claude-sonnet-4-5"

# Define tools
tools = [
    {
        "name": "calculate",
        "description": "Evaluate a mathematical expression and return the result. Use this tool when the user asks you to perform any kind of math calculation, arithmetic, or numeric computation. Supports basic arithmetic (+, -, *, /), exponents (**), parentheses, and common math 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"]
        }
    }
]


def calculate(expression):
    """Safely evaluate a math expression."""
    try:
        allowed_chars = set("0123456789+-*/.() ")
        if not all(c in allowed_chars for c in expression):
            return f"Error: Expression contains invalid characters. Only numbers and +-*/.() are allowed."
        result = eval(expression)
        return str(result)
    except Exception as e:
        return f"Error evaluating expression: {e}"


def process_tool_call(tool_name, tool_input):
    """Route a tool call to the correct function."""
    if tool_name == "calculate":
        return calculate(tool_input["expression"])
    else:
        return f"Unknown tool: {tool_name}"


def chat(user_message, messages):
    """Send a message and handle the full agentic loop."""
    messages.append({"role": "user", "content": user_message})

    while True:
        response = client.messages.create(
            model=MODEL_NAME,
            max_tokens=1024,
            tools=tools,
            messages=messages
        )

        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason == "end_turn":
            text_parts = []
            for block in response.content:
                if hasattr(block, "text"):
                    text_parts.append(block.text)
            return "\n".join(text_parts)

        if response.stop_reason == "tool_use":
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    tool_name = block.name
                    tool_input = block.input
                    print(f"  [Calling tool: {tool_name}({json.dumps(tool_input)})]")
                    result = process_tool_call(tool_name, tool_input)
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": result
                    })

            messages.append({"role": "user", "content": tool_results})


def main():
    """Run the interactive chat loop."""
    print("=" * 50)
    print("  Claude Tool-Use Assistant")
    print("  Type 'quit' or 'exit' to end the conversation")
    print("=" * 50)
    print()

    messages = []

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

        response_text = chat(user_input, messages)
        print(f"\nClaude: {response_text}\n")


if __name__ == "__main__":
    main()

Your agentic loop is working. Claude can now call tools and weave the results into its responses. Next up, you will give Claude two more tools to work with: a timezone clock and a dictionary lookup that connects to a real external API.

Add the Time and Dictionary Tools

Your assistant can do math, but a single tool is limiting. Real-world assistants need access to multiple capabilities so they can handle a wider range of requests.

In this step, you will add two more tools that teach Claude different patterns. The time tool demonstrates optional parameters. The dictionary tool demonstrates calling a real external API and parsing structured data.

In this step, get ready to:
  • Add a time tool with an optional timezone parameter.
  • Add a dictionary tool that fetches definitions from a live API.
  • Update the routing function and test all three tools in conversation.
Add the get_current_time tool schema

The time tool introduces an important concept: optional parameters. Unlike the calculator where an expression is required, the timezone parameter is optional. If the user just asks "what time is it?" without specifying a location, the tool defaults to UTC.

  • In assistant.py, find your tools list. Add this new tool definition after the closing } of the calculate tool schema (make sure to add a comma after the calculate schema's closing brace):
    {
        "name": "get_current_time",
        "description": "Get the current date and time. Optionally specify a timezone to get the time in that location. Use this when the user asks what time it is, what the date is, or anything about the current time in any location.",
        "input_schema": {
            "type": "object",
            "properties": {
                "timezone": {
                    "type": "string",
                    "description": "The timezone to get the time for, in IANA format (e.g. 'America/New_York', 'Europe/London', 'Asia/Tokyo'). Defaults to UTC if not specified."
                }
            },
            "required": []
        }
    },

What does this schema do?

The description field tells Claude when to use this tool. Claude reads these descriptions to decide which tool fits the user's request.

Notice that "required": [] is an empty list. This tells Claude that the timezone parameter is optional. Claude can call this tool with or without specifying a timezone.

Implement the get_current_time function
  • Below the calculate function, add the get_current_time function by copying this code:
def get_current_time(timezone=None):
    """Get the current time, optionally in a specific timezone."""
    try:
        if timezone:
            tz = ZoneInfo(timezone)
            now = datetime.now(tz)
        else:
            now = datetime.now(ZoneInfo("UTC"))
        return now.strftime("%Y-%m-%d %H:%M:%S %Z")
    except Exception as e:
        return f"Error getting time: {e}"

What does this function do?

  • ZoneInfo converts a timezone string like "Asia/Tokyo" into a timezone object that Python can use.
  • If no timezone is passed, the function defaults to UTC.
  • strftime formats the datetime into a human-readable string like "2026-05-05 14:30:00 JST".
  • Save assistant.py.
  • You won't see a change yet. The tool needs to be wired into the routing function before Claude can use it.
Add the dictionary_lookup tool schema

The dictionary tool connects Claude to a real external API. This is the pattern you will use most in production: Claude decides it needs data, calls your tool, and your tool fetches that data from the outside world.

  • Add this third tool definition to your tools list, after the get_current_time schema (add a comma after the get_current_time schema's closing brace):
    {
        "name": "dictionary_lookup",
        "description": "Look up the definition of an English word. Returns the word's meanings, parts of speech, and example usage. Use this when the user asks for the definition, meaning, or explanation of a word.",
        "input_schema": {
            "type": "object",
            "properties": {
                "word": {
                    "type": "string",
                    "description": "The English word to look up"
                }
            },
            "required": ["word"]
        }
    }

What's different about this schema?

Unlike the time tool, the word parameter is listed in "required": ["word"]. Claude must always provide a word when calling this tool. It would not make sense to look up a definition without specifying which word.

  • Below the get_current_time function, add the dictionary_lookup function by copying this code:
def dictionary_lookup(word):
    """Look up a word definition using the Free Dictionary API."""
    try:
        response = requests.get(
            f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}",
            timeout=10
        )
        if response.status_code == 404:
            return f"Word '{word}' not found in the dictionary."
        response.raise_for_status()
        data = response.json()
        entry = data[0]
        result_parts = [f"Word: {entry['word']}"]
        if entry.get("phonetic"):
            result_parts.append(f"Phonetic: {entry['phonetic']}")
        for meaning in entry.get("meanings", []):
            part_of_speech = meaning["partOfSpeech"]
            definitions = meaning["definitions"][:2]
            for defn in definitions:
                result_parts.append(f"({part_of_speech}) {defn['definition']}")
                if defn.get("example"):
                    result_parts.append(f"  Example: {defn['example']}")
        return "\n".join(result_parts)
    except requests.exceptions.Timeout:
        return f"Error: Request timed out while looking up '{word}'."
    except Exception as e:
        return f"Error looking up word: {e}"

What does this function do?

  • requests.get calls the Free Dictionary API with the word the user asked about. No API key is required.
  • If the word does not exist (404 response), the function returns a friendly error message instead of crashing.
  • The function parses the JSON response to extract the word, phonetic pronunciation, parts of speech, definitions (up to 2 per part of speech), and example sentences.
  • A 10-second timeout prevents the function from hanging if the API is slow.
  • Save assistant.py.
  • You won't see a change yet. Next, you will update the routing function so Claude can actually call these new tools.

Why use the Free Dictionary API?

This API requires no signup, no API key, and no rate limits for reasonable usage. It responds in roughly 100ms. This makes it perfect for a learning project where you want to focus on the tool-use pattern, not API authentication.

Update process_tool_call and test your assistant

Your tools and functions exist, but the agentic loop still only knows how to route the calculate tool. You need to update process_tool_call so it can route all three tools to the correct functions.

  • In assistant.py, find the process_tool_call function. It currently looks like this:
def process_tool_call(tool_name, tool_input):
    """Route a tool call to the correct function."""
    if tool_name == "calculate":
        return calculate(tool_input["expression"])
    else:
        return f"Unknown tool: {tool_name}"
  • Replace the entire process_tool_call function with this updated version that routes all three tools:
def process_tool_call(tool_name, tool_input):
    """Route a tool call to the correct function."""
    if tool_name == "calculate":
        return calculate(tool_input["expression"])
    elif tool_name == "get_current_time":
        return get_current_time(tool_input.get("timezone"))
    elif tool_name == "dictionary_lookup":
        return dictionary_lookup(tool_input["word"])
    else:
        return f"Unknown tool: {tool_name}"

What changed here?

  • Two new elif branches route the new tool names to their functions.
  • tool_input.get("timezone") uses .get() instead of bracket access because timezone is optional. If Claude does not provide it, .get() returns None instead of crashing.
  • The else branch stays as a safety net for any unrecognized tool name.
  • Save assistant.py.
  • Test your updated assistant by running:
python3 assistant.py
  • Try asking: What time is it in Tokyo?
  • You should see [Calling tool: get_current_time(...)] appear, followed by Claude's response with the current time in Tokyo.
  • Try asking: What does the word ephemeral mean?
  • You should see [Calling tool: dictionary_lookup(...)] appear, followed by Claude weaving the definition into a natural response.
  • Type quit to exit when you are done testing.

Not seeing tool calls or getting errors?

  • If you see Unknown tool: get_current_time, make sure your process_tool_call function has the new elif branches and you saved the file.
  • If the dictionary lookup fails with a timeout error, check your internet connection. The Free Dictionary API requires network access.
  • If you see a SyntaxError, check that you added commas between each tool schema in the tools list. Each } before the next { needs a comma.

Help me debug my tool-use assistant

✔️ Awesome, I've got everything!

Great. Double check you have saved assistant.py and that all three tools are working.

ⓧ I'd like to double check the full code

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

import json
import anthropic
import requests
from datetime import datetime
from zoneinfo import ZoneInfo

# Initialize the Anthropic client
client = anthropic.Anthropic()
MODEL_NAME = "claude-sonnet-4-5"

# Define tools
tools = [
    {
        "name": "calculate",
        "description": "Evaluate a mathematical expression and return the result. Use this tool when the user asks you to perform any kind of math calculation, arithmetic, or numeric computation. Supports basic arithmetic (+, -, *, /), exponents (**), parentheses, and common math 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. Optionally specify a timezone to get the time in that location. Use this when the user asks what time it is, what the date is, or anything about the current time in any location.",
        "input_schema": {
            "type": "object",
            "properties": {
                "timezone": {
                    "type": "string",
                    "description": "The timezone to get the time for, in IANA format (e.g. 'America/New_York', 'Europe/London', 'Asia/Tokyo'). Defaults to UTC if not specified."
                }
            },
            "required": []
        }
    },
    {
        "name": "dictionary_lookup",
        "description": "Look up the definition of an English word. Returns the word's meanings, parts of speech, and example usage. Use this when the user asks for the definition, meaning, or explanation of a word.",
        "input_schema": {
            "type": "object",
            "properties": {
                "word": {
                    "type": "string",
                    "description": "The English word to look up"
                }
            },
            "required": ["word"]
        }
    }
]


def calculate(expression):
    """Safely evaluate a math expression."""
    try:
        allowed_chars = set("0123456789+-*/.() ")
        if not all(c in allowed_chars for c in expression):
            return f"Error: Expression contains invalid characters. Only numbers and +-*/.() are allowed."
        result = eval(expression)
        return str(result)
    except Exception as e:
        return f"Error evaluating expression: {e}"


def get_current_time(timezone=None):
    """Get the current time, optionally in a specific timezone."""
    try:
        if timezone:
            tz = ZoneInfo(timezone)
            now = datetime.now(tz)
        else:
            now = datetime.now(ZoneInfo("UTC"))
        return now.strftime("%Y-%m-%d %H:%M:%S %Z")
    except Exception as e:
        return f"Error getting time: {e}"


def dictionary_lookup(word):
    """Look up a word definition using the Free Dictionary API."""
    try:
        response = requests.get(
            f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}",
            timeout=10
        )
        if response.status_code == 404:
            return f"Word '{word}' not found in the dictionary."
        response.raise_for_status()
        data = response.json()
        entry = data[0]
        result_parts = [f"Word: {entry['word']}"]
        if entry.get("phonetic"):
            result_parts.append(f"Phonetic: {entry['phonetic']}")
        for meaning in entry.get("meanings", []):
            part_of_speech = meaning["partOfSpeech"]
            definitions = meaning["definitions"][:2]
            for defn in definitions:
                result_parts.append(f"({part_of_speech}) {defn['definition']}")
                if defn.get("example"):
                    result_parts.append(f"  Example: {defn['example']}")
        return "\n".join(result_parts)
    except requests.exceptions.Timeout:
        return f"Error: Request timed out while looking up '{word}'."
    except Exception as e:
        return f"Error looking up word: {e}"


def process_tool_call(tool_name, tool_input):
    """Route a tool call to the correct function."""
    if tool_name == "calculate":
        return calculate(tool_input["expression"])
    elif tool_name == "get_current_time":
        return get_current_time(tool_input.get("timezone"))
    elif tool_name == "dictionary_lookup":
        return dictionary_lookup(tool_input["word"])
    else:
        return f"Unknown tool: {tool_name}"


def chat(user_message, messages):
    """Send a message and handle the full agentic loop."""
    messages.append({"role": "user", "content": user_message})

    while True:
        response = client.messages.create(
            model=MODEL_NAME,
            max_tokens=1024,
            tools=tools,
            messages=messages
        )

        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason == "end_turn":
            text_parts = []
            for block in response.content:
                if hasattr(block, "text"):
                    text_parts.append(block.text)
            return "\n".join(text_parts)

        if response.stop_reason == "tool_use":
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    tool_name = block.name
                    tool_input = block.input
                    print(f"  [Calling tool: {tool_name}({json.dumps(tool_input)})]")
                    result = process_tool_call(tool_name, tool_input)
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": result
                    })

            messages.append({"role": "user", "content": tool_results})


def main():
    """Run the interactive chat loop."""
    print("=" * 50)
    print("  Claude Tool-Use Assistant")
    print("  Type 'quit' or 'exit' to end the conversation")
    print("=" * 50)
    print()

    messages = []

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

        response_text = chat(user_input, messages)
        print(f"\nClaude: {response_text}\n")


if __name__ == "__main__":
    main()

Your assistant now has three distinct capabilities: math, time, and definitions. Next, you will build a proper interactive chat interface so you can have multi-turn conversations where Claude chooses the right tool for each question.

Create the Interactive Chat Interface

Your assistant now has three tools and a working agentic loop that handles tool calls automatically. The final piece is a friendly interface that lets you have a real conversation with it.

In this step, you will build the interactive chat loop that reads your input, passes it through the full agentic pipeline, and displays Claude's responses cleanly. Then you will test multi-turn conversations to see all three tools working together.

In this step, get ready to:
  • Build the main() function with a polished CLI header and input loop.
  • Add quit/exit handling and clean response display.
  • Test multi-turn conversations using all three tools.
Build the main() entry point

The main() function is where your assistant comes to life as an interactive program. It prints a welcome header, initializes an empty conversation history, and starts a loop that waits for your input.

  • In assistant.py, scroll down to the main() function at the bottom of your file.
  • Replace the existing main() function and the if __name__ block with the following code:
def main():
    """Run the interactive chat loop."""
    print("=" * 50)
    print("  Claude Tool-Use Assistant")
    print("  Type 'quit' or 'exit' to end the conversation")
    print("=" * 50)
    print()

    # Conversation history persists across turns
    messages = []

    while True:
        user_input = input("You: ").strip()
        # Skip empty lines
        if not user_input:
            continue
        # Let the user exit gracefully
        if user_input.lower() in ("quit", "exit"):
            print("\nGoodbye!")
            break

        # Send message through the agentic loop and display the response
        response_text = chat(user_input, messages)
        print(f"\nClaude: {response_text}\n")


if __name__ == "__main__":
    main()

What does this code do?

This function is the user-facing layer of your assistant.

  • The messages = [] list stores the full conversation history. Because it persists across turns, Claude remembers everything you said earlier in the session.
  • input("You: ").strip() pauses the loop and waits for you to type something. The .strip() removes accidental whitespace.
  • Each message you type gets passed to chat(), which handles the entire agentic loop (tool calls included) and returns Claude's final text response.
  • if __name__ == "__main__" ensures main() only runs when you execute the file directly, not when importing from it.
  • Save assistant.py.
Run your assistant

Your script is complete. Time to start a conversation with your tool-equipped Claude assistant.

  • Run your assistant by executing this command in your terminal:
python3 assistant.py
  • You should see the welcome header appear:
==================================================
  Claude Tool-Use Assistant
  Type 'quit' or 'exit' to end the conversation
==================================================

You:
  • Try typing a simple message like What is 25 * 4? and press Enter.

You should see Claude call the calculator tool (shown in square brackets) and then respond with the answer in natural language.

Seeing an error when you run the script?

  • If you see AuthenticationError, your ANTHROPIC_API_KEY environment variable is not set in this terminal session. Run the export command from Step 1 again.
  • If you see ModuleNotFoundError, make sure you installed both packages with pip3 install anthropic requests.
  • If the script runs but hangs without showing the header, check that your indentation is correct. Python is sensitive to whitespace.

Help me debug my assistant startup error.

Test multi-turn conversations

The real power of your assistant is that it maintains context across messages. Claude remembers what you asked earlier and can use any tool at any point in the conversation.

  • Ask Claude to define a word. Type something like What does the word ephemeral mean? and press Enter.

You should see [Calling tool: dictionary_lookup(...)] followed by a definition in Claude's response.

  • Now ask Claude to calculate something. Type What is (150 * 3) + 27? and press Enter.

Claude calls the calculator tool and responds with 477.

  • Finally, test the time tool. Type What time is it in Tokyo right now? and press Enter.

Claude calls get_current_time with the timezone Asia/Tokyo and tells you the current time in Tokyo.

  • Type quit to end the session.

Why does conversation history matter?

The messages list grows with every exchange. Each time you call chat(), it appends your message and Claude's response (including any tool calls and results) to this list.

This means Claude can reference earlier messages. You could ask "use that word in a sentence" after defining it, and Claude would know which word you mean.

🙋‍♀️ Claude not calling tools?

  • Make sure your question clearly relates to one of the tools. Asking "hi" won't trigger a tool call, but "what is 2+2" will.
  • Check that your tools list contains all three tool schemas. If one is missing, Claude cannot call it.
  • Verify the tool descriptions are detailed enough for Claude to understand when to use them.

Why isn't Claude calling my tools when I ask a question?

✔️ Awesome, I've got everything!

Great. Double check you have saved assistant.py and that all three tools work when you test them in the chat.

ⓧ I'd like to double check the full code

Here is the complete assistant.py file. Compare it with yours to spot any differences.

import json
import anthropic
import requests
from datetime import datetime
from zoneinfo import ZoneInfo

# Initialize the Anthropic client
client = anthropic.Anthropic()
MODEL_NAME = "claude-sonnet-4-5"

# Define tools
tools = [
    {
        "name": "calculate",
        "description": "Evaluate a mathematical expression and return the result. Use this tool when the user asks you to perform any kind of math calculation, arithmetic, or numeric computation. Supports basic arithmetic (+, -, *, /), exponents (**), parentheses, and common math 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. Optionally specify a timezone to get the time in that location. Use this when the user asks what time it is, what the date is, or anything about the current time in any location.",
        "input_schema": {
            "type": "object",
            "properties": {
                "timezone": {
                    "type": "string",
                    "description": "The timezone to get the time for, in IANA format (e.g. 'America/New_York', 'Europe/London', 'Asia/Tokyo'). Defaults to UTC if not specified."
                }
            },
            "required": []
        }
    },
    {
        "name": "dictionary_lookup",
        "description": "Look up the definition of an English word. Returns the word's meanings, parts of speech, and example usage. Use this when the user asks for the definition, meaning, or explanation of a word.",
        "input_schema": {
            "type": "object",
            "properties": {
                "word": {
                    "type": "string",
                    "description": "The English word to look up"
                }
            },
            "required": ["word"]
        }
    }
]


def calculate(expression):
    """Safely evaluate a math expression."""
    try:
        allowed_chars = set("0123456789+-*/.() ")
        if not all(c in allowed_chars for c in expression):
            return f"Error: Expression contains invalid characters. Only numbers and +-*/.() are allowed."
        result = eval(expression)
        return str(result)
    except Exception as e:
        return f"Error evaluating expression: {e}"


def get_current_time(timezone=None):
    """Get the current time, optionally in a specific timezone."""
    try:
        if timezone:
            tz = ZoneInfo(timezone)
            now = datetime.now(tz)
        else:
            now = datetime.now(ZoneInfo("UTC"))
        return now.strftime("%Y-%m-%d %H:%M:%S %Z")
    except Exception as e:
        return f"Error getting time: {e}"


def dictionary_lookup(word):
    """Look up a word definition using the Free Dictionary API."""
    try:
        response = requests.get(
            f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}",
            timeout=10
        )
        if response.status_code == 404:
            return f"Word '{word}' not found in the dictionary."
        response.raise_for_status()
        data = response.json()
        entry = data[0]
        result_parts = [f"Word: {entry['word']}"]
        if entry.get("phonetic"):
            result_parts.append(f"Phonetic: {entry['phonetic']}")
        for meaning in entry.get("meanings", []):
            part_of_speech = meaning["partOfSpeech"]
            definitions = meaning["definitions"][:2]
            for defn in definitions:
                result_parts.append(f"({part_of_speech}) {defn['definition']}")
                if defn.get("example"):
                    result_parts.append(f"  Example: {defn['example']}")
        return "\n".join(result_parts)
    except requests.exceptions.Timeout:
        return f"Error: Request timed out while looking up '{word}'."
    except Exception as e:
        return f"Error looking up word: {e}"


def process_tool_call(tool_name, tool_input):
    """Route a tool call to the correct function."""
    if tool_name == "calculate":
        return calculate(tool_input["expression"])
    elif tool_name == "get_current_time":
        return get_current_time(tool_input.get("timezone"))
    elif tool_name == "dictionary_lookup":
        return dictionary_lookup(tool_input["word"])
    else:
        return f"Unknown tool: {tool_name}"


def chat(user_message, messages):
    """Send a message and handle the full agentic loop."""
    messages.append({"role": "user", "content": user_message})

    while True:
        response = client.messages.create(
            model=MODEL_NAME,
            max_tokens=1024,
            tools=tools,
            messages=messages
        )

        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason == "end_turn":
            text_parts = []
            for block in response.content:
                if hasattr(block, "text"):
                    text_parts.append(block.text)
            return "\n".join(text_parts)

        if response.stop_reason == "tool_use":
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    tool_name = block.name
                    tool_input = block.input
                    print(f"  [Calling tool: {tool_name}({json.dumps(tool_input)})]")
                    result = process_tool_call(tool_name, tool_input)
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": result
                    })

            messages.append({"role": "user", "content": tool_results})


def main():
    """Run the interactive chat loop."""
    print("=" * 50)
    print("  Claude Tool-Use Assistant")
    print("  Type 'quit' or 'exit' to end the conversation")
    print("=" * 50)
    print()

    messages = []

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

        response_text = chat(user_input, messages)
        print(f"\nClaude: {response_text}\n")


if __name__ == "__main__":
    main()

Your tool-using assistant is fully operational. You have a conversational AI that can calculate, tell time, and look up definitions on demand. Next up, you will take on the Secret Mission and extend your assistant with even more capabilities.

Secret mission

Add a Weather Tool and Multi-Tool Chaining

Your assistant already knows math, time, and words. But what about the real world? In this Secret Mission, you'll add a fourth tool that fetches live weather data and watch Claude chain multiple tools together in a single response.

Clean Up Your Resources

This project runs entirely locally and uses the Anthropic API on a pay-per-use basis. There are no ongoing costs as long as you are not actively sending messages to Claude.

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

Resources you used:

  • assistant.py file (your main project script).
  • Anthropic API key (set as the ANTHROPIC_API_KEY environment variable).
  • Installed Python packages (anthropic and requests).
  • Anthropic console account at console.anthropic.com.

Keep everything running

No action needed. Choose this if you plan to keep experimenting with your assistant or adding new tools.

  • Your assistant.py file stays on your Desktop, ready to run anytime.
  • Your API key remains set in your terminal session. Note that environment variables set with export only last for the current terminal session. You will need to re-export the key if you open a new terminal window.
  • You are only charged when you actively send messages to the API, so leaving things as-is costs nothing.

Pause - I'll come back to this later

Since this project is entirely local and pay-per-use, pausing simply means closing your terminal. There is nothing running in the background.

  • Close your terminal window. This ends the chat session and clears the exported environment variable.
  • Your assistant.py file remains on your Desktop for when you are ready to return.
  • When you come back, re-export your API key and run the script again.

Delete - I don't want to use this again

Remove all project files and credentials from your machine.

  • Delete your project file by running:
rm ~/Desktop/assistant.py
  • Uninstall the Python packages by running:
pip3 uninstall anthropic requests -y
  • Clear the environment variable from your current session by running:
unset ANTHROPIC_API_KEY
  • If you want to revoke your API key entirely, go to console.anthropic.com, navigate to your API keys, and delete the key you created.

Nice Work!

You just built a tool-using AI assistant from scratch. This is the exact pattern behind every production Claude application that interacts with the real world.

You learned how to:

  • Define custom tools with JSON Schema descriptions that Claude can understand and call on its own.
  • Build the full agentic loop that detects tool calls, executes your functions, sends results back, and repeats until Claude is done.
  • Connect Claude to a live external API to fetch real-world data and weave it into conversational responses.
  • Added a weather tool and demonstrated Claude chaining multiple tools in a single response.

Ready to quiz yourself?