The Claude Messages API in practice
Job ads that say “LLM APIs, in particular Anthropic’s Claude (prompt design, structured outputs, tool/function calling)” are asking for one endpoint known cold: what a request looks like, what comes back, how the tool loop and the JSON guarantee work, and the operational habits — caching, streaming, retries, batches, token counting — that separate a demo from a service.
The chat API surface is the provider-neutral view; this is the Anthropic surface, Python SDK.
One endpoint, one shape
import anthropic
client = anthropic.Anthropic() # key from ANTHROPIC_API_KEY, never code
resp = client.messages.create(
model="claude-opus-5",
max_tokens=16000, # a cap, not a target - do not lowball
system="You are a support assistant for Acme. Answer from the docs given.",
messages=[
{"role": "user", "content": "How do I rotate an API key?"},
],
)
for block in resp.content: # a list of typed blocks, never a str
if block.type == "text":
print(block.text)
print(resp.stop_reason, resp.usage.input_tokens, resp.usage.output_tokens)Everything is POST /v1/messages. Tools, images, PDFs, structured output
and thinking are parameters of this call, not other endpoints. Three
things to know about the shape:
systemis separate frommessages. Operator instructions go insystem;messagesalternatesuserandassistant, starts withuser, and each message’scontentis a string or a list of blocks (text,image,document,tool_use,tool_result,thinking).- The API is stateless. Every request carries the whole history. That
is your responsibility, and the mistake that follows is appending only
the text of a reply — dropping
tool_useandthinkingblocks that the next turn needs. Appendresp.content, the whole list. stop_reasonis the control flow:end_turn,max_tokens(you truncated it — raise the cap),tool_use(run the tools),refusal(checkstop_details),pause_turn(re-send to continue).
Prompt design that survives production
- Stable first, variable last. Instructions, examples and reference
documents in
system; the user’s turn at the end. This is both the clearer prompt and the cacheable one (below). - Structure the input, not just the instructions. Wrap documents and
user data in labelled tags —
<document>,<ticket>— so the model can tell content from instruction; that is also half of your injection defence (Prompt injection). - Say what the output is for, and give one worked example of the shape when the shape matters. Do not ask for JSON in prose when structured output exists.
- Assistant prefill is gone on current models (a 400); format control
is
output_config, not a half-written answer. - Thinking is on by default on the current Opus;
thinking={"type": "adaptive"}makes it explicit andoutput_config={"effort": "low"}turns spend down for simple routes. The oldbudget_tokensis rejected on current models.
The prompt-engineering craft — few-shot, decomposition, evaluation — is in Prompt Engineering.
Structured outputs
from pydantic import BaseModel
class Ticket(BaseModel):
category: str
priority: int
needs_human: bool
summary: str
resp = client.messages.parse( # SDK helper: schema in, model out
model="claude-opus-5",
max_tokens=16000,
messages=[{"role": "user", "content": f"<ticket>{text}</ticket>"}],
output_format=Ticket,
)
ticket: Ticket = resp.parsed_output
# The raw form on create(): output_config, not the deprecated output_format
resp = client.messages.create(
..., output_config={"format": {"type": "json_schema",
"schema": Ticket.model_json_schema()}},
)The guarantee is that the text block parses and validates against the schema — a grammar constraint at decode time, not a strong tendency. What it does not guarantee is that the values are true, or that constraints the grammar cannot express (ranges, formats, cross-field rules) hold; run Pydantic after.
Schemas need additionalProperties: false and every property in
required; express optionality as a union with null. The mechanism and
its limits:
Function calling and structured output.
Tool use: the loop is yours
TOOLS = [{
"name": "get_order",
"description": "Look up an order by its ID. Use when the user cites one.",
"input_schema": {"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"], "additionalProperties": False},
"strict": True, # arguments validate exactly
}]
messages = [{"role": "user", "content": "Where is order A-1043?"}]
while True:
resp = client.messages.create(model="claude-opus-5", max_tokens=16000,
tools=TOOLS, messages=messages)
messages.append({"role": "assistant", "content": resp.content})
if resp.stop_reason != "tool_use":
break
results = []
for block in resp.content:
if block.type == "tool_use": # block.input is already a dict
try:
out = run_tool(block.name, block.input)
results.append({"type": "tool_result",
"tool_use_id": block.id, "content": out})
except ToolError as e:
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": str(e), "is_error": True})
messages.append({"role": "user", "content": results}) # ALL results, one messageWhat the model emits
The model does not call your function. It emits a tool_use block — an
id, a name, an input dict — and stops; you run the code, append the
assistant turn as-is, and reply with a user message of tool_result
blocks whose tool_use_ids match.
Two rules that are easy to break: a turn may contain several tool_use
blocks and every result goes back in one user message (splitting them
teaches the model to stop parallelising); and a failed tool returns
is_error: true, never nothing. tool_choice can force a tool where the
model still allows it; for most code {"type": "auto"} plus a clear
description is the right answer.
The SDK’s beta tool runner (@beta_tool on a typed function, then
client.beta.messages.tool_runner(...)) writes this loop for you and
generates the schema from the docstring — a Google-style docstring, which
is also what the ad’s documentation line means. The loop above is what to
be able to draw on a whiteboard.
Streaming
aclient = anthropic.AsyncAnthropic()
async def relay(ws, messages): # e.g. inside a FastAPI websocket
async with aclient.messages.stream(model="claude-opus-5", max_tokens=64000,
messages=messages) as stream:
async for text in stream.text_stream: # text deltas as they arrive
await ws.send_text(text)
return stream.get_final_message() # usage, stop_reason, blocksStreaming changes the felt latency, not the total — the first token
arrives in under a second instead of after the whole answer. The events
are message_start, content_block_start, content_block_delta (with
text_delta, thinking_delta or input_json_delta for tool arguments),
content_block_stop, message_delta (carries stop_reason and usage),
message_stop. Use text_stream unless you need the events.
The SDK refuses a non-streaming call whose max_tokens implies a
10-minute response, so long generations stream by construction. Relaying to a browser is an SSE or WebSocket problem: Server-Sent Events (SSE).
Prompt caching
resp = client.messages.create(
model="claude-opus-5", max_tokens=16000,
system=[{"type": "text", "text": BIG_STABLE_PROMPT,
"cache_control": {"type": "ephemeral"}}], # 5 min; "ttl": "1h"
messages=messages,
)
resp.usage.cache_read_input_tokens # zero every time => something variesCaching is a prefix match over tools → system → messages; one
changed byte before the breakpoint invalidates it. So: no timestamp, user
ID or unsorted json.dumps in the system prompt, a fixed tool list in a
fixed order, and dynamic context in the user turn.
Up to four breakpoints; prefixes shorter than the model’s minimum (hundreds to a few thousand tokens) silently do not cache. Cache reads cost about a tenth of an uncached token, which on a long system prompt is most of the bill.
Batches and token counting
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request
batch = client.messages.batches.create(requests=[
Request(custom_id=doc.id, params=MessageCreateParamsNonStreaming(
model="claude-opus-5", max_tokens=4000, messages=[...]))
for doc in docs])
# poll client.messages.batches.retrieve(batch.id).processing_status == "ended"
for r in client.messages.batches.results(batch.id): # any order: key by id
if r.result.type == "succeeded":
store(r.custom_id, r.result.message)
n = client.messages.count_tokens(model="claude-opus-5", system=system,
messages=messages).input_tokensBatches run asynchronously at half price, up to 100,000 requests, usually
within the hour — the right shape for nightly classification, backfills
and evals. count_tokens is the only correct tokenizer; tiktoken is a
different model’s and undercounts.
Errors, retries, concurrency
try:
resp = client.with_options(timeout=30.0, max_retries=3).messages.create(...)
except anthropic.RateLimitError as e: # 429: honour retry-after
wait = int(e.response.headers.get("retry-after", "10"))
except anthropic.APIStatusError as e: # other non-2xx; 529 = overloaded
log.error("claude %s %s req=%s", e.status_code, e.message, e.request_id)
except anthropic.APIConnectionError: # never got a response
...The SDK already retries 408, 409, 429 and 5xx with backoff (max_retries
default 2); a hand-rolled loop on top double-retries. Catch a chain, most
specific first — a single broad except loses the difference between “back
off” and “fix the request”. For throughput use anthropic.AsyncAnthropic
with asyncio.gather under a semaphore sized to your rate limit, and log
resp._request_id on every failure you report.
Where it breaks in production
- History rebuilt from text. Tool-use and thinking blocks dropped on
replay; the next turn cannot see what it called. Append
resp.content. max_tokensset to 500 “to save money”. The answer truncates withstop_reason == "max_tokens"and the retry costs more than the cap saved.- A date in the system prompt. Zero cache hits, full price on a 6k-token prefix, and nobody notices for a quarter.
- Model IDs from memory. A date-suffixed or retired ID is a 404 in production; pin the exact current string from the models list, in config.
- Secrets and content in logs. The API key in a repo, and user conversations in the logging platform at full fidelity. Env vars, and redact before logging.
- One broad
except. 400s retried, 429s not backed off, and the on-call cannot tell a bad request from an outage.
Related
Interview angle 4
- “Walk me through a tool call with Claude.” - the model returns
stop_reason: tool_usewithtool_useblocks (id, name, input); I run the code, append the assistant content unchanged, and reply with a user message holding onetool_resultper call, matched bytool_use_id, errors flagged withis_error. Loop untilend_turn. Follow-up: what if it calls three tools at once? Run them concurrently and return all three results in the same user message. - “How do you get reliable JSON out of it?” -
output_config.formatwith a JSON Schema (ormessages.parsewith a Pydantic model): the output is grammar-constrained to parse. Then Pydantic for ranges and cross-field rules, because the grammar guarantees shape, not truth. - “Your token bill doubled - where do you look first?” - cache hits.
usage.cache_read_input_tokensat zero means the prefix changes per request; the usual culprit is a timestamp or user ID in the system prompt. Fix the ordering, put a breakpoint on the stable block, and the long prefix costs a tenth. - “How do you handle rate limits?” - the SDK retries 429 and 5xx with
backoff already; I set
max_retriesand a timeout per call, honourretry-afterwhen I do retry myself, bound concurrency with a semaphore on the async client, and move anything not latency-sensitive to the Batches API at half price.