Build an Agentic Customer Service Bot

Build a Claude-powered agent with tool use, error handling, and business rules.

Introduction

30 Second Summary

Think about the last time you contacted customer support with multiple issues at once. The best agents handle everything in one go without making you repeat yourself or wait on hold between transfers.

In this project, you will build an AI customer service agent that autonomously decides which tools to call, retries when things go wrong, and enforces business rules like refund limits. You will learn the core patterns behind every production AI agent.

What You'll Build

A customer service agent for a fictional company called TechGear Electronics that uses Claude's tool use API with a full agentic loop to look up orders, process refunds, check inventory, and escalate issues.

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

  • A working agentic loop that autonomously executes multiple tool calls in sequence until Claude signals completion.
  • A structured error-handling system with categorized errors (transient, validation, permission) and automatic retry logic for transient failures.
  • A programmatic hook layer that intercepts tool calls to enforce business rules and triggers escalation workflows when thresholds are breached.
  • Secret Mission: Add a conversation memory system that tracks customer sentiment across turns and automatically escalates to a human agent when frustration is detected.

Are there any prerequisites?

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

Set Up Your Environment

Before you can build an AI agent, you need the right tools on your machine. Your agent will talk to Claude's API using Python, so you need Python installed and the Anthropic SDK available.

In this step, you will install Python, grab an API key from Anthropic, and set up a clean project folder. By the end, you will have confirmed that your machine can successfully communicate with Claude.

In this step, get ready to:
  • Install Python 3.9+ and the Anthropic Python SDK.
  • Set up your Anthropic API key as an environment variable.
  • Create your project folder with the four files you will build throughout this project.
Install Python and the Anthropic SDK

Python is the language you will use to build your agentic customer service bot. The Anthropic SDK is the official library that lets your Python code talk to Claude's API.

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

macOS

python3 --version

Windows

python --version

✔️ I see version 3.9 or higher

Python is ready to go. Move on to the next instruction below.

ⓧ I see an older version

You need Python 3.9 or higher for the Anthropic SDK. Download the latest Python installer from python.org/downloads.

  • Run the installer and follow the prompts.
  • On Windows, check the box that says Add Python to PATH before clicking Install Now.
  • Close and reopen your terminal, then run the version check again to confirm it shows 3.9 or higher.

ⓧ Command not found

Python is not installed yet. Download it from python.org/downloads.

  • Run the installer and follow the prompts.
  • On Windows, check the box that says Add Python to PATH before clicking Install Now.
  • Close and reopen your terminal, then run the version check again to confirm it works.
  • Install the Anthropic Python SDK by running this command:

macOS

pip3 install anthropic==0.101.0

Windows

pip install anthropic==0.101.0

What does this command do?

This installs version 0.101.0 of the anthropic package from PyPI. Pinning the version ensures your project behaves consistently regardless of when you run it.

Seeing a permission error?

If you get a permissions error, try adding --user to the end of the command. If pip is not found, try pip3 on macOS or ensure Python was added to your PATH on Windows.

Ask for help: Why can't I install the anthropic package with pip?

Get your Anthropic API key

Your code needs an API key to authenticate with Claude's servers. Think of it as a password that proves your app has permission to use the API.

  • Open console.anthropic.com in your browser and sign in (or create an account if you do not have one).
  • Navigate to API Keys in the left sidebar.
  • Click Create Key.
  • Give your key a name (e.g. techgear-agent) and click Create Key.
  • Copy the key immediately. You will not be able to see it again after you close the dialog.

Keep your key safe

Never share your API key or commit it to version control. Treat it like a password. If it is exposed, revoke it immediately from the console and create a new one.

  • Set your API key as an environment variable so the SDK can find it automatically. Run this in your terminal:

macOS

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

Windows

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

Why an environment variable?

The Anthropic SDK automatically looks for an environment variable called ANTHROPIC_API_KEY. Storing your key this way keeps it out of your source code. You will need to re-run this command each time you open a new terminal session.

Create your project folder and verify connectivity

Now you will set up the folder structure for your agent. The project has four files, each responsible for a different part of the system.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir techgear-agent
cd techgear-agent
  • Create the four project files by running these commands:

macOS

touch agent.py tools.py hooks.py main.py

Windows

New-Item agent.py, tools.py, hooks.py, main.py
  • Confirm the files exist by running:
ls

You should see agent.py, tools.py, hooks.py, and main.py in the output.

What are these files for?

  • agent.py will hold the core agentic loop that orchestrates tool calls.
  • tools.py will define the tools Claude can use and their simulated execution logic.
  • hooks.py will contain business rule enforcement that intercepts tool calls.
  • main.py will be the demo script that tests everything together.
  • Verify your API connection by running this quick test:

macOS

python3 -c "from anthropic import Anthropic; c = Anthropic(); r = c.messages.create(model='claude-sonnet-4-5-20250929', max_tokens=50, messages=[{'role': 'user', 'content': 'Say hello in one word'}]); print(r.content[0].text)"

Windows

python -c "from anthropic import Anthropic; c = Anthropic(); r = c.messages.create(model='claude-sonnet-4-5-20250929', max_tokens=50, messages=[{'role': 'user', 'content': 'Say hello in one word'}]); print(r.content[0].text)"

You should see a single-word greeting like "Hello" printed in your terminal. This confirms Python, the SDK, and your API key are all working together.

Seeing an authentication error?

If you see an error about an invalid API key, double-check that you copied the full key and set the environment variable in the same terminal session you are using now. Opening a new tab or window resets environment variables.

Ask for help: My Anthropic API test is failing with an authentication error. How do I fix it?

Your environment is ready. Next up, you will define the tools that your agent can use to help customers.

Define Your Tools with Disambiguating Descriptions

Your agent needs tools to do useful work. In a customer service context, that means looking up orders, processing refunds, and checking inventory.

The tricky part is helping Claude pick the right tool when multiple tools sound similar. In this step, you will define four tools with carefully written descriptions that prevent selection confusion. You will also build simulated execution functions that return structured error responses.

In this step, get ready to:
  • Define 4 tool schemas with detailed descriptions that help Claude disambiguate between similar tools.
  • Build a simulated database with mock orders and inventory data.
  • Implement tool execution functions that return structured error responses with categories and retry flags.
Define your tool schemas with disambiguating descriptions

Claude decides which tool to call based on each tool's description field. Anthropic's official guidance recommends at least 3-4 sentences per tool description. This is especially important when two tools do similar things.

  • Open tools.py in your editor and add the following imports and tool definitions:
import random
import time

# Tool definitions for Claude
TOOLS = [
    {
        "name": "lookup_order",
        "description": (
            "Look up a specific order by its order ID to retrieve order details "
            "including status, items, total amount, and shipping information. "
            "Use this tool when the customer provides a specific order ID (e.g., #12345). "
            "If you only have a customer's email address and need to find their orders, "
            "use lookup_customer_orders instead. Returns a single order object or an error "
            "if the order ID is not found or invalid."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "order_id": {
                    "type": "string",
                    "description": "The order ID to look up, e.g. '12345' or 'ORD-12345'"
                }
            },
            "required": ["order_id"]
        }
    },
    {
        "name": "lookup_customer_orders",
        "description": (
            "Find all orders associated with a customer's email address. Returns a list "
            "of order summaries (order ID, date, status, total) for that customer. "
            "Use this tool when you need to find orders but only have the customer's "
            "email address, not a specific order ID. If the customer has already provided "
            "a specific order ID, use lookup_order instead for faster and more detailed results. "
            "Returns an empty list if no orders are found for the email."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "customer_email": {
                    "type": "string",
                    "description": "The customer's email address to search orders for"
                }
            },
            "required": ["customer_email"]
        }
    },
    {
        "name": "process_refund",
        "description": (
            "Process a refund for a specific order. This will initiate the refund to the "
            "customer's original payment method. Requires the order ID and the refund amount. "
            "The refund amount must not exceed the original order total. Use this tool only "
            "after confirming the order exists (via lookup_order) and the customer has "
            "requested a refund. Returns a confirmation with refund reference number or "
            "an error if the refund cannot be processed."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "order_id": {
                    "type": "string",
                    "description": "The order ID to refund"
                },
                "amount": {
                    "type": "number",
                    "description": "The refund amount in USD"
                },
                "reason": {
                    "type": "string",
                    "description": "The reason for the refund"
                }
            },
            "required": ["order_id", "amount", "reason"]
        }
    },
    {
        "name": "check_inventory",
        "description": (
            "Check the current inventory stock level for a specific product by name or SKU. "
            "Returns the product name, current stock quantity, and availability status "
            "(in_stock, low_stock, out_of_stock). Use this when a customer asks about "
            "product availability or wants to know if an item is in stock before ordering. "
            "This tool does not place orders or reserve items."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "product_name": {
                    "type": "string",
                    "description": "The product name or SKU to check inventory for"
                }
            },
            "required": ["product_name"]
        }
    }
]

What does this code do?

The TOOLS list defines four tool schemas that Claude will see when deciding how to respond. Each schema has three parts:

  • name identifies the tool so your code knows which function to call.
  • description tells Claude exactly when to use this tool and when NOT to. Notice how lookup_order explicitly says "If you only have a customer's email address, use lookup_customer_orders instead."
  • input_schema tells Claude what parameters the tool accepts, using JSON Schema format.

Why write such long descriptions?

The lookup_order and lookup_customer_orders tools both find orders. Without explicit disambiguation notes, Claude might pick the wrong one when a customer says "find my orders."

Each description states: what the tool does, when to use it, when to use a different tool instead, and what it returns. This 3-4 sentence pattern is the recommended approach from Anthropic's documentation.

  • Save tools.py.

You will not see output yet. The tool schemas are data structures that Claude will receive when you call the API in a later step.

Seeing a syntax error when you save?

  • Check that every opening parenthesis ( for multiline strings has a matching closing ).
  • Make sure you have a comma after each tool dictionary in the TOOLS list.
  • Confirm the list closes with ] at the very end.

Help me fix a syntax error in my TOOLS list in tools.py

Build your simulated database and error helpers

Your tools need data to work with. In a real system, these functions would query a database. For this project, you will use mock dictionaries that simulate order records and inventory.

  • In tools.py, add the following mock data and helper functions below the TOOLS list:
# Simulated database
MOCK_ORDERS = {
    "12345": {
        "order_id": "12345",
        "customer_email": "alice@example.com",
        "status": "shipped",
        "items": [{"name": "UltraWidget Pro", "qty": 1, "price": 79.99}],
        "total": 79.99,
        "shipping": {"carrier": "FedEx", "tracking": "FX123456789"}
    },
    "67890": {
        "order_id": "67890",
        "customer_email": "alice@example.com",
        "status": "delivered",
        "items": [{"name": "MegaGadget X", "qty": 1, "price": 149.99}],
        "total": 149.99,
        "shipping": {"carrier": "UPS", "tracking": "1Z999AA10123456784"}
    },
    "11111": {
        "order_id": "11111",
        "customer_email": "bob@example.com",
        "status": "processing",
        "items": [{"name": "NanoSpeaker Mini", "qty": 2, "price": 29.99}],
        "total": 59.98,
        "shipping": None
    }
}

MOCK_INVENTORY = {
    "ultrawidget pro": {"name": "UltraWidget Pro", "stock": 42, "status": "in_stock"},
    "megagadget x": {"name": "MegaGadget X", "stock": 3, "status": "low_stock"},
    "nanospeaker mini": {"name": "NanoSpeaker Mini", "stock": 0, "status": "out_of_stock"},
}

# Simulate a transient failure counter for demonstration
_transient_failure_counter = {"count": 0}


def make_error_response(category, is_retryable, message):
    """Create a structured error response."""
    return {
        "error": True,
        "errorCategory": category,
        "isRetryable": is_retryable,
        "humanReadableMessage": message
    }


def execute_tool(tool_name, tool_input):
    """Execute a tool and return the result or a structured error."""
    if tool_name == "lookup_order":
        return _execute_lookup_order(tool_input)
    elif tool_name == "lookup_customer_orders":
        return _execute_lookup_customer_orders(tool_input)
    elif tool_name == "process_refund":
        return _execute_process_refund(tool_input)
    elif tool_name == "check_inventory":
        return _execute_check_inventory(tool_input)
    else:
        return make_error_response("validation", False, f"Unknown tool: {tool_name}")

What does this code do?

  • MOCK_ORDERS contains three sample orders with different statuses (shipped, delivered, processing). Two belong to the same customer to test the email lookup.
  • MOCK_INVENTORY has three products with different availability levels so you can test in-stock, low-stock, and out-of-stock responses.
  • _transient_failure_counter tracks how many times a tool has been called. You will use it later to simulate a database timeout on the first attempt.
  • make_error_response() creates structured error objects with three fields: errorCategory (transient, validation, or permission), isRetryable (boolean), and humanReadableMessage.
  • execute_tool() is a dispatcher that routes tool calls to the correct private function based on the tool name.

Why structured errors instead of plain strings?

When a tool fails, your agentic loop needs to decide what to do next. A plain error string forces your code to parse text. A structured object with errorCategory and isRetryable lets your loop automatically retry transient failures while surfacing validation errors directly to the user through Claude.

  • Save tools.py.

You will not see output yet. The execution functions (which actually use this data) come next.

Getting a NameError about execute functions?

The execute_tool() dispatcher references four private functions that do not exist yet. If you try to import and call execute_tool() now, you will see a NameError. The next chunk adds those functions.

Why am I seeing a NameError when I try to run tools.py?

Implement the tool execution functions

Each execution function simulates what a real database call would do. They return either a success object or a structured error. The _execute_lookup_order function is the most complex because it demonstrates all three error types: transient, validation, and a successful lookup.

  • Add the _execute_lookup_order function below execute_tool() in tools.py:
def _execute_lookup_order(tool_input):
    order_id = tool_input.get("order_id", "").replace("#", "").replace("ORD-", "")

    # Simulate transient failure on first attempt
    _transient_failure_counter["count"] += 1
    if _transient_failure_counter["count"] == 1:
        return make_error_response(
            "transient",
            True,
            "Database connection timed out. Please retry."
        )

    # Validation error for invalid format
    if not order_id.isdigit():
        return make_error_response(
            "validation",
            False,
            f"Invalid order ID format: '{tool_input.get('order_id')}'. Order IDs must be numeric."
        )

    # Look up order
    order = MOCK_ORDERS.get(order_id)
    if order:
        return {"success": True, "order": order}
    else:
        return make_error_response(
            "validation",
            False,
            f"Order '{order_id}' not found in our system."
        )

What does this code do?

  • The function first strips # and ORD- prefixes so customers can type order IDs in different formats.
  • On the very first call, it returns a transient "database timeout" error with isRetryable: True. Your agentic loop (built in the next step) will automatically retry this.
  • If the order ID is not numeric, it returns a validation error with isRetryable: False. Claude will explain the problem to the customer instead of retrying.
  • Now add the remaining three execution functions below _execute_lookup_order:
def _execute_lookup_customer_orders(tool_input):
    email = tool_input.get("customer_email", "")
    orders = [o for o in MOCK_ORDERS.values() if o["customer_email"] == email]
    if orders:
        summaries = [
            {"order_id": o["order_id"], "status": o["status"], "total": o["total"]}
            for o in orders
        ]
        return {"success": True, "orders": summaries}
    else:
        return {"success": True, "orders": [], "message": "No orders found for this email."}


def _execute_process_refund(tool_input):
    order_id = tool_input.get("order_id", "").replace("#", "").replace("ORD-", "")
    amount = tool_input.get("amount", 0)
    reason = tool_input.get("reason", "")

    # Check if order exists
    order = MOCK_ORDERS.get(order_id)
    if not order:
        return make_error_response(
            "validation",
            False,
            f"Cannot process refund: order '{order_id}' not found."
        )

    # Check amount does not exceed order total
    if amount > order["total"]:
        return make_error_response(
            "validation",
            False,
            f"Refund amount ${amount:.2f} exceeds order total ${order['total']:.2f}."
        )

    # Simulate permission error for already-refunded orders
    if order.get("refunded"):
        return make_error_response(
            "permission",
            False,
            "This order has already been refunded. Cannot process duplicate refund."
        )

    # Success
    return {
        "success": True,
        "refund": {
            "reference": f"REF-{random.randint(100000, 999999)}",
            "order_id": order_id,
            "amount": amount,
            "status": "processing",
            "estimated_days": 5
        }
    }


def _execute_check_inventory(tool_input):
    product_name = tool_input.get("product_name", "").lower()

    # Fuzzy match
    for key, product in MOCK_INVENTORY.items():
        if key in product_name or product_name in key:
            return {"success": True, "product": product}

    return {
        "success": True,
        "product": None,
        "message": f"Product '{tool_input.get('product_name')}' not found in catalog."
    }

What does this code do?

  • _execute_lookup_customer_orders() filters all orders by email address and returns summaries. It always returns a success object (even if no orders are found).
  • _execute_process_refund() validates the order exists, checks the refund amount does not exceed the total, and checks for duplicate refunds. It demonstrates both validation and permission error categories.
  • _execute_check_inventory() does a fuzzy match on the product name so customers do not need to type the exact product name.
  • Save tools.py.
  • Verify the file has no syntax errors by running:
python tools.py

You should see no output and no errors. The file loads cleanly because it only defines data and functions without calling anything.

Seeing an error when you run the file?

  • If you see IndentationError, check that each function body is indented with 4 spaces (not tabs).
  • If you see SyntaxError pointing to an f-string, make sure all curly braces inside f-strings use matching quotes (single quotes inside double-quoted strings or vice versa).
  • If you see NameError, make sure import random and import time are at the very top of the file.

Help me debug an error in my tools.py file

✔️ Awesome, I've got everything!

Great. Double check you have saved tools.py and that python tools.py runs with no output and no errors.

ⓧ I'd like to double check the full code

Here is the complete tools.py file for comparison:

import random
import time

# Tool definitions for Claude
TOOLS = [
    {
        "name": "lookup_order",
        "description": (
            "Look up a specific order by its order ID to retrieve order details "
            "including status, items, total amount, and shipping information. "
            "Use this tool when the customer provides a specific order ID (e.g., #12345). "
            "If you only have a customer's email address and need to find their orders, "
            "use lookup_customer_orders instead. Returns a single order object or an error "
            "if the order ID is not found or invalid."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "order_id": {
                    "type": "string",
                    "description": "The order ID to look up, e.g. '12345' or 'ORD-12345'"
                }
            },
            "required": ["order_id"]
        }
    },
    {
        "name": "lookup_customer_orders",
        "description": (
            "Find all orders associated with a customer's email address. Returns a list "
            "of order summaries (order ID, date, status, total) for that customer. "
            "Use this tool when you need to find orders but only have the customer's "
            "email address, not a specific order ID. If the customer has already provided "
            "a specific order ID, use lookup_order instead for faster and more detailed results. "
            "Returns an empty list if no orders are found for the email."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "customer_email": {
                    "type": "string",
                    "description": "The customer's email address to search orders for"
                }
            },
            "required": ["customer_email"]
        }
    },
    {
        "name": "process_refund",
        "description": (
            "Process a refund for a specific order. This will initiate the refund to the "
            "customer's original payment method. Requires the order ID and the refund amount. "
            "The refund amount must not exceed the original order total. Use this tool only "
            "after confirming the order exists (via lookup_order) and the customer has "
            "requested a refund. Returns a confirmation with refund reference number or "
            "an error if the refund cannot be processed."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "order_id": {
                    "type": "string",
                    "description": "The order ID to refund"
                },
                "amount": {
                    "type": "number",
                    "description": "The refund amount in USD"
                },
                "reason": {
                    "type": "string",
                    "description": "The reason for the refund"
                }
            },
            "required": ["order_id", "amount", "reason"]
        }
    },
    {
        "name": "check_inventory",
        "description": (
            "Check the current inventory stock level for a specific product by name or SKU. "
            "Returns the product name, current stock quantity, and availability status "
            "(in_stock, low_stock, out_of_stock). Use this when a customer asks about "
            "product availability or wants to know if an item is in stock before ordering. "
            "This tool does not place orders or reserve items."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "product_name": {
                    "type": "string",
                    "description": "The product name or SKU to check inventory for"
                }
            },
            "required": ["product_name"]
        }
    }
]

# Simulated database
MOCK_ORDERS = {
    "12345": {
        "order_id": "12345",
        "customer_email": "alice@example.com",
        "status": "shipped",
        "items": [{"name": "UltraWidget Pro", "qty": 1, "price": 79.99}],
        "total": 79.99,
        "shipping": {"carrier": "FedEx", "tracking": "FX123456789"}
    },
    "67890": {
        "order_id": "67890",
        "customer_email": "alice@example.com",
        "status": "delivered",
        "items": [{"name": "MegaGadget X", "qty": 1, "price": 149.99}],
        "total": 149.99,
        "shipping": {"carrier": "UPS", "tracking": "1Z999AA10123456784"}
    },
    "11111": {
        "order_id": "11111",
        "customer_email": "bob@example.com",
        "status": "processing",
        "items": [{"name": "NanoSpeaker Mini", "qty": 2, "price": 29.99}],
        "total": 59.98,
        "shipping": None
    }
}

MOCK_INVENTORY = {
    "ultrawidget pro": {"name": "UltraWidget Pro", "stock": 42, "status": "in_stock"},
    "megagadget x": {"name": "MegaGadget X", "stock": 3, "status": "low_stock"},
    "nanospeaker mini": {"name": "NanoSpeaker Mini", "stock": 0, "status": "out_of_stock"},
}

# Simulate a transient failure counter for demonstration
_transient_failure_counter = {"count": 0}


def make_error_response(category, is_retryable, message):
    """Create a structured error response."""
    return {
        "error": True,
        "errorCategory": category,
        "isRetryable": is_retryable,
        "humanReadableMessage": message
    }


def execute_tool(tool_name, tool_input):
    """Execute a tool and return the result or a structured error."""
    if tool_name == "lookup_order":
        return _execute_lookup_order(tool_input)
    elif tool_name == "lookup_customer_orders":
        return _execute_lookup_customer_orders(tool_input)
    elif tool_name == "process_refund":
        return _execute_process_refund(tool_input)
    elif tool_name == "check_inventory":
        return _execute_check_inventory(tool_input)
    else:
        return make_error_response("validation", False, f"Unknown tool: {tool_name}")


def _execute_lookup_order(tool_input):
    order_id = tool_input.get("order_id", "").replace("#", "").replace("ORD-", "")

    # Simulate transient failure on first attempt
    _transient_failure_counter["count"] += 1
    if _transient_failure_counter["count"] == 1:
        return make_error_response(
            "transient",
            True,
            "Database connection timed out. Please retry."
        )

    # Validation error for invalid format
    if not order_id.isdigit():
        return make_error_response(
            "validation",
            False,
            f"Invalid order ID format: '{tool_input.get('order_id')}'. Order IDs must be numeric."
        )

    # Look up order
    order = MOCK_ORDERS.get(order_id)
    if order:
        return {"success": True, "order": order}
    else:
        return make_error_response(
            "validation",
            False,
            f"Order '{order_id}' not found in our system."
        )


def _execute_lookup_customer_orders(tool_input):
    email = tool_input.get("customer_email", "")
    orders = [o for o in MOCK_ORDERS.values() if o["customer_email"] == email]
    if orders:
        summaries = [
            {"order_id": o["order_id"], "status": o["status"], "total": o["total"]}
            for o in orders
        ]
        return {"success": True, "orders": summaries}
    else:
        return {"success": True, "orders": [], "message": "No orders found for this email."}


def _execute_process_refund(tool_input):
    order_id = tool_input.get("order_id", "").replace("#", "").replace("ORD-", "")
    amount = tool_input.get("amount", 0)
    reason = tool_input.get("reason", "")

    # Check if order exists
    order = MOCK_ORDERS.get(order_id)
    if not order:
        return make_error_response(
            "validation",
            False,
            f"Cannot process refund: order '{order_id}' not found."
        )

    # Check amount does not exceed order total
    if amount > order["total"]:
        return make_error_response(
            "validation",
            False,
            f"Refund amount ${amount:.2f} exceeds order total ${order['total']:.2f}."
        )

    # Simulate permission error for already-refunded orders
    if order.get("refunded"):
        return make_error_response(
            "permission",
            False,
            "This order has already been refunded. Cannot process duplicate refund."
        )

    # Success
    return {
        "success": True,
        "refund": {
            "reference": f"REF-{random.randint(100000, 999999)}",
            "order_id": order_id,
            "amount": amount,
            "status": "processing",
            "estimated_days": 5
        }
    }


def _execute_check_inventory(tool_input):
    product_name = tool_input.get("product_name", "").lower()

    # Fuzzy match
    for key, product in MOCK_INVENTORY.items():
        if key in product_name or product_name in key:
            return {"success": True, "product": product}

    return {
        "success": True,
        "product": None,
        "message": f"Product '{tool_input.get('product_name')}' not found in catalog."
    }

Your tools layer is complete. You have four well-described tools, a simulated database, and structured error responses ready for the agentic loop. Next up, you will build the loop itself so Claude can actually call these tools and get results back.

Build the Agentic Loop

Your tools are defined and ready to execute. Now you need the decision-making engine that ties everything together.

An agentic loop is a pattern where you send a message to Claude, check whether it wants to call a tool or respond to the user, and keep looping until it signals completion. In this step, you will build this loop from scratch in agent.py.

In this step, get ready to:
  • Implement the core agentic loop that checks stop_reason after every Claude response.
  • Add tool result formatting that extracts, executes, and returns tool calls.
  • Add safety limits to prevent infinite loops and handle truncated responses.
Set up the agent module with imports and configuration

Every agentic loop needs a system prompt that defines the agent's personality and instructions. It also needs configuration constants that control how many times the loop can run before it gives up.

  • Open agent.py in your editor and add the following imports, system prompt, and constants:
import json
import time

from anthropic import Anthropic

from tools import TOOLS, execute_tool
from hooks import pre_tool_call


SYSTEM_PROMPT = """You are a helpful customer service agent for TechGear Electronics. \
You assist customers with order lookups, refunds, inventory checks, and general questions. \
Be friendly and professional. When handling multiple concerns in a single message, \
address each one systematically and provide a unified summary at the end. \
If a tool returns an error, explain the issue clearly to the customer and suggest next steps."""

MAX_ITERATIONS = 10
MAX_RETRIES_TRANSIENT = 2
RETRY_DELAY_SECONDS = 1

What does this code do?

The imports bring in the Anthropic client, your tool definitions from tools.py, and a pre_tool_call hook function you will implement in a later step.

SYSTEM_PROMPT gives Claude its customer service personality. MAX_ITERATIONS caps the loop at 10 cycles to prevent runaway execution. MAX_RETRIES_TRANSIENT and RETRY_DELAY_SECONDS control how the agent handles temporary failures.

  • Save agent.py.

Why import from hooks if it's empty?

You are setting up the import now so the file structure is complete. You will implement pre_tool_call in hooks.py during Step 5. The agent cannot run until that function exists.

Implement the core loop with stop_reason handling

Claude communicates its intent through a field called stop_reason. When stop_reason is "end_turn", Claude has finished responding. When it is "tool_use", Claude wants to call a tool and expects you to execute it.

  • Below your constants in agent.py, add the run_agent() function with the loop and end_turn handling:
def run_agent(user_message, model="claude-sonnet-4-5-20250929"):
    """Run the agentic loop for a single user message."""
    client = Anthropic()
    messages = [{"role": "user", "content": user_message}]

    print(f"\n{'='*60}")
    print(f"User: {user_message}")
    print(f"{'='*60}")

    for iteration in range(MAX_ITERATIONS):
        print(f"\n--- Iteration {iteration + 1} ---")

        response = client.messages.create(
            model=model,
            max_tokens=4096,
            system=SYSTEM_PROMPT,
            tools=TOOLS,
            messages=messages
        )

        print(f"Stop reason: {response.stop_reason}")

        # Handle end_turn: Claude is done, extract final text
        if response.stop_reason == "end_turn":
            final_text = ""
            for block in response.content:
                if block.type == "text":
                    final_text += block.text
            print(f"\nAgent: {final_text}")
            return final_text

What does this code do?

  • run_agent() initializes the Anthropic client, wraps the user's message in the messages list, then enters a loop bounded by MAX_ITERATIONS.
  • Each iteration calls client.messages.create() with the system prompt, tool definitions, and full conversation history.
  • When stop_reason == "end_turn", Claude is finished. The function extracts all text blocks from the response and returns them as the final answer.
  • Save agent.py. You won't see a change yet because the loop still needs the tool execution branch added next.

Seeing a syntax error?

Make sure the triple-quoted SYSTEM_PROMPT string uses matching """ at both ends. Also check that the backslash continuations (\) at the end of each line have no trailing spaces after them.

Help me fix a syntax error in my agent.py file

Add tool result formatting

When stop_reason is "tool_use", Claude's response contains one or more tool_use blocks. Each block has a tool name, input parameters, and a unique ID. Your job is to execute the tool and send back a tool_result block with that same ID so Claude can match the result to its request.

  • In agent.py, add the following tool_use branch directly below the end_turn block (still inside the for loop):
        # Handle tool_use: execute tools and continue the loop
        if response.stop_reason == "tool_use":
            # Add assistant message to conversation
            messages.append({"role": "assistant", "content": response.content})

            # Process each tool_use block
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    tool_name = block.name
                    tool_input = block.input
                    tool_use_id = block.id

                    print(f"  Tool call: {tool_name}({json.dumps(tool_input)})")

                    # Run pre-tool hook
                    hook_result = pre_tool_call(tool_name, tool_input)
                    if hook_result is not None:
                        # Hook blocked the call
                        print(f"  HOOK BLOCKED: {hook_result['reason']}")
                        result_content = json.dumps(hook_result)
                    else:
                        # Execute the tool with retry logic for transient errors
                        result_content = _execute_with_retry(tool_name, tool_input)

                    print(f"  Result: {result_content[:100]}...")

                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": tool_use_id,
                        "content": result_content
                    })

            # Add tool results to conversation
            messages.append({"role": "user", "content": tool_results})

What does this code do?

  • First, it appends Claude's full response (including the tool_use blocks) as an assistant message. The Messages API is stateless, so you must send the full conversation history each time.
  • It loops through all content blocks, processing each tool_use block individually. This handles the case where Claude calls multiple tools in a single turn.
  • Before executing, it checks the pre_tool_call hook. If the hook returns something (not None), the tool call is blocked and the hook's response is sent back to Claude instead.
  • Tool results are sent back as a user message with tool_result content blocks. Each result includes the matching tool_use_id so Claude can pair results with its original requests.

The tool_use branch references _execute_with_retry() which does not exist yet. This helper retries transient errors automatically so the agent can recover from temporary database timeouts without bothering the user.

  • Add the _execute_with_retry() function at the bottom of agent.py, after the run_agent() function:
def _execute_with_retry(tool_name, tool_input):
    """Execute a tool with automatic retry for transient errors."""
    for attempt in range(MAX_RETRIES_TRANSIENT + 1):
        result = execute_tool(tool_name, tool_input)

        # Check if it's a retryable error
        if isinstance(result, dict) and result.get("error") and result.get("isRetryable"):
            if attempt < MAX_RETRIES_TRANSIENT:
                print(f"  Transient error (attempt {attempt + 1}), retrying...")
                time.sleep(RETRY_DELAY_SECONDS)
                continue
            else:
                print(f"  Transient error persisted after {MAX_RETRIES_TRANSIENT + 1} attempts")

        return json.dumps(result)

    return json.dumps(result)

What does this code do?

  • It calls execute_tool() and inspects the result. If the result has "error": True and "isRetryable": True, it waits 1 second and tries again.
  • After MAX_RETRIES_TRANSIENT retry attempts (2 by default), it gives up and passes the error to Claude as a JSON string so Claude can explain the issue to the customer.
  • Save agent.py. You won't be able to run the agent yet because hooks.py does not export pre_tool_call. You will add that in Step 5.

Indentation looks off?

The tool_use branch must be at the same indentation level as the end_turn block (two levels deep: inside the function, inside the for loop). The _execute_with_retry() function is a top-level function with no indentation, placed after run_agent().

Help me fix the indentation in my agent.py agentic loop

Add safety limits for max_tokens truncation

Sometimes Claude's response gets cut off because it exceeds max_tokens. If the truncated response contains an incomplete tool_use block, you need to retry with a higher limit. Otherwise you extract whatever text is available.

  • In agent.py, add the following elif branch below the tool_use block (still inside the for loop). Then add the fallback return after the loop ends:
        # Handle max_tokens: response was truncated
        elif response.stop_reason == "max_tokens":
            print("  WARNING: Response truncated due to max_tokens")
            last_block = response.content[-1] if response.content else None
            if last_block and last_block.type == "tool_use":
                print("  Retrying with higher max_tokens...")
                response = client.messages.create(
                    model=model,
                    max_tokens=8192,
                    system=SYSTEM_PROMPT,
                    tools=TOOLS,
                    messages=messages
                )
            else:
                final_text = ""
                for block in response.content:
                    if block.type == "text":
                        final_text += block.text
                print(f"\nAgent (truncated): {final_text}")
                return final_text

    print("\nWARNING: Max iterations reached")
    return "I apologize, but I'm having trouble completing your request. Please try again."

What does this code do?

  • The max_tokens branch checks if the last content block is an incomplete tool_use. If so, it retries with double the token budget (8192 instead of 4096).
  • If the truncation happened mid-text (not mid-tool-call), it returns whatever text was generated so far with a "truncated" label.
  • The fallback after the for loop is your safety net. If the loop runs all 10 iterations without hitting end_turn, it returns an apology message rather than looping forever.
  • Save agent.py.
  • Verify the file has no syntax errors by running:
python -c "import ast; ast.parse(open('agent.py').read()); print('Syntax OK')"

You should see Syntax OK printed. This confirms your Python is valid even though the import from hooks will fail at runtime until Step 5.

Seeing a SyntaxError?

Check that the elif response.stop_reason == "max_tokens" line is at the same indentation level as the if response.stop_reason == "tool_use" line above it. The final print("\nWARNING: Max iterations reached") line should be inside the function but outside the for loop (one indent level in from def).

Help me fix a SyntaxError in my agentic loop

✔️ Awesome, I've got everything!

Great. Double check you have saved agent.py.

ⓧ I'd like to double check the full code

import json
import time

from anthropic import Anthropic

from tools import TOOLS, execute_tool
from hooks import pre_tool_call


SYSTEM_PROMPT = """You are a helpful customer service agent for TechGear Electronics. \
You assist customers with order lookups, refunds, inventory checks, and general questions. \
Be friendly and professional. When handling multiple concerns in a single message, \
address each one systematically and provide a unified summary at the end. \
If a tool returns an error, explain the issue clearly to the customer and suggest next steps."""

MAX_ITERATIONS = 10
MAX_RETRIES_TRANSIENT = 2
RETRY_DELAY_SECONDS = 1


def run_agent(user_message, model="claude-sonnet-4-5-20250929"):
    """Run the agentic loop for a single user message."""
    client = Anthropic()
    messages = [{"role": "user", "content": user_message}]

    print(f"\n{'='*60}")
    print(f"User: {user_message}")
    print(f"{'='*60}")

    for iteration in range(MAX_ITERATIONS):
        print(f"\n--- Iteration {iteration + 1} ---")

        response = client.messages.create(
            model=model,
            max_tokens=4096,
            system=SYSTEM_PROMPT,
            tools=TOOLS,
            messages=messages
        )

        print(f"Stop reason: {response.stop_reason}")

        if response.stop_reason == "end_turn":
            final_text = ""
            for block in response.content:
                if block.type == "text":
                    final_text += block.text
            print(f"\nAgent: {final_text}")
            return final_text

        if response.stop_reason == "tool_use":
            messages.append({"role": "assistant", "content": response.content})

            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    tool_name = block.name
                    tool_input = block.input
                    tool_use_id = block.id

                    print(f"  Tool call: {tool_name}({json.dumps(tool_input)})")

                    hook_result = pre_tool_call(tool_name, tool_input)
                    if hook_result is not None:
                        print(f"  HOOK BLOCKED: {hook_result['reason']}")
                        result_content = json.dumps(hook_result)
                    else:
                        result_content = _execute_with_retry(tool_name, tool_input)

                    print(f"  Result: {result_content[:100]}...")

                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": tool_use_id,
                        "content": result_content
                    })

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

        elif response.stop_reason == "max_tokens":
            print("  WARNING: Response truncated due to max_tokens")
            last_block = response.content[-1] if response.content else None
            if last_block and last_block.type == "tool_use":
                print("  Retrying with higher max_tokens...")
                response = client.messages.create(
                    model=model,
                    max_tokens=8192,
                    system=SYSTEM_PROMPT,
                    tools=TOOLS,
                    messages=messages
                )
            else:
                final_text = ""
                for block in response.content:
                    if block.type == "text":
                        final_text += block.text
                print(f"\nAgent (truncated): {final_text}")
                return final_text

    print("\nWARNING: Max iterations reached")
    return "I apologize, but I'm having trouble completing your request. Please try again."


def _execute_with_retry(tool_name, tool_input):
    """Execute a tool with automatic retry for transient errors."""
    for attempt in range(MAX_RETRIES_TRANSIENT + 1):
        result = execute_tool(tool_name, tool_input)

        if isinstance(result, dict) and result.get("error") and result.get("isRetryable"):
            if attempt < MAX_RETRIES_TRANSIENT:
                print(f"  Transient error (attempt {attempt + 1}), retrying...")
                time.sleep(RETRY_DELAY_SECONDS)
                continue
            else:
                print(f"  Transient error persisted after {MAX_RETRIES_TRANSIENT + 1} attempts")

        return json.dumps(result)

    return json.dumps(result)

Why this approach over a pre-built agent framework?

Agent frameworks like LangChain or the Anthropic Agent SDK abstract the loop away so you never see it. Building it yourself teaches you exactly how tool orchestration works: the message ping-pong, the stop_reason routing, and the retry logic.

When a pre-built framework breaks or behaves unexpectedly, understanding the underlying loop is what lets you debug it.

Your agentic loop is complete. It can send messages, route tool calls, retry transient errors, and handle edge cases. Next, you will add structured error handling to your tool execution functions so the agent can respond intelligently to different failure types.

Implement Business Rule Hooks

Your agentic loop can now execute tools, handle errors, and retry transient failures automatically. But in production, you need safety guardrails that prevent certain actions from executing at all.

In this step, you'll build a hook layer that intercepts tool calls before they execute. The hook enforces a business rule: refunds over $100 require manager approval and cannot be auto-processed by the agent.

In this step, get ready to:
  • Create a pre_tool_call hook that intercepts tool calls and enforces a $100 refund threshold.
  • Review how the hook is already wired into the agentic loop in agent.py.
  • Test the escalation flow end-to-end with a refund request over $100.
Create the pre_tool_call function

A hook is a function that runs at a specific point in a workflow to inspect or modify behavior. Your pre_tool_call hook will fire between Claude's tool request and actual tool execution.

Why hooks instead of hardcoding rules in the tool?

You could put the $100 check inside _execute_process_refund, but that mixes business policy with tool logic. A separate hook layer lets you add, remove, or change rules without touching tool code.

In production systems, hooks also centralize audit logging. Every blocked action flows through one checkpoint.

  • In hooks.py, replace the empty file contents with the following code:
import random


def pre_tool_call(tool_name, tool_input):
    """
    Intercept tool calls before execution to enforce business rules.

    Returns:
        None if the tool call should proceed normally.
        A dict with an escalation response if the call is blocked.
    """
    if tool_name == "process_refund":
        amount = tool_input.get("amount", 0)
        if amount > 100:
            return {
                "escalation": True,
                "escalation_reference": f"ESC-{random.randint(10000, 99999)}",
                "reason": "refund_threshold_exceeded",
                "message": (
                    f"Refund of ${amount:.2f} exceeds the $100.00 auto-approval threshold. "
                    f"This request has been escalated to a manager for review. "
                    f"The customer will receive an email within 24 hours with a decision."
                ),
                "blocked_tool": tool_name,
                "blocked_input": tool_input
            }

    return None

What does this code do?

  • pre_tool_call receives the tool name and its input parameters every time the agent tries to execute a tool.
  • For process_refund calls, it checks if the refund amount exceeds $100. If so, it returns a structured escalation dict instead of allowing execution.
  • The escalation response includes a reference number (ESC-XXXXX), the reason code, and a human-readable message that Claude will relay to the customer.
  • Returning None at the bottom means all other tool calls (order lookups, inventory checks, small refunds) pass through unblocked.
  • Save hooks.py.
  • You won't see output yet. The next substep verifies the hook fires correctly inside the agentic loop.

Seeing a SyntaxError?

  • Check that the f-string inside "message" uses parentheses ( ) to wrap the multi-line string. Without them, Python treats each line as a separate statement.
  • Make sure the final return None is at the function level (one indent), not inside the if block.

Help me fix a SyntaxError in my hooks.py pre_tool_call function.

Review how the hook integrates with the agentic loop

Your agent.py already calls pre_tool_call at the right moment. The hook fires after Claude requests a tool but before the tool actually executes.

  • In agent.py, find this section inside the for block in response.content loop:
                    # Run pre-tool hook
                    hook_result = pre_tool_call(tool_name, tool_input)
                    if hook_result is not None:
                        # Hook blocked the call
                        print(f"  HOOK BLOCKED: {hook_result['reason']}")
                        result_content = json.dumps(hook_result)
                    else:
                        # Execute the tool with retry logic for transient errors
                        result_content = _execute_with_retry(tool_name, tool_input)

How does the escalation reach the customer?

When the hook returns a non-None value, the agent skips tool execution entirely. Instead, it sends the escalation dict as the tool_result back to Claude.

Claude reads that result, sees the escalation message, and communicates it naturally to the customer. The agent never processes the refund.

Test the escalation with a refund over $100

Time to verify the full flow. You'll send a refund request for $149.99 and confirm the hook blocks it with an escalation response.

  • Run this test command in your terminal:
python -c "
from tools import _transient_failure_counter
_transient_failure_counter['count'] = 99
from agent import run_agent
run_agent('Please refund my order #67890 for 149.99 dollars, it arrived damaged.')
"
  • In the console output, look for HOOK BLOCKED: refund_threshold_exceeded. This confirms the hook intercepted the call.
  • Claude's final response should mention that the refund has been escalated to a manager and the customer will hear back within 24 hours.

What should I see?

The output should show Claude calling process_refund, then immediately HOOK BLOCKED instead of the tool executing. Claude then receives the escalation JSON and explains the situation to the customer in natural language.

Seeing an ImportError or NameError?

  • If you see ImportError: cannot import name 'pre_tool_call', make sure you saved hooks.py and that the function name is spelled exactly pre_tool_call.
  • If you see an AuthenticationError, confirm your ANTHROPIC_API_KEY environment variable is still set in this terminal session.
  • If the refund processes successfully without being blocked, check that the amount in your test message is above 100. The hook only triggers when amount > 100.

Help me debug why my pre_tool_call hook isn't blocking refunds over $100.

✔️ Awesome, I've got everything!

Great. Double check you've saved hooks.py and confirmed the escalation output in your terminal.

ⓧ I'd like to double check the full code

Here is the complete hooks.py file:

import random


def pre_tool_call(tool_name, tool_input):
    """
    Intercept tool calls before execution to enforce business rules.

    Returns:
        None if the tool call should proceed normally.
        A dict with an escalation response if the call is blocked.
    """
    if tool_name == "process_refund":
        amount = tool_input.get("amount", 0)
        if amount > 100:
            return {
                "escalation": True,
                "escalation_reference": f"ESC-{random.randint(10000, 99999)}",
                "reason": "refund_threshold_exceeded",
                "message": (
                    f"Refund of ${amount:.2f} exceeds the $100.00 auto-approval threshold. "
                    f"This request has been escalated to a manager for review. "
                    f"The customer will receive an email within 24 hours with a decision."
                ),
                "blocked_tool": tool_name,
                "blocked_input": tool_input
            }

    return None

Your agent now has a safety layer that enforces business rules before any tool executes. Next up, you'll throw a complex multi-concern message at the agent and watch it decompose the request into multiple tool calls with mixed outcomes.

Run the Full Demo

Your agent now has tools, an agentic loop, structured error handling, and business rule hooks. Each piece works independently, but you haven't seen them working together yet.

In this step, you'll write a demo script that fires real customer messages at your agent. Each test exercises a different pattern: retries, disambiguation, escalation, validation errors, and multi-concern decomposition.

In this step, get ready to:
  • Write test scenarios covering each agent pattern.
  • Test multi-concern messages that trigger mixed outcomes.
  • Run the full demo and verify the agentic loop end-to-end.
Write the first three test scenarios

Each test scenario resets the _transient_failure_counter to control whether the simulated database timeout fires. Setting it to 0 triggers the transient error on the first call. Setting it to 99 skips past the failure threshold so other patterns can be tested cleanly.

  • In main.py, replace the empty file contents with the following code:
from tools import _transient_failure_counter
from agent import run_agent


def main():
    print("\n" + "=" * 60)
    print("TECHGEAR ELECTRONICS - CUSTOMER SERVICE AGENT DEMO")
    print("=" * 60)

    # Test 1: Basic order lookup (also tests transient error retry)
    print("\n\n>>> TEST 1: Order lookup with transient retry")
    _transient_failure_counter["count"] = 0  # Reset to trigger transient error
    run_agent("Can you check the status of my order #12345?")

    # Test 2: Tool disambiguation - email lookup vs order ID lookup
    print("\n\n>>> TEST 2: Customer email lookup (tests tool disambiguation)")
    _transient_failure_counter["count"] = 99  # Skip transient errors
    run_agent("I placed some orders with alice@example.com, can you find them?")

    # Test 3: Refund under threshold (should succeed)
    print("\n\n>>> TEST 3: Refund under $100 threshold")
    _transient_failure_counter["count"] = 99
    run_agent("I need a refund for order #12345, the item was defective. The order was $79.99.")

What does this code do?

Each test sends a different customer message to your agent and exercises a specific pattern:

  • Test 1 resets the counter to 0 so the first lookup_order call returns a transient error. Your retry logic should silently retry and succeed on the second attempt.
  • Test 2 provides only an email address. Claude should pick lookup_customer_orders over lookup_order thanks to your disambiguating descriptions.
  • Test 3 requests a $79.99 refund, which is under the $100 hook threshold. It should process successfully without escalation.
  • Save main.py.
Add escalation, validation, and multi-concern tests

The next three tests push the agent into failure and mixed-outcome territory. Test 4 breaches the $100 refund threshold. Test 5 sends an invalid order ID. Test 6 combines all of the above into a single customer message.

  • In main.py, add the following code below the Test 3 block, still inside the main() function:
    # Test 4: Refund over threshold (should trigger escalation hook)
    print("\n\n>>> TEST 4: Refund over $100 threshold (escalation)")
    _transient_failure_counter["count"] = 99
    run_agent("Please refund my order #67890 for $149.99, it arrived damaged.")

    # Test 5: Validation error (invalid order ID)
    print("\n\n>>> TEST 5: Invalid order ID (validation error)")
    _transient_failure_counter["count"] = 99
    run_agent("What's the status of order #ABC-INVALID?")

    # Test 6: Multi-concern message
    print("\n\n>>> TEST 6: Multi-concern message")
    _transient_failure_counter["count"] = 99
    run_agent(
        "I have three things: "
        "1) Check the status of order #12345, "
        "2) I need a refund for order #67890 ($149.99) because it was damaged, "
        "3) Do you have the UltraWidget Pro in stock?"
    )


if __name__ == "__main__":
    main()

What does this code do?

  • Test 4 requests a $149.99 refund. Your pre_tool_call hook intercepts this and returns an escalation response instead of processing the refund.
  • Test 5 sends a non-numeric order ID. The tool returns a validation error, and Claude explains the problem to the user.
  • Test 6 fires a single message with three concerns. Claude decomposes it into multiple tool calls: a successful order lookup, an escalated refund, and an inventory check. It then synthesizes one unified response.
  • The if __name__ == "__main__" guard ensures the tests only run when you execute main.py directly.
  • Save main.py.

Seeing a syntax error?

Make sure Tests 4-6 are indented inside the main() function at the same level as Tests 1-3. Python uses indentation to define function bodies.

Also check that the if __name__ == "__main__": line is at the top level (no indentation), not inside main().

Help me fix a syntax error in my main.py file

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before running it.

ⓧ I'd like to double check the full code

from tools import _transient_failure_counter
from agent import run_agent


def main():
    print("\n" + "=" * 60)
    print("TECHGEAR ELECTRONICS - CUSTOMER SERVICE AGENT DEMO")
    print("=" * 60)

    # Test 1: Basic order lookup (also tests transient error retry)
    print("\n\n>>> TEST 1: Order lookup with transient retry")
    _transient_failure_counter["count"] = 0  # Reset to trigger transient error
    run_agent("Can you check the status of my order #12345?")

    # Test 2: Tool disambiguation - email lookup vs order ID lookup
    print("\n\n>>> TEST 2: Customer email lookup (tests tool disambiguation)")
    _transient_failure_counter["count"] = 99  # Skip transient errors
    run_agent("I placed some orders with alice@example.com, can you find them?")

    # Test 3: Refund under threshold (should succeed)
    print("\n\n>>> TEST 3: Refund under $100 threshold")
    _transient_failure_counter["count"] = 99
    run_agent("I need a refund for order #12345, the item was defective. The order was $79.99.")

    # Test 4: Refund over threshold (should trigger escalation hook)
    print("\n\n>>> TEST 4: Refund over $100 threshold (escalation)")
    _transient_failure_counter["count"] = 99
    run_agent("Please refund my order #67890 for $149.99, it arrived damaged.")

    # Test 5: Validation error (invalid order ID)
    print("\n\n>>> TEST 5: Invalid order ID (validation error)")
    _transient_failure_counter["count"] = 99
    run_agent("What's the status of order #ABC-INVALID?")

    # Test 6: Multi-concern message
    print("\n\n>>> TEST 6: Multi-concern message")
    _transient_failure_counter["count"] = 99
    run_agent(
        "I have three things: "
        "1) Check the status of order #12345, "
        "2) I need a refund for order #67890 ($149.99) because it was damaged, "
        "3) Do you have the UltraWidget Pro in stock?"
    )


if __name__ == "__main__":
    main()
Run the full demo and review the output

This is the moment of truth. Running the demo will make live API calls to Claude, and you'll see the entire agentic loop in action across all six test scenarios.

  • Run the demo by executing this command in your terminal:
python main.py

What should I see?

The console output shows the internal workings of your agentic loop for each test. Here's what to look for:

  • Test 1: You should see "Transient error (attempt 1), retrying..." followed by a successful order lookup on the second attempt.
  • Test 2: The tool call line should show lookup_customer_orders (not lookup_order), proving Claude disambiguated correctly.
  • Test 3: A refund reference number (REF-XXXXXX) confirms the refund processed without hook intervention.
  • Test 4: You should see "HOOK BLOCKED: refund_threshold_exceeded" and Claude explaining the escalation to the customer.
  • Test 5: The validation error passes through to Claude, which explains the invalid format to the user.
  • Test 6: Multiple iterations appear as Claude calls several tools in sequence, then synthesizes one unified response covering all three concerns.

Reading the iteration output

Each "--- Iteration N ---" line represents one round trip to Claude's API. When stop_reason shows tool_use, the loop continues. When it shows end_turn, Claude has composed its final answer.

Getting an authentication error?

Make sure your ANTHROPIC_API_KEY environment variable is still set in the same terminal session. If you opened a new terminal, you'll need to set it again.

Also verify your API key has available credits. The full demo makes approximately 6-10 API calls.

Help me debug an authentication error when running my agent demo

Your agent is fully operational. It autonomously decides which tools to call, retries transient failures, enforces business rules, and gracefully handles complex multi-concern requests. These are the core patterns behind every production AI agent system.

Secret mission

Sentiment-Aware Escalation

Your agent handles individual requests well, but real customers have emotions that build across a conversation. Can you add a sentiment tracking system that detects rising frustration and automatically offers to connect the customer with a human specialist?

Clean Up Your Resources

Clean Up Your Resources

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

This project runs entirely locally with pay-per-use API calls. There are no ongoing costs unless you actively run the script again.

Resources you used:

  • Local project files (agent.py, tools.py, hooks.py, main.py).
  • The anthropic Python package installed via pip.
  • The ANTHROPIC_API_KEY environment variable.
  • Your Anthropic account on console.anthropic.com.

Keep everything running

No action needed. Choose this if you plan to keep experimenting with the agent or extending it further.

  • Your project files stay on your Desktop.
  • The anthropic package remains installed for future projects.
  • Your API key stays set in your environment. You are only charged when you run the script.
  • Your Anthropic account remains active. There is no subscription fee for API access.

Pause - I'll come back to this later

Remove the API key from your active environment to avoid accidental usage. Your files and package stay in place.

  • Remove the environment variable from your current session by running:

macOS

unset ANTHROPIC_API_KEY

Windows

Remove-Item Env:ANTHROPIC_API_KEY
  • If you added the key to a shell profile file (like .bashrc or .zshrc), comment out or remove that line.
  • Your project files and the anthropic package will remain installed and ready when you come back.

Delete - I don't want to use this again

Remove all project files, uninstall the package, and clear the API key from your environment.

  • Delete your project folder from the Desktop by running:

macOS

cd ~/Desktop
ls

Find the folder that contains your project files, then delete it:

rm -rf your-project-folder-name

Windows

cd ~\Desktop
ls

Find the folder that contains your project files, then delete it:

Remove-Item -Recurse -Force your-project-folder-name
  • Uninstall the Anthropic Python package by running:
pip uninstall anthropic
  • Remove the environment variable from your session:

macOS

unset ANTHROPIC_API_KEY

If you added the key to your .bashrc or .zshrc, open the file and remove that line.

Windows

Remove-Item Env:ANTHROPIC_API_KEY

If you set it as a permanent system variable, open Settings, search for Environment Variables, and delete the ANTHROPIC_API_KEY entry.

  • To delete your Anthropic account, you need to contact Anthropic Support directly. There is no self-service deletion button in the console.

Nice Work!

Nice Work!

You just built a production-grade agentic customer service bot that autonomously orchestrates tool calls, handles errors gracefully, and enforces business rules. This is the same architecture behind every serious AI agent in production today.

Here's what you learned:

  • Built a complete agentic loop that checks stop_reason to orchestrate multi-step tool workflows until Claude signals completion.
  • Implemented structured error handling with categorized errors and automatic retry logic for transient failures.
  • Created a programmatic hook system that intercepts tool calls before execution to enforce business rules like refund thresholds.
  • Added sentiment-aware escalation that monitors customer frustration across conversation turns and automatically offers a human transfer.

Ready to quiz yourself?