a blurry photograph of a colorful object

Your First LLM API Call: Messages, Streaming and Tokens

·

Every integration with a language model starts in the same place: one HTTP request, one list of messages, one response. Frameworks, agents and orchestration layers are all optional. Your first LLM API call is not, so this post covers the bit everything else is built on — the messages shape, system prompts, streaming, and knowing what a request costs before you send it.

Install the SDK and make one call

Use the official Anthropic SDK rather than hand-rolling requests. It handles retries, timeouts and streaming state for you, and it gives you typed errors instead of dictionary archaeology.

python3 -m venv .venv
source .venv/bin/activate
pip install anthropic
export ANTHROPIC_API_KEY=sk-ant-...

Install it into a virtualenv, not system Python — if you want the reasoning behind that, I covered it in my guide to pip. Then the smallest thing that works:

import anthropic

client = anthropic.Anthropic()

response = client.messages.create(
    model="claude-opus-5",
    max_tokens=16000,
    messages=[{"role": "user", "content": "Summarise HNSW indexes in two sentences."}],
)

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

Note the loop. response.content is a list of content blocks, not a string. Reaching straight for response.content[0].text works right up until a non-text block arrives first, and then it throws in production. Check block.type every time.

The messages shape

The API is stateless. There is no session, no conversation ID on the server side. You send the entire history on every request, and the server sends back one assistant turn.

  • Each message is {"role": "user" | "assistant", "content": ...}.
  • The first message must be from user.
  • content is either a plain string or a list of typed blocks (text, image, tool results).
  • The system prompt is not a message. It is its own top-level parameter.
messages = [
    {"role": "user", "content": "What's the capital of Scotland?"},
    {"role": "assistant", "content": "Edinburgh."},
    {"role": "user", "content": "And roughly its population?"},
]

response = client.messages.create(
    model="claude-opus-5",
    max_tokens=16000,
    system="Answer in one short sentence. No preamble, no caveats.",
    messages=messages,
)

The system prompt is where you put role, tone, output rules and hard constraints. Keep it stable across requests — it sits at the front of the prompt, which is exactly where prompt caching gets you a discount on repeated context. One thing that has changed: assistant prefill (ending your messages array with a partial assistant turn to force a format) is rejected on current models. Use structured outputs or an explicit system instruction instead.

Stream by default

Streaming is not only about a nice typewriter effect. A long non-streaming request holds an idle HTTP connection open, and idle connections get dropped by proxies and load balancers. The SDK will actually refuse a non-streaming request whose max_tokens it estimates could run past the timeout.

with client.messages.stream(
    model="claude-opus-5",
    max_tokens=64000,
    system="You are a concise technical writer.",
    messages=[{"role": "user", "content": "Explain connection pooling."}],
) as stream:
    for text in stream.text_stream:
        print(text, end="", flush=True)

    final = stream.get_final_message()

print()
print(f"in={final.usage.input_tokens} out={final.usage.output_tokens}")

get_final_message() is the important line. Even if you do not care about individual chunks, streaming plus that call gives you the complete assembled message with timeout protection and no event handling. In TypeScript it is the same idea: client.messages.stream({...}) and await stream.finalMessage().

What tokens actually cost

Billing is per million tokens, split between input and output, and output is the expensive half — for Claude Opus 5 it is $5.00 per million input tokens and $25.00 per million output tokens. That five-to-one ratio drives almost every cost decision you will make: a bloated system prompt is annoying, an uncapped verbose response is what shows up on the bill.

usage = final.usage
cost = (usage.input_tokens * 5 + usage.output_tokens * 25) / 1_000_000
print(f"${cost:.5f}")

Count before you send

You do not have to guess at input size. There is an endpoint that counts tokens for the exact request you are about to make, which is the only way to catch an oversized payload before it becomes a 400 or an unexpectedly large invoice.

count = client.messages.count_tokens(
    model="claude-opus-5",
    system=system_prompt,
    messages=messages,
)

print(count.input_tokens)
if count.input_tokens > 200_000:
    raise SystemExit("payload too large — chunk it or summarise first")

Do not reach for tiktoken to do this. It is a different vendor’s tokeniser and it undercounts here, badly on code and non-English text. Count with the model you are actually going to call.

Errors you will hit

Catch a chain, not one broad exception. The distinction that matters is retryable versus not.

try:
    response = client.messages.create(...)
except anthropic.BadRequestError as e:
    log.error("malformed request: %s", e.message)   # never retry this
except anthropic.RateLimitError as e:
    wait = int(e.response.headers.get("retry-after", "60"))
    log.warning("rate limited, retry in %ss", wait)
except anthropic.APIStatusError as e:
    if e.status_code >= 500:
        log.warning("upstream error %s, retry later", e.status_code)
    else:
        raise
except anthropic.APIConnectionError:
    log.warning("network problem, retry later")

The SDK already retries 429s, 5xx responses and connection errors twice with backoff, so only add your own retry loop if you need something beyond that. Also check response.stop_reason: max_tokens means you got a truncated answer, not a finished one, and it is silent unless you look.

What I’d do

Stream everything, use get_final_message(), and log usage.input_tokens, usage.output_tokens and stop_reason for every request from day one. That one habit turns cost and truncation from mysteries into a query. Count tokens before you send anything user-supplied, and keep the system prompt boring and stable so it caches. If you are wiring this into scripts rather than a service, my notes on scripting Python with Claude Code cover the surrounding plumbing.


Leave a Reply