Prompting Claude & model migration
The prompting techniques Anthropic documents as first-class — XML tags, prefilling the assistant turn, long-context ordering, the Console prompt improver — plus the workflow for migrating safely when a model is deprecated or upgraded: pin the id, keep an eval set, diff old vs new, adjust prompts, and roll out behind a flag. Real SDK code, with one offline migration gate you can run.
Learning objectives
- Structure a prompt with XML tags (
<document>,<example>,<instructions>) so Claude can tell the parts apart. - Use the system prompt for the role and the user turn for the task and data.
- Understand prefilling the assistant turn — what it does, and that it is removed on current models (use
output_config.formatinstead). - Order a long-context prompt correctly: long document first, question last, and quote-then-answer.
- Know what the Anthropic Console prompt improver / generator is and when to reach for it.
- Run the safe model-migration loop: pin the id, keep an eval set, diff old vs new, adjust prompts, canary, roll out.
- Write an offline migration gate that turns eval pass-rates into a go / adjust / block decision.
1 · Anthropic-specific prompting, in one picture essential
You already know general prompting from ch02 and pe1 — be clear, give examples, ask for structure. This lesson is narrower and deeper: the handful of techniques that are specific to Claude and that Anthropic documents as first-class. There are four, and they stack:
Read it left to right as a build order. XML tags label the pieces of your prompt so Claude never confuses your data for your instructions. The system prompt sets who Claude is. Prefill (on older models) or structured output (on current ones) pins the shape of the reply. And when the prompt is huge, ordering — long document first, question last — is what keeps recall high. Everything else in this half is detail on those four.
2 · Recipe 1 — structure the prompt with XML tags essential
Claude is trained to pay attention to XML-style tags. When your prompt mixes a document, a few examples, and your instructions, wrapping each part in a tag (<document>, <example>, <instructions>) removes ambiguity: Claude can tell "the thing to act on" from "how to act on it". The tag names are yours to choose — there is no fixed vocabulary — but pick descriptive names and close every tag.
xml_prompt.py# needs: pip install anthropic + ANTHROPIC_API_KEY; makes a real API call.
# XML tags are just text in the prompt string — no special parameter, no beta header.
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment
CONTRACT = "... the full text of a 40-page contract ..."
prompt = f"""<document>
{CONTRACT}
</document>
<instructions>
Answer the question using ONLY the contract above. Quote the exact clause you
relied on inside <quote></quote> tags, then give your answer in <answer></answer> tags.
If the contract does not address the question, say so — do not guess.
</instructions>
<question>What is the termination notice period?</question>"""
resp = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
print(next(b.text for b in resp.content if b.type == "text"))
This is the core Anthropic move: wrap each part of your prompt in an XML-style tag so Claude can tell your data from your rules from your question. The tags are just text in the prompt string — there's no special parameter.
<document>...</document>wraps the material to act on.<instructions>holds the rules, and<question>holds the ask. Claude is trained to notice these boundaries.- The instructions tell Claude to answer only from the document and to put its answer inside
<answer></answer>tags — so on your side you just split on the tag instead of guessing where prose ends. - The f-string drops the contract text into the tag; the rest of the prompt is a plain triple-quoted string.
client.messages.create(...)sends it like any other message.
What the output means: Claude replies grounded in the contract, with the clause it used quoted in <quote> tags and the answer in <answer> tags.
Try this: Rename <document> to <contract> in BOTH the wrapper and the instructions and watch it still work — the names are yours, as long as they match and every tag is closed.
Two things are doing work here. First, the tags give Claude a frame: the contract is data, the instructions are rules, the question is the ask. Second, asking for the answer inside <answer> tags makes the reply easy to parse on your side — you split on the tag instead of guessing where the prose ends.
<document>…"). Consistency is what lets Claude follow the reference. Nesting is fine (<example><input>…</input></example>) as long as tags are balanced.3 · The system prompt's job essential
Claude takes a system prompt separately from the conversation. Put the stable stuff there — who Claude is, the rules it must always follow, the output contract — and keep the variable stuff (the actual document, this turn's question) in the user message. The split matters for two reasons: it reads more reliably, and it caches better (the frozen system prefix is exactly what prompt caching reuses).
system_role.py# needs: pip install anthropic + ANTHROPIC_API_KEY; makes a real API call.
import anthropic
client = anthropic.Anthropic()
resp = client.messages.create(
model="claude-opus-4-8",
max_tokens=512,
# STABLE: role + hard rules + output contract — same every request, caches well.
system=(
"You are a senior contracts analyst. Answer only from the document the user "
"provides. Be explicit and literal. If something is not stated, say 'not stated' "
"rather than inferring. Always cite the clause you used."
),
# VARIABLE: this turn's data + question.
messages=[{"role": "user", "content": "<document>...</document>\n\nWhat is the SLA credit?"}],
)
print(resp.stop_reason, "|", resp.usage.input_tokens, "input tokens")
This shows the split that makes prompts both reliable and cheap: the stable stuff (who Claude is, the hard rules, the output contract) goes in system=; the variable stuff (this turn's document and question) goes in the user message.
system=takes the role and rules once. Because it's identical every request, it's exactly the frozen prefix that prompt caching reuses — you pay for it once.- The
messageslist carries only what changes this turn. Keeping the document out ofsystemis what lets the cache hit. - The rules are stated plainly and literally ("say 'not stated' rather than inferring"). Current Claude models follow instructions closely, so precise beats aggressive.
What the output means: resp.stop_reason and the input-token count print — the point here is the structure of the call, not the answer text.
Try this: Move the role sentence out of system and into the user message. It still works, but you lose the clean cache boundary — the whole prompt is now 'variable'.
4 · Recipe 2 — prefilling the assistant turn (and its modern replacement) intermediate
Prefilling means you put the first words of Claude's reply into the conversation yourself, as an assistant message with no answer after it. Claude then continues from your words. The classic use is forcing a format: start the assistant turn with { and Claude keeps writing JSON; start it with a label and Claude fills it in. It skips preambles like "Sure, here is…".
assistant message returns a 400. The technique is real and still works on older models; the recipe below shows both the classic form (on an older model) and the replacement you should use going forward.prefill_old.py# needs: pip install anthropic + ANTHROPIC_API_KEY; makes a real API call.
# PREFILL PATTERN — works on older models (e.g. claude-sonnet-4-5). On Opus 4.6+ /
# Sonnet 4.6 / Fable 5 a trailing assistant turn returns a 400. See the replacement below.
import anthropic
client = anthropic.Anthropic()
resp = client.messages.create(
model="claude-sonnet-4-5", # older model: prefill still allowed
max_tokens=256,
messages=[
{"role": "user", "content": "Give the capital and population of France as JSON."},
{"role": "assistant", "content": "{"}, # PREFILL: Claude continues the JSON
],
)
# The reply starts after your "{" — prepend it back to reconstruct the full object.
text = next(b.text for b in resp.content if b.type == "text")
print("{" + text)
This is prefilling: you write the first characters of Claude's reply yourself, as a trailing assistant message, and Claude continues from them. Here the prefill is {, so Claude keeps writing JSON.
- The
messageslist ends with anassistantturn ("{") that has no answer after it. Claude picks up exactly where you left off — it never re-writes your{. - Because the reply starts after your
{, you prepend the{back to reconstruct the full object — that's whatprint("{" + text)does. - This uses
claude-sonnet-4-5on purpose: prefill is removed on current models (Opus 4.6+, Sonnet 4.6, Fable 5), where a trailing assistant turn returns a 400. The next recipe is the modern replacement.
What the output means: A JSON object with France's capital and population, reconstructed by gluing your { back onto the front of the reply.
Try this: Change the model to claude-opus-4-8 and you'll get a 400 — that error is the migration lesson: a prompt tuned for one model can break on the next.
On current models you get the same guarantee — and a stronger one — with structured output. Instead of nudging the first token, you hand Claude a JSON schema and the reply is constrained to match it. This is the recommended replacement for every "prefill to force a format" case.
structured_output.py# needs: pip install anthropic + ANTHROPIC_API_KEY; makes a real API call.
# Replaces format-forcing prefills on current models. output_config.format constrains
# the reply to your JSON schema — no prefill, guaranteed shape.
import anthropic
client = anthropic.Anthropic()
resp = client.messages.create(
model="claude-opus-4-8",
max_tokens=256,
messages=[{"role": "user", "content": "Give the capital and population of France."}],
output_config={
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"capital": {"type": "string"},
"population": {"type": "integer"},
},
"required": ["capital", "population"],
"additionalProperties": False,
},
}
},
)
import json
text = next(b.text for b in resp.content if b.type == "text")
print(json.loads(text)) # {'capital': 'Paris', 'population': ...}
This is how you force a format on current models — no prefill. You hand Claude a JSON schema in output_config.format and the reply is constrained to match it.
output_config={"format": {"type": "json_schema", "schema": {...}}}declares the exact shape: acapitalstring and apopulationinteger, both required, no extra keys.- The reply's first text block is guaranteed valid JSON for that schema, so
json.loads(text)parses it straight into a dict — no reconstruction, no prefix to glue back on. - This is stronger than a prefill: the prefill only nudged the first token, while the schema constrains the whole reply. It's the recommended replacement for every 'prefill to force JSON' case.
What the output means: A parsed Python dict like {'capital': 'Paris', 'population': ...} — typed and ready to use.
Try this: Add a "region": {"type": "string"} property to the schema (and to required) and re-run — the reply gains the field, still valid JSON.
| You want to… | Older model | Current model (Opus 4.6+/Sonnet 4.6/Fable 5) |
|---|---|---|
| Force JSON / a schema | Prefill with { | output_config.format (structured output) |
| Force a classification label | Prefill the label start | Strict tool with an enum, or structured output |
| Skip "Sure, here is…" preambles | Prefill the first real word | System instruction: respond directly, no preamble |
| Continue an interrupted reply | Prefill the tail | Put the continuation ask in the user turn |
5 · Recipe 3 — long-context ordering intermediate
Claude's models have very large context windows (up to 1M tokens on Opus 4.8), so you can drop a whole book in a prompt. But where you put things changes the answer quality. Anthropic's documented long-context guidance is three rules:
Long-context rules
- Put the long document first, the question last. Claude attends best to a query that comes after the material it's about. A 50-page doc followed by "now answer X" beats "answer X about the following: [50 pages]".
- Wrap each document in tags (
<document>, or<doc index="1">when there are several) so Claude can reference them and you can ask it to cite by index. - Ask it to quote first, then answer. Instruct Claude to pull the relevant sentences into
<quotes>before writing the answer. Grounding the answer in extracted quotes raises accuracy and gives you something to audit.
long_context.py# needs: pip install anthropic + ANTHROPIC_API_KEY; makes a real API call.
import anthropic
client = anthropic.Anthropic()
DOCS = {1: "... report one ...", 2: "... report two ..."}
# Long material FIRST ...
doc_blocks = "\n".join(f'<doc index="{i}">\n{txt}\n</doc>' for i, txt in DOCS.items())
# ... question and instructions LAST.
prompt = f"""{doc_blocks}
Using only the documents above:
1. Pull the sentences relevant to the question into <quotes></quotes>, tagging each with
the doc index it came from.
2. Then write your answer in <answer></answer>, citing the doc index.
<question>Which report projects higher Q4 revenue, and by how much?</question>"""
resp = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
print(next(b.text for b in resp.content if b.type == "text"))
This shows Anthropic's long-context ordering rules in code: put the long material first, the question last, and make Claude quote before it answers.
doc_blocksbuilds the documents up front, each wrapped in<doc index="1">tags so Claude can cite them by index — and they come before anything else in the prompt.- The instructions and the
<question>come after all the documents. Claude attends best to a question that follows the material it's about. - Step 1 forces Claude to pull relevant sentences into
<quotes>before writing the answer. Grounding the answer in extracted quotes raises accuracy and gives you something to audit.
What the output means: A <quotes> block (sentences tagged by doc index) followed by an <answer> that cites which report projects higher revenue.
Try this: Flip the order — put the question first and the documents last — on a long input and compare. The document-first version is the one Anthropic recommends for a reason.
<quotes> block is exactly what you show a reviewer when they ask "where did this come from?"6 · The Console prompt improver & generator advanced
You don't have to write every prompt from a blank page. The Anthropic Console (console.anthropic.com) ships two tools that most people underuse:
| Tool | What it is | When to reach for it |
|---|---|---|
| Prompt generator | Describe the task in plain language; it drafts a structured prompt (role, XML tags, examples) for you. | Starting a new prompt from scratch, or when you're staring at a blank page. |
| Prompt improver | Paste an existing prompt; it rewrites it applying Anthropic best practices — adds tags, tightens instructions, adds a reasoning step. | You have a prompt that mostly works but is inconsistent, or you're about to migrate models and want a clean baseline. |
Treat the output as a strong first draft, not a finished artifact. The Console applies the exact techniques in this lesson — XML tags, explicit instructions, an answer contract — so its drafts are a good scaffold. But you still own the evals: a generated prompt that reads beautifully can still score worse on your task than the one you had. Generate, then measure.
7 · Why migration is a workflow, not a find-and-replace advanced
Models get deprecated and upgraded. A retired model id starts returning 404; a deprecated one keeps working until a cutoff date, then stops. So you will migrate — the only question is whether it's controlled or a fire drill. Two facts make it more than swapping a string:
What actually changes across a model version
- The request surface can change. Moving to Opus 4.6+ / Sonnet 4.6 / Fable 5, assistant-turn prefills 400,
budget_tokensis replaced bythinking={"type":"adaptive"}, and (Opus 4.7+) sampling paramstemperature/top_pare rejected. A blind id swap can start erroring. - Behavior shifts even when the code is valid. Newer models follow instructions more literally, reach for tools differently, and calibrate verbosity to the task. A prompt tuned for the old model may over- or under-trigger on the new one — so the prompt often needs adjusting, not just the id.
The upshot: a prompt is tuned for a model. When the model changes, you re-validate the prompt against evidence — which is exactly what the loop below is for. This is where your eval set (ch05) earns its keep: it's the evidence.
8 · The safe migration loop professional
The whole workflow is a loop you can draw. Nothing exotic — pin, test, diff, adjust, canary, watch — but doing it in this order is the difference between a boring migration and a Friday incident:
This is the whole model-migration workflow as a loop. It's the safe path from 'a new model exists' to 'the new model is serving all my traffic' — and the order of the steps is what keeps it boring instead of risky.
- New model announced → Run evals: old vs new: you pin both ids and run your fixed eval set (ch05) against each. Same prompts, same inputs, two models.
- Pass? is the gate. If it doesn't pass, you adjust the prompts and re-run — a regression is usually prompt fit, not a bad model. This is the loop-back arrow.
- Once it passes, Canary sends a small slice of real traffic to the new model behind a flag (instant rollback, no deploy), and only then Full rollout promotes it to 100%.
In short: Never skip the canary. The eval set tells you the new model is good on your known cases; the canary tells you it's good on real traffic — and the flag lets you undo it in seconds if it isn't.
The loop, step by step
- Pin the model id. Put the id in one config constant (
MODEL = "claude-opus-4-8"), never scattered as string literals. You can't migrate safely what you can't find in one place. - Keep an eval set. A fixed set of representative inputs with known-good expectations (from ch05). This is the ground truth you'll diff against.
- Run BOTH old and new against the evals. Same prompts, same inputs, two model ids. Compare pass-rates and eyeball the diffs — where the new model changed matters as much as the aggregate number.
- Adjust prompts if needed. If the new model regresses, it's usually prompt fit, not a bad model: dial back aggressive tool instructions, add a length instruction, remove prefills. Re-run the evals after each change.
- Roll out behind a flag (canary). Send a small slice of real traffic to the new model, keep the rest on the old one. A flag means instant rollback with no deploy.
- Watch, then promote. Monitor the canary — latency, cost, error rate, refusals, and any quality signal you have — then widen the flag to 100% once it holds.
9 · Recipe 4 — the migration gate (this one runs offline) professional
The "Pass?" diamond in the diagram deserves an actual rule, not a vibe. The helper below is pure stdlib and runs with a plain python file.py — no API key, no network. It takes per-case results for the old and new model on the same eval set and returns a decision: go (new is as good or better), adjust (small regression — tune prompts and re-run), or block (real regression — do not ship).
migration_gate.pydef migration_gate(old_results, new_results, adjust_band=0.03):
"""Decide whether a new model is safe to roll out, from eval results.
old_results / new_results: dict[case_id] -> bool (True = passed the eval).
Both must cover the SAME case ids. Returns a decision dict.
Rule (per-case regressions matter, not just the aggregate):
no case regressed -> "go" (as good or better)
some regressed, rate held within band -> "adjust" (small/hidden regression)
rate dropped past the band -> "block" (real regression)
A "regression" is a case the old model passed but the new one fails. Note a
migration can hold its aggregate pass-rate while still regressing cases (it fixed
as many as it broke) — that is exactly why go requires ZERO regressions.
"""
ids = sorted(old_results)
assert ids == sorted(new_results), "eval sets must cover the same case ids"
n = len(ids)
old_rate = sum(old_results[i] for i in ids) / n
new_rate = sum(new_results[i] for i in ids) / n
regressions = [i for i in ids if old_results[i] and not new_results[i]]
if not regressions and new_rate >= old_rate:
decision = "go"
elif old_rate - new_rate <= adjust_band:
decision = "adjust"
else:
decision = "block"
return {
"old_rate": round(old_rate, 3),
"new_rate": round(new_rate, 3),
"delta": round(new_rate - old_rate, 3),
"regressions": regressions,
"decision": decision,
}
# Fake eval set: 10 cases, old model (e.g. sonnet-4-5) vs new (opus-4-8).
old = {f"case-{i:02d}": True for i in range(10)}
old["case-07"] = False # old already failed this one
new = dict(old)
new["case-03"] = False # new regressed one case the old passed
new["case-07"] = True # new fixed the one old failed
result = migration_gate(old, new)
for k, v in result.items():
print(f"{k}: {v}")
old_rate: 0.9
new_rate: 0.9
delta: 0.0
regressions: ['case-03']
decision: adjust
This is the offline decision rule behind the 'Pass?' diamond in the migration diagram. It takes per-case pass/fail for the old and new model on the SAME eval set and returns go, adjust, or block. It's pure stdlib — run it with python migration_gate.py.
- It computes each model's pass-rate and, crucially, the list of regressions — cases the old model passed but the new one fails.
gorequires zero regressions, not just an equal-or-better rate. - A small regression that keeps the rate within
adjust_bandreturnsadjust(tune prompts, re-run); a drop past the band returnsblock(don't ship). - The fake eval set is rigged so the new model fixes
case-07and breakscase-03— the pass-rate stays 0.9 → 0.9, but a case regressed.
What the output means: old_rate: 0.9, new_rate: 0.9, delta: 0.0, regressions: ['case-03'], decision: adjust. The aggregate held, but a case regressed — so it's not an automatic go.
Try this: Set new["case-03"] = True so nothing regresses and re-run — the decision flips to go. Then break two more cases and watch it become block.
Read the output: overall pass-rate is unchanged (0.9 → 0.9), but the models disagree on which cases — the new one fixed case-07 and broke case-03. The aggregate hid a real regression, which is why the gate reports regressions explicitly and returns adjust: go look at case-03, tune the prompt, re-run. A pass-rate that only went up would return go; a drop past the band would return block.
10 · Tech-lead — owning migrations across a fleet tech-lead
A lead's job is to make model changes routine and reversible across many services, not heroic one-offs. The levers:
Fleet-level migration discipline
- One pinned id per service, discoverable in one grep. A single
MODELconstant (or a shared config key) per service. When a deprecation lands, you wantrg 'claude-'to find every call site in seconds — not a scavenger hunt. - Evals are a shipping gate, tied to the prompt. The eval set lives with the code and runs in CI. A model or prompt change that drops the gate (the
migration_gatereturningblock) fails the build. - The model id is a flag, not a constant edit. Route it through a feature flag / config so you can canary and roll back without a deploy. Deploy-to-rollback is too slow when a migration goes sideways in production.
- Migration is a checklist, not a memory. Codify the breaking changes (prefill → structured output,
budget_tokens→ adaptive thinking, drop sampling params) so every service applies the same edits. Anthropic publishes a per-version migration guide — start from it. - Watch refusals and stop reasons after cutover. New models can refuse more appropriately (handle
stop_reason == "refusal") and hit the context window differently. Add these to the canary dashboard, not just latency and cost. - Keep the old id available during the canary. Until the new model is at 100% and holding, the old (non-retired) id is your instant fallback. Don't delete the old path until the new one has soaked.
The mental model: a model version is a dependency, and you manage it like any other — pinned, tested in CI, rolled out behind a flag, monitored, and rolled back cleanly. Prompting well (Half A) makes each service work; the migration loop (Half B) keeps it working as the model underneath it moves.
404 immediately. If you learn a model is being retired, treat the migration as time-boxed work with a deadline — not a someday task. The safe loop above is exactly what lets you hit that deadline without gambling on quality.Exercise AP9.1 — Convert a prefill to structured output
Context: The clearest way to feel the prefill-to-schema migration is to run both: watch the old prefill get rejected on a current model, then watch the schema version parse cleanly — and notice what the schema gives you that a brace never did.
Your task: Take a prompt that uses an assistant-turn prefill to force JSON (or the France-JSON example) and rewrite it for a current model using output_config.format with a real JSON schema. Confirm the prefill would 400 on claude-opus-4-8 and that the structured-output version parses with json.loads(), then state one advantage the schema gives you.
Requirements:
- Show the legacy prefill form and note it returns a 400 on
claude-opus-4-8 - Rewrite it with a typed JSON schema passed through
output_config.format - Remove the assistant-turn prefill entirely from the messages
- Confirm the structured output parses cleanly with
json.loads() - State one concrete advantage of the schema (e.g. guaranteed field types / validation) that the prefill lacked
💡 Hint: The schema does more than force JSON — it constrains which fields and types come back, which is exactly the guarantee a lone { could never give.
Exercise AP9.2 — Gate a pretend migration
Context: The whole reason a migration gate inspects cases is that the headline number lies. Build a set where the average holds steady while real cases regress, and the gate's value becomes obvious.
Your task: Invent a 12-case eval set as two dicts (old vs new pass/fail) where the aggregate pass-rate is identical but three cases regressed. Run migration_gate on it, then explain why it returns 'adjust' or 'block' despite the unchanged headline, and your next step per regressed case.
Requirements:
- Construct 12 aligned old/new cases whose total pass counts are equal
- Ensure three cases flip from pass to fail (offset by three that flip the other way)
- Run
migration_gateand report its verdict and regressed indexes - Explain in your own words why the per-case view overrides the flat aggregate
- State a concrete next step for each regressed case (re-prompt, hold, investigate)
💡 Hint: Balance every pass-to-fail flip with a fail-to-pass flip so the sum is unchanged — that is precisely the situation the aggregate can't see but the gate can.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: When instructions and data blur together, the model guesses which is which. XML tags draw a hard line so Claude knows the <document> is the material and the <question> is the ask.
Your task: Rewrite a messy instruction-plus-data prompt to use <document> and <question> tags so the model can tell content from task.
Requirements:
- Wrap the source material in
<document>...</document> - Wrap the ask in
<question>...</question> - Send the tagged text as a single user-turn message
- The tags carry the structure — no restating the task in prose outside them
- Print the model's answer
💡 Hint: Build the prompt by concatenating the tagged sections; the tags themselves are the boundary the model reads, so keep them clean and closed.
Show solution
XML tags give the model unambiguous boundaries between the data and the ask.
import anthropic
client = anthropic.Anthropic()
doc = "Our refund window is 30 days from delivery."
prompt = (
"<document>\n" + doc + "\n</document>\n"
"<question>How long do customers have to request a refund?</question>"
)
resp = client.messages.create(
model="claude-sonnet-4-6", max_tokens=128,
messages=[{"role": "user", "content": prompt}],
)
print(resp.content[0].text)
Context: Splitting the stable role from the changing data is both cleaner prompting and a caching win: a fixed system prompt can be cached across calls while only the user turn varies.
Your task: Split a prompt so the stable role/rules go in system and only the variable data goes in messages, and state why this helps caching.
Requirements:
- The
systemparameter holds the durable persona and output contract (e.g. a fixed label set) - The user turn carries only the changing input for this call
- A comment explains the stable system prompt is what becomes cacheable
- Keep
max_tokenstight for a terse classification reply - Print the model's label
💡 Hint: Anything that would be identical on the next request belongs in system; anything that changes per call belongs in messages.
Show solution
The system prompt is the stable contract; the user turn carries the changing task. Keeping the stable part separate makes it cacheable.
import anthropic
client = anthropic.Anthropic()
SYSTEM = ("You are a support classifier. Reply with exactly one label: "
"billing, technical, or other.") # stable -> cacheable
resp = client.messages.create(
model="claude-sonnet-4-6", max_tokens=8,
system=SYSTEM,
messages=[{"role": "user", "content": "My card was charged twice."}], # variable
)
print(resp.content[0].text)
Context: The old trick of prefilling the assistant turn with { to force JSON now returns a 400 on current models. The modern replacement constrains the whole reply with a JSON schema via output_config.
Your task: Take legacy code that prefilled the assistant turn with { to force JSON and replace it with output_config and a JSON schema.
Requirements:
- Remove the assistant-turn prefill entirely — there is no trailing assistant message
- Define a JSON schema with typed properties and a
requiredlist - Pass it via
output_config={"format": {"type": "json_schema", "schema": ...}} - The user turn just states the task in plain language
- The reply is valid JSON conforming to the schema — note it now parses with
json.loads()
💡 Hint: The schema, not a prefilled brace, is what pins the output shape — let output_config.format do the job the prefill used to.
Show solution
Prefilling is rejected on Opus 4.6+/Sonnet 4.6/Fable 5. The modern replacement constrains the whole reply via a JSON schema.
import anthropic
client = anthropic.Anthropic()
schema = {"type": "object",
"properties": {"label": {"type": "string"},
"confidence": {"type": "number"}},
"required": ["label", "confidence"]}
resp = client.messages.create(
model="claude-sonnet-4-6", max_tokens=128,
output_config={"format": {"type": "json_schema", "schema": schema}},
messages=[{"role": "user", "content": "Classify: the app keeps crashing."}],
)
print(resp.content[0].text) # valid JSON per the schema
Context: On long inputs, position matters: models attend better when the big document comes first and the question comes last, and a quote-then-answer instruction forces the model to ground itself before it speaks.
Your task: Assemble a long-context prompt correctly — long document first, question last, quote-then-answer — and explain why the ordering matters.
Requirements:
- The
<document>block leads the prompt - The
<question>comes last, after the instructions - Instructions require quoting the exact supporting sentence before answering
- A comment explains document-first / question-last improves grounding on long inputs
- Print the model's quote-then-answer response
💡 Hint: Order the sections document → instructions → question, and make the quote requirement explicit so the answer is anchored to a real sentence.
Show solution
Placing the document first and the question last, plus a quote-first instruction, improves grounding on long inputs.
import anthropic
client = anthropic.Anthropic()
long_doc = "...(many pages of policy text)..."
prompt = (
"<document>\n" + long_doc + "\n</document>\n"
"<instructions>First quote the exact sentence that answers the "
"question, then answer in one line.</instructions>\n"
"<question>What is the SLA for critical incidents?</question>"
)
resp = client.messages.create(
model="claude-sonnet-4-6", max_tokens=256,
messages=[{"role": "user", "content": prompt}],
)
print(resp.content[0].text)
Context: A headline pass-rate can stay flat while individual cases quietly regress. A migration gate looks case-by-case so a swap that breaks specific inputs can't sneak through on an unchanged average.
Your task: Implement migration_gate(old, new, band=0.03) over aligned per-case pass/fail lists that returns 'go' (no regressions), 'adjust' (net drop within band), or 'block' (drop beyond band), and lists the regressed case indexes.
Requirements:
- Detect a regression as a case that passed in
oldbut fails innew - Return
'go'when there are no regressions at all - Return
'adjust'when the net pass-rate drop is withinband, else'block' - Report the list of regressed case indexes and the rounded net drop
- Assert the two lists are the same length; runs fully offline
💡 Hint: Compute the regressed indexes first, then branch on whether that set is empty and, if not, on whether the net drop clears the band.
Show solution
Aggregate pass-rate hides per-case disagreements, so the gate inspects each case.
def migration_gate(old, new, band=0.03):
assert len(old) == len(new)
regressed = [i for i, (o, n) in enumerate(zip(old, new)) if o and not n]
drop = (sum(old) - sum(new)) / len(old)
if not regressed:
decision = "go"
elif drop <= band:
decision = "adjust"
else:
decision = "block"
return {"decision": decision, "regressed": regressed, "net_drop": round(drop, 4)}
old = [1,1,1,1,1,1,1,1,1,1]
new = [1,1,0,1,1,1,1,1,1,1] # one regression, 10% drop
print(migration_gate(old, new)) # block (drop 0.1 > band)
Context: Migrating a fleet of prompts to a new model is a workflow, not a flag flip: some prompts use now-broken prefills, and the rest must be proven safe per-case before any canary.
Your task: Write a migration driver that moves prompts from claude-sonnet-4-5 to claude-sonnet-4-6: detect prompts that use a prefill (a breaking change), route the safe ones through the gate, and produce a rollout plan (canary vs block).
Requirements:
- Detect a prefill by a trailing
assistant-role message and flag it for refactor tooutput_config.format - Route only the non-prefill prompts through the per-case migration gate
- Map a
'go'verdict to a canary-behind-a-flag rollout - Map any non-go verdict to a hold, surfacing which verdict caused it
- Produce a plan keyed by prompt name; runs fully offline
💡 Hint: Screen for the breaking change first so prefill prompts never reach the gate — then the gate only ever sees prompts that could actually ship.
Show solution
Migration is a workflow: find breaking changes, evaluate old vs new per case, canary the safe prompts behind a flag.
def uses_prefill(messages):
return bool(messages) and messages[-1].get("role") == "assistant"
def gate(old, new, band=0.03):
reg = [i for i,(o,n) in enumerate(zip(old,new)) if o and not n]
drop = (sum(old)-sum(new))/len(old)
return "go" if not reg else ("adjust" if drop<=band else "block")
prompts = {
"p1": {"messages": [{"role":"user","content":"x"}],
"old": [1,1,1,1], "new": [1,1,1,1]},
"p2": {"messages": [{"role":"user","content":"x"},
{"role":"assistant","content":"{"}], # prefill!
"old": [1,1,1,1], "new": [1,1,1,1]},
}
plan = {}
for name, p in prompts.items():
if uses_prefill(p["messages"]):
plan[name] = "refactor: prefill -> output_config.format"
else:
d = gate(p["old"], p["new"])
plan[name] = "canary behind flag" if d == "go" else f"hold ({d})"
print(plan)
✓ Checkpoint — you can move on when you can…
- Structure a prompt with XML tags and ask for the answer inside a named tag.
- Say what goes in the system prompt vs the user turn, and why.
- Explain prefilling, why it 400s on current models, and the
output_config.formatreplacement. - Order a long-context prompt: document first, question last, quote-then-answer.
- Say what the Console prompt improver/generator is and when to use it.
- Run the migration loop: pin id → eval old vs new → adjust → canary → full.
- Read a
migration_gateresult and act on per-case regressions, not just the pass-rate.
Knowledge check check yourself
Why did assistant-turn prefilling stop working on Opus 4.6+/Sonnet 4.6, and what replaces it for forcing JSON output?
Show answer
{ to force JSON) now returns a 400 on those models. The replacement is output_config.format with a JSON schema, which is stronger because it constrains the whole reply to match the schema rather than just biasing the first token.Why can aggregate pass-rate hide a regression during a model migration, and what does the safe migration loop do about it?