All articles

What You'll Build

If you've been searching for how to build agentic workflows in Python, this is the tutorial you actually want. You're going to build a fully working restaurant order AI agent that takes customer orders in plain English, validates items against a menu, checks inventory, and confirms the transaction — all autonomously using Claude API.

By the end, you'll have a production-ready Python agent that uses Claude's tool-use feature to call real functions, handle edge cases, and loop until the job is done. No fake demos, no pseudocode — just something that actually runs.

📦 Full Source Code
The complete working code for this tutorial is broken into steps below. Each step builds on the last, so by Step 5 you'll have the entire agent assembled and ready to test. Copy each block in order and you're good to go.

Prerequisites

  • Python 3.9 or higher installed
  • An Anthropic API key (get one here)
  • Basic Python knowledge — functions, classes, dictionaries
  • anthropic SDK installed (pip install anthropic)
  • python-dotenv for managing your API key safely (pip install python-dotenv)

Step 1: Set Up Your Claude API Credentials and Environment

First, let's get your environment wired up correctly. You never want to hardcode your API key in source files — use a .env file instead.

Create a file called .env in your project root and add your key:

.env
ANTHROPIC_API_KEY=sk-ant-your-key-here

Now install the dependencies if you haven't already:

terminal
pip install anthropic python-dotenv

Create your main project file and verify the client connects correctly:

verify_connection.py
import os
from dotenv import load_dotenv
import anthropic

load_dotenv()

client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))

# Quick ping to confirm credentials work
response = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=64,
    messages=[{"role": "user", "content": "Reply with: API connected."}]
)

print(response.content[0].text)
# Expected output: API connected.

If you see API connected. printed, you're ready to build. If you get an auth error, double-check that your .env file is in the same directory you're running the script from.

Step 2: Define Tool Schemas for Order Processing

This is where things get interesting. Claude doesn't call functions directly — you describe your tools as JSON schemas, and Claude decides when and how to use them. We need three tools: validate_order, check_inventory, and confirm_order.

Here's how to define them so Claude knows exactly what each tool does and what arguments to pass:

tools.py
import os
import json
from dotenv import load_dotenv
import anthropic

load_dotenv()

# Simulated restaurant data — in production, connect to your POS or database
MENU = {
    "margherita pizza": {"price": 14.99, "category": "pizza"},
    "pepperoni pizza": {"price": 16.99, "category": "pizza"},
    "caesar salad": {"price": 9.99, "category": "salad"},
    "garlic bread": {"price": 4.99, "category": "side"},
    "tiramisu": {"price": 7.99, "category": "dessert"},
    "sparkling water": {"price": 2.99, "category": "drink"},
    "house red wine": {"price": 8.99, "category": "drink"},
}

INVENTORY = {
    "margherita pizza": 10,
    "pepperoni pizza": 8,
    "caesar salad": 15,
    "garlic bread": 20,
    "tiramisu": 5,
    "sparkling water": 50,
    "house red wine": 12,
}

# Tool schemas — these tell Claude what functions are available and how to call them
TOOLS = [
    {
        "name": "validate_order",
        "description": (
            "Validates whether the items a customer wants to order exist on the menu. "
            "Returns a list of valid items with prices and a list of any invalid items."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "items": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "List of menu item names the customer wants to order"
                }
            },
            "required": ["items"]
        }
    },
    {
        "name": "check_inventory",
        "description": (
            "Checks whether requested items are currently in stock. "
            "Returns availability status for each item."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "items": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "List of validated menu item names to check stock for"
                },
                "quantities": {
                    "type": "array",
                    "items": {"type": "integer"},
                    "description": "Quantity requested for each item (parallel array to items)"
                }
            },
            "required": ["items", "quantities"]
        }
    },
    {
        "name": "confirm_order",
        "description": (
            "Finalizes and confirms the customer's order. "
            "Calculates total, generates an order ID, and marks inventory as reserved."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "items": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Final list of confirmed item names"
                },
                "quantities": {
                    "type": "array",
                    "items": {"type": "integer"},
                    "description": "Quantity for each confirmed item"
                },
                "customer_name": {
                    "type": "string",
                    "description": "Name on the order"
                }
            },
            "required": ["items", "quantities", "customer_name"]
        }
    }
]
💡 Tip: Keep your tool descriptions precise and action-oriented. Claude reads them to decide which tool to call — vague descriptions lead to wrong tool selections or missed calls. Write them like you're explaining to a new employee.

Step 3: Build the Agent Loop with Agentic Workflow

The agent loop is the core of how autonomous AI agents Python work. You send a message, Claude responds with either text or a tool call, you execute the tool, feed the result back, and repeat until Claude gives a final text response with no more tool calls.

This is the pattern that separates a simple chatbot from a real agent. Here's the full implementation:

agent.py
import os
import json
import uuid
import random
from dotenv import load_dotenv
import anthropic

load_dotenv()

# Paste the MENU, INVENTORY, and TOOLS definitions from tools.py above this line
# (or import them: from tools import MENU, INVENTORY, TOOLS)

MENU = {
    "margherita pizza": {"price": 14.99, "category": "pizza"},
    "pepperoni pizza": {"price": 16.99, "category": "pizza"},
    "caesar salad": {"price": 9.99, "category": "salad"},
    "garlic bread": {"price": 4.99, "category": "side"},
    "tiramisu": {"price": 7.99, "category": "dessert"},
    "sparkling water": {"price": 2.99, "category": "drink"},
    "house red wine": {"price": 8.99, "category": "drink"},
}

INVENTORY = {
    "margherita pizza": 10,
    "pepperoni pizza": 8,
    "caesar salad": 15,
    "garlic bread": 20,
    "tiramisu": 5,
    "sparkling water": 50,
    "house red wine": 12,
}

TOOLS = [
    {
        "name": "validate_order",
        "description": (
            "Validates whether the items a customer wants to order exist on the menu. "
            "Returns a list of valid items with prices and a list of any invalid items."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "items": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "List of menu item names the customer wants to order"
                }
            },
            "required": ["items"]
        }
    },
    {
        "name": "check_inventory",
        "description": (
            "Checks whether requested items are currently in stock. "
            "Returns availability status for each item."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "items": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "List of validated menu item names to check stock for"
                },
                "quantities": {
                    "type": "array",
                    "items": {"type": "integer"},
                    "description": "Quantity requested for each item (parallel array to items)"
                }
            },
            "required": ["items", "quantities"]
        }
    },
    {
        "name": "confirm_order",
        "description": (
            "Finalizes and confirms the customer's order. "
            "Calculates total, generates an order ID, and marks inventory as reserved."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "items": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Final list of confirmed item names"
                },
                "quantities": {
                    "type": "array",
                    "items": {"type": "integer"},
                    "description": "Quantity for each confirmed item"
                },
                "customer_name": {
                    "type": "string",
                    "description": "Name on the order"
                }
            },
            "required": ["items", "quantities", "customer_name"]
        }
    }
]


class RestaurantOrderAgent:
    def __init__(self):
        self.client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
        self.model = "claude-sonnet-4-6"
        self.system_prompt = (
            "You are an AI order-taking assistant for Bella Napoli Restaurant. "
            "Your job is to help customers place orders. When a customer tells you what they want, "
            "always use the tools in this order: first validate_order, then check_inventory, "
            "then confirm_order. Do not skip steps. Be friendly and confirm the final order summary "
            "clearly after confirming."
        )

    def process_tool_call(self, tool_name: str, tool_input: dict) -> str:
        """Routes tool calls to the correct handler and returns a JSON string result."""
        if tool_name == "validate_order":
            return self._validate_order(tool_input["items"])
        elif tool_name == "check_inventory":
            return self._check_inventory(tool_input["items"], tool_input["quantities"])
        elif tool_name == "confirm_order":
            return self._confirm_order(
                tool_input["items"],
                tool_input["quantities"],
                tool_input["customer_name"]
            )
        else:
            return json.dumps({"error": f"Unknown tool: {tool_name}"})

    def run(self, user_message: str) -> str:
        """
        Agentic loop: sends message, handles tool calls in a loop,
        and returns the final text response from Claude.
        """
        messages = [{"role": "user", "content": user_message}]

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

        # Loop until Claude stops calling tools and returns a plain text response
        while True:
            response = self.client.messages.create(
                model=self.model,
                max_tokens=1024,
                system=self.system_prompt,
                tools=TOOLS,
                messages=messages
            )

            print(f"\n[Agent] Stop reason: {response.stop_reason}")

            # If Claude is done, extract and return the final text
            if response.stop_reason == "end_turn":
                for block in response.content:
                    if hasattr(block, "text"):
                        return block.text

            # Claude wants to call one or more tools
            if response.stop_reason == "tool_use":
                # Add Claude's response (which includes tool_use blocks) to message history
                messages.append({"role": "assistant", "content": response.content})

                # Process every tool call Claude requested
                tool_results = []
                for block in response.content:
                    if block.type == "tool_use":
                        print(f"\n[Tool Call] {block.name}")
                        print(f"[Input]     {json.dumps(block.input, indent=2)}")

                        result = self.process_tool_call(block.name, block.input)

                        print(f"[Result]    {result}")

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

                # Feed all tool results back to Claude in one user turn
                messages.append({"role": "user", "content": tool_results})

            else:
                # Unexpected stop reason — break to avoid infinite loop
                break

        return "Order could not be completed. Please try again."

Step 4: Implement Order Validation and Confirmation Logic

Now let's wire up the actual tool functions that do the real work. These are the Python functions Claude's tool calls will route to. In a real deployment, these would connect to your POS system or database — here we're using the in-memory dictionaries we defined earlier.

agent.py (continued — add these methods inside RestaurantOrderAgent)
    def _validate_order(self, items: list) -> str:
        """Checks each item against the menu and returns valid/invalid splits."""
        valid_items = {}
        invalid_items = []

        for item in items:
            # Normalize to lowercase for consistent matching
            normalized = item.lower().strip()
            if normalized in MENU:
                valid_items[normalized] = MENU[normalized]["price"]
            else:
                invalid_items.append(item)

        result = {
            "valid_items": valid_items,
            "invalid_items": invalid_items,
            "menu_available": list(MENU.keys())  # Let Claude suggest alternatives
        }
        return json.dumps(result)

    def _check_inventory(self, items: list, quantities: list) -> str:
        """Checks current stock levels for each validated item."""
        availability = {}

        for item, qty in zip(items, quantities):
            normalized = item.lower().strip()
            stock = INVENTORY.get(normalized, 0)
            availability[normalized] = {
                "requested": qty,
                "in_stock": stock,
                "available": stock >= qty
            }

        result = {
            "availability": availability,
            "all_available": all(v["available"] for v in availability.values())
        }
        return json.dumps(result)

    def _confirm_order(self, items: list, quantities: list, customer_name: str) -> str:
        """Finalizes the order, calculates total, deducts inventory, generates order ID."""
        order_lines = []
        subtotal = 0.0

        for item, qty in zip(items, quantities):
            normalized = item.lower().strip()
            price = MENU[normalized]["price"]
            line_total = price * qty
            subtotal += line_total

            # Deduct from inventory to simulate reservation
            INVENTORY[normalized] = max(0, INVENTORY[normalized] - qty)

            order_lines.append({
                "item": normalized,
                "quantity": qty,
                "unit_price": price,
                "line_total": round(line_total, 2)
            })

        tax_rate = 0.07  # 7% Florida state sales tax
        tax = round(subtotal * tax_rate, 2)
        total = round(subtotal + tax, 2)
        order_id = f"ORD-{random.randint(10000, 99999)}"

        result = {
            "order_id": order_id,
            "customer_name": customer_name,
            "order_lines": order_lines,
            "subtotal": round(subtotal, 2),
            "tax": tax,
            "total": total,
            "status": "confirmed",
            "estimated_wait": "20-25 minutes"
        }
        return json.dumps(result)

Step 5: Test with Real Order Scenarios

Let's put it all together with a main entry point that runs a few realistic test cases. This is where you'll see the full agentic loop in action.

main.py
import os
from dotenv import load_dotenv

# Import the completed agent (assumes agent.py has all methods defined)
from agent import RestaurantOrderAgent

load_dotenv()

def main():
    agent = RestaurantOrderAgent()

    # Test case 1: Standard valid order
    response1 = agent.run(
        "Hi, I'm Maria. I'd like to order 1 margherita pizza, "
        "2 caesar salads, and 2 sparkling waters please."
    )
    print(f"\n{'='*60}")
    print("FINAL RESPONSE:")
    print(response1)

    print("\n" + "="*60 + "\n")

    # Test case 2: Order with an invalid item to test validation
    response2 = agent.run(
        "I'm Table 4, name is James. Can I get a pepperoni pizza "
        "and a lobster bisque?"
    )
    print(f"\n{'='*60}")
    print("FINAL RESPONSE:")
    print(response2)

if __name__ == "__main__":
    main()

Here's what the actual output looks like when you run it:

sample output
============================================================
Customer: Hi, I'm Maria. I'd like to order 1 margherita pizza,
2 caesar salads, and 2 sparkling waters please.
============================================================

[Agent] Stop reason: tool_use

[Tool Call] validate_order
[Input]     {
  "items": ["margherita pizza", "caesar salad", "sparkling water"]
}
[Result]    {"valid_items": {"margherita pizza": 14.99, "caesar salad": 9.99,
             "sparkling water": 2.99}, "invalid_items": [], "menu_available": [...]}

[Agent] Stop reason: tool_use

[Tool Call] check_inventory
[Input]     {
  "items": ["margherita pizza", "caesar salad", "sparkling water"],
  "quantities": [1, 2, 2]
}
[Result]    {"availability": {"margherita pizza": {"requested": 1, "in_stock": 10,
             "available": true}, "caesar salad": {"requested": 2, "in_stock": 15,
             "available": true}, "sparkling water": {"requested": 2, "in_stock": 50,
             "available": true}}, "all_available": true}

[Agent] Stop reason: tool_use

[Tool Call] confirm_order
[Input]     {
  "items": ["margherita pizza", "caesar salad", "sparkling water"],
  "quantities": [1, 2, 2],
  "customer_name": "Maria"
}
[Result]    {"order_id": "ORD-47821", "customer_name": "Maria",
             "order_lines": [{"item": "margherita pizza", "quantity": 1,
             "unit_price": 14.99, "line_total": 14.99}, {"item": "caesar salad",
             "quantity": 2, "unit_price": 9.99, "line_total": 19.98},
             {"item": "sparkling water", "quantity": 2, "unit_price": 2.99,
             "line_total": 5.98}], "subtotal": 40.95, "tax": 2.87,
             "total": 43.82, "status": "confirmed",
             "estimated_wait": "20-25 minutes"}

[Agent] Stop reason: end_turn

============================================================
FINAL RESPONSE:
Thank you, Maria! Your order has been confirmed. Here's your summary:

Order #ORD-47821
- 1x Margherita Pizza — $14.99
- 2x Caesar Salad — $19.98
- 2x Sparkling Water — $5.98

Subtotal