2.3 The Messages API: Request/Response Mechanics
2.3.1 Anatomy of a Messages API Request
The Messages API is the core surface for talking to Claude programmatically. A request is a list of messages, each with a role (user or assistant) and content, plus a model, a required max_tokens, and optional system, tools, temperature, and stream parameters.
from anthropic import Anthropic
client = Anthropic() # reads ANTHROPIC_API_KEY from the environment
resp = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
system="You are a concise assistant.",
messages=[{"role": "user", "content": "Summarize this ticket: ..."}],
)
print(resp.content[0].text)
print(resp.usage.input_tokens, resp.usage.output_tokens)2.3.1 -- Key Concept
max_tokens is required on every single Messages API request. There is no default -- omit it and the call fails before Claude ever sees your prompt.
2.3.2 System Is a Parameter, Not a Message
Conversations alternate user and assistant turns. Unlike some other chat APIs you may have used, the system prompt in the Messages API is not a message with role: "system" -- it is a separate, top-level system parameter that sits alongside messages, not inside it. This is one of the most consistently tested facts on the exam because it's an easy habit to carry over incorrectly from a different API's convention.
| What you might expect | What the Messages API actually does |
|---|---|
| A message with role: "system" as the first item | A top-level system parameter, separate from the messages list |
| Roles can be system / user / assistant | Roles alternate strictly between user and assistant |
The single most common exam trap on this task statement.
2.3.3 stop_reason: Branch On It, Don't Assume It
Every response reports why generation stopped, and integration code should branch on that value instead of assuming a response always ends the same way.
| stop_reason | Meaning |
|---|---|
| end_turn | The model finished its answer naturally |
| max_tokens | The response was cut off by the token limit -- likely truncated, not complete |
| stop_sequence | A configured stop sequence was hit |
| tool_use | Claude wants to call a tool -- your code must execute it and continue the loop |
stop_reason values and what integration code should do with each.
A response with stop_reason == "max_tokens" is not a complete answer that happens to be short -- it's a truncated one, and code that treats it as final without checking will silently ship cut-off output.
2.3.4 usage & Statelessness
Every response includes a usage object (input_tokens, output_tokens, plus cache read/creation tokens where caching is in play) -- the basis for any cost model you build on top of the API. Separately, and just as important: the Messages API itself is stateless. It retains no memory of previous calls. If you want a multi-turn conversation, you resend the entire conversation history yourself on every request.
# Statelessness in practice: the caller owns conversation history
history = [{"role": "user", "content": "What's our refund policy?"}]
resp1 = client.messages.create(model="claude-sonnet-4-5", max_tokens=512, messages=history)
history.append({"role": "assistant", "content": resp1.content[0].text})
history.append({"role": "user", "content": "And for enterprise customers specifically?"})
resp2 = client.messages.create(model="claude-sonnet-4-5", max_tokens=512, messages=history)
# The API never remembers resp1 on its own -- history is resent in full each call.Exam trap
Two facts get tested together constantly: the system prompt is a top-level parameter (not role: "system"), and the API is stateless (it does not remember prior turns). Assuming either one works like a typical chat framework is the trap.
2.3.5 Streaming, Tool Use, and Vision
Three capabilities round out the core surface. Streaming (stream=True) delivers the response as server-sent events as Claude generates it -- you consume message_start, content_block_delta, and message_stop events and assemble the text incrementally. This lowers perceived latency for interactive UIs; it does not reduce total tokens or cost, and it does not change what gets billed -- only when it arrives.
Tool use lets Claude call functions you've described. Pass a tools array of JSON-schema tool definitions; when Claude decides to use one, stop_reason is tool_use and content contains a tool_use block naming the tool and its arguments. Your code executes the tool and sends the result back as a tool_result block inside a new user message -- the response is not a final answer, it's a signal to continue the loop.
Vision means content blocks can include images (base64-encoded or by URL) alongside text in the same request, so Claude can read a screenshot, a chart, or a scanned document as part of the same call that carries your text instruction. It's the same Messages API, not a separate endpoint.
# Consuming a tool_use response and continuing the loop
resp = client.messages.create(model="claude-sonnet-4-5", max_tokens=1024,
tools=[lookup_order_tool], messages=messages)
if resp.stop_reason == "tool_use":
tool_block = next(b for b in resp.content if b.type == "tool_use")
result = run_tool(tool_block.name, tool_block.input)
messages.append({"role": "assistant", "content": resp.content})
messages.append({"role": "user", "content": [
{"type": "tool_result", "tool_use_id": tool_block.id, "content": result}
]})2.3.6 Structured Outputs: Constraining Output Shape at the API Level
Every prompting technique covered so far -- system prompts, few-shot examples, a written output constraint -- shapes output by asking Claude to follow a format and hoping it does. That works on the inputs you tested and can still slip on an edge case you didn't, because a written instruction is a request, not an enforcement mechanism. Structured outputs take a different approach: through constrained decoding, the API itself narrows which tokens Claude is even allowed to generate next to whatever stays valid against your schema, so a response breaking that schema was never a token sequence the model had access to. That's a categorically different guarantee than "the model was told to produce JSON and usually does."
Two distinct mechanisms fall under structured outputs, and they constrain two different parts of a request. JSON outputs constrain the final response text -- set output_config.format with type json_schema and your schema, and the response text is guaranteed to match it. Strict tool use constrains the arguments Claude sends to a tool -- set strict: true directly on the tool definition, and Claude's arguments are validated against input_schema before your code ever runs.
# JSON outputs: constrain the response itself against a schema
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
output_config={
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"category": {"type": "string", "enum": ["BILLING", "TECHNICAL", "ESCALATION"]},
"summary": {"type": "string"},
},
"required": ["category", "summary"],
"additionalProperties": False,
},
},
},
messages=[{"role": "user", "content": f"Extract fields from: {ticket_text}"}],
)
# Strict tool use: constrain the ARGUMENTS Claude passes to a tool
tools = [{
"name": "book_flight",
"description": "Book a flight to a destination on a given date.",
"strict": True,
"input_schema": {
"type": "object",
"properties": {
"destination": {"type": "string"},
"date": {"type": "string", "format": "date"},
"passengers": {"type": "integer", "enum": [1, 2, 3, 4]},
},
"required": ["destination", "date", "passengers"],
"additionalProperties": False,
},
}]Strict tool use matters most in agentic loops: a malformed tool argument doesn't just produce an odd response, it can crash the function it's handed to or trigger the wrong action outright. strict: true pushes that risk earlier in the pipeline -- by the time your code receives the call, the arguments have already passed validation against the contract you wrote, so there's nothing left to check before acting on them. This complements the manual schema-design discipline covered later in this module (required/optional fields, exclusion conditions in descriptions) -- that discipline decides what the schema means; strict: true guarantees Claude's arguments actually respect that shape every single time, not just on the inputs you happened to test.
- •First-request latency: before the API can enforce a schema it has to compile it into a grammar, a one-time build cost on the first call. That compiled grammar sits in cache for 24 hours since it was last used, so a schema that stays stable across many calls pays the build cost once; a workload that swaps schemas constantly re-pays it every time.
- •Input tokens tick up: turning on structured outputs makes the API inject its own system prompt describing the required format, and that addition is billed as ordinary input -- a small bump per call, but one to fold into a cost model once volume is high.
- •A schema guarantee isn't a success guarantee: stop_reason values of refusal (the model declined to answer for safety reasons) or max_tokens (generation was cut off partway) can still leave you without valid output, constraint or not. Your code still has to check stop_reason.
- •Won't combine with message prefilling: JSON outputs and prefilling the assistant's turn can't both govern one request -- pick whichever one the task actually needs.
2.3.6 -- Key Concept
Constrained decoding is enforced at generation time, not requested in words. But "the schema is enforced" and "the response is complete and usable" are two separate guarantees -- a refusal or a max_tokens cutoff can still leave stop_reason pointing at trouble even with output_config.format set.
2.3.7 The Four Message Block Types & the tool_use/tool_result Pairing Invariant
A tool-use conversation is not plain text -- each turn's content is a list of typed blocks, and four block types do the work: text (Claude's prose), tool_use (a tool name, a unique id, and input arguments), tool_result (the matching tool_use_id, result content, and an optional is_error flag), and thinking (Claude's internal reasoning, present only when extended thinking is enabled, carrying an opaque signature).
| Block type | Appears on | Rule that governs it |
|---|---|---|
| text | assistant | If paired with a tool_use block in the same turn, both must be preserved when appended to history -- dropping the text block corrupts context Claude relied on |
| tool_use | assistant | Must be answered by a tool_result in the immediately following user turn, matched by id |
| tool_result | user | tool_use_id must match exactly -- this is how Claude reconnects a result to its call when a turn issues several at once |
| thinking | assistant (extended thinking only) | Must be passed back to the API completely unchanged on the next turn, signature included, or the request is rejected |
The four message block types in a tool-use conversation and the rule each one enforces.
The pairing invariant is the rule the exam leans on hardest: every tool_use block an assistant turn emits must be answered by a tool_result block with a matching id, in the user turn immediately after -- not two turns later, not with a mismatched id, not omitted. A violation fails request validation. This isn't a prompting problem; it's structural, and your code has to get the sequence right on every request. Domain 8 covers the broader question of who executes a tool call inside the agent loop; this note is about the wire-level block contract that loop has to satisfy underneath, regardless of who's driving it.
Streaming turns the tool_use block into a moving target. Its input argument arrives as a partial JSON string spread across many content_block_delta events, and that string is not valid JSON until content_block_stop fires. Parsing or acting on it earlier either throws on malformed JSON or, worse, occasionally succeeds on a syntactically valid but semantically incomplete fragment -- and the tool then runs with arguments missing. The discipline is simple: collect the deltas, act only after content_block_stop.
The same discipline extends to how a stream ends. A turn is complete only once message_stop has arrived -- not whenever the read loop happens to exit. If a handler commits whatever it accumulated to conversation history the moment its loop ends, a dropped network connection mid-tool_use-block leaves a partially-assembled block sitting in history. The next request then fails validation, and because the corrupted turn is now several messages back, the error looks unrelated to the dropped stream that actually caused it. On an interrupted stream, discard the partial assistant turn entirely and retry the original request -- don't save it and don't patch around it.
blocks, complete = {}, False
try:
with client.messages.stream(model=model, max_tokens=4096,
messages=messages, tools=tools) as stream:
for event in stream:
if event.type == "content_block_start":
blocks[event.index] = init_block(event)
elif event.type == "content_block_delta":
apply_delta(blocks[event.index], event.delta)
elif event.type == "content_block_stop":
finalize_block(blocks[event.index]) # first safe point to parse tool_use.input
elif event.type == "message_stop":
complete = True
except (ConnectionError, TimeoutError):
pass # dropped stream -- fall through with complete == False
if complete:
messages.append({"role": "assistant", "content": assemble(blocks)})
else:
# Discard the partial turn. Do not append a half-built tool_use block.
retry_request(messages, tools)Exam trap
A redacted (encrypted) thinking block is still subject to the carry-back rule -- its content is unreadable, but the signature check runs regardless. Stripping it to save context, exactly like stripping a plaintext thinking block, breaks the next request.
2.3.8 The count_tokens Endpoint: Checking Request Size Before You Pay for It
count_tokens is a dedicated endpoint that accepts the same request body you'd send to a real Messages API call -- model, messages, system, tools, everything -- and returns the token count that request would consume, without running inference. No completion is generated and no output tokens are billed; you get back a number that reflects the exact request shape, including the token weight of tool definitions and any images or documents in the payload.
count = client.messages.count_tokens(
model="claude-sonnet-4-5",
system=LONG_POLICY_DOCUMENT,
tools=tools,
messages=messages,
)
print(count.input_tokens) # exact input-token cost of this request, zero inference cost
if count.input_tokens > TOKEN_BUDGET:
# Gate the request before it's sent, rather than discovering the overrun
# from a validation error or a model_context_window_exceeded stop_reason.
messages = compact(messages)This endpoint does two different jobs in the same lifecycle. During development, run it against production-shaped inputs -- not just short test fixtures -- to verify your context-budget assumptions actually hold; a budget that looks comfortable against an 800-token fixture can be badly wrong against a 3,200-token production tool result. In production, call it as a pre-flight gate before an expensive or context-sensitive request goes out, so an over-budget call gets compacted, trimmed, or rejected before you pay for it -- rather than discovering the overrun only after a model_context_window_exceeded stop_reason comes back.
Where this shows up on the exam
count_tokens takes the whole request -- system, tools, and message history included -- not just the newest message. A scenario that names a context-budget check before an expensive call is describing this endpoint.
2.3.9 Multimodal Token Cost & the PDF document Block
Images are not free against the context budget, and the cost is computable, not vague. Claude views images in 28x28-pixel patches, and each patch is one visual token: visual_tokens = ceil(width / 28) x ceil(height / 28). A 1,000 x 1,000 pixel image works out to ceil(1000/28) x ceil(1000/28) = 36 x 36 = about 1,296 visual tokens. At that rate, a handful of full-resolution screenshots in one request can outweigh a substantial system prompt.
| Image size | Patches | Visual tokens (approx.) |
|---|---|---|
| 400 x 300 px (small screenshot) | 15 x 11 | ~165 |
| 1,000 x 1,000 px | 36 x 36 | ~1,296 |
| 1,920 x 1,080 px (full HD screenshot) | 69 x 39 | ~2,691 |
| 3,000 x 2,000 px (high-res photo) | 108 x 72 | ~7,776 |
The same formula (ceil(w/28) x ceil(h/28)) applied to a range of common image sizes.
Each model tier has its own maximum native image resolution -- a long-edge pixel limit and a visual-token ceiling, and both differ by model tier and generation. An image that exceeds either limit is downscaled before processing, and the token-cost formula then runs on the scaled dimensions, not the original ones -- so a naive calculation against the original size will overstate the actual cost of an oversized image, while also losing whatever detail the downscale discarded.
PDFs get their own block type -- document, distinct from image. Inside it, the source follows the same shape used for images (base64, a URL, or a Files API file_id), though a document block doesn't require a name field the way some other blocks do. It supports optional title (a human-readable label) and context (extra metadata), and skipping both still produces a valid PDF submission.
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": "<base64-encoded-pdf-bytes>"
},
"title": "contract_review.pdf"
}Once loaded, a PDF's pages are tokenized under the same visual-token accounting as any other image content -- the patch formula and per-tier resolution limits both still apply. Separately, inline base64 encoding puts the full payload directly in the message content, which means it travels on every single request. That's fine for a genuinely one-off image or document, where an upload step would add complexity with no payoff. But for an asset reused across many requests -- a reference diagram, a standing policy PDF -- base64 means re-transferring (and, where it counts toward tokens, re-tokenizing) the same bytes on every call. That's exactly the case the Files API exists to solve: upload once, reference by file_id thereafter.
Exam trap
Sending a PDF as an image-type block, assuming a document block requires a name field, or applying the token formula to an oversized image's original dimensions instead of its downscaled ones are all common missteps on this topic.
Key Takeaways
- ✓A Messages API request needs messages (alternating user/assistant), model, and required max_tokens; system, tools, temperature, and stream are optional.
- ✓The system prompt is a top-level system parameter, not a message with role: "system".
- ✓stop_reason (end_turn, max_tokens, stop_sequence, tool_use) tells integration code why generation stopped and what to do next -- code should branch on it.
- ✓usage reports input/output/cache tokens and is the basis for cost modeling; the API itself is stateless and remembers nothing between calls.
- ✓Streaming lowers perceived latency via server-sent events without changing total tokens or cost; tool_use means execute-then-return-a-tool_result, not a final answer; vision is image content blocks in the same Messages API request.
- ✓Structured outputs (output_config.format for JSON, strict: true for tool arguments) constrain output shape via constrained decoding at the API level -- but stop_reason of refusal or max_tokens can still leave a schema-guaranteed request without usable output.
- ✓Every tool_use block must be answered by a tool_result with a matching id in the immediately following user turn; thinking blocks (including redacted ones) must be passed back completely unchanged, signature included, or the request is rejected.
- ✓A tool_use block's input is unparseable until content_block_stop fires; on a dropped stream, discard the partial assistant turn and retry rather than committing it to history.
- ✓count_tokens accepts the same request body as a real call and returns its token cost without running inference -- use it to verify budgets in development and as a pre-flight gate in production.
- ✓Image cost is ceil(width/28) x ceil(height/28) visual tokens (a 1,000x1,000px image is ~1,296 tokens); PDFs use a document content block (not image), with no required name field and optional title/context.
Check Your Understanding
Test what you learned in this lesson.
Q1.In the Messages API, how is the system prompt supplied?
Q2.A response comes back with stop_reason == "max_tokens". What does this tell the integration code?
Q3.What does enabling stream=True change about a Messages API response?
Q4.After sending a second user message in the same conversation, does the Messages API remember the first exchange on its own?
Q5.A developer sets output_config.format with a JSON schema on a Messages API request. What guarantee does this actually provide?
Q6.An assistant turn contains a tool_use block, and the very next request fails with a validation error about an unrecognized or missing tool_use_id. What is the most likely structural cause?
Q7.A team wants to check whether a request (including its full message history and tool definitions) will exceed their context budget before sending it. Which mechanism fits, and why?
Q8.A pipeline sends a 2,000 x 1,400 pixel product photo in every request, expecting to reuse the same image across thousands of calls. What issue does this design have, and what's the fix?
Practice This Lesson