One endpoint does everything. Tools, thinking, caching and structured output are all parameters on the same call — and several of the shapes you remember from a year ago now return a 400.
Most of what looks like API surface area is one request. POST /v1/messages takes messages, tools, and a handful of config objects, and returns content blocks. Tool use is not a separate API. Structured output is not a separate API. Once that lands, the rest is knowing which parameters exist and which ones quietly changed.
Everything goes through the Messages endpoint. What varies is what you attach to it: tools for function calling, thinking for reasoning depth, cache_control for cost, output_config for effort and response format. See the Messages API section for the full field reference.
Models, context, and what they cost
List prices per million tokens at the time of writing — check current pricing before you budget anything, because this is the part that moves.
| Model | Model ID | Context | Input | Output |
|---|---|---|---|---|
| Claude Fable 5 | claude-fable-5 | 1M | $10 | $50 |
| Claude Opus 5 | claude-opus-5 | 1M | $5 | $25 |
| Claude Sonnet 5 | claude-sonnet-5 | 1M | $2 | $10 |
| Claude Haiku 4.5 | claude-haiku-4-5 | 200K | $1 | $5 |
Two things to note. The IDs are complete as written — appending a date suffix is a habit from older snapshots and gets you a model-not-found. And the ratio that matters for architecture is output tokens costing five times input across the board, which is why the cost lever people reach for last (shorter outputs) is often the one with the most left in it.
The request everything else builds on
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY, or a logged-in profile
response = client.messages.create(
model="claude-opus-5",
max_tokens=16000,
system="You answer support questions from the retrieved policy text only.",
messages=[{"role": "user", "content": "Can I return an opened item?"}],
)
for block in response.content:
if block.type == "text":
print(block.text)
response.content is a list of blocks, not a string. Reaching straight for content[0].text works right up until the model returns a thinking block or a tool call first, which is the single most common source of “it worked in testing”.
Four things now return a 400 rather than working: temperature, top_p and top_k (sampling parameters are gone on current models), budget_tokens inside thinking, assistant-message prefill, and the old top-level output_format. Each has a replacement below.
Thinking and effort replaced the knobs you used to turn
Where you once tuned temperature, you now choose how hard the model works. Two parameters, and they compose.
response = client.messages.create(
model="claude-opus-5",
max_tokens=16000,
thinking={"type": "adaptive", "display": "summarized"},
output_config={"effort": "high"}, # low | medium | high | xhigh | max
messages=[{"role": "user", "content": "..."}],
)
adaptive lets the model decide when and how deeply to think, which is what replaced fixed token budgets. effort is the dial: low for routing and subagents, high as the default, xhigh for coding and long-horizon agent work, max when correctness outweighs cost.
One non-obvious default: on current models, thinking output is omitted unless you ask for it. If you stream reasoning to users and see a long pause before any text, that is why — set display: "summarized" explicitly.
Tool use, and the mistake that silently kills parallelism
A tool is a name, a description, and a JSON schema. The loop is: call, check stop_reason, run what it asked for, send the results back.
tools = [{
"name": "get_order",
"description": ("Look up one order by id. Use for status, items and shipping. "
"Do NOT use for refunds - call create_refund."),
"input_schema": {
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"],
"additionalProperties": False,
},
"strict": True, # guarantees the input validates against the schema
}]
while True:
response = client.messages.create(
model="claude-opus-5", max_tokens=16000,
tools=tools, messages=messages,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break
# Every tool_result for this turn goes back in ONE user message.
results = [
{"type": "tool_result", "tool_use_id": b.id, "content": run(b)}
for b in response.content if b.type == "tool_use"
]
messages.append({"role": "user", "content": results})
That last comment is the part worth internalising. One assistant message can contain several tool_use blocks, and splitting their results across multiple user messages trains the model to stop asking for parallel calls at all. Your agent gets slower and you never see an error. A failed tool still returns a tool_result, with is_error: True — dropping it is the other half of the same bug.
strict: True belongs on the tool definition, not on tool_choice, and needs additionalProperties: false plus required. For the description itself, tool surface design and the MCP post both make the same case: the sentence saying when not to use a tool does most of the work.
Streaming is not a nicety above a certain size
Current models will emit up to 128K output tokens, and a non-streaming request that large will hit an HTTP timeout before it finishes. The SDK enforces the practical rule: stream anything with a big max_tokens.
with client.messages.stream(
model="claude-opus-5", max_tokens=64000,
messages=messages,
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
final = stream.get_final_message() # the assembled Message when you need it
Sensible defaults: around 16K max_tokens for non-streaming calls, around 64K when streaming. Setting it low to save money does not work — you are billed for what is generated, and a truncated answer costs a retry. See streaming.
Prompt caching is a prefix match — and that is the whole design constraint
Caching reuses the computed state for the front of your prompt. The word prefix carries all the implications: it only works from the very beginning, and any byte that changes before your breakpoint invalidates everything after it. Request order is fixed: tools, then system, then messages.
response = client.messages.create(
model="claude-opus-5", max_tokens=16000,
system=[{"type": "text", "text": FROZEN_INSTRUCTIONS,
"cache_control": {"type": "ephemeral"}}],
messages=messages,
)
print(response.usage.cache_read_input_tokens) # served from cache
print(response.usage.cache_creation_input_tokens) # written to cache
print(response.usage.input_tokens) # paid in full
Those three numbers are the only honest way to know caching is working. If cache_read_input_tokens stays at zero across repeated calls with the same prefix, something is moving near the front — a timestamp, a request id, a dict serialised in non-deterministic order, or a tool list you rebuild each time. A handful of breakpoints are allowed per request, and prefixes below roughly a thousand tokens may not cache at all. Prompt caching goes deeper.
Structured output, without asking nicely
Constrain the response rather than requesting a format in prose. The parameter lives inside output_config — the older top-level output_format is deprecated.
response = client.messages.create(
model="claude-opus-5", max_tokens=16000,
output_config={"format": {"type": "json_schema", "schema": TICKET_SCHEMA}},
messages=messages,
)
This buys syntactic validity, not semantic validity. A perfectly shaped object can still carry a wrong date, so keep validating the values. See structured output.
Read stop_reason before you read content
stop_reason | What to do |
|---|---|
end_turn | Normal completion — read the text |
tool_use | Run the tools, append results, call again |
max_tokens | Truncated. Raise the cap or stream; do not just retry |
refusal | Declined on safety grounds. stop_details carries the category — and is null for every other stop reason, so guard before reading it |
pause_turn | Long-running server tool paused; resume by sending the response back |
The API is one endpoint and a set of parameters, and most production problems are a handful of specific mistakes: reading content[0].text without checking the block type, splitting parallel tool results across messages, setting max_tokens low to save money, and letting a timestamp sit in front of the cache breakpoint. None of those raise an error. All four are visible in usage and stop_reason if you look.
Next: tool use in depth, extended thinking, and cost and latency for the arithmetic behind the model choice.