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
- Raw inputEmail, PDF text or form
- Build requestSystem prompt, tagged input, schema
- Call the APIModel id and max_tokens set in config
- Check stop reasonend_turn, max_tokens or refusal
- Parse into a typePydantic model or json.loads
- Apply business rulesRanges, formats, allowed values
- Store or send to reviewFailures become test cases
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.
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
Design a schema and validator without an API key
30 minGoal: Practice the validation half of the pattern, which you can run today for free.
- Install pydantic with pip install pydantic.
- Copy the Invoice model from this lesson into a file.
- Write three JSON strings by hand: one valid, one with total as text, one missing currency.
- Call Invoice.model_validate_json on each and print the result or the error.
- 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.
Iterate a system prompt in a free chat
20 minGoal: See how instructions and tags change the output.
- In a free LLM chat, paste the system prompt from this lesson as your first message, then an invoice text inside <invoice> tags.
- Ask for JSON with the four fields and check the result.
- Try an invoice with no invoice number and see what happens.
- 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
- What goes in the system prompt and what goes in the user message?
- What does structured output guarantee, and what does it not guarantee?
- Name three stop reasons and what your code should do for each.
- Why should the input data be placed inside tags?