Build a Travel Assistant with Claude

Use Claude's tool use feature to build an AI travel planning assistant.

Introduction

30 Second Summary

When you ask an AI to plan a trip, it usually makes up flight prices and hotel availability. What if it could actually look that information up for you instead?

In this project, you will build a travel planning assistant powered by Claude that calls custom tools you define to check the weather, search for flights, and find hotels, then synthesizes the results into a complete travel recommendation.

What You'll Build

A Python-based travel assistant that uses Claude's tool use feature (also known as function calling) to connect to external functions and return real information instead of guessing.

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

  • Three custom tool definitions using JSON Schema that tell Claude how to check weather, search flights, and find hotels.
  • A complete agentic loop that handles multiple rounds of tool calls automatically until Claude has all the information it needs.
  • Built-in error handling so Claude recovers gracefully when a tool fails instead of crashing your script.
  • A secret mission that adds a currency exchange tool and uses tool_choice to force Claude to check exchange rates before making recommendations.

Are there any prerequisites?

You need basic Python knowledge (variables, functions, dictionaries, and loops). No prior experience with the Claude API is needed. This project uses mock data for all tool responses, so you won't need any external API accounts beyond your Anthropic API key.

Before We Start

Before We Start...

Set Up Your Environment

Before you can build a travel assistant, you need the right tools in place. In this step, you'll install Python and the Anthropic Python SDK, grab an API key, and verify everything works with a quick test call to Claude.

In This Step, Get Ready To:
  • Install Python and the Anthropic SDK.
  • Generate and configure your Anthropic API key.
  • Create your project file and verify your setup with a test API call.
Install Python and the Anthropic SDK

The Anthropic Python SDK requires Python 3.9 or later. Let's confirm you have a compatible version installed, then add the SDK.

  • Open your terminal and check your Python version.
python3 --version

I see a version number

You should see something like Python 3.12.4. Any version 3.9 or higher works.

Command not found

If you see "command not found" or a version below 3.9, you need to install or update Python.

  • Download and install the latest version from python.org.
  • After installation, close and reopen your terminal, then run python3 --version again.

Still having trouble?

Help me troubleshoot my Python installation.

  • Install the Anthropic Python SDK.
pip install anthropic

What does this command do?

This installs the official Anthropic Python SDK, which gives you a clean Python interface for sending messages to Claude and handling responses. It takes care of authentication, request formatting, and response parsing so you can focus on building your assistant.

Pro tip: use a virtual environment

If you prefer to keep project dependencies isolated, create a virtual environment first with python3 -m venv venv and activate it with source venv/bin/activate (macOS/Linux) or venv\Scripts\activate (Windows). Then run the pip install command.

Get Your Anthropic API Key

The SDK needs an API key to authenticate your requests to Claude. You'll generate one from the Anthropic Console and store it as an environment variable so the SDK can find it automatically.

  • Go to console.anthropic.com.
  • Create an account if you don't have one.
  • Navigate to **Settings**, then **API Keys**.
  • Click Create Key and give it a name like travel-assistant.
  • Copy the key. You'll only see it once.

Now set the key as an environment variable so the SDK picks it up automatically.

macOS

  • Run this command in your terminal, replacing the placeholder with your actual key.
export ANTHROPIC_API_KEY='your-api-key-here'

This sets the key for your current terminal session. To make it persist across sessions, add the same line to your ~/.zshrc or ~/.bashrc file.

Windows

  • Run this command in PowerShell, replacing the placeholder with your actual key.
$env:ANTHROPIC_API_KEY = 'your-api-key-here'

This sets the key for your current PowerShell session. To make it persist, use the System Environment Variables dialog or run [System.Environment]::SetEnvironmentVariable('ANTHROPIC_API_KEY', 'your-api-key-here', 'User').

Why an environment variable?

The Anthropic SDK automatically reads the ANTHROPIC_API_KEY environment variable when you create a client. This means you never need to hardcode your key in your Python files, which keeps it safe from accidentally being shared or committed to version control.

Create Your Project File and Test the Connection

With the SDK installed and your API key configured, let's make sure everything is wired up correctly by sending a simple message to Claude.

  • Create a new file called travel_assistant.py in your preferred code editor.
  • Add the following code to test your setup.
import anthropic

# Create a client (automatically reads ANTHROPIC_API_KEY from environment)
client = anthropic.Anthropic()

# Send a simple test message to Claude
response = client.messages.create(
    model="claude-sonnet-4-5-20250929",
    max_tokens=256,
    messages=[
        {"role": "user", "content": "Say hello and tell me you're ready to help plan a trip!"}
    ]
)

# Print Claude's response
print(response.content[0].text)

What does this code do?

This script creates an Anthropic client, sends a single message to Claude Sonnet 4.5, and prints the response. The response.content field is a list of content blocks. For a simple text reply, the first block contains Claude's text in its .text attribute.

  • Run the script from your terminal.
python3 travel_assistant.py

It worked

You should see a friendly greeting from Claude. The exact wording will vary each time, but it confirms your SDK, API key, and Python setup are all working correctly.

I see an error

Here are the most common issues:

  • AuthenticationError: Your API key is missing or incorrect. Double-check that you ran the export command in the same terminal session where you're running the script.
  • ModuleNotFoundError: No module named 'anthropic': The SDK isn't installed. Run pip install anthropic again. If you're using a virtual environment, make sure it's activated.
  • PermissionError or billing error: Make sure your Anthropic account has billing set up. The API requires a payment method even though this project costs only a few cents.

Still stuck?

Help me debug my Anthropic SDK setup.

Your environment is ready and Claude is responding. Next up, you'll define your first tool and teach Claude how to check the weather.

Define Your First Tool and Make a Single Tool Call

Your environment is ready and Claude can respond to basic messages. But right now, Claude can only generate text from its training data. It has no way to check today's weather, look up real flight prices, or access any live information.

Tool use changes that. You give Claude a structured description of a function. When Claude decides it needs that function's data, it pauses its response and asks you to run it. You execute the function, send the result back, and Claude incorporates it into its answer.

In this step, you will define your first tool, send it to Claude, and complete the full round trip of a single tool call.

In This Step, Get Ready To:
  • Define a tool schema using JSON Schema that tells Claude about your get_weather function.
  • Send a message to Claude with the tool attached and handle the tool_use response.
  • Execute the tool locally and send the result back to Claude in a tool_result block.
Define the get_weather Tool Schema

Before Claude can use a tool, it needs a precise description of what the tool does, what inputs it expects, and which inputs are required. This description is called a tool schema, and it follows the JSON Schema format. The quality of your tool description is the single most important factor in how well Claude uses your tools.

  • Open travel_assistant.py and replace its contents with the following tool definition.
import anthropic
import json

client = anthropic.Anthropic()
MODEL = "claude-sonnet-4-5-20250929"

# Define the get_weather tool schema
tools = [
    {
        "name": "get_weather",
        "description": (
            "Get the current weather conditions for a specific location. "
            "Use this tool when the user asks about weather, temperature, "
            "or climate conditions for any city or region. Returns the "
            "current temperature and weather condition."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "location": {
                    "type": "string",
                    "description": "The city and country, e.g. 'Tokyo, Japan'"
                },
                "unit": {
                    "type": "string",
                    "enum": ["celsius", "fahrenheit"],
                    "description": "The temperature unit to use. Defaults to celsius."
                }
            },
            "required": ["location"]
        }
    }
]

What Does This Code Do?

You are creating a list of tool definitions that you will pass to Claude. Each tool has three parts:

  • name is the identifier Claude uses to request this tool. It must match the pattern ^[a-zA-Z0-9_-]{1,64}$.
  • description tells Claude exactly when and how to use this tool. Detailed descriptions are the most important factor in tool performance.
  • input_schema defines the parameters using JSON Schema. location is required, while unit is optional with an enum constraint.
Send a Message with the Tool and Handle the Response

Now you will send Claude a message that should trigger a tool call. When Claude sees a question about weather and knows a get_weather tool is available, it will stop generating text and instead output a tool_use block asking you to run the tool with specific inputs.

  • Add the following code to the bottom of travel_assistant.py.
# Send a message that should trigger a tool call
response = client.messages.create(
    model=MODEL,
    max_tokens=1024,
    tools=tools,
    messages=[
        {"role": "user", "content": "What's the weather like in Tokyo?"}
    ]
)

# Check if Claude wants to use a tool
print(f"Stop reason: {response.stop_reason}")

# Find the tool_use block in the response
for block in response.content:
    if block.type == "tool_use":
        print(f"Tool called: {block.name}")
        print(f"Tool input: {json.dumps(block.input, indent=2)}")
        print(f"Tool use ID: {block.id}")

What Does This Code Do?

You are calling client.messages.create() with the tools parameter included. This tells Claude what functions are available. Claude reads the user message, decides it needs weather data, and responds with a tool_use block containing the tool name and structured inputs it generated from the user's question.

  • Run your script with python3 travel_assistant.py.

What Should I See?

You should see output like this:

Stop reason: tool_use
Tool called: get_weather
Tool input: {
  "location": "Tokyo, Japan",
  "unit": "celsius"
}
Tool use ID: toolu_abc123...

The stop_reason is tool_use instead of end_turn. This means Claude paused its response because it needs data from your tool before it can continue.

Why Does response.content Have Multiple Blocks?

response.content is a list of content blocks, not a single block. Claude might include a text block (like "Let me check the weather for you") followed by a tool_use block in the same response. That is why you need to iterate through the list and filter by type.

Execute the Tool and Send the Result Back

Claude has asked you to run get_weather with specific inputs. Now you need to actually execute the function, then send the result back in a tool_result block. The tool_use_id in your result must match the id from Claude's tool_use block. This is how Claude knows which tool call each result belongs to.

  • Replace the entire contents of travel_assistant.py with the following complete script.
import anthropic
import json

client = anthropic.Anthropic()
MODEL = "claude-sonnet-4-5-20250929"

# Define the get_weather tool schema
tools = [
    {
        "name": "get_weather",
        "description": (
            "Get the current weather conditions for a specific location. "
            "Use this tool when the user asks about weather, temperature, "
            "or climate conditions for any city or region. Returns the "
            "current temperature and weather condition."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "location": {
                    "type": "string",
                    "description": "The city and country, e.g. 'Tokyo, Japan'"
                },
                "unit": {
                    "type": "string",
                    "enum": ["celsius", "fahrenheit"],
                    "description": "The temperature unit to use. Defaults to celsius."
                }
            },
            "required": ["location"]
        }
    }
]

# Mock tool execution function
def run_tool(name, tool_input):
    """Execute a tool and return mock results."""
    if name == "get_weather":
        return {
            "temperature": 22,
            "unit": tool_input.get("unit", "celsius"),
            "condition": "Partly cloudy",
            "humidity": 65,
            "location": tool_input["location"]
        }
    return {"error": f"Unknown tool: {name}"}

# Step 1: Send the initial message with tools
print("Sending message to Claude...\n")
response = client.messages.create(
    model=MODEL,
    max_tokens=1024,
    tools=tools,
    messages=[
        {"role": "user", "content": "What's the weather like in Tokyo?"}
    ]
)

# Step 2: Find the tool_use block and execute the tool
tool_use_block = None
for block in response.content:
    if block.type == "tool_use":
        tool_use_block = block
        break

if tool_use_block:
    print(f"Claude wants to call: {tool_use_block.name}")
    print(f"With input: {json.dumps(tool_use_block.input, indent=2)}\n")

    # Execute the tool locally
    tool_result = run_tool(tool_use_block.name, tool_use_block.input)
    print(f"Tool returned: {json.dumps(tool_result, indent=2)}\n")

    # Step 3: Send the tool result back to Claude
    follow_up = client.messages.create(
        model=MODEL,
        max_tokens=1024,
        tools=tools,
        messages=[
            {"role": "user", "content": "What's the weather like in Tokyo?"},
            {"role": "assistant", "content": response.content},
            {
                "role": "user",
                "content": [
                    {
                        "type": "tool_result",
                        "tool_use_id": tool_use_block.id,
                        "content": json.dumps(tool_result)
                    }
                ]
            }
        ]
    )

    # Print Claude's final response
    print("Claude's response:")
    for block in follow_up.content:
        if hasattr(block, "text"):
            print(block.text)

What Does This Code Do?

This script completes the full tool use round trip in three stages:

  • You send Claude a question with the tool definitions attached. Claude responds with a tool_use block instead of a text answer.
  • You extract the tool name and inputs, then run your local run_tool() function to get mock weather data.
  • You send the result back in a tool_result block inside a user role message. The tool_use_id must match the id from Claude's tool_use block so Claude knows which call this result belongs to.

Notice that the assistant's content (response.content) is passed directly into the messages list. The SDK handles serialization for you.

  • Run the script with python3 travel_assistant.py.

What Should I See?

You should see Claude call the tool, your function return mock data, and then Claude compose a natural-language weather report using that data:

Sending message to Claude...

Claude wants to call: get_weather
With input: {
  "location": "Tokyo, Japan",
  "unit": "celsius"
}

Tool returned: {
  "temperature": 22,
  "unit": "celsius",
  "condition": "Partly cloudy",
  "humidity": 65,
  "location": "Tokyo, Japan"
}

Claude's response:
The current weather in Tokyo, Japan is partly cloudy with a
temperature of 22°C and 65% humidity.

Claude did not make up the weather data. It used the exact values your function returned. This is the core power of tool use.

Getting an API Key Error?

If you see an authentication error, make sure your ANTHROPIC_API_KEY environment variable is set in the same terminal session where you are running the script. Run echo $ANTHROPIC_API_KEY to verify it is not empty. Help me debug my API key issue.

You have completed a full tool use round trip. Claude received a question, decided it needed external data, asked you to run a function, and used the result to compose its answer. But right now, your script only handles a single tool call. In the next step, you will build an agentic loop that lets Claude call multiple tools in sequence to answer complex travel questions.

Build the Agentic Loop with Multiple Tools

In the previous step, you handled a single tool call. Claude asked for the weather, you ran the tool, and sent the result back. But a real travel assistant needs to check the weather, search flights, and find hotels. That means Claude needs to make multiple tool calls in sequence, and your code needs to keep looping until Claude has all the information it needs.

This pattern is called the agentic loop. It is the core pattern behind every production AI agent that interacts with external systems. In this step, you will build it.

In This Step, Get Ready To:
  • Define two more tool schemas for flight and hotel search.
  • Build a while loop that keeps calling Claude until all tool calls are complete.
  • Test the full loop with a complex travel planning query.
Add Flight and Hotel Tool Definitions

Your travel assistant currently only knows about weather. To plan a full trip, Claude also needs access to flight search and hotel search tools. Each tool gets its own JSON Schema definition that tells Claude what inputs the tool accepts.

  • Add the following two tool definitions to your travel_assistant.py file, alongside your existing get_weather tool.
search_flights_tool = {
    "name": "search_flights",
    "description": "Search for available flights between two cities on a specific date. Use this tool when the user wants to find flights for their trip. Returns a list of flight options with prices, airlines, and duration.",
    "input_schema": {
        "type": "object",
        "properties": {
            "origin": {
                "type": "string",
                "description": "The departure city, e.g. 'San Francisco'"
            },
            "destination": {
                "type": "string",
                "description": "The arrival city, e.g. 'Tokyo'"
            },
            "departure_date": {
                "type": "string",
                "description": "The departure date in YYYY-MM-DD format"
            },
            "max_price": {
                "type": "number",
                "description": "Maximum price in USD. Optional filter to only show flights under this price."
            }
        },
        "required": ["origin", "destination", "departure_date"]
    }
}

search_hotels_tool = {
    "name": "search_hotels",
    "description": "Search for available hotels in a specific city for given dates. Use this tool when the user needs accommodation recommendations. Returns a list of hotels with prices, ratings, and amenities.",
    "input_schema": {
        "type": "object",
        "properties": {
            "location": {
                "type": "string",
                "description": "The city to search for hotels, e.g. 'Tokyo'"
            },
            "check_in": {
                "type": "string",
                "description": "Check-in date in YYYY-MM-DD format"
            },
            "check_out": {
                "type": "string",
                "description": "Check-out date in YYYY-MM-DD format"
            },
            "max_price_per_night": {
                "type": "number",
                "description": "Maximum price per night in USD. Optional filter."
            }
        },
        "required": ["location", "check_in", "check_out"]
    }
}

Why Are Tool Descriptions So Detailed?

According to Anthropic's documentation, detailed descriptions are "by far the most important factor in tool performance." Claude uses these descriptions to decide when to call a tool and what inputs to provide. Vague descriptions lead to Claude calling the wrong tool or passing incomplete inputs.

  • Update your run_tool() function to handle all three tools and return mock data for each one.
def run_tool(tool_name, tool_input):
    """Execute a tool and return mock results."""
    if tool_name == "get_weather":
        return {
            "temperature": 22,
            "condition": "Partly cloudy",
            "humidity": 65,
            "location": tool_input["location"]
        }
    elif tool_name == "search_flights":
        return {
            "flights": [
                {
                    "airline": "ANA",
                    "price": 1200,
                    "duration": "11h 30m",
                    "departure": "08:00",
                    "arrival": "15:30+1"
                },
                {
                    "airline": "United",
                    "price": 1350,
                    "duration": "11h 15m",
                    "departure": "10:30",
                    "arrival": "18:45+1"
                }
            ],
            "origin": tool_input["origin"],
            "destination": tool_input["destination"]
        }
    elif tool_name == "search_hotels":
        return {
            "hotels": [
                {
                    "name": "Hotel Sunroute Plaza",
                    "price_per_night": 150,
                    "rating": 4.2,
                    "neighborhood": "Shinjuku"
                },
                {
                    "name": "Cerulean Tower Tokyu",
                    "price_per_night": 190,
                    "rating": 4.5,
                    "neighborhood": "Shibuya"
                }
            ],
            "location": tool_input["location"]
        }
    else:
        return {"error": f"Unknown tool: {tool_name}"}

What Does This Function Do?

The run_tool() function acts as a router. It receives the tool name and inputs that Claude chose, then returns the appropriate mock data. In a production application, you would replace these hardcoded dictionaries with real API calls to weather services, flight booking APIs, and hotel search engines.

  • Collect all three tool definitions into a single list that you will pass to the API.
tools = [get_weather_tool, search_flights_tool, search_hotels_tool]
Build the Agentic While Loop

In the previous step, your code handled exactly one tool call and one result. But when you ask Claude to plan a full trip, it needs to call multiple tools in sequence. It might check the weather first, then search for flights, then look up hotels.

The agentic loop solves this. It keeps calling the API in a while loop as long as Claude's stop_reason is "tool_use". When Claude finally returns "end_turn", it means Claude has all the information it needs and is ready to give a final answer.

  • Replace your single-call code with the following agentic loop.
import anthropic
import json

client = anthropic.Anthropic()

# Start the conversation with a travel planning request
messages = [
    {
        "role": "user",
        "content": "I want to plan a trip from San Francisco to Tokyo next month. Check the weather, find me flights under $1500, and suggest hotels under $200/night."
    }
]

print("Sending request to Claude...")
print("=" * 50)

# Make the initial API call
response = client.messages.create(
    model="claude-sonnet-4-5-20250929",
    max_tokens=4096,
    tools=tools,
    messages=messages
)

# The agentic loop: keep going while Claude wants to use tools
while response.stop_reason == "tool_use":
    # Find all tool_use blocks in the response
    tool_use_blocks = [
        block for block in response.content
        if block.type == "tool_use"
    ]

    # Execute each tool and collect results
    tool_results = []
    for tool_use in tool_use_blocks:
        print(f"Claude is calling: {tool_use.name}")
        print(f"  Inputs: {json.dumps(tool_use.input, indent=2)}")

        # Run the tool and get the result
        result = run_tool(tool_use.name, tool_use.input)
        print(f"  Result: {json.dumps(result, indent=2)}")
        print()

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

    # Append the assistant's response and the tool results to messages
    messages.append({"role": "assistant", "content": response.content})
    messages.append({"role": "user", "content": tool_results})

    # Call the API again with the updated conversation
    response = client.messages.create(
        model="claude-sonnet-4-5-20250929",
        max_tokens=4096,
        tools=tools,
        messages=messages
    )

# Claude is done with tool calls - print the final response
print("=" * 50)
print("Claude's Travel Recommendation:")
print("=" * 50)
for block in response.content:
    if hasattr(block, "text"):
        print(block.text)

How Does the Agentic Loop Work?

The loop follows a simple pattern. First, check if stop_reason is "tool_use". If it is, find every tool_use block in the response, execute each tool, and send all the results back. Then call the API again. Repeat until stop_reason changes to "end_turn".

Why Process All Tool Use Blocks at Once?

A single response from Claude can contain multiple tool_use blocks if it decides to call tools in parallel. Your loop must process all of them and send all results back in one user message. Every tool_result must include a tool_use_id that matches the id from the corresponding tool_use block. If these IDs do not match, the API will return an error.

Test with a Complex Travel Query

Your agentic loop is complete. Time to see it in action with a query that requires Claude to call all three tools.

  • Run your script from the terminal.
python3 travel_assistant.py

What Should I See?

You should see Claude calling each tool one at a time (or sometimes in parallel). The terminal output will show each tool name, the inputs Claude chose, and the mock results. After all tool calls are complete, Claude will print a comprehensive travel recommendation that synthesizes the weather, flight options, and hotel suggestions into a single response.

The exact order of tool calls may vary between runs. Claude decides the sequence based on what it thinks is most logical.

Getting an Error About tool_use_id?

The most common issue is a mismatch between the tool_use_id in your tool_result and the id from the tool_use block. Make sure you are using tool_use.id directly from the response, not a hardcoded value. Ask NextWork for help debugging your tool_use_id matching.

Your travel assistant can now handle complex, multi-tool queries automatically. Next up, you will add error handling so Claude can recover gracefully when a tool fails instead of crashing your entire script.

Add Error Handling and Polish

Your agentic loop handles the happy path beautifully. But in the real world, tools fail. APIs go down, users send bad input, and edge cases pop up everywhere. Right now, if any tool throws an exception, your entire script crashes. In this step, you'll make your assistant resilient by teaching Claude to handle tool errors gracefully and giving it a polished personality.

In This Step, Get Ready To:
  • Add error handling to your tool execution so failures don't crash the script.
  • Test Claude's ability to recover from tool errors and respond helpfully.
  • Add a system prompt that gives your travel assistant a polished personality.
Add Error Handling to Your Tool Execution

When a tool fails in production, you don't want your entire application to crash. The Claude API supports a special flag called is_error in the tool_result block. When you set is_error to True, Claude reads the error message and responds to the user appropriately instead of trying to use broken data.

First, let's create a deliberate error case so you can see this in action. Update your run_tool() function to raise an error when someone searches for flights where the origin and destination are the same city.

  • Open travel_assistant.py and find your run_tool() function.
  • Add a check inside the search_flights case that raises a ValueError when the origin and destination match.
  • Wrap the entire tool execution in a try/except block that catches exceptions and returns them as error results.

Replace your existing tool execution logic inside the agentic loop with this pattern:

def run_tool(tool_name, tool_input):
    if tool_name == "get_weather":
        return {
            "temperature": 22,
            "condition": "Partly cloudy",
            "humidity": 65,
            "location": tool_input["location"]
        }
    elif tool_name == "search_flights":
        # Deliberate error case: same origin and destination
        if tool_input["origin"].lower() == tool_input["destination"].lower():
            raise ValueError(
                f"Origin and destination cannot be the same city: {tool_input['origin']}"
            )
        return {
            "flights": [
                {"airline": "Sky Airlines", "price": 1200, "duration": "11h 30m"},
                {"airline": "Pacific Air", "price": 1350, "duration": "10h 45m"}
            ]
        }
    elif tool_name == "search_hotels":
        return {
            "hotels": [
                {"name": "Tokyo Grand Hotel", "price_per_night": 150, "rating": 4.5},
                {"name": "Sakura Inn", "price_per_night": 95, "rating": 4.2}
            ]
        }
    else:
        raise ValueError(f"Unknown tool: {tool_name}")

Now update the section of your agentic loop where you process tool_use blocks. Wrap each call to run_tool() in a try/except so errors get sent back to Claude as tool_result blocks with is_error: True:

# Inside your while loop, replace the tool processing section:
tool_results = []
for block in response.content:
    if block.type == "tool_use":
        print(f"\nClaude wants to use: {block.name}")
        print(f"With inputs: {block.input}")

        try:
            # Try to execute the tool normally
            result = run_tool(block.name, block.input)
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": str(result)
            })
            print(f"Tool result: {result}")
        except Exception as e:
            # Send the error back to Claude instead of crashing
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": f"Error: {str(e)}",
                "is_error": True
            })
            print(f"Tool error: {e}")

What Does is_error Do?

The is_error flag tells Claude that the tool failed. Claude reads the error message in the content field and uses it to craft a helpful response. Without this flag, Claude would try to interpret the error text as valid tool output, leading to confusing results.

Test Error Recovery

Time to trigger the error on purpose and see how Claude handles it.

  • Update the user message in your script to something that triggers the error case:
user_message = "Find me flights from Tokyo to Tokyo"
  • Run your script:
python3 travel_assistant.py

What Should I See?

You should see Claude attempt to call search_flights with Tokyo as both the origin and destination. Your script catches the error and sends it back with is_error: True. Instead of crashing, Claude reads the error message and responds with something like "I can't search for flights where the origin and destination are the same city. Could you provide a different destination?"

This is a powerful pattern. Claude doesn't just ignore the error. It understands what went wrong and communicates it to the user in natural language.

Add a System Prompt for Personality

Your assistant works, but its responses might feel generic. A system prompt lets you shape how Claude responds by giving it a role, guidelines, and formatting preferences. This is your chance to turn a functional tool into a polished assistant.

  • Add a system parameter to your client.messages.create() call:
response = client.messages.create(
    model="claude-sonnet-4-5-20250929",
    max_tokens=4096,
    system="You are a helpful and enthusiastic travel planning assistant. "
           "Always check the weather before recommending activities. "
           "Present information in a clear, organized format with sections "
           "for flights, hotels, and weather. Include practical tips for "
           "the destination. If a tool returns an error, explain the issue "
           "clearly and suggest alternatives.",
    tools=tools,
    messages=messages
)

Pro tip: system prompt placement

Make sure you add the system parameter to every client.messages.create() call in your loop, not just the first one. Each API call is independent, so Claude needs the system prompt every time.

  • Update your user message back to the full travel query:
user_message = "I want to plan a trip from San Francisco to Tokyo next month. Check the weather, find me flights under $1500, and suggest hotels under $200/night."
  • Run your script one more time:
python3 travel_assistant.py

Compare this output to your earlier runs. The system prompt should produce a more structured, organized response with clear sections and practical travel tips.

Getting Unexpected Errors?

If your script crashes instead of handling errors gracefully, make sure the try/except block wraps the run_tool() call, not the entire loop. Also verify that every tool_result includes the correct tool_use_id from the matching tool_use block. Help me debug my tool error handling.

Your travel assistant is now production-ready in its design. It handles multiple tools, processes sequential calls automatically, recovers from errors gracefully, and responds with a polished personality. Next up, you'll have the chance to extend it even further in the secret mission.

Secret mission

Add Currency Exchange and Forced Tool Use

Your travel assistant gives great recommendations, but it never mentions how much things will actually cost in local currency. Add a currency exchange tool and use tool_choice to force Claude to always check the exchange rate before planning anything.

Before you go

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs locally with mock data, so the only ongoing cost is API usage if you run the script again (roughly $0.05-0.10 per full run).

Resources you used:

  • travel_assistant.py file with your travel assistant code.
  • Anthropic API key stored as the ANTHROPIC_API_KEY environment variable.
  • Anthropic Python SDK installed via pip install anthropic.
  • Anthropic Console account at console.anthropic.com.

Keep everything running

No action needed. Choose this if you are still actively building or want to experiment with your travel assistant further.

  • Your travel_assistant.py file stays where it is.
  • Your API key remains set as an environment variable.
  • The Anthropic SDK stays installed in your Python environment.
  • You will only incur costs when you actively run the script.

Pause - I'll come back to this later

Remove your API key from the environment to prevent accidental usage. Keep your project file for later.

  • Remove the ANTHROPIC_API_KEY line from your ~/.zshrc or ~/.bashrc file if you added it there.
  • Run unset ANTHROPIC_API_KEY in your current terminal session to clear it immediately.
  • Keep your travel_assistant.py file. You can re-add the API key when you are ready to continue.

Delete - I don't want to use this again

Remove all project resources and credentials. You can always recreate them later if you change your mind.

  • Delete your travel_assistant.py file.
  • Remove the ANTHROPIC_API_KEY line from your ~/.zshrc or ~/.bashrc file.
  • Run unset ANTHROPIC_API_KEY in your current terminal session.
  • Go to the Anthropic Console at console.anthropic.com and revoke your API key under Settings > API Keys.
  • Optionally, uninstall the SDK by running pip uninstall anthropic.

Mission accomplished

Nice work! You just built an AI travel assistant that connects Claude to custom tools, letting it check weather, search flights, and find hotels on your behalf.

Here's what you learned:

  • Defined tool schemas using JSON Schema to tell Claude what functions are available, what inputs they expect, and when to use them.
  • Built the agentic loop pattern that handles multiple sequential tool calls automatically, matching tool_use and tool_result blocks until Claude finishes with end_turn.
  • Implemented error handling with is_error so Claude can recover gracefully from tool failures instead of crashing your application.
  • Used tool_choice to force Claude to call a specific tool first, then switched back to auto mode for the rest of the conversation.

Ready to quiz yourself?