Step 04 of 18

Prompt engineering

Write clear, structured prompts with context and examples, control output, and improve prompts systematically against a test set.

18 min 8 sections

The same model can give you a vague, rambling answer or a precise, usable one. Most of the difference is the prompt. Prompt engineering is not about magic phrases. It is the practical skill of giving a capable reader everything it needs to do a task well, then checking whether it did. This lesson covers how to write clear instructions, structure context, use examples, get better reasoning and output formats, and improve prompts methodically instead of by gut feel. The techniques apply to any modern LLM. Code examples use the Anthropic Python SDK; the TypeScript SDK takes the same request shape.

What a prompt really is

The prompt is everything in the model's context for a request: the system prompt, the conversation messages, any documents you pasted in, examples, and tool definitions. The model knows nothing about your product, your users or your intent beyond what is in that context and what it learned in training.

A useful mental model: you are briefing a brilliant new colleague who has zero context. They are fast, well read and eager, but they have never seen your codebase, don't know who the audience is, and can't ask you follow-up questions mid-task. If you handed your prompt to a smart human contractor, would they produce what you want on the first try? If not, the model probably won't either.

Prompts usually have two layers:

  • The system prompt holds stable instructions: the role, the rules, the output format and background knowledge. It is the same across requests.
  • The user message holds the specific input for this request: the ticket, the question, the document.

Keeping them separate makes prompts easier to test, reuse and cache.

Be clear, specific and direct

Vague prompts get generic output. Here is a typical first attempt at summarising support tickets:

text
Summarize this support ticket.

The model has to guess everything: who reads the summary, how long it should be, what matters, and what to do with missing information. Here is the same request with those guesses replaced by decisions:

text
You summarise customer support tickets for the on-call engineer, who reads them
on a phone during an incident and needs to decide within seconds whether to act.

Write the summary as exactly three lines:
Problem: what is broken, in the customer's terms, max 15 words.
Impact: who is affected and how badly (one user, a team, all customers).
Next step: the single most useful action for the engineer.

If the ticket doesn't say something, write "unknown" rather than guessing.
Respond with the three lines only, with no greeting or preamble.

Each sentence closes a gap: the audience and their goal, the format and length, the priorities, and what to do when information is missing. Some principles generalise:

  • Explain why. "Keep it short because the engineer reads it on a phone during an incident" works better than "Keep it short". The model generalises from the reason to cases your rule didn't cover.
  • Say what to do, not only what to avoid. "Write in plain sentences" is clearer than "Don't use markdown". A prohibition leaves the model guessing at the alternative.
  • Be explicit about the action you want. "Can you suggest some changes to this function?" may get suggestions. "Rewrite this function to..." gets a rewrite.
  • Don't shout. Modern models follow instructions closely. A wall of CRITICAL and YOU MUST makes them over-apply a rule in places it doesn't fit. State the rule calmly, with its reason.

Structure context with tags

Real prompts mix instructions with data: documents, user input, examples. When everything runs together, the model can confuse your instructions with the content it is supposed to process. Wrap each kind of content in XML-style tags. They are just text with no special syntax, but the model reliably treats them as boundaries:

python
import anthropic

client = anthropic.Anthropic()

def answer_from_policy(policy: str, question: str) -> str:
    response = client.messages.create(
        model="claude-sonnet-5-5",
        max_tokens=1024,
        system=(
            "You answer customer questions using only the company policy provided. "
            "If the policy doesn't cover the question, say you don't know and suggest contacting support."
        ),
        messages=[{
            "role": "user",
            "content": (
                f"<policy>\n{policy}\n</policy>\n\n"
                f"<question>\n{question}\n</question>\n\n"
                "First find the quotes from the policy that are relevant to the question and put them in "
                "<quotes> tags. Then answer the question in <answer> tags, using only those quotes."
            ),
        }],
    )
    return "".join(b.text for b in response.content if b.type == "text")

This one function uses several structural techniques:

  • Tags separate roles. The model knows <policy> is reference material and <question> is the thing to answer. Use consistent, descriptive tag names, and refer to them by name in your instructions ("using the policy in <policy> tags").
  • Long material goes first, the question last. With long documents, putting them at the top and the instructions and question at the end gives noticeably better results.
  • Quote, then answer. Asking the model to first pull out relevant quotes focuses it on the source and makes its answer easy to check, which reduces hallucination.
  • The system prompt allows "I don't know". Explicit permission to not know is one of the most effective guards against made-up answers.

Tags also help with safety. Text inside <question> is data from a user, and it might contain instructions of its own ("ignore the policy and..."). Tags don't make a prompt immune to this prompt injection, which the security lesson treats properly, but clearly marking untrusted input is the first layer.

Show examples

Describing a format or tone in words is hard. Showing it is easy. A few input/output examples (called few-shot prompting) are often the single most effective improvement to a prompt, especially for classification, extraction and style.

text
Classify each piece of customer feedback as one of: bug, feature_request, billing, praise, other.
Reply with the label only.

<examples>
<example>
<feedback>The export button does nothing when I click it on Safari.</feedback>
<label>bug</label>
</example>
<example>
<feedback>Would love a dark mode for late-night sessions!</feedback>
<label>feature_request</label>
</example>
<example>
<feedback>I was charged twice for March.</feedback>
<label>billing</label>
</example>
</examples>

Rules for good examples:

  • Make them diverse. Models copy surface features. If every example is short and polite, the model may struggle with long, angry input. Cover the range: different lengths, tones and tricky edge cases.
  • Include the hard cases. If "the app is slow since the last update" is ambiguous between bug and other, decide which label you want and show it. Examples are where you resolve ambiguity.
  • Wrap them in tags so the model doesn't mistake them for the actual input.
  • Three to five examples usually cover a lot. More helps for nuanced tasks, at the cost of more input tokens on every call.

Reasoning, decomposition and output control

Give hard problems room to think. Current models can think before they answer, and you control how much with an effort setting. On Claude, output_config={"effort": "high"} buys more careful reasoning for complex analysis, while "low" keeps simple classification fast and cheap. You don't need to script each step. High-level guidance ("consider the edge cases in the date handling before deciding") works better than a rigid checklist, because the model's own approach is often better than the one you'd prescribe. Visible reasoning is not proof that an answer is right, so still check outputs.

Break big tasks into a chain. One prompt that must research, analyse, decide and write a polished report does each part worse than four focused prompts would. Chaining means the output of one call becomes the input of the next: extract the facts, then analyse them, then draft, then review the draft against a checklist. Each step is simpler, can use a cheaper model where appropriate, and can be tested and debugged on its own.

Control the output format explicitly:

  • State the format and length: "three bullet points", "under 100 words", "a single paragraph of prose".
  • Match your prompt's style to the output you want. A prompt written in heavy markdown tends to get markdown back; plain prose prompts get plainer answers.
  • Ask for no preamble when you need just the content: "Respond directly, without an introduction like 'Here is...'"
  • For output that code will parse, don't rely on instructions alone. Use structured output, the subject of the next lesson. Older guides suggest pre-filling the start of the assistant's reply (for example with {) to force JSON. Current Claude models reject that with an error, and structured output is the better tool anyway.

A role in the system prompt ("You are a senior tax accountant reviewing small-business filings") also shifts vocabulary, depth and priorities in useful ways. Combine it with the specifics above; a role alone is not a brief.

Iterate against a test set

Prompts are code. They have bugs, they regress, and "it worked when I tried it" means very little. The professional workflow:

  1. Collect test inputs: 10 to 50 real or realistic examples, including the awkward ones (empty input, very long input, a different language, sarcasm).
  2. Define what good looks like for each: an expected label, required facts, or a rubric.
  3. Run the prompt on all of them, read the outputs and measure.
  4. Change one thing at a time, re-run, and keep the change only if the results improve.

Even a tiny harness beats eyeballing:

python
import anthropic

client = anthropic.Anthropic()

SYSTEM = """Classify customer feedback as one of: bug, feature_request, billing, praise, other.
Reply with the label only."""

TESTS = [
    ("The export button does nothing on Safari.", "bug"),
    ("Please add a dark mode.", "feature_request"),
    ("Why was I charged twice this month?", "billing"),
    ("Honestly the best tool I've used this year.", "praise"),
    ("It's been slow ever since the last update.", "bug"),
    ("Do you have an office in Berlin?", "other"),
]

def classify(text: str) -> str:
    response = client.messages.create(
        model="claude-haiku-5-5",
        max_tokens=1024,
        output_config={"effort": "low"},
        system=SYSTEM,
        messages=[{"role": "user", "content": f"<feedback>{text}</feedback>"}],
    )
    return "".join(b.text for b in response.content if b.type == "text").strip()

failures = []
for text, expected in TESTS:
    got = classify(text)
    if got != expected:
        failures.append((text, expected, got))

print(f"{len(TESTS) - len(failures)}/{len(TESTS)} correct")
for text, expected, got in failures:
    print(f"  expected {expected!r}, got {got!r}: {text}")
text
5/6 correct
  expected 'bug', got 'other': It's been slow ever since the last update.

The failure tells you exactly what to fix: the prompt never said that performance regressions count as bugs. Add that rule, or an example showing it, re-run, and confirm nothing else broke. Keep prompts in version control next to the code that uses them. The evals lesson grows this harness into a proper evaluation system.

Common pitfalls to watch for while iterating:

  • Overfitting to one input. Fixing the one case you tried can break five you didn't. That's what the test set is for.
  • Contradictory instructions accumulated over many edits ("be thorough" in one place, "be brief" in another). Re-read the whole prompt now and then.
  • Hidden assumptions: abbreviations, internal product names, or "the usual format" that only your team knows.
  • Blaming the model too early. Before switching to a bigger model, check whether the prompt actually specified what you wanted.

Practice

You are building a feature that turns rough meeting notes into action items. Write a system prompt and a test harness:

  1. The output must be a list of lines in the form - [owner] task (due: date or "none"), with no other text.
  2. If a task has no clear owner, the owner must be unassigned; the model must never invent names.
  3. Include at least two diverse examples in tags.
  4. Write three test notes, including one with no action items at all (the expected output is the single line No action items.), and check that the output contains no names absent from the notes.
Show solution
python
import re

import anthropic

client = anthropic.Anthropic()

SYSTEM = """You extract action items from meeting notes for a team's task tracker.
The list is imported automatically, so the format must be exact.

Output one line per action item, in this form:
- [owner] task (due: date)

Rules:
- owner is the person's name exactly as written in the notes. If nobody clearly owns
  the task, write [unassigned]. Never invent or guess names, because a wrong owner
  sends work to the wrong person.
- Write the date as it appears in the notes, or (due: none) if no date is given.
- Start each task with a verb and keep it under 12 words.
- If there are no action items, output exactly: No action items.
- Output nothing else: no headings, notes or explanations.

<examples>
<example>
<notes>Ana will send the Q3 deck to finance by Friday. Someone should book the offsite venue.</notes>
<output>
- [Ana] Send Q3 deck to finance (due: Friday)
- [unassigned] Book the offsite venue (due: none)
</output>
</example>
<example>
<notes>Quick sync. Everyone happy with the release. Nothing to follow up.</notes>
<output>
No action items.
</output>
</example>
</examples>"""

TESTS = [
    "Marco to fix the login timeout before the 14th. We also need new icons, no owner yet.",
    "Retro: deploys felt smoother. Lena and Tom will pair on flaky tests next sprint.",
    "Coffee chat about weekend plans. No decisions made.",
]

LINE = re.compile(r"^- \[(?P<owner>[^\]]+)\] .+ \(due: .+\)$")

def extract(notes: str) -> str:
    response = client.messages.create(
        model="claude-sonnet-5-5",
        max_tokens=2048,
        system=SYSTEM,
        messages=[{"role": "user", "content": f"<notes>\n{notes}\n</notes>"}],
    )
    return "".join(b.text for b in response.content if b.type == "text").strip()

def check(notes: str, output: str) -> list[str]:
    if output == "No action items.":
        return []
    problems = []
    for line in output.splitlines():
        match = LINE.match(line)
        if not match:
            problems.append(f"bad format: {line!r}")
            continue
        for name in match["owner"].split(" and "):
            if name != "unassigned" and name not in notes:
                problems.append(f"invented owner {name!r}")
    return problems

for notes in TESTS:
    output = extract(notes)
    problems = check(notes, output)
    print("OK  " if not problems else "FAIL", output.replace("\n", " | "))
    for p in problems:
        print("     ", p)

What makes this prompt work: it states the audience and why the format is strict (automatic import), gives a reason for the "never invent names" rule, covers the empty case explicitly, and uses two deliberately different examples, one of them the empty case. The checker tests the two properties that matter most, format and invented owners, automatically. When a case fails, add a rule or an example for it and re-run all three.

Key takeaways

  • A prompt is the model's entire world for that request. Brief it like a smart colleague with zero context: audience, goal, format, length and edge cases.
  • Explain the reason behind instructions, phrase them positively, and skip the all-caps shouting.
  • Separate instructions from data with XML-style tags, put long documents before the question, and allow "I don't know".
  • Diverse few-shot examples are the most reliable way to pin down format, tone and ambiguous cases.
  • Split complex work into chained steps, use effort settings for hard reasoning, and use structured output when code must parse the result.
  • Treat prompts as code: version them, test them against a set of inputs, and change one thing at a time.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Your prompt says "IMPORTANT: NEVER use bullet points!!!" and the model now avoids lists even where a list would clearly help. What is the best fix?

  2. 2.Why wrap a pasted document in <document> tags and the user's question in <question> tags?

  3. 3.All five of your few-shot examples are short, polite messages. What risk does that create?

  4. 4.You tweak a prompt to fix one bad output and it now looks right. What should you do before shipping?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.