About this course
<p>Large language models can answer questions, summarise documents, write code, and interact with external systems. But building a reliable AI application requires more than sending a prompt and displaying the response.</p>
<p>A production-ready application must manage conversation history, provide relevant context, use tools safely, handle different response types, and evaluate whether the generated output is useful.</p>
<p>In this tutorial, we’ll build <strong>ShopHelper</strong>, a customer-support assistant for an imaginary online shop. By the end, ShopHelper will be able to:</p>
<ul>
<li><p>Answer general questions in a consistent tone</p>
</li>
<li><p>Remember what a customer said earlier</p>
</li>
<li><p>Look up order statuses by calling a function in your code</p>
</li>
<li><p>Handle Claude’s multi-block responses safely</p>
</li>
<li><p>Process support tickets using workflows</p>
</li>
<li><p>Evaluate whether prompt changes improve results</p>
</li>
</ul>
<p>Each section adds one piece, so you can follow along in your own editor.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-the-project-and-keep-your-api-key-secure">How to Set Up the Project and Keep Your API Key Secure</a></p>
</li>
<li><p><a href="#heading-how-to-make-your-first-request">How to Make Your First Request</a></p>
</li>
<li><p><a href="#heading-how-to-manage-conversation-history">How to Manage Conversation History</a></p>
</li>
<li><p><a href="#heading-how-to-structure-prompts-with-clear-boundaries">How to Structure Prompts with Clear Boundaries</a></p>
</li>
<li><p><a href="#heading-how-to-use-a-system-prompt">How to Use a System Prompt</a></p>
</li>
<li><p><a href="#heading-how-to-add-tools">How to Add Tools</a></p>
</li>
<li><p><a href="#heading-how-to-handle-a-tool-use-response">How to Handle a Tool-Use Response</a></p>
</li>
<li><p><a href="#heading-claude-responses-can-contain-multiple-blocks">Claude Responses Can Contain Multiple Blocks</a></p>
</li>
<li><p><a href="#heading-workflows-vs-agents">Workflows vs Agents</a></p>
</li>
<li><p><a href="#heading-chaining-parallelisation-routing-and-evaluator-optimizer">Chaining, Parallelisation, Routing and Evaluator-Optimizer</a></p>
</li>
<li><p><a href="#heading-how-to-evaluate-prompt-quality">How to Evaluate Prompt Quality</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You should have:</p>
<ul>
<li><p>Basic Python knowledge</p>
</li>
<li><p>Python 3.9 or later</p>
</li>
<li><p>An Anthropic API key</p>
</li>
<li><p>Familiarity with functions and JSON</p>
</li>
</ul>
<h2 id="heading-how-to-set-up-the-project-and-keep-your-api-key-secure">How to Set Up the Project and Keep Your API Key Secure</h2>
<p>Create a virtual environment and install the Anthropic Python SDK:</p>
<pre><code class="language-bash">python -m venv .venv
source .venv/bin/activate
pip install anthropic python-dotenv
</code></pre>
<p>On Windows:</p>
<pre><code class="language-bash">.venv\Scripts\activate
</code></pre>
<p>Create a <code>.env</code> file:</p>
<pre><code class="language-text">ANTHROPIC_API_KEY=your_api_key_here
</code></pre>
<p>An API key is a secret credential. Never place it in browser JavaScript, mobile-app code, or client-side configuration. Never commit it to a repository:</p>
<pre><code class="language-bash">echo ".env" >> .gitignore
</code></pre>
<p>If you add a web interface later, keep the key on your backend:</p>
<pre><code class="language-text">Browser → Your backend → Claude API
</code></pre>
<p>Create <code>app.py</code>:</p>
<pre><code class="language-python">import os
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv()
MODEL = "claude-sonnet-5"
client = Anthropic(
api_key=os.environ["ANTHROPIC_API_KEY"]
)
</code></pre>
<p><code>load_dotenv()</code> loads the value from <code>.env</code>. The <code>MODEL</code> constant means you only need to change the model name in one place. Confirm that the model identifier is available to your account before running the example.</p>
<h2 id="heading-how-to-make-your-first-request">How to Make Your First Request</h2>
<pre><code class="language-python">response = client.messages.create(
model=MODEL,
max_tokens=500,
messages=[
{
"role": "user",
"content": "Explain what an API is in simple terms."
}
],
)
answer = "".join(
block.text
for block in response.content
if block.type == "text"
)
print(answer)
</code></pre>
<p>A request contains three important parts:</p>
<ul>
<li><p><code>model</code> selects the Claude model that handles the request. Models can differ in capability, speed, and cost.</p>
</li>
<li><p><code>max_tokens</code> limits the maximum amount of text Claude can generate. A smaller value can reduce latency, but Claude may stop before completing its answer.</p>
</li>
<li><p><code>messages</code> contains the conversation. Each message has a <code>role</code> and <code>content</code>. The role is usually <code>user</code> or <code>assistant</code>.</p>
</li>
</ul>
<p>For example, a one-off request contains one user message. A multi-turn conversation contains earlier user and assistant messages.</p>
<p>Claude returns <code>response.content</code>, which is a list of typed content blocks. Common blocks include:</p>
<table>
<thead>
<tr>
<th>Block type</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td><code>text</code></td>
<td>Generated text</td>
</tr>
<tr>
<td><code>tool_use</code></td>
<td>A request for your application to call a tool</td>
</tr>
<tr>
<td><code>thinking</code></td>
<td>Reasoning content when enabled</td>
</tr>
</tbody></table>
<p>The example collects text blocks instead of assuming <code>response.content[0]</code> is always text.</p>
<p>You can inspect usage information for monitoring:</p>
<pre><code class="language-python">print(response.usage.input_tokens)
print(response.usage.output_tokens)
</code></pre>
<h2 id="heading-how-to-manage-conversation-history">How to Manage Conversation History</h2>
<p>Claude doesn't automatically remember separate API requests. Send relevant history with every request:</p>
<pre><code class="language-python">messages = [
{
"role": "user",
"content": "What is your returns policy?"
},
{
"role": "assistant",
"content": "Items can be returned within 30 days."
},
{
"role": "user",
"content": "How long do I have?"
},
]
response = client.messages.create(
model=MODEL,
max_tokens=300,
messages=messages,
)
</code></pre>
<p>The assistant message records Claude’s earlier answer, allowing the final question to be interpreted in context.</p>
<p>A simple chat function can maintain the history:</p>
<pre><code class="language-python">def chat(history, user_text):
history.append({
"role": "user",
"content": user_text,
})
response = client.messages.create(
model=MODEL,
max_tokens=500,
messages=history,
)
reply = "".join(
block.text
for block in response.content
if block.type == "text"
)
history.append({
"role": "assistant",
"content": reply,
})
return reply
history = []
print(chat(history, "What is your returns policy?"))
print(chat(history, "How long do I have?"))
</code></pre>
<p>Each call adds the new user message, sends the complete history, and stores Claude’s response for the next turn. In production, store histories by customer or session ID.</p>
<h3 id="heading-how-to-manage-history-as-it-grows">How to Manage History as it Grows</h3>
<p>Unlimited history increases input size and may make it harder for Claude to focus. One option is to retain only recent messages:</p>
<pre><code class="language-python">def trim_history(history, max_messages=10):
trimmed = history[-max_messages:]
while trimmed and trimmed[0]["role"] != "user":
trimmed.pop(0)
return trimmed
</code></pre>
<p>Another option is to summarise older turns while keeping recent messages:</p>
<pre><code class="language-python">def summarise_history(history, keep_last=6):
old = history[:-keep_last]
recent = history[-keep_last:]
transcript = "\n".join(
f"{message['role']}: {message['content']}"
for message in old
)
response = client.messages.create(
model=MODEL,
max_tokens=250,
messages=[{
"role": "user",
"content": (
"Summarise this conversation in under 100 words. "
"Keep order numbers and unresolved issues.\n\n"
f"<conversation>{transcript}</conversation>"
),
}],
)
summary = "".join(
block.text
for block in response.content
if block.type == "text"
)
return summary, recent
</code></pre>
<p>Keep the summary as separate application state and include it as context in the next request. Don't insert it as an additional user message before <code>recent</code>, because that can create invalid consecutive user messages.</p>
<p>Sensitive information should also be redacted before storage or transmission:</p>
<pre><code class="language-python">import re
def redact(text):
return re.sub(
r"\b(?:\d[ -]?){13,16}\b",
"[REDACTED CARD]",
text,
)
</code></pre>
<h2 id="heading-how-to-structure-prompts-with-clear-boundaries">How to Structure Prompts with Clear Boundaries</h2>
<p>XML-style tags are ordinary text, not special API commands. They make each part of a prompt explicit:</p>
<pre><code class="language-python">prompt = """
<customer_reviews>
The product is comfortable, but the available colours are limited.
Customers also describe it as durable.
</customer_reviews>
<sales_data>
January: 120 units
February: 150 units
March: 98 units
</sales_data>
<task>
Compare the reviews with the sales data.
Identify possible relationships and state uncertainty.
</task>
"""
</code></pre>
<p>Here, <code><customer_reviews></code> identifies reference material, <code><sales_data></code> identifies the data, and <code><task></code> identifies the instruction. Use similar boundaries for policies, user-generated content, examples, and output requirements.</p>
<h2 id="heading-how-to-use-a-system-prompt">How to Use a System Prompt</h2>
<p>A system prompt defines ShopHelper’s general behaviour:</p>
<pre><code class="language-python">system_prompt = """
You are ShopHelper, a friendly customer-support assistant.
Keep answers concise and clear.
Do not invent prices, policies, or order details.
If information is missing, ask for it.
"""
</code></pre>
<p>Pass it separately from the conversation:</p>
<pre><code class="language-python">response = client.messages.create(
model=MODEL,
max_tokens=500,
system=system_prompt,
messages=[
{"role": "user", "content": "Where is my order?"}
],
)
</code></pre>
<p>Because the customer didn't provide an order number, ShopHelper should ask for one instead of guessing.</p>
<h2 id="heading-how-to-add-tools">How to Add Tools</h2>
<p>Claude can't directly access your database. A tool gives it a structured way to request information from your application:</p>
<pre><code class="language-python">def get_order_status(order_id):
orders = {
"ORD-1001": "shipped",
"ORD-1002": "processing",
}
return {
"order_id": order_id,
"status": orders.get(order_id, "not_found"),
}
</code></pre>
<p>The function accepts an order ID, looks it up, and returns predictable data. In production, the dictionary would be replaced by a database query. Claude doesn't execute the function. Your application does.</p>
<p>Describe the function with a schema:</p>
<pre><code class="language-python">tools = [{
"name": "get_order_status",
"description": "Get the current status of a customer order.",
"input_schema": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "An order ID such as ORD-1001."
}
},
"required": ["order_id"],
},
}]
</code></pre>
<p>Claude may return a <code>tool_use</code> block instead of a final answer:</p>
<pre><code class="language-text">type="tool_use"
id="toolu_example"
name="get_order_status"
input={"order_id": "ORD-1001"}
</code></pre>
<p>The <code>name</code> identifies the function, <code>input</code> contains its arguments, and <code>id</code> is needed when returning the result. A <code>stop_reason</code> of <code>"tool_use"</code> means your application should handle the request before asking Claude to continue.</p>
<h2 id="heading-how-to-handle-a-tool-use-response">How to Handle a Tool-Use Response</h2>
<p>A tool-use response is a response containing the <code>tool_use</code> block described above.</p>
<p>Validate the tool name, arguments, and user permissions before execution:</p>
<pre><code class="language-python">import re
ORDER_ID_PATTERN = re.compile(r"^ORD-\d{4}$")
def validate_tool_request(name, tool_input, current_user):
if name != "get_order_status":
return False, "Unknown tool"
order_id = tool_input.get("order_id")
if not isinstance(order_id, str):
return False, "order_id must be a string"
if not ORDER_ID_PATTERN.fullmatch(order_id):
return False, "Invalid order ID format"
if order_id not in current_user["order_ids"]:
return False, "The customer cannot access this order"
return True, None
</code></pre>
<p>A complete loop can then validate and execute the request:</p>
<pre><code class="language-python">def run_conversation(user_text, current_user):
messages = [{"role": "user", "content": user_text}]
while True:
response = client.messages.create(
model=MODEL,
max_tokens=500,
system=system_prompt,
tools=tools,
messages=messages,
)
if response.stop_reason != "tool_use":
return "".join(
block.text
for block in response.content
if block.type == "text"
)
messages.append({
"role": "assistant",
"content": response.content,
})
results = []
for block in response.content:
if block.type != "tool_use":
continue
valid, error = validate_tool_request(
block.name,
block.input,
current_user,
)
if valid:
result = get_order_status(block.input["order_id"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(result),
})
else:
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": error,
"is_error": True,
})
messages.append({
"role": "user",
"content": results,
})
</code></pre>
<p>The <code>tool_use_id</code> connects the result to the original request. The application remains responsible for authorisation and execution.</p>
<h2 id="heading-claude-responses-can-contain-multiple-blocks">Claude Responses Can Contain Multiple Blocks</h2>
<p>This assumption is fragile:</p>
<pre><code class="language-python">answer = response.content[0].text
</code></pre>
<p>It assumes that the first block exists and is text. Instead, inspect each block:</p>
<pre><code class="language-python">for block in response.content:
if block.type == "text":
print(block.text)
elif block.type == "tool_use":
print("Validate and execute:", block.name)
elif block.type == "thinking":
continue
else:
print("Unhandled block type:", block.type)
</code></pre>
<p>ShopHelper displays text, validates and executes approved tool requests, doesn't display internal thinking, and logs unknown block types.</p>
<h2 id="heading-workflows-vs-agents">Workflows vs Agents</h2>
<p>A workflow follows a predefined sequence:</p>
<pre><code class="language-text">Receive ticket
↓
Extract details
↓
Draft reply
↓
Review reply
</code></pre>
<pre><code class="language-python">def ask(prompt, max_tokens=500):
response = client.messages.create(
model=MODEL,
max_tokens=max_tokens,
messages=[{"role": "user", "content": prompt}],
)
return "".join(
block.text
for block in response.content
if block.type == "text"
)
def handle_ticket_workflow(ticket):
details = ask(
f"<ticket>{ticket}</ticket>\n"
"<task>Extract the problem and desired outcome.</task>"
)
draft = ask(
f"<details>{details}</details>\n"
"<task>Draft a concise support reply.</task>"
)
review = ask(
f"<draft>{draft}</draft>\n"
"<task>List unsupported promises, or say OK.</task>"
)
return draft, review
</code></pre>
<p>An agent is more flexible: Claude decides whether to use a tool and what to do next. Agents still require validation and a maximum step count. The <code>run_conversation()</code> function above can be reused inside an agent loop.</p>
<p>Use workflows when the steps are known and repeatability matters. Use agents when the next action depends on the current result.</p>
<h2 id="heading-chaining-parallelisation-routing-and-evaluator-optimizer">Chaining, Parallelisation, Routing, and Evaluator-Optimizer</h2>
<p><strong>Chaining</strong> passes each result to the next stage:</p>
<pre><code class="language-python">def chained_reply(ticket, policy):
draft = ask(
f"<ticket>{ticket}</ticket>\n"
"<task>Draft a support reply.</task>"
)
issues = ask(
f"<policy>{policy}</policy>\n"
f"<draft>{draft}</draft>\n"
"<task>List unsupported claims.</task>"
)
return ask(
f"<draft>{draft}</draft>\n"
f"<issues>{issues}</issues>\n"
"<task>Rewrite the final reply.</task>"
)
</code></pre>
<p><strong>Parallelisation</strong> runs independent tasks concurrently:</p>
<pre><code class="language-python">from concurrent.futures import ThreadPoolExecutor
tickets = [
"My headphones arrived broken.",
"I was charged twice.",
"How do I change my address?",
]
def summarise(ticket):
return ask(
f"<ticket>{ticket}</ticket>\n"
"<task>Summarise in one sentence.</task>",
max_tokens=100,
)
with ThreadPoolExecutor(max_workers=3) as pool:
summaries = list(pool.map(summarise, tickets))
digest = ask(
"<summaries>\n"
+ "\n".join(summaries)
+ "\n</summaries>\n"
"<task>Summarise today's support themes.</task>"
)
</code></pre>
<p><strong>Routing</strong> classifies a request before selecting a specialised workflow:</p>
<pre><code class="language-python">def route(ticket):
label = ask(
f"<ticket>{ticket}</ticket>\n"
"<task>Return exactly refund, delivery, or general.</task>",
max_tokens=10,
).strip().lower()
return label if label in {"refund", "delivery", "general"} else "general"
</code></pre>
<p><strong>Evaluator-optimizer</strong> generates, reviews, and revises an answer:</p>
<pre><code class="language-python">def improve_reply(ticket, rounds=2):
reply = ask(
f"<ticket>{ticket}</ticket>\n"
"<task>Write a support reply.</task>"
)
for _ in range(rounds):
review = ask(
f"<reply>{reply}</reply>\n"
"<task>List accuracy or tone problems, or say PASS.</task>"
)
if review.strip().upper() == "PASS":
break
reply = ask(
f"<reply>{reply}</reply>\n"
f"<review>{review}</review>\n"
"<task>Rewrite the reply.</task>"
)
return reply
</code></pre>
<p>Use chaining for dependent stages, parallelisation for independent work, routing for specialised paths, and evaluator-optimizer loops when additional quality justifies extra API calls.</p>
<h2 id="heading-how-to-evaluate-prompt-quality">How to Evaluate Prompt Quality</h2>
<p>Use representative test cases:</p>
<pre><code class="language-python">test_cases = [
{
"ticket": "I want a refund for broken headphones.",
"expected": "refund",
},
{
"ticket": "Where is ORD-1002?",
"expected": "delivery",
},
{
"ticket": "Do you sell gift cards?",
"expected": "general",
},
]
</code></pre>
<p>These cases cover different request types. Run the same cases after changing the system prompt, examples, model, token limit, or routing instructions:</p>
<pre><code class="language-python">def evaluate(route_fn, cases):
passed = 0
for case in cases:
result = route_fn(case["ticket"])
if result == case["expected"]:
passed += 1
else:
print("Failed:", case["ticket"], result)
score = passed / len(cases)
print(f"{passed}/{len(cases)} passed")
return score
</code></pre>
<p>Use code-based graders for labels and JSON. Use human or model-based graders for tone, accuracy, and helpfulness.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Building with the Claude API involves more than writing prompts. A reliable application needs structured context, managed conversation state, validated tool execution, deliberate response handling, suitable workflows, and repeatable evaluation.</p>
<p>The goal isn't to find one perfect prompt. It's to build a system around Claude that provides the right context, limits unsafe actions, handles uncertainty, and measures whether changes improve the result.</p>