Skip to content
beginner Phase 3 · LLM API Fundamentals

Anthropic SDK Essentials

Send messages to Claude models with system prompts, max tokens, and stop sequences.

45m
0 problems
Topic Progress 0%

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. Only user and assistant are legal roles inside messages, so {"role": "system", ...} in the list is rejected with Input should be 'user' or 'assistant'.
  • max_tokens=1024 has no default. Leave it out and the response is max_tokens: Field required. Bound it from the longest answer you expect: generation stops there and comes back with stop_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 try wraps the whole with block. 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_stream yields assembled fragments per content block. The raw event sequence (message_start, content_block_delta, message_delta) is available through stream.events when you need the final stop_reason or usage, both of which arrive in message_delta after the last text.
  • timeout=30.0 is a budget for the whole response, including the seconds of silence while the model thinks. Budget too tight and APITimeoutError fires 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?

Question 1 options

2. A request with max_tokens=64 returns readable text that stops mid-sentence, with stop_reason max_tokens. What fixes the root cause?

Question 2 options

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?

Question 3 options

4. Using client.messages.stream(), where does a 429 surface?

Question 4 options

Flashcards

Question

Where does the system prompt go in an Anthropic request?

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?

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?

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?

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?

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_turn ok · max_tokens truncated · stop_sequence delimiter hit · tool_use parse the tool block
  • message.stop_sequence → which delimiter matched
  • message.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.events for message_start / content_block_delta / message_delta (final stop_reason and usage live in message_delta)

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading