Module 07 of 11 / 6 lessons / 12 practice exercises / 45 links

Building the POC with LLMs

This module is where the design becomes working software. You will call a model from code and validate its output, give it tools, ground it in client documents with RAG, decide when an agent is worth it, connect tools through MCP, and use AI coding assistants to build quickly without losing control of quality.

New to a word? Check Start here: the basics first. How to use this module. Read each lesson top to bottom: the diagram first, then the explanation and the worked example. Use "Find more here" to go deeper with docs, videos and courses. Then do the practice. Tick "I did this" to track your progress in this browser.

  1. 7.1Prompts and structured output in code
  2. 7.2Tool use and function calling
  3. 7.3RAG in practice
  4. 7.4Agents: when to use them and when not to
  5. 7.5Connecting tools with MCP
  6. 7.6Building fast with Claude Code, Cursor or Copilot

Lesson 7.1

Prompts and structured output in code

You will be able to call the Claude API from Python with a system prompt, get JSON back in a fixed shape, and validate it before using it.

Why this matters on a client engagement

A chat window is fine for exploring an idea, but a proof of concept has to run the same prompt on hundreds of real items and feed the results into other systems. That means calling the model from code, controlling its instructions, and getting output that a program can read safely. Almost every POC an FDE builds starts with this pattern.

From input to trusted data

  1. Raw inputEmail, PDF text or form
  2. Build requestSystem prompt, tagged input, schema
  3. Call the APIModel id and max_tokens set in config
  4. Check stop reasonend_turn, max_tokens or refusal
  5. Parse into a typePydantic model or json.loads
  6. Apply business rulesRanges, formats, allowed values
  7. Store or send to reviewFailures become test cases
Each step either passes data on or sends it to review; nothing skips validation.

The short version

You send the model a request over HTTPS and get a response back, just like any other web API. The official Python package, anthropic, wraps this for you. A request has a model id, a maximum number of output tokens, an optional system prompt and a list of messages. The response contains content blocks, usually text, plus a stop reason and token usage.

For a program to use the answer, ask for JSON that follows a schema you define, then validate it in your code. If it does not pass, you reject it or send it for review. Never pass unchecked model output into a database or another system.

Setting up

Install the package with pip install anthropic, and also pydantic for validation. Create an API key in the Claude Console and store it in an environment variable called ANTHROPIC_API_KEY. The client reads it automatically, so the key never appears in your code. On a client project, keys come from their secrets manager, not from a file in the repository.

Choose a model id from the models overview page and keep it in one place in your code or configuration. The example in this lesson uses claude-sonnet-5-5, a current model at the time of writing. Model ids change over time, so check the page rather than copying an id from an old project.

System prompt and messages

The system prompt sets the role, rules and context for the whole conversation: what the task is, what to do when information is missing, and what tone or format to use. User messages carry the specific input for this request. Keep the stable instructions in the system prompt and the changing data in the user message.

Put input data inside clear tags such as <invoice>...</invoice>. This helps the model tell your instructions apart from the content it is processing, and it is also a first defence against instructions hidden in that content. Be explicit about edge cases: 'If a field is missing, return an empty string' is far better than hoping the model guesses what you want.

Treat prompts like code. Keep them in files under version control, change one thing at a time, and rerun your test examples after each change.

Getting JSON in a fixed shape

There are two levels of control. The simple level is asking for JSON in the prompt and parsing the reply. That works most of the time but can fail on edge cases, for example extra text around the JSON. The stronger level is structured outputs: you pass a JSON schema in the output_config parameter, and the API constrains the response to match that schema. Check the structured outputs page for which models support it and which schema features are allowed; the schema in the example sets additionalProperties to false and lists every required field.

The Python SDK also offers client.messages.parse, which takes a Pydantic model directly and returns the parsed object. Both routes are shown in the official structured outputs page. The example below uses the raw schema so you can see exactly what is sent.

python
import anthropic
from pydantic import BaseModel, ValidationError

class Invoice(BaseModel):
    supplier: str
    invoice_number: str
    total: float
    currency: str

FIELDS = {"supplier": "string", "invoice_number": "string", "total": "number", "currency": "string"}
SCHEMA = {
    "type": "object",
    "properties": {name: {"type": kind} for name, kind in FIELDS.items()},
    "required": list(FIELDS),
    "additionalProperties": False,
}
SYSTEM = ("You extract fields from supplier invoices. Use only facts in the text. "
          "If a field is missing, return an empty string for it.")

client = anthropic.Anthropic()  # reads the ANTHROPIC_API_KEY environment variable
text = "Invoice INV-2291 from Northwind Supplies. Total due: 1,240.50 EUR."
response = client.messages.create(
    model="claude-sonnet-5-5",
    max_tokens=1024,
    system=SYSTEM,
    messages=[{"role": "user", "content": f"<invoice>{text}</invoice>"}],
    output_config={"format": {"type": "json_schema", "schema": SCHEMA}},
)
if response.stop_reason != "end_turn":
    raise RuntimeError(f"Unexpected stop: {response.stop_reason}")
raw = next(block.text for block in response.content if block.type == "text")
try:
    invoice = Invoice.model_validate_json(raw)
    print(invoice)
except ValidationError as err:
    print("Rejected:", err)

Validating the output

A schema guarantees shape, not truth. The model can still return a total of 0, a supplier name it made up, or a currency code that does not exist. So validate in two steps. First, parse into a typed object, which the code does with Pydantic's model_validate_json. Second, apply business rules: totals must be positive, currency must be in an allowed list, the invoice number must match the client's format, and values should appear in the source text.

Also check the stop reason. end_turn means the model finished normally. max_tokens means it ran out of room and the output may be cut off. refusal means it declined. Handle each one on purpose instead of assuming success. Record failures with the input that caused them; they become test cases for your next prompt change.

From one call to a batch run

A POC usually processes a folder of real items, not one string. Wrap the call in a function that takes one item and returns either a validated object or an error record, then loop over the items and write the results to a CSV or SQLite table with the item id, the output, the stop reason, the validation result and the token usage from the response. That table is your first evaluation report: you can sort by failures, count how many passed, and work out cost per item.

Add basic resilience. The SDK retries some temporary errors for you, but you should still catch exceptions per item so one bad file does not stop the run, and set a sensible timeout. For large runs that do not need instant answers, the batch processing API accepts many requests at once at a lower price and returns the results later.

Worked example

Worked example: extracting invoice fields

The finance team wants supplier, invoice number, total and currency from invoice emails. The code in the section above defines the fields twice: as a Pydantic model for validation and as a JSON schema for the API. The system prompt tells the model to use only facts in the text and what to do when a field is missing. The invoice text goes inside <invoice> tags.

For the sample text the expected result is supplier 'Northwind Supplies', invoice number 'INV-2291', total 1240.5 and currency 'EUR'. Notice the input wrote the total as '1,240.50'; the schema forces a number, so your code receives 1240.5 and does not have to strip commas.

To make this production-ready you would add business rules after parsing, for example checking that the invoice number appears in the original text. That single check catches a whole class of invented values.

  • Run it on 20 real samples, not one.
  • Record each failure with its input.
  • Change the prompt or schema, then rerun all 20.
  • Only then try a smaller, cheaper model on the same 20.

Start practicing

Practice 1

Design a schema and validator without an API key

30 min

Goal: Practice the validation half of the pattern, which you can run today for free.

  1. Install pydantic with pip install pydantic.
  2. Copy the Invoice model from this lesson into a file.
  3. Write three JSON strings by hand: one valid, one with total as text, one missing currency.
  4. Call Invoice.model_validate_json on each and print the result or the error.
  5. Add a rule that rejects a total of zero or less, using a Pydantic field validator, and test it.

Done when: Your script accepts the valid string and prints a clear error for each invalid one.

Practice 2

Iterate a system prompt in a free chat

20 min

Goal: See how instructions and tags change the output.

  1. In a free LLM chat, paste the system prompt from this lesson as your first message, then an invoice text inside <invoice> tags.
  2. Ask for JSON with the four fields and check the result.
  3. Try an invoice with no invoice number and see what happens.
  4. Tighten the instruction for missing fields and try again.

Done when: You have a prompt that returns valid JSON with an empty string for the missing field.

Common mistakes

  • Putting the API key in the code or a committed file. Use an environment variable or a secrets manager.
  • Trusting JSON because it parsed. Shape is not truth; apply business rules too.
  • Ignoring stop_reason. A max_tokens stop can give you half an answer.
  • Changing several things in a prompt at once. You will not know which change helped or hurt.
  • Testing on one example. Use a small set of real, messy samples from the start.

Check yourself

  1. What goes in the system prompt and what goes in the user message?
  2. What does structured output guarantee, and what does it not guarantee?
  3. Name three stop reasons and what your code should do for each.
  4. Why should the input data be placed inside tags?

Lesson 7.2

Tool use and function calling

You will understand how a model asks your code to run a function, how the request and result loop works, and how to build it with one tool.

Why this matters on a client engagement

On its own a model only knows what is in its training data and the prompt. Client value usually needs live data and actions: the status of an order, the balance on an account, the next free appointment. Tool use is how a model gets those safely, with your code deciding what is allowed. It is the foundation of every assistant and agent you will build.

One tool call, end to end

  1. User: Asks: where is order A-1002?
  2. Your code: Sends message plus tool list
  3. Claude API: Replies tool_use: get_order_status
  4. Your code: Checks input and permissions
  5. Order system: Returns: waiting for stock
  6. Your code: Sends tool_result with same id
  7. Claude API: Writes the final answer
  8. User: Reads the answer
Time runs top to bottom; only your code touches the order system.

The short version

You describe one or more tools to the model: a name, a plain-language description and a JSON schema for the inputs. When the model decides a tool would help, it does not run anything. It replies with a request: 'call get_order_status with order_id A-1002'. Your code runs the function, sends the result back, and the model uses it to continue.

This is also called function calling. The key idea is that the model proposes and your code disposes. You stay in control of what actually runs, with what permissions, and what the model gets to see.

Defining a tool

A tool definition has three parts. The name is a short identifier such as get_order_status. The description says what the tool does, when to use it and what the inputs mean. The input_schema is a JSON schema listing the parameters, their types and which are required.

The description matters more than people expect. The model decides whether and how to call a tool mostly from that text. 'Gets order' is weak. 'Look up the delivery status of one customer order by its id, for example A-1001' is clear. Say what the tool does not do as well, if confusion is likely.

Keep tools small and specific. One tool per clear action is easier for the model to use correctly and easier for you to secure and test than one large tool with many modes.

The loop, step by step

The loop is the core of tool use. Get it right once and you will reuse it everywhere.

  • Send the user's message plus the tool definitions to the API.
  • Read the response. If stop_reason is 'tool_use', it contains one or more tool_use blocks, each with an id, a tool name and an input object.
  • Add the assistant's full response to the message history unchanged.
  • For each tool_use block, run your matching function with the given input.
  • Send a new user message containing a tool_result block for each call, with the matching tool_use_id and the output as content.
  • Call the API again with the updated history. Repeat until stop_reason is not 'tool_use', then read the final text.

Handling results and errors

Tool results are plain text or structured content. Return what the model needs to answer, not a full database row with internal fields. If a tool fails, still send a tool_result, mark it with is_error set to true, and describe the problem in words, such as 'no order with that id'. The model can then ask the user for a correct id instead of inventing an answer.

Validate the input the model sends before running anything. The schema helps, and strict tool use can enforce it, but your function should still check values: is this order id in the expected format, and does the current user have the right to see it? The model choosing a tool is not the same as the user being allowed to use it.

The model may ask for several tools in one response. The loop in the example handles that by collecting a result for every tool_use block before calling the API again.

Choosing what to expose

Start with read-only tools. A tool that looks things up cannot do much damage if the model calls it at the wrong time. Add tools that change data only when needed, and put a human approval step in front of them, as covered in the security lesson.

The SDK also provides a tool runner helper that runs this loop for you. It is worth using once you understand the loop. Build it by hand first, so that you know what is happening when something goes wrong.

Controlling when tools are used

By default the model decides on each turn whether to call a tool or answer directly. That is usually what you want. The tool_choice parameter lets you change it: you can require that some tool is used, force one named tool, or turn tools off for a turn. Forcing a tool is handy when the tool is really a way to get structured data out, for example a record_invoice tool whose input schema is the shape you want.

Your system prompt also steers tool use. A line such as 'Always look up the order before answering questions about delivery' makes the model check live data instead of answering from general knowledge. If the model asks for a value the user has not given, such as a missing order id, it may ask the user, or it may guess. Check for guessed inputs in your function, and tell the model in the prompt to ask when a required value is missing.

Every tool definition is sent with every request, so it adds input tokens. Keep the tool list to what the task needs.

Worked example

Worked example: an order status tool

A retailer wants its support assistant to answer 'where is my order?' from real data. You define one tool, get_order_status, backed here by a small dictionary standing in for the order system. The code below sends the question with the tool list, runs the loop and prints the final answer.

Trace one run. The first response has stop_reason 'tool_use' and a block asking for get_order_status with order_id 'A-1002'. Your code appends that response, runs the function, which returns 'waiting for stock', and sends a tool_result with the same id. The second response has stop_reason 'end_turn' and text such as 'Order A-1002 is waiting for stock.' The model never touched the order system; your function did.

python
import anthropic

ORDERS = {"A-1001": "shipped on 14 Oct", "A-1002": "waiting for stock"}

def get_order_status(order_id: str) -> str:
    return ORDERS.get(order_id, "no order with that id")

tools = [{
    "name": "get_order_status",
    "description": "Look up the delivery status of one customer order by its id, e.g. A-1001.",
    "input_schema": {
        "type": "object",
        "properties": {"order_id": {"type": "string", "description": "Order id like A-1001"}},
        "required": ["order_id"],
    },
}]
client = anthropic.Anthropic()
messages = [{"role": "user", "content": "Where is my order A-1002?"}]

while True:
    response = client.messages.create(
        model="claude-sonnet-5-5", max_tokens=1024, tools=tools, messages=messages)
    if response.stop_reason != "tool_use":
        break
    messages.append({"role": "assistant", "content": response.content})
    results = []
    for block in response.content:
        if block.type == "tool_use":
            output = get_order_status(**block.input)
            results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
    messages.append({"role": "user", "content": results})

print(next(b.text for b in response.content if b.type == "text"))

Start practicing

Practice 1

Design three tools on paper

25 min

Goal: Practice writing tool definitions a model can use correctly.

  1. Pick a business assistant, such as an HR helper for leave requests.
  2. Write three tool definitions as JSON: name, description and input_schema with required fields.
  3. Mark each tool as read or write, and write who must approve each write tool.
  4. Write two user questions and, for each, which tool calls you expect and in what order.
  5. Ask a free LLM chat to review your descriptions for anything ambiguous and fix what it finds.

Done when: You have three valid JSON tool definitions and an expected call sequence for two questions.

Practice 2

Run the loop with a fake model

30 min

Goal: Understand the message history without needing an API key.

  1. Write a Python function fake_model(messages) that returns a dict like the API response: first a tool_use block, then a text block once it sees a tool_result.
  2. Copy the while loop from this lesson and replace the API call with fake_model.
  3. Print the messages list after each turn.
  4. Check that each tool_result has the same id as its tool_use block.

Done when: Your script prints the full history and ends with the final text, with ids that match.

Common mistakes

  • Vague tool descriptions. The model picks and fills tools from the description, so make it specific.
  • Dropping the assistant's tool_use response from the history. The next request must include it, followed by the matching tool_result.
  • Running tool inputs without checks. Validate format and the user's permissions in your function.
  • Returning huge raw records as results. Send only what the model needs; it saves tokens and reduces leakage.
  • Failing silently. Return an error result with a clear message so the model can recover.

Check yourself

  1. What are the three parts of a tool definition?
  2. What does stop_reason 'tool_use' tell your code to do?
  3. How does a tool_result get matched to the right tool call?
  4. Why does the model choosing a tool not mean the user is allowed to use it?

Lesson 7.3

RAG in practice

You will be able to build a simple retrieval-augmented generation pipeline: split documents, search them, put the best passages in the prompt and get answers with citations.

Why this matters on a client engagement

Clients want answers from their own documents: policies, contracts, manuals, past tickets. A model does not know these, and the full set rarely fits in one prompt. Retrieval-augmented generation (RAG) finds the few passages that matter and gives only those to the model. It is one of the most common POC patterns, and getting the retrieval step right usually matters more than the choice of model.

The RAG pipeline

  1. Load documentsPolicies, contracts, manuals
  2. Split into chunksKeep source, page and access metadata
  3. Index the chunksKeyword index, vectors, or both
  4. Search with the questionFilter by user permissions first
  5. Build the promptNumbered sources plus the question
  6. Model answers with citationsOr says it does not know
  7. Show answer and sourcesUser can click to check
The first three steps run when documents change; the rest run for every question.

The short version

RAG has two phases. Before any question is asked, you prepare the documents: split them into small pieces called chunks and store them in a searchable index. When a question arrives, you search the index for the most relevant chunks, put them in the prompt with the question, and ask the model to answer using only those chunks and to cite them.

An everyday comparison: an open-book exam. The model is the student, the index is the textbook with a good table of contents, and citations are the page numbers that let the examiner check the answer.

Chunking

A chunk is a piece of a document small enough to be relevant to one idea and large enough to make sense alone. Common starting points are a few hundred words, or splitting on headings and paragraphs. Many pipelines add a small overlap between neighbouring chunks so a sentence that crosses a boundary is not lost.

Keep metadata with every chunk: the source file, section heading, page number, date and who is allowed to see it. You need this for citations and for permission filtering. Structure-aware splitting, for example one chunk per contract clause, usually beats splitting at a fixed word count.

Searching: keywords, embeddings or both

Keyword search finds chunks that share words with the question. It is simple, fast, needs no extra service and is excellent for exact terms like product codes and policy names. SQLite's built-in FTS5 extension, used in the code below, ranks results with the BM25 method.

Embedding search turns each chunk and the question into a list of numbers, a vector, that represents meaning. Chunks whose vectors are close to the question's vector are about the same thing even if they use different words, for example 'annual leave' and 'vacation days'. You need an embedding model and a place to store vectors, such as a vector database or a Postgres extension. Anthropic does not offer its own embedding model; its documentation points to providers such as Voyage AI.

Many production systems use both and merge the results. For a first POC, keyword search is often good enough to prove value and much easier to debug.

python
import sqlite3

def chunk(text: str, size: int = 20, overlap: int = 5) -> list[str]:
    words = text.split()
    step = size - overlap
    return [" ".join(words[i:i + size]) for i in range(0, max(len(words) - overlap, 1), step)]

policy = {
    "travel-policy.md": "Economy class is required for flights under six hours. "
        "Business class needs written approval from a director. Hotel stays are "
        "capped at the city rate published by finance each January.",
    "expenses-faq.md": "Receipts are required for every expense over 25 EUR. "
        "Claims must be submitted within 30 days of the trip end date.",
}
db = sqlite3.connect(":memory:")
db.execute("CREATE VIRTUAL TABLE chunks USING fts5(source, body)")
for source, text in policy.items():
    for piece in chunk(text):
        db.execute("INSERT INTO chunks VALUES (?, ?)", (source, piece))

def search(question: str, k: int = 3) -> list[tuple[str, str]]:
    terms = " OR ".join(w.strip("?.,").lower() for w in question.split() if len(w) > 3)
    sql = "SELECT source, body FROM chunks WHERE chunks MATCH ? ORDER BY rank LIMIT ?"
    return db.execute(sql, (terms, k)).fetchall()

for source, body in search("Do I need approval for business class flights?"):
    print(source, "|", body[:70])

Putting retrieved text in the prompt

Place each retrieved chunk in its own tagged block with an id and its source. Tell the model to answer only from the sources, to cite the id after each claim, and to say it does not know when the sources do not contain the answer. That last instruction is what stops the model filling gaps with plausible guesses.

Send a handful of good chunks rather than many weak ones. Too much loosely related text can distract the model and costs more. The Claude API also has a citations feature that returns exact cited passages from documents you pass in; it is worth reading about once your basic pipeline works.

Checking quality

Test retrieval and generation separately. For retrieval, write 20 to 50 real questions with the chunk that should answer each one, and measure how often that chunk appears in the top results. For generation, check that answers are correct, that citations point to chunks that actually support the claim, and that the system says 'I do not know' when it should.

When answers are wrong, look at what was retrieved first. Most RAG problems are retrieval problems: the right chunk was never found, was split in half, or was ranked below irrelevant ones.

Keeping the index fresh and safe

Documents change. Policies get a new version, contracts are amended, old manuals are withdrawn. Plan how the index is updated: a nightly job that re-reads changed files, removes chunks from deleted files and records the document version on each chunk. Show the document date next to each citation so users can see if an answer comes from an old version.

Retrieved text is untrusted input, just like an email. A document in the index could contain hidden instructions, so the security controls from the previous module apply here too: mark sources as data, keep tools limited and check outputs. Also apply each user's document permissions at search time. A RAG system that returns a confidential board paper to anyone who asks the right question is a data leak with a friendly interface.

Finally, keep the original documents reachable from the citation, so a user can open the source and read the full context in one click.

Worked example

Worked example: a travel policy question

An employee asks: 'Can I fly business class to Madrid?' The keyword search in the section above finds the travel policy chunk that mentions business class and director approval. The function below turns the retrieved chunks into a prompt with numbered sources and clear rules.

A good answer would be: 'Business class needs written approval from a director [1].' It is short, it cites its source, and it does not invent a rule about destinations that is not in the policy. If the employee asked about train travel, which the sources do not cover, the right answer is that the policy provided does not say.

python
def build_prompt(question: str, hits: list[tuple[str, str]]) -> str:
    sources = "\n".join(
        f'<source id="{i}" file="{name}">\n{text}\n</source>'
        for i, (name, text) in enumerate(hits, start=1)
    )
    return (
        "Answer the question using only the sources below.\n"
        "After each sentence, cite the source id in square brackets, like [1].\n"
        "If the sources do not contain the answer, say you do not know.\n\n"
        f"<sources>\n{sources}\n</sources>\n\n<question>{question}</question>"
    )

hits = [
    ("travel-policy.md", "Business class needs written approval from a director."),
    ("expenses-faq.md", "Receipts are required for every expense over 25 EUR."),
]
print(build_prompt("Can I fly business class to Madrid?", hits))

Start practicing

Practice 1

Build keyword RAG on your own documents

45 min

Goal: Run retrieval end to end with only Python and SQLite.

  1. Collect three to five short public documents, such as policy pages or product FAQs, and save them as text files.
  2. Adapt the chunk and search code from this lesson to load your files.
  3. Write five questions, and for each write which file and passage should answer it.
  4. Run the search for each question and record whether the right chunk is in the top three.
  5. Build a prompt with build_prompt for one question and paste it into a free LLM chat to see the cited answer.

Done when: You have a small table of five questions with a hit or miss for each, and one cited answer.

Practice 2

Tune the chunk size

20 min

Goal: See how chunking changes retrieval.

  1. Rerun your five questions with chunk sizes of 20, 60 and 150 words.
  2. Record the hit rate for each size.
  3. Write one sentence on which size worked best for your documents and why.

Done when: You have three hit rates and a reasoned choice of chunk size.

Common mistakes

  • Blaming the model first. Check what was retrieved before changing the prompt or the model.
  • Dropping metadata when chunking. Without source and page you cannot cite, and without access data you cannot filter.
  • Filtering by permission after retrieval and generation. Filter before chunks go into the prompt.
  • Stuffing the prompt with many weak chunks. A few strong ones work better and cost less.
  • No 'I do not know' instruction. Without it, the model may fill gaps with guesses.

Check yourself

  1. What are the two phases of a RAG system?
  2. When is keyword search a better choice than embedding search?
  3. What metadata should every chunk carry, and why?
  4. How would you test retrieval separately from generation?

Lesson 7.4

Agents: when to use them and when not to

You will be able to tell a workflow from an agent, explain the agent loop, and add guardrails that keep an agent safe and bounded.

Why this matters on a client engagement

Clients often ask for 'an agent' because the word is everywhere. Sometimes an agent is the right tool. Often a fixed workflow with one or two model calls is cheaper, faster, easier to test and just as useful. An FDE must be able to make that call, explain it, and when an agent is justified, build one with limits that a client's risk team will accept.

The agent loop

  1. Read goal and historyIncludes all earlier tool results
  2. Decide next stepAnswer, or ask for a tool
  3. Check guardrailsTurn limit, allowed tool, approval
  4. Run the toolYour code, with scoped permissions
  5. Add result to historyShort, clear, marked if an error

Then back to step 1: Read goal and history

The loop repeats until the goal is met, a person is needed, or a limit is reached.

The short version

A workflow is a path you design in code: step one, then step two, maybe a branch. The model fills in parts, such as classifying or drafting, but your code decides the order. An agent is a model that decides its own next step in a loop: it looks at the goal and what has happened so far, picks a tool, sees the result, and repeats until it thinks the job is done.

Anthropic's guide 'Building effective agents' makes this same distinction and recommends starting with the simplest approach that works, adding agent behaviour only when it clearly helps. Agents trade cost, speed and predictability for flexibility.

Workflows first

Several workflow patterns cover most business tasks. Prompt chaining splits a job into fixed steps, such as extract, then check, then draft. Routing classifies the input and sends it down the right path. Parallel calls run several checks at once and combine them. An evaluator step asks a second call to review the first one's output.

If you can draw the steps on a whiteboard in advance, build a workflow. It is easier to test because each step has a clear input and output, and it is easier to explain to the client and their auditors.

When an agent fits

Use an agent when the number and order of steps truly cannot be known in advance and depends on what is found along the way. Examples: investigating why a shipment is late across several systems, researching a question across many documents, or fixing a failing test in a codebase. Coding assistants are a good example of agents that work well, because the environment gives clear feedback: the tests pass or fail.

Good agent tasks also have a clear finish line and a way to check the result. Without those, an agent can wander, spend a lot and still not be clearly right.

Weigh the costs honestly. Each turn of an agent is another model call, so an agent task may cost many times more than a single call and take much longer. Its path also changes from run to run, which makes testing harder: you test outcomes on a set of cases rather than checking each step. Explain these trade-offs to the client before you recommend one.

A useful middle ground is a workflow with one agent step inside it. The fixed parts, such as fetching the case and writing the result, stay predictable, and only the investigation in the middle is left to the agent, with its own turn limit.

The agent loop

Underneath, an agent is the tool-use loop from the previous lesson, run with more tools and less fixed structure. The model receives the goal and the tools. It thinks about the next step, asks for a tool, your code runs it, the result goes back, and the model decides again. The loop ends when the model gives a final answer, when it asks for a person, or when a limit is hit.

Context grows with every turn, because each tool call and result is added to the history. Long-running agents need care with what goes into context: keep tool results short and summarize old steps. Anthropic's article on context engineering covers this in depth.

The system prompt for an agent should describe the goal, the tools and when to use each, what a finished result looks like, and when to stop and ask a person. Good tool results help too: a clear message such as 'no order with that id, check the format' lets the agent correct itself, while a raw stack trace often sends it in circles. You do not need a framework to build this loop; the code below is plain Python on top of the API, which keeps every step visible.

Guardrails

Guardrails are the rules around the loop that keep the agent safe even when the model makes a poor choice. Put them in code, not only in the prompt.

Many teams also add an evaluator or a person at the end to check the final result before it is used.

  • A maximum number of turns and a maximum cost per task.
  • A small set of tools, read-only by default.
  • Human approval for any action that sends, pays, deletes or changes access.
  • Clear error results so the agent can recover instead of looping.
  • An audit log of every tool call, input and output.
  • A defined hand-over to a person when the agent is stuck.
python
import anthropic

MAX_TURNS = 8                       # hard stop so a confused agent cannot loop forever
NEEDS_APPROVAL = {"issue_refund"}   # actions a person must confirm

def run_tool(name: str, args: dict) -> tuple[str, bool]:
    if name == "lookup_order":
        return f"Order {args['order_id']}: paid 49.00, delivered damaged", False
    if name == "issue_refund":
        answer = input(f"Approve refund {args}? (yes/no) ")
        if answer.strip().lower() != "yes":
            return "A person declined this refund. Do not retry.", True
        return f"Refund of {args['amount']} issued", False
    return f"Unknown tool {name}", True

def run_agent(client, task: str, tools: list) -> str:
    messages = [{"role": "user", "content": task}]
    for turn in range(MAX_TURNS):
        response = client.messages.create(
            model="claude-sonnet-5-5", max_tokens=2048, tools=tools, messages=messages)
        if response.stop_reason != "tool_use":
            return next((b.text for b in response.content if b.type == "text"), "")
        messages.append({"role": "assistant", "content": response.content})
        results = []
        for block in response.content:
            if block.type == "tool_use":
                output, failed = run_tool(block.name, block.input)
                print(f"turn {turn}: {block.name} -> {output}")   # audit trail
                results.append({"type": "tool_result", "tool_use_id": block.id,
                                "content": output, "is_error": failed})
        messages.append({"role": "user", "content": results})
    return "Stopped: turn limit reached, handing over to a person."

Worked example

Worked example: damaged order refunds

A retailer wants help with 'my order arrived damaged' emails. First ask: can this be a workflow? Often yes: look up the order, check the rules (delivered within 30 days, value under a limit), draft a reply and a refund for approval. That is a fixed path and should be built as one.

Suppose the client's cases are messier: some need the courier's tracking history, some need a photo check, some involve several orders. Then an agent with a few tools is reasonable. The code in the guardrails section shows the shape: a turn limit of eight, a lookup tool that runs freely, a refund tool that always asks a person, error results marked with is_error, and a printed audit line for every call. If the limit is reached, the task goes to a person instead of continuing.

  • Write the fixed workflow first and see how many cases it handles.
  • Collect the cases it cannot handle.
  • Only if those cases matter, add an agent for them, with the guardrails above.
  • Measure cost, time and accuracy for both paths on the same test cases.

Start practicing

Practice 1

Workflow or agent?

25 min

Goal: Practice making and defending the choice.

  1. Write down five tasks from a business you know, such as expense approval, onboarding a supplier or answering a policy question.
  2. For each, try to draw the steps in advance on paper.
  3. Mark each task as workflow or agent, with one sentence of reason.
  4. For any agent task, write its finish line and how you would check the result.
  5. List the guardrails you would need for that agent.

Done when: You have five tasks classified with reasons, and at least one agent design with a finish line and guardrails.

Practice 2

Watch an agent work

20 min

Goal: See the loop and its limits in a real tool.

  1. Watch the Anthropic video 'Tips for building AI agents' linked below.
  2. In a free LLM chat with web search, give a small research task and watch the steps it takes.
  3. Write down each step it took, where it went off track, and what limit would have stopped it sooner.

Done when: You have a written step list and at least one guardrail you would add.

Common mistakes

  • Building an agent for a task with fixed steps. A workflow is cheaper, faster and easier to test.
  • Guardrails only in the prompt. Turn limits, tool permissions and approvals belong in code.
  • No finish line. Without one, the agent cannot know when to stop and you cannot know if it succeeded.
  • Letting context grow without limit. Long raw tool outputs slow the agent and raise cost.
  • Not logging each step. When an agent goes wrong, the log is the only way to understand why.

Check yourself

  1. In one sentence each, define a workflow and an agent.
  2. Give two signs that a task needs an agent rather than a workflow.
  3. List four guardrails and say where each one is enforced.
  4. Why does context grow in an agent loop, and what can you do about it?

Lesson 7.5

Connecting tools with MCP

You will understand what the Model Context Protocol is, how servers and clients fit together, and how to build and test a small MCP server in Python.

Why this matters on a client engagement

In the tool-use lesson you wrote tools inside one application. On a client engagement the same tools, such as 'search our policies' or 'look up a customer', are often wanted in several places: a custom app, Claude Code, Claude Desktop, or other AI tools. The Model Context Protocol (MCP) lets you build a connector once and use it from any application that supports the protocol. Knowing MCP lets an FDE reuse work across tools and plug into connectors the client may already have.

How an MCP tool call flows

  1. Host and MCP client: Starts server, lists its tools
  2. MCP server: Returns lookup_policy schema
  3. User: Asks about business class
  4. Host and MCP client: Model asks for lookup_policy
  5. MCP server: Runs the tool
  6. Data source: Returns the policy text
  7. Host and MCP client: Gives result to the model
  8. User: Reads the answer
The host owns the model conversation; the server owns access to the data.

The short version

MCP is an open standard for connecting AI applications to data and tools. Think of it like a standard plug: any device with the right socket can use any appliance with the right plug. An MCP server exposes capabilities, such as tools, and an MCP client inside an AI application connects to it and makes those capabilities available to the model.

Anthropic introduced MCP and released it as an open protocol, and it is now supported by many AI applications and developer tools. The official site, modelcontextprotocol.io, holds the specification, guides and SDKs for several languages.

Hosts, clients and servers

The host is the AI application the user works in, for example Claude Desktop, Claude Code or your own app. Inside the host, an MCP client manages the connection to one MCP server. The server is a separate program that provides the capabilities, such as access to a database, a file system or a company API.

Servers can run locally, started by the host and talking over standard input and output (the stdio transport), or remotely, reached over HTTP. Local servers are simple for development and personal tools. Remote servers suit shared company connectors, where authentication and access control matter.

Tools, resources and prompts

A server can offer three main kinds of capability. Tools are functions the model can call, like the tools in the previous lesson, for example lookup_policy. Resources are data the application can read and put in context, identified by a URI, for example a list of policy topics or a file. Prompts are reusable templates a user can pick, for example 'check this expense claim against policy'.

The difference is about who is in control. The model decides when to call tools. The application or user decides which resources to include. The user chooses prompts explicitly. Most servers start with tools only and add resources and prompts when they help.

A minimal server in Python

The official Python SDK is installed with pip install "mcp[cli]" (the official tutorial uses uv add "mcp[cli]") and needs Python 3.10 or newer. The current tutorial creates a server with the MCPServer class and registers capabilities with decorators. Type hints and the docstring become the tool's input schema and description, so write them carefully. Older tutorials use a class called FastMCP; check the version of the guide you are following.

The example below defines one tool, one resource and one prompt, then runs over stdio. Because a stdio server talks through standard input and output, do not print debug messages to standard output from inside it; use logging to standard error instead, as the official guide explains.

python
from mcp.server import MCPServer

mcp = MCPServer("policy-helper")

POLICIES = {
    "travel": "Economy class for flights under six hours. Business class needs director approval.",
    "expenses": "Receipts are required for every expense over 25 EUR.",
}

@mcp.tool()
def lookup_policy(topic: str) -> str:
    """Return the company policy text for a topic.

    Args:
        topic: One of travel or expenses
    """
    return POLICIES.get(topic.lower(), f"No policy found for {topic}")

@mcp.resource("policy://index")
def policy_index() -> str:
    """List the policy topics this server knows about."""
    return ", ".join(POLICIES)

@mcp.prompt()
def check_claim(claim: str) -> str:
    """Ask the model to check an expense claim against policy."""
    return f"Check this expense claim against company policy and list any problems:\n{claim}"

if __name__ == "__main__":
    mcp.run(transport="stdio")

Testing and connecting

Test a server before connecting it to any assistant. The MCP Inspector is the official developer tool: run npx @modelcontextprotocol/inspector followed by the command that starts your server, for example python server.py. It opens a browser view where you can list tools, call them with test inputs and see the raw messages. It needs a recent version of Node.js.

To use the server from Claude Code, register it with claude mcp add, for example claude mcp add --transport stdio policy-helper -- python /full/path/server.py, then check it with claude mcp list. Other hosts have their own configuration steps, described in their documentation.

Treat every MCP server like any other software you install. A server can read data and take actions with the permissions you give it. Use servers from trusted sources, read what each tool does, and give remote servers only the access they need.

When MCP is worth it

MCP adds a separate program and a protocol between your app and your tools. That is worth it when the same capability will be used from more than one host, when the client wants their staff to use company data inside off-the-shelf assistants, or when a ready-made MCP server already exists for the system you need, such as a database, a ticket tracker or a file store. The official servers repository and many vendors publish servers you can start from.

If you are building one custom application with two or three tools that nobody else will use, plain tool use inside the app is simpler and has fewer moving parts. You can still move those functions into an MCP server later, because the idea is the same: a name, a description, an input schema and a function.

For a shared, remote server, plan authentication from the start. Each user should reach only the data their own account allows, and every call should be logged.

Worked example

Worked example: a policy helper server

An HR team wants their staff's AI assistants to answer policy questions from the official text, not from memory. You build the policy-helper server from the section above. The lookup_policy tool returns the policy text for a topic. The policy://index resource lists the available topics, so a host can show them. The check_claim prompt gives users a ready-made instruction for reviewing an expense claim.

In this lesson's tests the server was loaded in-process and asked to list and call its capabilities. It listed one tool, lookup_policy, one resource, policy://index, which returned 'travel, expenses', and one prompt, check_claim. Calling lookup_policy with topic 'travel' returned the travel policy text. In a real deployment the dictionary would be replaced by a call to the client's document system, with the user's permissions.

  • Write the server with one tool first.
  • Test it in the MCP Inspector.
  • Add it to one host and ask a real question.
  • Only then add resources, prompts and a real data source.

Start practicing

Practice 1

Build and inspect your first MCP server

45 min

Goal: Run a working MCP server on your own machine.

  1. Create a new folder and a virtual environment with Python 3.10 or newer, then run pip install "mcp[cli]".
  2. Save the policy-helper code from this lesson as server.py and replace the policies with two topics from your own life, such as gym rules or house chores.
  3. Run npx @modelcontextprotocol/inspector python server.py and open the page it prints.
  4. In the Inspector, list the tools and call lookup_policy with each topic and with an unknown one.
  5. Read the resource and fetch the prompt, and note what each returns.

Done when: The Inspector shows your tool, resource and prompt, and each returns the expected text.

Practice 2

Map MCP onto a client case

20 min

Goal: Decide what should be a tool, a resource or a prompt.

  1. Pick a client scenario, such as a sales team that needs CRM data in their assistant.
  2. List five capabilities they would want.
  3. Label each one tool, resource or prompt, with one sentence of reason.
  4. Write which ones need write access and who should approve them.

Done when: You have five labelled capabilities with reasons and an approval note for any write action.

Common mistakes

  • Printing to standard output inside a stdio server. It breaks the protocol messages; log to standard error.
  • Weak docstrings and type hints. They become the tool description and schema the model relies on.
  • Installing community servers without reading what they can access. Treat them as third-party software.
  • Building an MCP server when only one app will ever use the tool. Plain tool use inside the app may be simpler.
  • Following an old tutorial without checking the SDK version. Class names and commands have changed over time.

Check yourself

  1. What are the roles of the host, the client and the server?
  2. How do tools, resources and prompts differ in who controls them?
  3. When would you choose a remote server over a local stdio server?
  4. How do you test an MCP server before connecting it to an assistant?

Lesson 7.6

Building fast with Claude Code, Cursor or Copilot

You will be able to use an AI coding assistant in a plan, build, test and review loop, write a clear brief for it, and keep control of quality.

Why this matters on a client engagement

An FDE often has days, not months, to build a working proof of concept, usually in an unfamiliar codebase. AI coding assistants such as Claude Code, Cursor and GitHub Copilot can multiply what one person builds in that time. They can also produce a lot of plausible code that is wrong. The difference is the working method: clear briefs, small steps, tests the assistant must pass, and your own review before anything ships.

Working loop with a coding assistant

  1. Brief the goal and contextFiles, patterns, constraints, done-when
  2. Assistant proposes a planYou correct it before any edits
  3. Build one small stepCommit each working state
  4. Run the checksTests, build, sample outputs
  5. Review the diff yourselfScope, tests, secrets, errors

Then back to step 1: Brief the goal and context

Each pass is small; the loop repeats for every piece of the POC.

The short version

Modern coding assistants do more than complete lines. In agent modes they read files, run commands and tests, and edit many files to complete a task. Claude Code works in the terminal, IDEs and desktop app; Cursor is an editor with built-in agents; GitHub Copilot works in editors and on GitHub. The ideas in this lesson apply to all of them.

Treat the assistant like a very fast, capable new colleague who does not know the project. You set the goal, give context, agree a plan, let it build, make it prove the result with tests, and review the change yourself.

The loop: plan, build, test, review

Plan. Ask the assistant to read the relevant code and propose a plan before writing anything. Claude Code has a plan mode for exactly this. Read the plan, correct it, and only then let it build. Skip planning for tiny, obvious changes.

Build. Let it implement one small piece at a time. Small steps are easier to check and easier to undo. Commit working states to Git often, so you can always return to the last good version.

Test. Give the assistant a check it can run itself: a test suite, a build, a script that compares output to an expected file. The Claude Code best practices guide calls this the single most useful habit, because the assistant can then loop until the check passes instead of stopping when the code merely looks done.

Review. Read the diff yourself. Look for changes outside the task, deleted tests, hard-coded values, secrets and missing error handling. A fresh review session or a second assistant asked to find gaps can help, but the decision to merge is yours.

How to brief the assistant

Most poor results come from vague requests. A good brief states the goal, points to the files and existing patterns, lists constraints, gives example inputs and outputs, says what 'done' means in a way that can be checked, and names what is out of scope. The worked example shows one.

Put long-lived project knowledge in a project memory file so you do not repeat it every time. Claude Code reads CLAUDE.md, Cursor uses rules files and Copilot supports custom instructions. Keep these short: the commands to build and test, code style rules that differ from defaults, and project-specific gotchas. Long files get ignored.

Keeping control of quality

Write or agree the tests first when you can. Tests written before the code describe what you want, independently of how the assistant chose to build it. The test file in this section was written before asking for the function; the assistant's job is to make it pass without changing it.

Watch for an assistant 'fixing' a failing test by weakening it or deleting it. Say in the brief that tests must not be changed without asking. Keep permission settings sensible: let the assistant run tests and read files freely, but approve commands that touch remote systems, delete data or deploy. Never paste client secrets into a prompt.

Know the client's rules on AI coding tools. Some clients restrict which tools may see their code. Ask in the first week, not after you have used one.

python
# Written before asking the assistant to build parse_amount. Run with: pytest -q
import pytest
from invoice_utils import parse_amount

def test_plain_amount():
    assert parse_amount("1240.50 EUR") == (1240.50, "EUR")

def test_thousands_separator():
    assert parse_amount("1,240.50 EUR") == (1240.50, "EUR")

def test_dollar_symbol():
    assert parse_amount("$99") == (99.0, "USD")

def test_rejects_text_without_amount():
    with pytest.raises(ValueError):
        parse_amount("see attached")

Managing a long session

Assistants work within a context window that fills up with files, command output and conversation. As it fills, quality drops. Start a fresh session between unrelated tasks, keep investigations scoped, and if you have corrected the same mistake twice, start again with a better brief that includes what you learned. Use subagents or a separate session for broad research so the main session stays focused.

Using the assistant to understand a codebase

On a new engagement, one of the most valuable uses of a coding assistant is reading, not writing. Ask it the questions you would ask a senior engineer on the client team: how does authentication work here, where are environment variables loaded, how is a new API endpoint added, what does this job do every night. Ask it to point to the files and lines behind each answer, then open them and check. You will learn the codebase in hours instead of days.

Use what you learn to write better briefs. A brief that says 'follow the pattern in the existing orders endpoint' produces code that fits the client's style, which matters at handover. The client's developers will maintain this code after you leave, so it should look like their code, use their libraries and pass their checks, not look like it came from somewhere else.

Worked example

Worked example: a brief that gets a good result

You need a function that turns invoice amount strings into a number and a currency for the extraction POC. A weak request would be 'add amount parsing'. The brief below is better: it names the file and the pattern to follow, gives concrete examples, says what to do on bad input, defines 'done' as a passing test run with evidence, and limits the scope.

With the test file from the section above already in the repository, the assistant has a clear target. A straightforward implementation of about a dozen lines passes all four tests; that was checked when this lesson was written. If the assistant had changed the test file to get a pass, your review of the diff would catch it, because the brief said tests are fixed.

text
Goal: add parse_amount(text) to invoice_utils.py.
It turns an amount string from an invoice into (value, currency).

Context:
- Read invoice_utils.py and test_parse_amount.py first.
- Use type hints and a one-line docstring, like the rest of the file.
- Standard library only, no new packages.

Rules:
- "1,240.50 EUR" gives (1240.5, "EUR"); "$99" gives (99.0, "USD").
- Raise ValueError when no amount is found. Never return a guess.

Done when:
- pytest -q passes. Do not edit the test file.
- You show me the test output and a short list of files changed.

Out of scope: currency conversion, other files.

Start practicing

Practice 1

Brief, build and verify a small function

45 min

Goal: Run the full loop once with any AI assistant you can use for free.

  1. Create a folder with an empty invoice_utils.py and the test file from this lesson, then install pytest with pip install pytest.
  2. Run pytest -q and confirm the tests fail.
  3. Open any AI coding assistant or a free LLM chat and give it the brief from the worked example, plus the test file.
  4. Put the code it returns into invoice_utils.py and run pytest -q again.
  5. Read the code line by line and write down one thing you would change before merging.
  6. Add one more test of your own, such as an amount with a space before the currency, and repeat.

Done when: All tests, including yours, pass, and you have a written review note on the generated code.

Practice 2

Write a project memory file

20 min

Goal: Practice giving an assistant lasting, short context.

  1. Pick a project you have, or this lesson's folder.
  2. Write a CLAUDE.md (or equivalent rules file) of at most 15 lines: build and test commands, style rules, and two gotchas.
  3. Ask an assistant to perform a small task in the project and note whether it followed each line.
  4. Delete any line it did not need and make unclear lines more specific.

Done when: You have a short memory file where every line changed or protected the assistant's behaviour.

Common mistakes

  • Vague requests such as 'make it better'. State the goal, the files, examples and what done means.
  • Accepting code because it looks right. Require a check the assistant runs and read the diff yourself.
  • Letting the assistant edit or delete tests to make them pass. Say in the brief that tests are fixed.
  • One very long session for everything. Start fresh between tasks and after repeated corrections.
  • Using an AI coding tool on client code without checking the client's policy first.

Check yourself

  1. What are the four stages of the working loop?
  2. List five things a good brief contains.
  3. Why does giving the assistant a runnable check change how well it works?
  4. What should you look for when reviewing an assistant's diff?