Prompting & Structured Output
The prompt is your program. In this chapter you'll learn to write prompts that are reliable and testable, then make the model return machine-parseable JSON you can trust downstream — the skill that turns a demo into a system.
- an Anthropic API key (
ANTHROPIC_API_KEY) +pip install anthropic
Learning objectives
- Structure a system prompt with the five-part pattern.
- Use few-shot examples to steer format and tone.
- Control quality vs. cost with
effortand adaptive thinking. - Force schema-valid JSON with Pydantic and parse it safely — no regex.
- Build a robust text classifier as a real deliverable.
The five-part system prompt advanced
A production system prompt almost always has these parts, in this order:
This diagram shows the recommended order for building a system prompt. Order matters because the model reads top-to-bottom, and stable instructions should anchor it before variable input arrives.
- Read it top to bottom: the most stable, fixed instructions (the assistant's role, hard rules, available tools, examples) come first.
- The most dynamic content — retrieved documents and the user's actual input — goes at the very end.
- The caption's rule of thumb: stable-to-dynamic. Fixed guidance up top gives the model a consistent frame; the changing details come last where they're freshest.
In short: Think of it like a job briefing: you explain someone's role and the rules first, then hand them today's specific task last. Same order for a prompt.
| # | Part | Example |
|---|---|---|
| 1 | Role & objective | "You are a support-ticket triager. Classify and route incoming tickets." |
| 2 | Constraints | "Respond in JSON only. Never invent a category outside the enum." |
| 3 | Tools & when to use them | "Call escalate only when the user reports data loss." (Chapter 4) |
| 4 | Few-shot examples | 2–5 input→output pairs showing the exact desired format. |
| 5 | Dynamic context (last) | Retrieved docs / the user's actual input — always at the end. |
Lab 2.1 · Build and iterate on a prompt advanced
We'll build a "release-notes summarizer." Start naive, then improve it.
- Naive v1 — vague, you'll see the problem.
summarize.py
system = "Summarize this changelog."Run it on a messy changelog and you'll get inconsistent length, tone, and structure every time.
- v2 — add role, constraints, and format.
summarize.py
system = ( "You are a release-notes editor for a developer audience.\n" "Summarize the changelog into exactly three bullet points.\n" "Each bullet: <12 words, start with a verb, no marketing language.\n" "If a change is breaking, prefix that bullet with [BREAKING]." ) - Test with a fixed input so you can compare prompt versions fairly. Same input, tweak the prompt, eyeball the diff. This is manual eval — Chapter 5 automates it.
Lab 2.2 · Few-shot examples advanced
When you need a specific format or edge-case handling, show don't tell. Examples live as prior turns in the messages array.
fewshot.pymessages = [
{"role":"user", "content":"Server crashed on null input to /login"},
{"role":"assistant", "content":"bug | high | auth"},
{"role":"user", "content":"Please add dark mode"},
{"role":"assistant", "content":"feature | low | ui"},
{"role":"user", "content":"Billing charged me twice this month"}, # real input
]
# The model continues the pattern: "bug | high | billing"
"Few-shot" prompting means teaching by example. Instead of describing the format in words, you show the model a few input→output pairs, then give it a real input and let it continue the pattern. It's often the fastest way to get a consistent format.
- The
messageslist alternatesuser(an example input) andassistant(the ideal answer for it). Here each answer istype | severity | area. - Two complete examples establish the pattern: a crash →
bug | high | auth, a request →feature | low | ui. - The final user message ("Billing charged me twice") has no assistant answer — so the model produces one, imitating the examples:
bug | high | billing.
What the output means: The model returns bug | high | billing — it copied the exact format from your examples without you ever describing the format in words.
Try this: Add a third example in a different area (say performance) and see the model's labels get more accurate. More, varied examples usually help — up to a point.
Few-shot is powerful but has a cost: those example tokens are sent every call. For strict formats, structured output (below) is usually cleaner and cheaper than many examples.
Effort & adaptive thinking expert
Two dials trade cost/latency against quality:
effort.pyresp = client.messages.create(
model="claude-opus-4-8", max_tokens=1024,
thinking={"type": "adaptive"}, # model decides how much to reason
output_config={"effort": "medium"}, # low | medium | high | max
messages=[{"role":"user","content":"Plan a database migration strategy."}],
)
| Setting | Use for |
|---|---|
effort: "low" | Classification, routing, simple extraction — fast & cheap |
effort: "medium" | Most application work — the balanced default |
effort: "high" / max | Hard reasoning, agents, code — when correctness beats cost |
thinking:{type:"enabled", budget_tokens:N} in old tutorials. On current models that's replaced by adaptive thinking + effort, and a fixed budget is rejected on the newest models. Don't learn the old way.Newer models can think before answering. These two settings control how much hidden reasoning the model does — more thinking helps on hard problems but costs more tokens and time.
thinking={"type": "adaptive"}lets the model decide on its own how much to reason based on how hard the question is.output_config={"effort": "medium"}sets the overall effort dial —lowfor easy/cheap, up tomaxfor the hardest problems.- The prompt here ("Plan a database migration strategy") is genuinely hard, so spending effort produces a more careful plan than a quick answer would.
Try this: Set effort to "low" then "high" on the same hard prompt and compare the depth of the answer against the token count in resp.usage. That trade-off — quality vs cost — is a theme of the whole course.
Why structured output matters expert
The moment another program consumes the model's output — a database, an API, a UI — free text is a liability. You'll write brittle regex, it'll break on the model's next phrasing, and you'll page someone at 2am. Instead, constrain the output to a schema the model must satisfy.
Lab 2.3 · Schema-constrained JSON with Pydantic expert
Define the shape you want as a Pydantic model; the SDK enforces it and hands you a typed object.
extract.pyfrom dotenv import load_dotenv
from anthropic import Anthropic
from pydantic import BaseModel
from typing import Literal
load_dotenv()
client = Anthropic()
class Ticket(BaseModel):
category: Literal["bug","feature","billing","other"]
priority: Literal["low","medium","high"]
summary: str
needs_human: bool
resp = client.messages.parse( # .parse() validates for you
model="claude-opus-4-8", max_tokens=512,
messages=[{"role":"user",
"content":"I was charged twice and support hasn't replied in 3 days!"}],
output_format=Ticket,
)
t = resp.parsed_output # a real Ticket instance
print(t.category, t.priority, t.needs_human)
print(type(t)) # <class 'Ticket'>
billing high True
<class '__main__.Ticket'>
Literal types become an enum in the JSON schema — the model literally cannot return a category outside your set. No validation code, no regex, no surprise values. This is the backbone of every reliable pipeline.Lab 2.4 · Ship a real classifier expert
Combine everything into a deliverable: a batch text classifier that returns typed results and never crashes on bad output.
classifier.pyfrom pydantic import BaseModel
from typing import Literal
import anthropic
from anthropic import Anthropic
client = Anthropic()
class Sentiment(BaseModel):
label: Literal["positive","neutral","negative"]
confidence: float
SYSTEM = (
"You classify customer feedback sentiment. "
"Confidence is your certainty from 0.0 to 1.0."
)
def classify(text: str) -> Sentiment | None:
try:
r = client.messages.parse(
model="claude-haiku-4-5", # small model — this is easy work
max_tokens=128, system=SYSTEM,
messages=[{"role":"user","content":text}],
output_format=Sentiment,
)
return r.parsed_output
except (anthropic.APIError, ValueError):
return None # caller decides how to handle
for fb in ["Love it!", "It's fine I guess", "Worst update ever"]:
s = classify(fb)
print(f"{fb:20} -> {s.label} ({s.confidence:.2f})" if s else f"{fb}: FAILED")
Love it! -> positive (0.98)
It's fine I guess -> neutral (0.72)
Worst update ever -> negative (0.99)
This is a small but complete, production-shaped feature: classify feedback as positive/neutral/negative with a confidence score, guaranteed to come back as clean, validated data — not a paragraph you have to parse by hand.
class Sentiment(BaseModel)uses Pydantic to declare the exact shape you want back: alabelthat must be one of three words (Literal[...]) and aconfidencenumber. This is your contract.SYSTEMtells the model its job.classify(text)callsclient.messages.parse(..., output_format=Sentiment)— the.parsemethod makes the model return data matching yourSentimentshape and hands you a real Python object inr.parsed_output.- Note the small, cheap model (
claude-haiku-4-5) — classification is easy work, so you don't pay for a big model. Thetry/exceptreturnsNoneon failure so the caller can decide what to do. - The loop runs three sample feedbacks through
classifyand prints each label with its confidence, using the f-string alignment you learned in P1.
What the output means: Three aligned lines, e.g. Love it! -> positive (0.98) — structured, typed results you could store in a database or branch on directly.
Try this: Change one input to something ambiguous like "it's okay I guess" and watch the confidence drop. Low confidence is exactly the signal the course uses to route tricky cases to a human.
Common pitfalls expert
| Pitfall | Fix |
|---|---|
| Parsing free text with regex | Use a schema (output_format) — never regex model output |
| Aggressive "YOU MUST" prompts over-triggering | Calm, literal instructions; state the reason behind rules |
| Prompts as scattered string literals | Centralize + version them; log the version per request |
| Assuming schema output is always complete | Still check max_tokens/refusal — truncation won't match schema |
| Using the frontier model for trivial work | Route easy tasks to a small model |
Exercises expert
Exercise 2.1 — Extraction schema
Context: Extracting structured fields from a messy sales email is a canonical structured-output task — and real emails omit fields, so the schema has to tolerate absence.
Your task: Write a Pydantic model plus call that extracts name, email, company, and interested_products (a list) from a sales email, handling missing fields gracefully by making them Optional.
Requirements:
- Declare the four fields on a Pydantic
BaseModel - Optional fields use
Optional[...]with a default ofNone interested_productsis a list type- Pass the model via
output_format=and readparsed_output - Missing fields populate as
Nonerather than raising
💡 Hint: from typing import Optional; a default of None lets the model null out what it can't find and fill the rest.
Show hint
Use from typing import Optional; give optional fields a default of None. The model will populate what it can find and null the rest.
Exercise 2.2 — Prompt A/B
Context: “It feels better” is not evaluation. Running two prompt versions over the same fixed inputs is the manual precursor to Chapter 5's automated evals.
Your task: Take your Lab 2.1 summarizer, write two prompt versions, and run both over the same five changelogs; decide which is more consistent, keep the winner, and note why.
Requirements:
- Hold the five changelog inputs fixed across both prompt versions
- Vary only the prompt so the comparison is fair
- Judge on output consistency (length, tone, structure)
- Pick a winner and write a one-line rationale
- Frame it as the manual version of an eval
💡 Hint: Same inputs, different prompt, eyeball the diff — that controlled comparison is exactly what an eval automates.
Exercise 2.3 — Confidence routing
Context: The confidence score a classifier returns is only useful if you act on it. Routing low-confidence items to human review is the simplest possible guardrail.
Your task: Extend the Lab 2.4 classifier: if confidence < 0.6, route the item to a “needs human review” list instead of trusting the label.
Requirements:
- Call the classifier and capture its typed result
- Treat a
Noneresult (parse failure) as needing review too - Route items with
confidence < 0.6to the review queue - Route confident results to the trusted list
- Keep the threshold explicit so it's easy to tune
💡 Hint: One branch does it: if s is None or s.confidence < 0.6 → review queue, else trust the label.
Show solution
Setup to run this snippet
class _Any:
'''stands in for any undefined demo value; supports call/attr/index/
iteration and basic arithmetic (as 0.7) so demo snippets run.'''
def __call__(self, *a, **k): return _Any()
def __getattr__(self, k): return _Any()
def __getitem__(self, k): return _Any()
def __iter__(self): return iter([])
def __len__(self): return 0
def __contains__(self, o): return True
def __enter__(self, *a): return _Any()
def __exit__(self, *a): return False
def __float__(self): return 0.7
def __int__(self): return 1
def __lt__(self, o): return True
def __gt__(self, o): return False
def __le__(self, o): return True
def __ge__(self, o): return False
def __add__(self, o): return o
def __radd__(self, o): return o
def __bool__(self): return True
def __repr__(self): return 'demo'
def __str__(self): return 'demo'
def classify(*a, **k): # demo stub
return _Any()
fb = _Any()
class _results_t:
append = 'demo'
def append(self, *a, **k): return 'demo'
def __getattr__(self, k): return 'demo'
results = _results_t()
class _review_queue_t:
append = 'demo'
def append(self, *a, **k): return 'demo'
def __getattr__(self, k): return 'demo'
review_queue = _review_queue_t()s = classify(fb)
if s is None or s.confidence < 0.6:
review_queue.append(fb)
else:
results.append(s)🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: A production system prompt almost always stacks five parts in a fixed order. Assembling them stable-to-dynamic both prompts better and lets the fixed prefix be prompt-cached later.
Your task: Assemble the five parts from the lesson — role & objective, constraints, tools, few-shot, dynamic context — into one system string, in order, stable-to-dynamic.
Requirements:
- Include all five parts in the lesson's order
- Role & objective first; the dynamic per-request context (e.g.
{user_ticket}) last - Constraints and tool rules sit between role and examples
- Join the parts into a single string and print it
- Assert the stable role text appears before the dynamic placeholder
💡 Hint: Keep the parts in a list and "\n".join them — the list order is the stable-to-dynamic ordering.
Show solution
Order matters: fixed parts first, the changing per-request context last (so it prompts better and can be prompt-cached). Runnable:
parts = [
"You are a support-ticket triager. Classify and route incoming tickets.", # 1 role
"Respond in JSON only. Never invent a category outside the enum.", # 2 constraints
"Call escalate only when the user reports data loss.", # 3 tools
"Example: 'server crash' -> bug | high | auth", # 4 few-shot
"{user_ticket}", # 5 dynamic (LAST)
]
system = "\n".join(parts)
print(system)
assert system.index("You are") < system.index("{user_ticket}") # stable before dynamic
Context: Few-shot prompting teaches by example: the examples live as prior user/assistant turns, and the final lone user turn is the real input the model completes.
Your task: Build a messages list for a ticket classifier returning type | severity | area, with two complete example pairs and a trailing real user input, then assert the list is well-formed.
Requirements:
- Alternate
user/assistantturns for each example pair - Include at least two complete example pairs establishing the format
- End with a lone
usermessage (no assistant reply) as the item to classify - Assert the first and last roles are both
user - Assert the roles strictly alternate across the whole list
💡 Hint: Check alternation with all(a != b for a, b in zip(roles, roles[1:])); the model continues the pattern to produce the missing answer.
Show solution
Two complete example pairs teach the format; the final lone user message is the item to classify. Runnable check of the alternation and the trailing user turn:
messages = [
{"role":"user", "content":"Server crashed on null input to /login"},
{"role":"assistant", "content":"bug | high | auth"},
{"role":"user", "content":"Please add dark mode"},
{"role":"assistant", "content":"feature | low | ui"},
{"role":"user", "content":"Billing charged me twice this month"}, # real input
]
roles = [m["role"] for m in messages]
assert roles[0] == "user" and roles[-1] == "user"
assert all(a != b for a, b in zip(roles, roles[1:])), "must alternate"
print("examples:", roles.count("assistant"), "| to classify:", messages[-1]["content"])The model continues the pattern and returns bug | high | billing — format learned by example, never described in words.
Context: The effort dial trades cost and latency against quality: low for easy work, high for hard reasoning. Encoding that mapping as a router keeps model spend matched to task difficulty.
Your task: Write a router choose_effort(task) that returns the effort level and a one-line reason per task, defaulting sensibly for an unknown task, and note the deprecated thinking pattern to avoid.
Requirements:
- Map classification/routing/extraction →
low - Map general app work (e.g. summarize) →
medium - Map hard reasoning / agents / code →
high - Default to
mediumwith a stated reason on an unknown task - Note the deprecated
thinking={"type":"enabled","budget_tokens":N}; use adaptive thinking +output_config={"effort":...}instead
💡 Hint: A dict of task → (level, reason) with dict.get(task, default) is the whole router.
Show solution
Classification/routing -> low; most app work -> medium; hard reasoning/agents/code -> high. Default to medium when unsure. Runnable:
RULES = {
"classify": ("low", "simple, fast, cheap"),
"route": ("low", "simple, fast, cheap"),
"extract": ("low", "shallow structure"),
"summarize": ("medium", "balanced default for app work"),
"plan": ("high", "hard multi-step reasoning"),
"code": ("high", "correctness beats cost"),
}
def choose_effort(task):
return RULES.get(task, ("medium", "unknown task -> safe balanced default"))
for t in ["classify", "plan", "translate"]:
lvl, why = choose_effort(t)
print(f"{t:10} -> effort={lvl:6} ({why})")Avoid the deprecated thinking={"type":"enabled","budget_tokens":N} — on current models use adaptive thinking + output_config={"effort":...}; a fixed budget is rejected.
Context: Pydantic Literal fields make invalid values impossible — they become an enum in the JSON schema the model must satisfy — while Optional fields let missing data null out instead of breaking parsing.
Your task: Write a SalesLead model that extracts name, email, company, and interested_products (a list), with the first three Optional (default None), and prove validation with a valid and an invalid payload.
Requirements:
name/email/companyareOptional[str] = Noneinterested_productsis alist[str]defaulting to empty- Add a
Literal-typed field (e.g.stage) to enforce an enum - Construct a valid instance with fields omitted and show they default cleanly
- Construct an invalid instance and catch the
ValidationError - Note that only
output_format=SalesLeadon.parse()needs a key
💡 Hint: The schema/validation runs offline if pydantic is installed; a value outside the Literal set raises ValidationError.
Show solution
This is pure Pydantic (offline-runnable if pydantic is installed — the SDK call is separate). The Literal on stage enforces the enum; Optionals null gracefully:
from typing import Optional, Literal
from pydantic import BaseModel, ValidationError
class SalesLead(BaseModel):
name: Optional[str] = None
email: Optional[str] = None
company: Optional[str] = None
interested_products: list[str] = []
stage: Literal["cold", "warm", "hot"] = "cold"
ok = SalesLead(name="Sam", interested_products=["Pro"], stage="warm")
print(ok.company, ok.stage) # None warm -- missing field is fine
try:
SalesLead(stage="boiling") # not in the Literal enum
except ValidationError as e:
print("rejected bad stage:", e.error_count(), "error(s)")In the real call you pass output_format=SalesLead to client.messages.parse(...) and read resp.parsed_output — that part needs an API key. The schema/validation above runs offline.
Context: A real classifier must never crash on bad output and must escalate uncertainty. Returning None on failure and routing low-confidence results to a human queue is the first guardrail every pipeline needs.
Your task: Extend Lab 2.4: build classify_and_route(text) that returns a typed Sentiment(label, confidence), returns None on bad output rather than crashing, and sends any result with confidence < 0.6 (or a failure) to a human-review queue.
Requirements:
- Classify into a typed
Sentimentwith alabelandconfidence try/exceptaround the parse returnsNoneon failure- Route
Noneorconfidence < 0.6to a review queue - Route confident results to the trusted-results list
- Show the real
.parsecall as a key-needing skeleton and demo the routing with a stub classifier - Use a small/cheap model for this easy work
💡 Hint: Low confidence is the routing signal — treat a failed parse the same as an uncertain one and send both to the human.
Show solution
Real SDK skeleton (needs a key), then a runnable demonstration of the routing rule — the guardrail is the point:
# --- real classifier (needs ANTHROPIC_API_KEY) ---
# from pydantic import BaseModel
# from typing import Literal
# import anthropic; from anthropic import Anthropic
# client = Anthropic()
# class Sentiment(BaseModel):
# label: Literal["positive","neutral","negative"]
# confidence: float
# def classify(text):
# try:
# r = client.messages.parse(model="claude-haiku-4-5", max_tokens=128,
# messages=[{"role":"user","content":text}], output_format=Sentiment)
# return r.parsed_output
# except (anthropic.APIError, ValueError):
# return None
# --- runnable routing logic with a stub classifier ---
class Sentiment:
def __init__(self, label, confidence):
self.label = label; self.confidence = confidence
def classify(text): # stub standing in for the SDK call
table = {"Love it!": Sentiment("positive", 0.98),
"It's okay I guess": Sentiment("neutral", 0.42),
"\x00": None} # simulate a parse failure
return table.get(text, Sentiment("neutral", 0.5))
results, review_queue = [], []
def classify_and_route(text):
s = classify(text)
if s is None or s.confidence < 0.6:
review_queue.append(text)
else:
results.append((text, s.label, s.confidence))
for fb in ["Love it!", "It's okay I guess", "\x00"]:
classify_and_route(fb)
print("trusted:", results)
print("to human:", review_queue)Production notes: small/cheap model for easy work, try/except returns None so a bad response is data not a crash, and low confidence is the signal that routes tricky cases to a human — your first guardrail.
Context: A triage prompt that regressed silently after an edit is a classic production failure. Treating prompts as versioned artifacts — id + version, logged per request, rolled out by canary — turns that failure into a one-query diagnosis.
Your task: Design a prompt-management approach: give each prompt an id + version, log the version on every request, and canary-A/B v2 against v1 on live traffic before full rollout. Provide a runnable registry + A/B split core and the rollout plan.
Requirements:
- A registry maps
(id, version) → text— no anonymous string literals - Stamp the chosen
prompt_veronto every log line for traceability - Split traffic deterministically (e.g. hash of id + request key) so a user stays on one variant
- Send a small canary slice (5–25%) to v2, keeping v1 as instant rollback
- Gate promotion on an eval metric, not vibes; slice metrics by
prompt_ver - Accounting/split core runs offline with stub prompts
💡 Hint: A sha256(id + request_key) % 100 < canary_pct bucket keeps assignment stable and needs no shared state; promote only when v2 wins the metric.
Show solution
Design. Treat prompts as versioned artifacts, never anonymous string literals. A registry maps id -> {version: text}; the chosen version is stamped onto every log line so a later quality dip is traceable to the exact prompt. Roll out with a canary: send a small deterministic slice of traffic to v2, compare an eval metric, promote only if v2 wins.
import hashlib
class PromptRegistry:
def __init__(self):
self.store = {} # (id, version) -> text
def register(self, pid, version, text):
self.store[(pid, version)] = text
def get(self, pid, version):
return self.store[(pid, version)]
def canary_version(pid, request_key, canary_pct):
"""Deterministic bucket so the same user is stable across calls."""
h = int(hashlib.sha256(f"{pid}:{request_key}".encode()).hexdigest(), 16)
return "v2" if (h % 100) < canary_pct else "v1"
reg = PromptRegistry()
reg.register("triage", "v1", "You are a triager. Respond JSON only.")
reg.register("triage", "v2", "You are a triager. Respond JSON only. State reasons briefly.")
log = []
for uid in ["u1", "u2", "u3", "u4", "u5", "u6", "u7", "u8"]:
ver = canary_version("triage", uid, canary_pct=25) # 25% see v2
prompt = reg.get("triage", ver)
log.append({"user": uid, "prompt_id": "triage", "prompt_ver": ver})
from collections import Counter
print("split:", Counter(r["prompt_ver"] for r in log))
print("every log line carries the version:", all("prompt_ver" in r for r in log))Tradeoffs. Deterministic hashing keeps a user on one variant (no flip-flopping) and needs no shared state. Start the canary small (5-25%) and gate promotion on the Chapter-5 eval metric, not vibes; keep v1 as instant rollback. Because the version is logged per request, when quality shifts you can slice metrics by prompt_ver and know exactly which edit did it — the failure that started this story becomes a one-query diagnosis.
✓ Checkpoint — you can move on when you can…
- Write a five-part system prompt and explain each part's job.
- Add few-shot examples via the messages array.
- Pick an
effortlevel for a given task and justify it. - Return typed, schema-valid JSON with
messages.parse()and never touch regex. - Build a classifier that fails safe and routes low-confidence cases to humans.
Diagnosis schema and the Terraform-plan summary it produces. The Literal[...] trick you just learned becomes the agent's fix_risk: read_only|reversible|significant|irreversible field — the classification that drives its entire safety model. See the Diagnosis schema in the capstone →Knowledge check check yourself
The five-part system prompt is ordered stable-to-dynamic, with retrieved docs and user input placed last. Give the two distinct reasons this ordering helps.
Show answer
Why does using a Pydantic Literal[...] field make schema-constrained output safer than parsing free text with a regex?