If you've been searching for how to build a chatbot with Claude, you've probably found a lot of vague overviews that stop short of actual working code. This tutorial is different — I'm going to show you exactly how to build a production-ready customer support agent using the Claude API and Python, with every line of code included.
The agent will handle real customer inquiries, create support tickets, look up existing ones, and maintain full conversation history across multiple turns. By the end of this, you'll have something you can actually deploy.
What You'll Build
You're building a customer support AI agent that can have real back-and-forth conversations with customers. It uses Claude's tool use feature to create and look up support tickets in a simulated database — the same pattern you'd use with a real CRM or helpdesk system.
The agent remembers the full conversation history, knows when to call a tool versus when to just respond, and handles multi-turn dialogue naturally. You can drop this pattern into a Flask API, a Slack bot, or a web chat widget with minimal changes.
All the code you need is written out step by step in the sections below. Each snippet builds on the last, and the final section puts it all together into one working file you can run immediately. No placeholder logic — everything here actually runs.
Prerequisites
- Python 3.9 or higher installed
- An Anthropic API key (get one at console.anthropic.com)
- The
anthropicPython SDK installed (pip install anthropic) - Basic familiarity with Python classes and dictionaries
- About 20 minutes and a terminal open
Step 1: Set Up Your Claude API Credentials
First, install the Anthropic SDK if you haven't already. Run pip install anthropic in your terminal. Once that's done, store your API key as an environment variable — never hardcode it in your source files.
On Mac/Linux, add this to your ~/.bashrc or ~/.zshrc: export ANTHROPIC_API_KEY="your-key-here". On Windows, set it through System Properties or use a .env file with the python-dotenv package.
import os
import anthropic
# Quick sanity check — make sure the key is loaded before building anything
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
raise ValueError("ANTHROPIC_API_KEY environment variable not set.")
client = anthropic.Anthropic(api_key=api_key)
# Send a minimal test message to confirm the connection works
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=64,
messages=[{"role": "user", "content": "Say 'API connection confirmed' and nothing else."}]
)
print(response.content[0].text)
# Output: API connection confirmed
Run that file. If you see "API connection confirmed", you're good to move on. If you get an authentication error, double-check that the environment variable is exported in the same shell session you're running Python from.
Step 2: Define Customer Support Tools and Functions
This is where the agent gets its capabilities. Claude's tool use lets you define functions that the model can decide to call when it needs to take an action — like creating a ticket or looking one up. You define what the tool does and what parameters it needs, and Claude handles the decision of when to use it.
I'm using an in-memory dictionary as the "database" here so the example runs without any external dependencies. In a real deployment, you'd replace those functions with calls to Zendesk, Freshdesk, your own API, or a SQL database.
tools.pyimport json
import uuid
from datetime import datetime
# In-memory ticket store — swap this out for a real database in production
TICKET_DB = {}
def create_support_ticket(customer_name: str, issue_description: str, priority: str = "medium") -> dict:
"""Creates a new support ticket and returns the ticket details."""
ticket_id = f"TKT-{str(uuid.uuid4())[:8].upper()}"
ticket = {
"ticket_id": ticket_id,
"customer_name": customer_name,
"issue_description": issue_description,
"priority": priority,
"status": "open",
"created_at": datetime.now().isoformat(),
}
TICKET_DB[ticket_id] = ticket
return ticket
def lookup_support_ticket(ticket_id: str) -> dict:
"""Looks up an existing ticket by ID. Returns ticket details or an error."""
ticket = TICKET_DB.get(ticket_id)
if not ticket:
return {"error": f"No ticket found with ID {ticket_id}"}
return ticket
def process_tool_call(tool_name: str, tool_input: dict) -> str:
"""Routes tool calls from Claude to the correct Python function."""
if tool_name == "create_support_ticket":
result = create_support_ticket(
customer_name=tool_input["customer_name"],
issue_description=tool_input["issue_description"],
priority=tool_input.get("priority", "medium"),
)
elif tool_name == "lookup_support_ticket":
result = lookup_support_ticket(ticket_id=tool_input["ticket_id"])
else:
result = {"error": f"Unknown tool: {tool_name}"}
# Claude expects tool results as strings
return json.dumps(result)
# Tool definitions formatted for the Anthropic API
TOOL_DEFINITIONS = [
{
"name": "create_support_ticket",
"description": (
"Creates a new customer support ticket in the system. "
"Use this when a customer reports a problem or needs assistance "
"that requires follow-up from the support team."
),
"input_schema": {
"type": "object",
"properties": {
"customer_name": {
"type": "string",
"description": "The full name of the customer submitting the ticket.",
},
"issue_description": {
"type": "string",
"description": "A clear description of the customer's issue or request.",
},
"priority": {
"type": "string",
"enum": ["low", "medium", "high", "urgent"],
"description": "Priority level of the ticket based on the severity of the issue.",
},
},
"required": ["customer_name", "issue_description"],
},
},
{
"name": "lookup_support_ticket",
"description": (
"Looks up an existing support ticket by its ticket ID. "
"Use this when a customer asks about the status of a previous request."
),
"input_schema": {
"type": "object",
"properties": {
"ticket_id": {
"type": "string",
"description": "The ticket ID in the format TKT-XXXXXXXX.",
}
},
"required": ["ticket_id"],
},
},
]
The
description field in each tool definition is not just documentation — Claude actually reads it to decide when and how to use the tool. Write it like you're explaining the function to a smart colleague, not writing a docstring for a linter.
Step 3: Build the Agent Loop with Message History
The agent loop is the core of this whole thing. It handles sending messages to Claude, detecting when Claude wants to use a tool, executing that tool, returning the result, and continuing the conversation. Most tutorials skip this part or show pseudocode — I'm not going to do that.
The loop runs until Claude produces a final end_turn response with no more tool calls. That's the signal that it's done reasoning and has a response ready for the user.
import os
import anthropic
from tools import TOOL_DEFINITIONS, process_tool_call
class CustomerSupportAgent:
"""
A customer support AI agent powered by Claude with tool use.
Maintains full conversation history across multiple turns.
"""
def __init__(self):
self.client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
self.model = "claude-sonnet-4-6"
self.conversation_history = []
self.system_prompt = (
"You are a helpful customer support agent for Acme Corp. "
"You assist customers with their issues, create support tickets when needed, "
"and look up existing tickets when customers ask for status updates. "
"Always be polite, clear, and concise. When you create a ticket, confirm the "
"ticket ID with the customer so they can reference it later."
)
def _run_agentic_loop(self, user_message: str) -> str:
"""
Adds the user message to history, then runs the agentic loop until
Claude produces a final response with no pending tool calls.
Returns the final text response as a string.
"""
# Append the new user message to the running conversation history
self.conversation_history.append({
"role": "user",
"content": user_message
})
while True:
response = self.client.messages.create(
model=self.model,
max_tokens=1024,
system=self.system_prompt,
tools=TOOL_DEFINITIONS,
messages=self.conversation_history,
)
# Claude signals it's done with end_turn and no tool_use blocks
if response.stop_reason == "end_turn":
# Extract the text from the final response content
final_text = ""
for block in response.content:
if hasattr(block, "text"):
final_text += block.text
# Store Claude's final response in history for next turn
self.conversation_history.append({
"role": "assistant",
"content": response.content
})
return final_text
# Claude wants to use a tool — handle all tool_use blocks in this response
if response.stop_reason == "tool_use":
# Add Claude's response (including tool call blocks) to history
self.conversation_history.append({
"role": "assistant",
"content": response.content
})
# Build the tool_result blocks for every tool call in this response
tool_results = []
for block in response.content:
if block.type == "tool_use":
tool_result = process_tool_call(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": tool_result,
})
# Return all tool results to Claude in a single user message
self.conversation_history.append({
"role": "user",
"content": tool_results
})
# Loop again — Claude will now process the tool results
def chat(self, user_message: str) -> str:
"""Public method to send a message and get a response."""
print(f"\nCustomer: {user_message}")
response = self._run_agentic_loop(user_message)
print(f"Agent: {response}")
return response
def reset(self):
"""Clears conversation history to start a fresh session."""
self.conversation_history = []
Step 4: Handle Multi-Turn Conversations
Now let's put the agent to work with a realistic multi-turn conversation. The key thing here is that the conversation_history list grows with every exchange — Claude always sees the full context of what's been said, which is what makes it feel coherent instead of forgetful.
Run this as your main entry point. It demonstrates the agent creating a ticket in one turn, then the customer asking about it in the next turn — exactly the kind of workflow a real support interaction follows.
main.pyfrom agent import CustomerSupportAgent
def run_demo():
agent = CustomerSupportAgent()
print("=" * 60)
print("Customer Support Agent Demo")
print("=" * 60)
# Turn 1: Customer reports a problem
agent.chat("Hi, my name is Maria Lopez and my order #98234 never arrived. It's been 2 weeks.")
# Turn 2: Customer asks for ticket confirmation details
agent.chat("Can you tell me what priority level you set for my ticket?")
# Turn 3: Customer asks to look up the ticket by ID
# (The agent will extract the ticket ID from conversation history)
agent.chat("What's the current status of my ticket? Can you look it up?")
# Turn 4: Customer has a second issue in the same session
agent.chat(
"Also, I was charged twice for the same order. "
"My name is still Maria Lopez. Can you open another ticket for that?"
)
print("\n" + "=" * 60)
print(f"Total messages in history: {len(agent.conversation_history)}")
print("=" * 60)
if __name__ == "__main__":
run_demo()
Here's what the actual output looks like when you run this. The ticket IDs will be different each time since they're generated with UUID, but the structure will match:
sample_output.txt============================================================ Customer Support Agent Demo ============================================================ Customer: Hi, my name is Maria Lopez and my order #98234 never arrived. It's been 2 weeks. Agent: I'm sorry to hear your order hasn't arrived, Maria! That's definitely frustrating, especially after two weeks. I've gone ahead and created a support ticket for you: - Ticket ID: TKT-A3F92B1C - Issue: Order #98234 not delivered after 2 weeks - Priority: High - Status: Open Please save your ticket ID (TKT-A3F92B1C) — our team will follow up within 24 hours. Is there anything else I can help you with? Customer: Can you tell me what priority level you set for my ticket? Agent: I set your ticket to **High** priority given that it's been two weeks without delivery. That means our team will treat it as an urgent follow-up. Is there anything else you'd like to know? Customer: What's the current status of my ticket? Can you look it up? Agent: I just looked up your ticket TKT-A3F92B1C. Here are the current details: - Ticket ID: TKT-A3F92B1C - Customer: Maria Lopez - Issue: Order #98234 not delivered after 2 weeks - Priority: High - Status: Open - Created: 2026-04-19T14:22:37.104821 The ticket is still open and in the queue for our team. You should hear back within 24 hours. Anything else I can help with? Customer: Also, I was charged twice for the same order. My name is still Maria Lopez. Can you open another ticket for that? Agent: Of course, Maria. I've opened a second ticket for the billing issue: - Ticket ID: TKT-D71E04A9 - Issue: Double charge on the same order - Priority: High - Status: Open You now have two open tickets with us. Our billing team will review the duplicate charge and reach out to you directly. Is there anything else I can do for you today? ============================================================ Total messages in history: 8 ============================================================
How It Works
When you call agent.chat(), the message gets appended to conversation_history and sent to Claude along with the tool definitions. Claude reads everything — the system prompt, the full conversation so far, and the available tools — and decides what to do next.
If Claude needs to create a ticket, it returns a tool_use block instead of a text response. The agent loop catches that, runs the actual Python function, and sends the result back to Claude as a tool_result. Claude then writes a natural language response based on what the function returned.
The whole conversation history is stored in plain Python lists of dictionaries. Every request to the API includes the complete history, which is why the agent remembers the ticket ID it created three messages ago. That stateful loop is what separates an AI agent from a one-shot chatbot.
Common Errors and Fixes
anthropic.AuthenticationError: 401 UnauthorizedWhy it happens: Your API key isn't loaded in the current environment, or you have a typo in the variable name.
Fix: Run
echo $ANTHROPIC_API_KEY in your terminal. If it prints nothing, the variable isn't exported. Run export ANTHROPIC_API_KEY="sk-ant-..." in the same shell session before running Python. Don't restart your terminal after exporting or you'll need to export again unless it's in your shell profile.
anthropic.BadRequestError: messages: roles must alternate between "user" and "assistant"Why it happens: You're appending two user messages or two assistant messages in a row to
conversation_history. This usually happens when you're returning tool results incorrectly — the tool result message needs the "role": "user" key.Fix: Make sure every
tool_result block is wrapped in a message with "role": "user", exactly as shown in the _run_agentic_loop method above. Print conversation_history to inspect the alternating pattern if you're unsure where the mismatch is.
KeyError: 'text' or AttributeError: 'ToolUseBlock' object has no attribute 'text'Why it happens: You're trying to access
.text on a response content block that's a ToolUseBlock, not a TextBlock. This happens when you iterate over response.content without checking the block type first.Fix: Always check
block.type == "text" or use hasattr(block, "text") before accessing the text attribute, exactly as shown in the final text extraction inside the end_turn branch of the loop.
Next Steps
This is a solid foundation — here are four practical ways to extend it into something production-grade.
- Connect a real helpdesk: Replace the
TICKET_DBdictionary with actual API calls to Zendesk, Freshdesk, or HubSpot Service Hub. The tool functions are already isolated, so it's a clean swap. - Add a web API layer: Wrap the agent in a FastAPI app with a
POST /chatendpoint. Store conversation history in Redis keyed by session ID so multiple users can have independent conversations simultaneously. - Stream responses: Use
client.messages.stream()instead ofclient.messages.create()for a typewriter effect in your UI. The Anthropic SDK supports streaming natively and it makes a huge difference in perceived responsiveness. - Add more tools: Extend
TOOL_DEFINITIONSwith tools likeupdate_ticket_status,escalate_to_human, orsend_email_confirmation. The agentic loop already handles multiple tool calls per turn, so you just add the definition and the handler function.
FAQ
How do I use Claude API with Python for beginners?
Install the anthropic package with pip install anthropic, set your API key as an environment variable, and create a client with anthropic.Anthropic(). From there, client.messages.create() is your main entry point — pass a model name, a max_tokens limit, and a list of messages. The full setup walkthrough is in Step 1 above.
What is Claude tool use and how does it work?
Tool use lets you give Claude the ability to call external functions — like querying a database or hitting an API — instead of just generating text. You define tools as JSON schemas describing what the function does and what parameters it takes. When Claude decides a tool is needed, it returns a tool_use block with the tool name and arguments. Your code runs the actual function, sends the result back, and Claude continues from there.