The Triage Request
A ticket lands in the queue — "charged twice for invoice 88" — and the product needs a category, an urgency, and a one-line summary back. The chat-completions shape from Your First LLM API Call looks close enough to copy, so you paste a system entry into messages and post it. The endpoint answers with 400 twice, and each rejection teaches one of the two structural differences that define this API.
from anthropic import Anthropic
client = Anthropic() # key resolved from $ANTHROPIC_API_KEY
message = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024, # omit it and the request 400s
system="You are a support triage bot. Reply with "
"category, urgency, summary — nothing else.",
messages=[
{"role": "user", "content": "Ticket: charged twice for invoice 88"},
],
)
print(message.content[0].text)
print(message.stop_reason, message.usage.output_tokens)
Input: Ticket: charged twice for invoice 88
Output:
category=billing urgency=medium summary=duplicate charge on invoice 88
end_turn 34
The envelope differs from the one you already know, and the differences are the whole lesson:
chat-completions messages endpoint
---------------- -----------------
{ {
"model": "...", "model": "...",
"messages": [ "system": "...", <- top-level, not a role
{"role":"system", ...}, ---> "max_tokens": 512, <- required, no default
{"role":"user", ...} "messages": [
] {"role":"user", ...}
} ] }
Three non-obvious lines in the code above:
system=is a top-level argument. Onlyuserandassistantare legal roles insidemessages, so{"role": "system", ...}in the list is rejected withInput should be 'user' or 'assistant'.max_tokens=1024has no default. Leave it out and the response ismax_tokens: Field required. Bound it from the longest answer you expect: generation stops there and comes back withstop_reason="max_tokens".message.content[0].text— the reply is a list of typed blocks (text, tool use, thinking), not a string. Indexing block zero is right for a plain answer and silently wrong once the model emits several blocks.
message.usage.input_tokens and message.usage.output_tokens are the billing pair, playing the role OpenAI's usage fields play in OpenAI SDK Essentials. The system prompt counts as input on every request, so a 900-word one is a recurring charge — Token Cost Estimation is where that gets priced. Which standing rules belong in the system prompt versus the dialogue is decided in Messages, Roles, and Turn Structure, which picks up this exact request.
Cutting the Answer with Stop Sequences
The consumer of the triage verdict is a parser, and parsers break on prose. Ask for "category, then urgency, then summary" in one paragraph and the separator between fields is whatever punctuation the model felt like using that run. A stop sequence removes the guesswork: generation halts at a delimiter you chose, and you receive exactly the text before it.
message = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=512,
stop_sequences=["\n\n"], # halt at the blank line
messages=[{
"role": "user",
"content": "Ticket: charged twice for invoice 88\n"
"Report the verdict as: CATEGORY <tab> URGENCY",
}],
)
print(repr(message.content[0].text))
print(message.stop_reason, repr(message.stop_sequence))
Input: the ticket above with the CATEGORY <tab> URGENCY format instruction.
Output:
'billing\tmedium'
'stop_sequence' '\n\n'
stop_reason says why generation stopped, and each value sends you down a different branch:
| stop_reason | Meaning | What your code does |
|---|---|---|
end_turn |
The model finished its own answer | Read the text and proceed |
max_tokens |
The token cap was reached mid-answer | Raise the bound or send a follow-up turn |
stop_sequence |
One of your delimiters matched | Split at the boundary, then branch on stop_sequence |
tool_use |
The model wants to call a tool | Parse input off the tool block and execute it |
Two sharp edges. The delimiter is matched anywhere it appears in the output, so a stop sequence of " kills the answer the moment the model quotes something — pick markers a model has no reason to write (<<<, END, a tab-run). And when you pass several sequences, message.stop_sequence names the one that matched: the text itself ends at the boundary and cannot tell you which rule fired, so anything downstream that branches per delimiter has to read that field.
Stop sequences are also the cheapest guarantee you get for answers that must not leak past a marker — a JSON blob, a code block, a signature line — because nothing needs to be un-sent. Post-hoc regex over free text only ever approximates the same boundary.
Streaming the Verdict, Handling What Breaks
A triage bot that reveals nothing until the whole answer lands feels broken at 800 tokens even when the model finished in two seconds. The SDK's stream helper turns the same request into text fragments you render as they arrive — and forces you to confront the failures that a non-streaming call hides behind one create().
from anthropic import (
Anthropic, AuthenticationError, BadRequestError,
RateLimitError, APITimeoutError,
)
client = Anthropic(timeout=30.0)
try:
with client.messages.stream(
model="claude-sonnet-4-5",
max_tokens=512,
system="You are a support triage bot.",
messages=[{"role": "user", "content": ticket}],
) as stream:
for text in stream.text_stream:
render(text) # a finished fragment, ready to display
except AuthenticationError: # 401: key missing, revoked, mistyped
alert("bad ANTHROPIC_API_KEY") # config fault — retrying changes nothing
except BadRequestError as exc: # 400: schema or model name wrong
raise # your bug — fix the request, not the clock
except RateLimitError: # 429: quota or per-minute limit
schedule_retry(with_jitter())
except APITimeoutError: # budget spent before the answer finished
schedule_retry(with_jitter())
Input: Ticket: my order never arrived and support ignored me
Output (rendered): category=shipping urgency=high summary=missing order, unanswered support thread
Three details make this correct rather than merely short:
- The
trywraps the wholewithblock. A 401, a 400, a 429, or a timeout raises when the stream is opened — before your loop exists — so an exception handler placed inside the loop catches nothing that matters. text_streamyields assembled fragments per content block. The raw event sequence (message_start,content_block_delta,message_delta) is available throughstream.eventswhen you need the finalstop_reasonorusage, both of which arrive inmessage_deltaafter the last text.timeout=30.0is a budget for the whole response, including the seconds of silence while the model thinks. Budget too tight andAPITimeoutErrorfires on a healthy call; budget unstated and a stalled socket parks your worker indefinitely.
Retrying a stream forces a decision the non-streaming path never raises: text already rendered cannot be un-rendered. Retry only when nothing was emitted. When you hold a partial answer, keep the buffer, mark the answer truncated, and continue in a follow-up turn. Streaming Responses takes the wire format apart event by event, and Rate Limiting and Retries owns the backoff policy behind schedule_retry.
Quiz
1. You send messages=[{"role": "system", ...}, {"role": "user", ...}] to the Messages API and get a 400. What is wrong?
2. A request with max_tokens=64 returns readable text that stops mid-sentence, with stop_reason max_tokens. What fixes the root cause?
3. You pass stop_sequences=['\n\n', 'END'] and the reply comes back with stop_reason stop_sequence. How do you learn which delimiter matched?
4. Using client.messages.stream(), where does a 429 surface?
Flashcards
Question
Where does the system prompt go in an Anthropic request?
Click to reveal answer
Answer
Top-level system argument. messages accepts only user and assistant roles, so a system entry inside the list returns 400.
Question
Why is max_tokens mandatory, and what does hitting it look like?
Click to reveal answer
Answer
There is no default output cap — omitting it returns max_tokens: Field required. Generation stops at the bound and returns stop_reason max_tokens with a text that reads as if it were finished.
Question
What do the four stop_reason values tell you?
Click to reveal answer
Answer
end_turn: the model finished. max_tokens: the cap cut it. stop_sequence: one of your delimiters matched (named by message.stop_sequence). tool_use: a tool call is waiting to be parsed and executed.
Question
What is the shape of a non-streaming Anthropic response?
Click to reveal answer
Answer
content is a list of typed blocks — message.content[0].text for plain text — alongside stop_reason and usage.input_tokens / usage.output_tokens.
Question
Which failures raise before a single token streams, and where must the handler sit?
Click to reveal answer
Answer
401 AuthenticationError, 400 BadRequestError, 429 RateLimitError, and APITimeoutError all raise when the stream opens, so the try block must wrap the with statement, not the delta loop.
Revision Notes
Key Takeaways
- 1. The system prompt is a top-level argument and messages carries only user and assistant turns — the two structural differences from chat completions
- 2. max_tokens has no default and doubles as a hard truncation point you must detect via stop_reason
- 3. stop sequences buy exact parse boundaries, and message.stop_sequence tells you which delimiter fired
- 4. Stream failures raise before the first delta, so exception handling wraps the stream open, not the delta loop
Interview Tips
- • Be ready to draw the two request envelopes side by side — interviewer ask: what changes when you move from OpenAI to Anthropic?
- • Expect a failure walkthrough: you get 400 max_tokens Field required and 400 invalid role; say what each response means and which side owns it
- • Have a number for max_tokens: how you bound it for a triage verdict versus a long-form summary, and what happens when you guess low
- • When asked about streaming reliability, separate open-time failures (401/400/429/timeout) from mid-stream interruption and say what you keep
Cheat Sheet
Anthropic SDK Essentials — Cheat Sheet
Request shape
client = Anthropic(timeout=30.0) # key from $ANTHROPIC_API_KEY
message = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024, # required, no default
system="standing rules here", # top-level, never a role in messages
stop_sequences=["\n\n"], # optional hard cut
messages=[{"role": "user", "content": "..."}],
)
Response fields
message.content[0].text→ text (content is a list of typed blocks)message.stop_reason→end_turnok ·max_tokenstruncated ·stop_sequencedelimiter hit ·tool_useparse the tool blockmessage.stop_sequence→ which delimiter matchedmessage.usage.input_tokens/.output_tokens→ billing pair
stop_reason decision table
| stop_reason | Meaning | Action |
|---|---|---|
| end_turn | finished normally | consume the text |
| max_tokens | hit the cap mid-answer | raise max_tokens or send a follow-up turn |
| stop_sequence | delimiter matched | split, then branch on stop_sequence |
| tool_use | model wants a tool | parse block.input, execute, send tool_result |
Error routing (raise at stream open, before any delta)
| Exception | Status | Handling |
|---|---|---|
| AuthenticationError | 401 | fix $ANTHROPIC_API_KEY, never retry |
| BadRequestError | 400 | payload/model is wrong — fix the request |
| RateLimitError | 429 | back off with jitter, then retry |
| APITimeoutError | — | budget exhausted — retry or extend timeout |
Streaming
with client.messages.stream(... ) as stream:
for text in stream.text_stream:
render(text)
- Retry only if nothing was rendered; keep the partial buffer otherwise
stream.eventsfor message_start / content_block_delta / message_delta (final stop_reason and usage live in message_delta)
Continue Learning
Before You Start
- No prerequisites listed.