Claude for Developers

Lesson 3 of 24

The anatomy of a Messages API call

Everything you will ever build with Claude — chat, extraction, RAG, agents — goes through one endpoint: the Messages API. Tools, structured output, caching, and streaming are all features of this single call, not separate products. Understand its shape deeply once and every later lesson becomes a variation on a theme.

The minimal call, and what each part means

python
import anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from the environment

response = client.messages.create(
    model="claude-opus-5",
    max_tokens=1024,
    messages=[
        {"role": "user", "content": "Summarize why HTTP/2 multiplexing matters, in 3 bullets."}
    ],
)

for block in response.content:
    if block.type == "text":
        print(block.text)

Three parameters are required, and each carries a design decision:

  • model — the exact ID string from lesson one.
  • max_tokens — a hard cap on generated output, not a suggestion. If the model hits it, generation stops mid-thought and the response reports stop_reason: "max_tokens". On current models the cap covers internal thinking plus visible text together, so a tightly-sized cap can truncate answers that would have fit under older models. Don't lowball it: around 16,000 is a sane default for non-streaming calls, and only go low when you have a hard reason (a classifier that answers in one word can take 256).
  • messages — the conversation so far, as a list of {role, content} turns. The first message must be from user, and roles alternate between user and assistant.

Note what you read back: response.content is a list of typed blocks, not a string. Today it might hold one text block; tomorrow — when you enable thinking or tools — it will hold several kinds. Code that assumes content[0].text is code that breaks the day you adopt any advanced feature. Iterate and check block.type from day one.

The API is stateless — and that is a feature

Claude does not remember your previous call. Every request carries the full conversation history, and the model sees exactly what you send — nothing more. This feels wasteful until you realise what it buys you: total control over context. You decide what history to keep, what to summarize, what to inject, and what to drop. Every context-engineering technique in this course exists because the API is stateless.

Multi-turn is therefore just an append loop:

python
messages = []

def send(user_text: str) -> str:
    messages.append({"role": "user", "content": user_text})
    response = client.messages.create(
        model="claude-opus-5", max_tokens=16000, messages=messages,
    )
    reply = next(b.text for b in response.content if b.type == "text")
    messages.append({"role": "assistant", "content": reply})
    return reply

send("My name is Priya and I'm debugging a race condition.")
send("What's my name and what am I working on?")  # Claude knows — because you resent it

Errors are typed — handle them as a chain

The SDK raises a specific exception class per failure mode, and the useful split is retryable versus not. Rate limits (429) and server errors (5xx) are retried automatically by the SDK with exponential backoff — configure with max_retries — while 4xx errors mean your request is wrong and retrying is pure waste:

python
import anthropic

try:
    response = client.messages.create(...)
except anthropic.NotFoundError:
    ...  # bad model ID — fix the string, don't retry
except anthropic.RateLimitError as e:
    retry_after = e.response.headers.get("retry-after")
    ...  # back off (the SDK already retried twice for you)
except anthropic.APIStatusError as e:
    ...  # other HTTP error: e.status_code, e.message
except anthropic.APIConnectionError:
    ...  # network failure before any response

Two production habits worth forming now. Log response._request_id (it is public despite the underscore) — it is how you and Anthropic trace a specific request when something goes wrong. And read response.usage on every call: input_tokens and output_tokens are your cost meter, and later, your cache-hit meter.

Counting tokens before you spend them

The count_tokens endpoint answers "how big is this prompt?" against the actual model tokenizer — which matters because tokenizers differ between model generations, and third-party estimators like tiktoken are built for other providers' models and miscount Claude's tokens badly:

python
n = client.messages.count_tokens(
    model="claude-opus-5",
    messages=[{"role": "user", "content": open("big_doc.md").read()}],
).input_tokens

Use it to enforce input budgets, to decide when to summarize history, and to re-baseline costs when you change models — the same text can tokenize to meaningfully different counts across model generations, so counts measured on one model must never be reused on another.

What to take into the next lesson

One endpoint, stateless, typed blocks in, typed blocks out, typed errors when it fails. The two beginner-proofing habits — iterate content by block type, and never treat max_tokens as decoration — will save you real debugging time within the week. Next: the parameter we deliberately skipped, and the highest-leverage one there is — the system prompt.

← Previous