1:1 mentoring with Big Tech AI engineers
Back to blog
By AgenticPrep Team12 min read

The Claude API in Practice — Tools, Thinking, Streaming, Caching

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.

claude-apitool-useprompt-cachingtutorial

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.

THE SHAPE

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.

ModelModel IDContextInputOutput
Claude Fable 5claude-fable-51M$10$50
Claude Opus 5claude-opus-51M$5$25
Claude Sonnet 5claude-sonnet-51M$2$10
Claude Haiku 4.5claude-haiku-4-5200K$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”.

IF YOU LEARNED THIS API IN 2025

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.

Cache from the front — one moving byte early costs you the whole prefix
RENDER ORDER IS ALWAYS TOOLS → SYSTEM → MESSAGES the cache breakpoint splits what is reused from what is re-read STABLE tools system messages cache breakpoint reused every request VOLATILE tools system messages datetime.now() everything after it is re-read, every single call if cache_read_input_tokens is always zero, look for a moving byte near the front
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_reasonWhat to do
end_turnNormal completion — read the text
tool_useRun the tools, append results, call again
max_tokensTruncated. Raise the cap or stream; do not just retry
refusalDeclined on safety grounds. stop_details carries the category — and is null for every other stop reason, so guard before reading it
pause_turnLong-running server tool paused; resume by sending the response back
THE TAKEAWAY

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.

Enjoyed this post? The full curriculum has 74+ sections, system design problems, and AI-reviewed practice runs.

See the full guide