How to Build a Reliable AI Assistant with the Claude API — Opportunihub
Course Remote

How to Build a Reliable AI Assistant with the Claude API

Chidozie Managwu · Remote

At a glance

Type
Course
Organisation
Chidozie Managwu
Location
Remote
Work mode
Remote
Deadline
Rolling / not stated
Posted
29 Sep 2026

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" &gt;&gt; .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"&lt;conversation&gt;{transcript}&lt;/conversation&gt;" ), }], ) 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 = """ &lt;customer_reviews&gt; The product is comfortable, but the available colours are limited. Customers also describe it as durable. &lt;/customer_reviews&gt; &lt;sales_data&gt; January: 120 units February: 150 units March: 98 units &lt;/sales_data&gt; &lt;task&gt; Compare the reviews with the sales data. Identify possible relationships and state uncertainty. &lt;/task&gt; """ </code></pre> <p>Here, <code>&lt;customer_reviews&gt;</code> identifies reference material, <code>&lt;sales_data&gt;</code> identifies the data, and <code>&lt;task&gt;</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"&lt;ticket&gt;{ticket}&lt;/ticket&gt;\n" "&lt;task&gt;Extract the problem and desired outcome.&lt;/task&gt;" ) draft = ask( f"&lt;details&gt;{details}&lt;/details&gt;\n" "&lt;task&gt;Draft a concise support reply.&lt;/task&gt;" ) review = ask( f"&lt;draft&gt;{draft}&lt;/draft&gt;\n" "&lt;task&gt;List unsupported promises, or say OK.&lt;/task&gt;" ) 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"&lt;ticket&gt;{ticket}&lt;/ticket&gt;\n" "&lt;task&gt;Draft a support reply.&lt;/task&gt;" ) issues = ask( f"&lt;policy&gt;{policy}&lt;/policy&gt;\n" f"&lt;draft&gt;{draft}&lt;/draft&gt;\n" "&lt;task&gt;List unsupported claims.&lt;/task&gt;" ) return ask( f"&lt;draft&gt;{draft}&lt;/draft&gt;\n" f"&lt;issues&gt;{issues}&lt;/issues&gt;\n" "&lt;task&gt;Rewrite the final reply.&lt;/task&gt;" ) </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"&lt;ticket&gt;{ticket}&lt;/ticket&gt;\n" "&lt;task&gt;Summarise in one sentence.&lt;/task&gt;", max_tokens=100, ) with ThreadPoolExecutor(max_workers=3) as pool: summaries = list(pool.map(summarise, tickets)) digest = ask( "&lt;summaries&gt;\n" + "\n".join(summaries) + "\n&lt;/summaries&gt;\n" "&lt;task&gt;Summarise today's support themes.&lt;/task&gt;" ) </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"&lt;ticket&gt;{ticket}&lt;/ticket&gt;\n" "&lt;task&gt;Return exactly refund, delivery, or general.&lt;/task&gt;", 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"&lt;ticket&gt;{ticket}&lt;/ticket&gt;\n" "&lt;task&gt;Write a support reply.&lt;/task&gt;" ) for _ in range(rounds): review = ask( f"&lt;reply&gt;{reply}&lt;/reply&gt;\n" "&lt;task&gt;List accuracy or tone problems, or say PASS.&lt;/task&gt;" ) if review.strip().upper() == "PASS": break reply = ask( f"&lt;reply&gt;{reply}&lt;/reply&gt;\n" f"&lt;review&gt;{review}&lt;/review&gt;\n" "&lt;task&gt;Rewrite the reply.&lt;/task&gt;" ) 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>

How to apply

  1. 1 Read the full details above and confirm you meet the eligibility criteria.
  2. 2 Prepare your documents — an updated CV, and any cover letter, proposal or certificates required.
  3. 3 Click Apply on official site to complete your application on Chidozie Managwu’s official page.
  4. 4 Submit as early as possible — many close once filled.
Apply on official site

Sourced from freecodecamp. Always verify details on the official website. Opportunihub never charges you to apply.

Frequently asked questions

How do I apply for How to Build a Reliable AI Assistant with the Claude API?

Review the full details and eligibility on this page, prepare your documents, then use the “Apply on official site” button to complete your application on Chidozie Managwu’s official page.

Is this opportunity remote or location-based?

This opportunity is remote-friendly and open to applicants who can work from anywhere.

Is How to Build a Reliable AI Assistant with the Claude API free to apply for?

Opportunihub lists this Course for free. Legitimate Courses do not ask for payment to apply — never pay a fee to submit an application.