Files API, vision & PDF
Claude reads pictures and PDFs, not just text. This cookbook shows the API mechanics: image and document content blocks, inline base64 vs the upload-once Files API, document Q&A with citations, and the cost and cleanup discipline production needs.
Learning objectives
- Send an image to Claude as an inline base64
imagecontent block. - Send a PDF as a
documentblock and ask questions about it. - Upload a file once with the Files API and reference it by
file_idacross many calls. - Decide when to inline bytes vs upload to the Files API.
- Turn on citations so answers point back to the source document.
- Budget image/document token cost and clean up files in production.
1 · Two ways to give Claude a file essential
Picture the moment your text-only chatbot meets its first real user. They don't paste a paragraph — they drop in a screenshot of an error, a photo of a receipt, a 40-page contract PDF. Text is the easy case; the world runs on pictures of things. The good news is that Claude reads them natively, and the mechanics are surprisingly small: you don't call a separate "vision API," you just add one more block to the same message you already know how to send. This whole lesson is about that one extra block and the single decision that hangs off it.
Here's the mental model, and it's worth holding onto because it makes everything downstream obvious. A message to Claude is not a string — it's a list of content blocks, and each block is either text, an image, or a document. Sending a file is just putting a non-text block in that list. Think of it like an email: the body is your question, and the file is an attachment sitting alongside it. Claude reads the whole list together, so it can answer "what trend does this chart show?" because the chart and the question arrived in the same envelope.
The one real decision — the fork the rest of the lesson keeps returning to — is where the bytes live. You can staple the file's raw bytes directly into the message (inline), or you can hand the bytes to Anthropic once, get back a short receipt, and quote that receipt on every future message (Files API). The common beginner mistake is to reach for inline every time because it's the first thing that works — then re-upload a 20 MB PDF on all fifty follow-up questions and wonder why the bill and the latency crept up. Keeping "how many times will I send these same bytes?" in mind from the start is what separates a demo from a system.
Claude can read images and PDFs, not just text. There are exactly two ways to hand it a file, and everything in this lesson is a variation on one of them. Inline: you put the raw bytes (base64-encoded) directly inside the message as a content block — one self-contained request. Files API: you upload the file once, get back a file_id, and then reference that id in as many messages as you like — the bytes are stored on Anthropic's side and never re-sent.
The message body is the same shape either way. A user message's content is a list of blocks. For a picture you send an {"type": "image", ...} block; for a PDF or text file you send an {"type": "document", ...} block; and you usually add a {"type": "text", ...} block with your question. The only thing that changes between the two paths is the block's source: raw base64 bytes inline, versus a {"type": "file", "file_id": ...} reference.
The top diagram is the inline path — the whole file rides inside one request.
- Your file is a picture or PDF on your disk.
- Inline base64 — you encode the raw bytes as base64 text and drop them straight into a content block. The bytes become part of the message.
- messages.create — a single API call carries the block (bytes and all) up to Anthropic.
- Claude reads it and answers. Simple, self-contained — but the bytes travel on every call you make.
In short: One file, one question, one request. Everything is in that request; nothing is stored afterward.
The bottom diagram is the Files API path — upload the bytes once, then pass a tiny id forever after.
- files.upload sends the bytes to Anthropic one time and hands back a
file_id— a short string that names the stored file. - file_id is all you keep. It's a reference, not the bytes.
- many messages.create — each later call includes the
file_idinstead of the file. The bytes never travel again. - Claude reads the stored file on every call, just as if you'd inlined it — but you only paid to move the bytes once.
In short: Compare the two pictures: the top re-sends bytes each call; the bottom sends them once. That difference is the whole reason the Files API exists.
Read the two diagrams together: the top one uploads the bytes on every call; the bottom one uploads once and then passes a small id string forever after. Same picture reaching Claude — very different network and cost profile when you ask more than one question.
2 · Recipe: an image as inline base64 essential
This is the recipe you'll write first and reach for most. A user has an image, you have a question about it, and you want an answer in one round trip — no setup, no cleanup, no stored state. Inline base64 is exactly that: the whole picture rides inside a single request, and when the reply comes back you're done. If you learn only one thing on this page, make it this block shape, because every other recipe is a variation on it.
The one wrinkle is base64, and it trips people up until they see why it's there. An image file is raw binary — bytes that mean nothing as text and would corrupt a JSON request if you pasted them in directly. Base64 is a reversible way to rewrite any binary as plain ASCII letters and digits, so it survives the trip through a JSON body untouched. The cost is size: base64 makes the payload roughly a third larger, which is fine for one image but is exactly why big or repeated files belong in the Files API instead. Think of it as bubble-wrapping the bytes so they can travel safely inside text.
The other detail that must be right is media_type — you tell the API what kind of image the bytes are (image/png, image/jpeg, image/gif, image/webp). The classic mistake is base64-encoding a .jpg but leaving media_type at image/png because you copied the snippet: the bytes and the label disagree and the request fails. The rule is simple — the label must match the actual file, not whatever the example happened to use. The most direct way to send a picture: read the file, base64-encode the bytes, and put them in an image block whose source.type is "base64". You must tell the API the media_type (e.g. image/png, image/jpeg, image/gif, image/webp). This block is REAL SDK code — it opens a real file and calls the API.
media_type label go in an image block, sitting beside your text block in the same message.
Scan left to right: a file on disk becomes text, and that text becomes one block in the message you already know how to send.
- The left box is the raw image file — pure binary that would corrupt a JSON request if pasted in directly.
- The amber middle box is the same bytes rewritten as base64 ASCII (
iVBORw0K…) — safe to travel inside text. - The right panel is the message
contentlist: animageblock carrying themedia_typeand the base64data, followed by atextblock with your question. - The two arrows are the two transforms you write in code: read-and-encode, then drop into the block list.
In short: Encode the bytes, label the type, put the block before your question — that is the whole inline recipe.
image_inline.py# needs: pip install anthropic + ANTHROPIC_API_KEY (+ a real image/PDF file)
import base64
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment
with open("chart.png", "rb") as f:
image_b64 = base64.standard_b64encode(f.read()).decode("utf-8")
resp = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png", # match the actual file type
"data": image_b64,
},
},
{"type": "text", "text": "What trend does this chart show?"},
],
}],
)
print(next(b.text for b in resp.content if b.type == "text"))
This is the most direct recipe: read an image file, base64-encode it, and send it as an image block. It hits the real API, so it needs a key and network — the ▶ needs API key + network label flags that.
anthropic.Anthropic()builds the client; it picks upANTHROPIC_API_KEYfrom the environment, so the key never appears in code.base64.standard_b64encode(f.read()).decode("utf-8")turns the raw image bytes into the base64 text the API expects.- The
contentis a list of blocks: animageblock (withsource.type == "base64"and the matchingmedia_type) followed by atextblock holding your question. next(b.text for b in resp.content if b.type == "text")pulls the first text block out of the reply — the reply is a list of blocks too.
Try this: Point open("chart.png") at a real image you have and change the question. The media_type must match the file — use image/jpeg for a .jpg.
"source": {"type": "url", "url": "https://…/chart.png"}. Anthropic fetches the image for you — handy when the file already lives on a public URL and you don't want to download-then-re-upload it yourself.3 · See the block shape — offline essential
The SDK can feel like magic, and magic is hard to debug. When a vision call misbehaves, the instinct is to blame the model or the network — but nine times out of ten the real problem is the dict you built was slightly wrong: a missing field, a mislabelled media type, bytes that never got encoded. The cure for that anxiety is to look under the hood once, with no key and no network in the way, and confirm that a content block is nothing more mysterious than a plain Python dictionary.
That's the whole point of this section: it's the one recipe on the page you can run right now, offline, for free. There is no Image class doing hidden work — the SDK takes the dict you hand it and serializes it to JSON, byte for byte. Seeing the structure printed out demystifies everything that follows, because you realize the "API mechanics" are just a small, predictable shape you assemble yourself. It's the same instinct as reading the HTML a framework generates instead of trusting it blindly.
The trick that makes this runnable with zero setup is a hardcoded PNG byte literal — the first few bytes of a 1×1 transparent pixel, baked straight into the source. Because the bytes are already in the file, there's nothing to open, download, or authenticate. When a real call later goes wrong, this is your reference: build the block offline, print it, and diff it against what you think you're sending. The mismatch you find is almost always the bug. Before spending a single token, it helps to see what an image content block actually is: a plain Python dict. The helper below takes raw bytes and a media type, base64-encodes them with the standard library, and builds the exact block the SDK sends. It uses a tiny hardcoded PNG byte literal so it needs no file, no key, and no network — it just prints the block's shape. This is the one recipe on the page you can run right now.
type and a source; the source holds the type, the media_type label, and the base64 data. That is all the SDK serializes to JSON.
Read it top to bottom as a nested structure — the outer box is the whole block, and it contains the pieces below it.
- The top blue panel is the block itself: a plain Python dict, not a hidden SDK object.
- The middle box is the first key,
"type": "image", naming what kind of block this is. - The amber box is the
sourcesub-dict, and inside it the two rows are its keys:type: base64, then themedia_typelabel and the base64datastring. - The nesting is the payoff: seeing the shape once means you can print any block and diff it against what you meant to send.
In short: Every image block is this same little tree of keys — the only thing that changes call to call is the data string.
block_shape.pyimport base64
# A tiny hardcoded PNG (the 1x1 transparent pixel) — no file needed.
PNG_BYTES = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06"
)
def image_block(raw: bytes, media_type: str) -> dict:
"""Build the inline base64 image content block the SDK would send."""
b64 = base64.standard_b64encode(raw).decode("utf-8")
return {
"type": "image",
"source": {"type": "base64", "media_type": media_type, "data": b64},
}
block = image_block(PNG_BYTES, "image/png")
print("type: ", block["type"])
print("source.type: ", block["source"]["type"])
print("media_type: ", block["source"]["media_type"])
print("data length: ", len(block["source"]["data"]), "base64 chars")
print("data preview:", block["source"]["data"][:16], "...")
type: image
source.type: base64
media_type: image/png
data length: 36 base64 chars
data preview: iVBORw0KGgoAAAAN ...
This is the one recipe you can run right now — pure standard library, no key, no network. It shows that an image content block is just a Python dict, using a tiny hardcoded PNG so there's no file to find.
PNG_BYTESis a hardcoded byte literal (the start of a 1x1 PNG). Because it's baked in, the script needs no file on disk.image_block(raw, media_type)base64-encodes the bytes and assembles the exact dict the SDK sends: atype: "image"with asourceholdingtype,media_type, anddata.- The
printlines pull each field back out so you can see the shape — the same shape every image recipe on this page produces.
What the output means: You see the block's fields printed: type: image, source.type: base64, the media type, and the base64 data length plus a short preview. That dict is all the SDK ever sends over the wire.
Try this: Swap in your own tiny bytes literal, or change the media_type, and re-run. The data length changes with the input; the structure never does.
4 · Recipe: a PDF document & document Q&A intermediate
Most business knowledge lives in PDFs — contracts, invoices, reports, forms, scanned pages someone signed and filed. If you can put a PDF in front of Claude and ask "what's the termination clause?", you've unlocked the single most requested feature in enterprise AI: document Q&A. This section is where a vision toy becomes a document-understanding tool.
Here's the detail that makes Claude's PDF handling different from the "just extract the text" tools you may have used before. A naive extractor pulls the character stream and throws away the layout — so it can't tell you which cell of a table a number sits in, whether a signature block is filled, or how a two-column page actually reads. Claude sees each page as a page: the text and the visual arrangement — tables, charts, stamps, handwriting. That means you can ask questions that depend on how the page looks, not merely on the words it contains. It's the difference between reading a transcript of a document and looking at the document itself.
Mechanically, almost nothing new is required — and that's the payoff of the block model from §1. You swap the image block for a document block and set media_type to "application/pdf"; everything else (base64 the bytes, add a text question, read the reply) is identical to the image recipe. The one ordering habit worth forming: put the document block before your question, the same way you'd hand someone the file and then ask about it. The mistake to avoid here isn't syntax — it's forgetting the ceiling: inline PDFs are capped at 32 MB per request and 600 pages, so a fat scanned document quietly exceeds inline and belongs in the Files API instead (§5). PDFs use a document block instead of an image block, with media_type: "application/pdf". Claude reads both the text and the visual layout of each page — tables, charts, signatures — so you can ask questions that depend on how the page looks, not just its extracted text. Put the document block before your question text. This is REAL SDK code.
document block lets Claude see tables, charts and signatures (green), so you can ask questions that depend on how the page looks.
Start at the PDF page on the left; the two arrows show two different ways to consume it.
- The upper red path is a naive text extractor: it flattens the page to a bare character stream and loses which cell a number was in or whether a box was signed.
- The lower green path is Claude via a
documentblock: it keeps the visual arrangement, so tables, charts, and signatures survive as things it can reason about. - The colors are the point — red is what you lose, green is what you keep.
- The bottom line is the mechanic: the request is the image recipe with the block type swapped to
documentandmedia_typeset toapplication/pdf.
In short: Send the PDF as a document block and you can ask layout-dependent questions a text extractor could never answer.
pdf_inline.py# needs: pip install anthropic + ANTHROPIC_API_KEY (+ a real image/PDF file)
import base64
import anthropic
client = anthropic.Anthropic()
with open("contract.pdf", "rb") as f:
pdf_b64 = base64.standard_b64encode(f.read()).decode("utf-8")
resp = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{
"role": "user",
"content": [
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": pdf_b64,
},
},
{"type": "text", "text": "What is the termination clause? Quote it."},
],
}],
)
print(next(b.text for b in resp.content if b.type == "text"))
Same idea as the image recipe, but for a PDF — the block type is document instead of image. This is real API code (▶ needs API key + network).
- The
media_typeis"application/pdf". Claude reads the text and the visual layout of each page, so questions about tables or signatures work. - The
documentblock comes before thetextblock — put the file first, then the question about it. - Otherwise the request is identical to the image recipe: base64 the bytes, build the block list, read the first text block out of the reply.
Try this: This is document Q&A — swap the question for "list every date mentioned" and Claude answers from the same PDF. Remember inline requests cap at 32 MB total, so a huge scanned PDF needs the Files API instead.
| Limit (inline base64) | Value |
|---|---|
| Max request size | 32 MB total |
| Max PDF pages | 600 (100 for 200K-context models) |
| Image formats | PNG, JPEG, GIF, WebP |
| Newlines in base64 | must be stripped (the SDK does this for you) |
5 · Recipe: upload once, reuse with the Files API intermediate
Imagine emailing the same 20 MB attachment to a colleague every time you had a question about it. After the third message they'd say: "just send it once and reference it." That's the entire idea of the Files API. Inline base64 re-ships the bytes on every single call — fine for a one-shot, wasteful the moment you ask a document more than one question. The Files API is the answer to "I have one document and twenty questions": pay the upload once, then quote a short id forever after.
The mechanic is a two-step swap. First you upload the file and get back a file_id — a short string that names the stored bytes, like a coat-check ticket. Then, in place of the base64 source you used inline, you drop in {"type": "file", "file_id": ...}. The block type stays the same (document for PDFs, image for pictures) — only the source changes from "here are the bytes" to "here's the receipt." Claude reads the stored file on every call exactly as if you'd inlined it, but the bytes crossed the wire just once.
Two things about this recipe catch people out, and both are worth internalizing now. First, the Files API is currently beta, so you call it on the client.beta.* namespace and must pass betas=["files-api-2025-04-14"] on both the upload and every messages.create that touches the file — forget it on one and that call fails. Second — and this is the one that bites in production — uploaded files don't clean themselves up. Unlike a chat that ends, a stored file sits against your organization's 100 GB quota until you explicitly delete it. The discipline this section quietly teaches is: every upload deserves a matching cleanup. The Files API is the answer to "I have one document and twenty questions". You upload the file once and get back a file_id. Every later message references that id via a {"type": "file", "file_id": ...} source — the bytes never travel again. The Files API is beta, so you call it on the client.beta.* namespace and pass betas=["files-api-2025-04-14"] on both the upload and any messages.create that references the file. This is REAL SDK code.
file_id reference. Upload once, reference the id on many calls — but the stored file lives until you explicitly delete it.
Left half: what changes in the block. Right half: the three-step life of an uploaded file.
- The left red box is the inline source — the raw base64
datalives in the block and ships every call. - The left green box is the Files API source — the block instead carries a tiny
file_id; the blocktype(document/image) is unchanged. - The right column reads top to bottom:
files.uploadhands back afile_id, you reference that id across many calls, and finallyfiles.delete— the red step you must remember. - The payoff: the swap is trivial, but the delete step is the discipline that keeps the 100 GB org quota healthy.
In short: Swap bytes for an id, then treat upload-and-delete as a pair.
files_api.py# needs: pip install anthropic + ANTHROPIC_API_KEY (+ a real image/PDF file)
import anthropic
client = anthropic.Anthropic()
# 1. Upload ONCE. The file argument is a (filename, fileobj, media_type) tuple.
uploaded = client.beta.files.upload(
file=("contract.pdf", open("contract.pdf", "rb"), "application/pdf"),
betas=["files-api-2025-04-14"],
)
print("file_id:", uploaded.id, "| size:", uploaded.size_bytes, "bytes")
# 2. Reference the SAME file_id across many calls — bytes never re-sent.
questions = [
"What are the key terms?",
"What is the termination clause?",
"Summarize the payment schedule.",
]
for q in questions:
resp = client.beta.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{
"role": "user",
"content": [
{"type": "document", "source": {"type": "file", "file_id": uploaded.id}},
{"type": "text", "text": q},
],
}],
betas=["files-api-2025-04-14"],
)
answer = next(b.text for b in resp.content if b.type == "text")
print(f"\nQ: {q}\nA: {answer[:120]}")
# 3. Manage stored files. Uploads count against a per-org storage quota.
for f in client.beta.files.list(betas=["files-api-2025-04-14"]):
print(f.id, f.filename, f.size_bytes)
# 4. Clean up when you're done with it.
client.beta.files.delete(uploaded.id, betas=["files-api-2025-04-14"])
This recipe is "one document, many questions" done right: upload once, then reference the stored file by id on every later call. Real API code — and the Files API is beta, so note the client.beta.* calls and the betas=[...] argument.
- Step 1 —
client.beta.files.upload(...)sends the bytes once and returns an object whose.idis yourfile_id. - Step 2 — the loop asks three questions. Each message uses a
documentblock whose source is{"type": "file", "file_id": uploaded.id}— the bytes are never re-sent, only the id. - Step 3 —
client.beta.files.list(...)shows what's stored; uploads count against your organization's storage quota. - Step 4 —
client.beta.files.delete(uploaded.id, ...)cleans up. Uploaded files persist until you delete them, so always pair an upload with a cleanup.
Try this: Note the betas=["files-api-2025-04-14"] on both the upload and each messages.create — the beta header is required on any call that touches the file.
image block: {"type": "image", "source": {"type": "file", "file_id": id}}. The block type must match the file's MIME type — document for PDFs/text, image for pictures.6 · Inline vs Files API — choosing advanced
You now know both paths — so which do you actually reach for? Getting this wrong doesn't crash anything, which is exactly why it's dangerous: the wrong default just quietly costs you money and latency for months. A team that inlines everything re-uploads the same reference PDF on every request; a team that runs everything through the Files API accumulates thousands of forgotten uploads against their quota. Neither is a bug you'll see in a stack trace — it's a design smell you only catch if you decide deliberately.
The decision collapses to two questions, and the analogy is a photocopy versus a library book. How many times will you ask? One question about one file is a photocopy — hand it over, done, inline it. Twenty questions about the same contract is a library book — check it in once and reference the call number, which is the Files API. How big is the file? Inline is hard-capped at 32 MB per request because the bytes are the message; the Files API tops out at 500 MB per file because the bytes aren't part of the message at all. A file that busts the 32 MB inline ceiling has no choice — it must go through the Files API regardless of reuse.
The common mistake is optimizing for the wrong axis — reaching for the Files API because "it sounds more production-grade" even for a genuine one-shot, and taking on file lifecycle and a beta header you didn't need. Simplicity is a real cost too. The honest rule: default to inline; graduate to the Files API when reuse or size forces your hand. The table below makes the trade-offs side by side. Both paths get the same bytes to Claude. The decision is about how many times you'll ask and how big the file is:
Follow the arrows top to bottom — it is a two-gate decision, and the first gate that fires wins.
- The first gate asks size: a file over 32 MB can't be inlined at all (the bytes are the message), so it branches straight to the green Files API — no further questions.
- The second gate asks reuse: if you'll query the same file twice or more, upload once and reference the id (Files API); the repeated bytes aren't worth re-sending.
- The amber bottom box is the fall-through — a small, one-shot file — where plain inline base64 is simplest and cheapest.
- The order matters: check the hard limit first, then optimize for reuse, exactly as the ingestion gate in the exercises does.
In short: Big file → Files API; reused file → Files API; small one-shot → inline.
| Question | Inline base64 | Files API |
|---|---|---|
| How many times you'll ask | once | many times |
| Bytes re-sent per call | every call | never (only the id) |
| File size ceiling | 32 MB per request | 500 MB per file |
| State to manage | none | stored files + a quota to clean up |
| Beta header needed | no | yes (files-api-2025-04-14) |
Rule of thumb: inline for a one-shot (extract a field from this one receipt), Files API when the same document drives many turns (a contract you'll interrogate across a conversation, or a reference doc shared by a batch of requests). The Files API also lets a big file exceed the 32 MB inline request cap, since the bytes aren't part of the message.
cache_control breakpoint after it so its processed tokens are cached too. The Files API saves re-uploading the bytes; caching (see AP2) saves re-processing them into tokens. Together they cut both bandwidth and cost.7 · Recipe: citations that point at the source advanced
"The contract auto-renews in year three" — says who? The instant an answer drives a real decision, the next question is always where did that come from? An LLM that confidently states a figure with no way to check it is a liability in any regulated, legal, or financial setting — one hallucinated clause and the whole answer is untrustworthy. Citations are the feature that turns "trust me" into "see for yourself," and they're the difference between a demo and a document tool anyone will actually rely on.
The mechanic is almost anticlimactic: set citations: {"enabled": True} on the document block. What changes is the shape of the reply, and that's the part to understand. Instead of one block of prose, Claude returns several text blocks; any block whose claim came from the document carries a citations array, and each entry names the source, quotes the exact cited_text, and pins a location — a page number for PDFs, a character range for plain text. Think of it as Claude showing its work: every sourced sentence arrives stapled to the line it was drawn from, so you can jump straight to page 7 and verify.
Two gotchas keep this from surprising you later. First, citations are all-or-none across a request — enable them on every document block or none; you can't cite one attachment and ignore another. Second, and the one people hit hardest: citations are incompatible with output_config.format (forced JSON schema). Ask for both in the same request and the API returns a 400. It's a genuine fork in the road — you can have a traceable, cited answer or a rigidly structured JSON one, not both at once — so decide which property the downstream code actually needs. For document Q&A you often want the answer to cite where it came from — which page, which sentence. Set citations: {"enabled": True} on the document block. Claude then splits its reply into multiple text blocks, and blocks that draw on the document carry a citations array — each citation names the source, the cited text, and a location (a page number for PDFs, a character range for plain text). This is REAL SDK code.
text blocks; a block drawn from the document carries a citations array (amber) naming the title, the exact cited_text, and a page or character location.
Left is the request change; right is the response shape it produces — read the stack of blocks top to bottom.
- The left green box is the one new ingredient:
citations: {"enabled": True}on thedocumentblock. - The right stack is the reply — no longer one paragraph but several
textblocks. Plain blocks (blue) carry no citation. - The amber block is one drawn from the document; nested inside it is the
citations[]array with the sourcetitle, the exactcited_text, and a location — a page number for PDFs, a character range for plain text. - The two footnotes are the gotchas: citations are all-or-none across documents, and can't be combined with forced-JSON
output_config.format(that returns a 400).
In short: Flip one flag and the answer arrives as sourced blocks — each claim stapled to the page it came from.
citations.py# needs: pip install anthropic + ANTHROPIC_API_KEY (+ a real image/PDF file)
import base64
import anthropic
client = anthropic.Anthropic()
with open("report.pdf", "rb") as f:
pdf_b64 = base64.standard_b64encode(f.read()).decode("utf-8")
resp = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{
"role": "user",
"content": [
{
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": pdf_b64},
"title": "Q4 Report",
"citations": {"enabled": True}, # turn citations on
},
{"type": "text", "text": "What were Q4 revenues? Cite the page."},
],
}],
)
for block in resp.content:
if block.type != "text":
continue
print(block.text)
for c in (block.citations or []):
# PDF citations carry a page_location; plain text carries char indices.
print(f' ↳ cited "{c.cited_text[:50]}" from {c.document_title}')
This recipe makes the answer traceable: with citations on, Claude tells you which page and sentence each claim came from. Real API code (▶ needs API key + network).
"citations": {"enabled": True}on thedocumentblock is the only new ingredient — everything else is the PDF recipe from earlier.- The reply is split into multiple
textblocks. A block that drew on the document carries acitationsarray; a block that didn't has none — henceblock.citations or []. - Each citation names the source (
document_title), the exactcited_text, and a location — a page number for PDFs, a character range for plain text.
Try this: Ask a question whose answer spans two pages and watch multiple citations come back. Note you can't combine citations with output_config.format (JSON schema) — the API returns a 400 if you try both.
citations on a document with output_config.format (JSON schema output) — the API returns a 400. Pick one: cite the source, or force a JSON shape. Citations are also all-or-none across a request's documents — enable them on every document block or none.8 · Cost, limits & production hygiene professional
The recipes work on your laptop. The surprises arrive at scale. A vision feature that's delightful in a demo can become the line item nobody budgeted for when a thousand users start uploading full-resolution phone photos, and the "why are uploads failing?" incident three months in is almost always a storage quota nobody was watching. This section is the difference between a feature that ships and a feature that survives contact with production traffic.
The cost model is the first thing to internalize, because it's not intuitive. Images and documents don't have a flat "per file" price — they're billed as input tokens, and for an image the token count scales roughly with its pixel area. A full-resolution camera dump can quietly cost thousands of input tokens, most of which buy you nothing if the task only needed to read a label. The lever, then, isn't some pricing tier — it's how many pixels and how many times you send. Downsampling an oversized image before you send it, and not re-shipping the same bytes on every call, are the two biggest knobs, and they're both in your control.
The other half is hygiene, and the trap is that files behave unlike everything else in the API. A request is stateless — it happens and it's gone — but a Files API upload is persistent: it sits in your org's 100 GB quota until you explicitly delete it. A pipeline that uploads per request and never cleans up doesn't fail gradually; it works perfectly right up until the quota fills, then every upload starts erroring at once. The checklist below is the standing discipline that keeps that from happening — treat each item as a habit, not a one-time chore. Images and documents are billed as input tokens — a page or an image costs roughly what its visual complexity works out to, and a full-resolution image can be thousands of tokens. That means the recurring cost lever is how often you re-send the same bytes. The production checklist:
Two panels, one per cost lever — left is per-image size, right is how many times the bytes travel.
- The left red square is a full-resolution image; the small green square is the same image with each edge halved. Because tokens track area, that smaller square is only a quarter of the pixels — about 75% cheaper.
- The right red bar is inline transport: the base64 bytes are re-sent on every call. The green bar is the Files API: the bytes cross the wire once.
- Red means the wasteful default, green the cheaper choice, on both sides.
- The payoff is the two numbered levers: send fewer pixels than the camera captured, and stop re-sending the same bytes.
In short: Downsample to shrink each request, and upload-once to stop paying for repeats.
Production checklist for files & vision
- Don't re-inline. If a document is used more than once, upload it via the Files API and pass the
file_id— you stop paying to ship the bytes each call. - Cache big prefixes. Combine the Files API with
cache_controlso a large reused document is neither re-uploaded nor re-tokenized (see AP2). - Downsample when you can. A smaller image is fewer input tokens; send the resolution the task actually needs, not the original camera dump.
- Mind the quotas. Files API storage is capped per organization (100 GB) and individual files up to 500 MB — track what you upload.
- Clean up. Uploaded files persist until you
deletethem. Delete files you no longer need so storage doesn't creep and stale documents don't linger. - Count before you send. Use token counting (see AP4) on representative files to budget cost before rolling out.
files.list() entries.9 · Tech-lead — a document-ingestion contract tech-lead
Every decision in this lesson is one a developer will otherwise make five different ways. Inline or upload? Delete or leave it? Cite or not? Left to individual PRs, those choices drift — one service re-litigates "inline or Files API?" on every review, another quietly leaks files, a third returns uncited answers to users who act on them. The job of a lead isn't to write the vision call; it's to make these choices once, write them down, and let the codebase inherit the answer. A convention beats a clever call site every time, because a convention scales to a team.
The artifact that captures this is a short document-ingestion contract — four clauses, each one lifting a decision out of ad-hoc territory. A routing rule: a concrete size/reuse threshold that decides inline vs Files API, so nobody guesses. A file lifecycle: who uploads, who deletes, and the retention window — the clause that keeps the 100 GB quota from becoming a 2 a.m. incident. A citation requirement: any answer a user acts on must be traceable to its source page, non-negotiable in regulated work. And a cost model: an explicit account of image/document input tokens plus the caching and Files-API levers that pull them down.
The elegant part is that you already built every clause in this lesson — the contract is just the assembly. The inline-vs-Files table (§6) is the routing rule; the cleanup step (§8) is the lifecycle; the citations recipe (§7) is the traceability requirement; the token math (§8) is the budget. A lead's leverage is turning four mechanics you've learned into four policies a team follows without re-deriving them. A lead owns the document-handling policy for the whole system, not just one call site. That means a written contract: which paths inline vs upload (a size/reuse threshold, not per-developer whim); a file lifecycle (who uploads, who deletes, what the retention window is) so the org quota never becomes an incident; a citation requirement for any answer users act on, so claims are traceable to the source page; and a cost model that accounts for image/document input tokens and the caching + Files-API levers that reduce them.
Every mechanic in this lesson maps to one clause of that contract. The inline vs Files API table is the routing rule; the cleanup step is the lifecycle; citations are the traceability requirement; token cost is the budget. A lead makes these decisions once, writes them down, and lets the codebase follow the contract instead of re-litigating "inline or upload?" on every PR.
Read each row left to right: a mechanic from the lesson (blue) becomes a policy clause (green) via the arrow.
- §6's inline-vs-Files table becomes the routing rule — a concrete size/reuse threshold, not per-developer whim.
- §8's cleanup step becomes the file lifecycle — who uploads, who deletes, and the retention window that keeps the 100 GB quota safe.
- §7's citations recipe becomes the traceability clause — any answer a user acts on must point back to its source page.
- §8's token math becomes the cost model — an explicit account of image/document tokens and the caching + Files-API levers that pull them down.
- The four green boxes together are the document-ingestion contract; the lead's job is the assembly, not new invention.
In short: Four mechanics you already know, promoted to four policies a team follows.
Exercise AP3.1 — Run the offline block builder, then wire a real call
Context: Knowing exactly what an image content block looks like — before any network call — demystifies vision requests. Matching the offline shape to the real block is the bridge from theory to a working call.
Your task: Run block_shape.py and confirm its printed shape matches the out() block, then point image_inline.py at a real PNG, add ANTHROPIC_API_KEY, and ask Claude what is in the image.
Requirements:
- First confirm the offline-printed block shape matches the expected output
- Point
image_inline.pyat a real PNG you have - Set
ANTHROPIC_API_KEYin your environment before the real call - Get Claude's description of the image back
- Note which parts of the real block are identical to the offline helper's output
💡 Hint: The base64 data string is the only part that changes between the offline shape and the real call — the surrounding block structure is identical.
Exercise AP3.2 — One document, inline vs Files API
Context: Asking one PDF three questions two ways makes the Files API's payoff concrete: you can watch the bytes-on-the-wire drop while comparing input-token cost. Adding citations shows the answers stay auditable either way.
Your task: Ask a multi-page PDF three questions inline (pdf_inline.py) and again by uploading once and referencing file_id (files_api.py), compare bytes and cost, then enable citations and delete the upload.
Requirements:
- Run the three questions both ways: inline base64 per call, and one upload referenced three times
- Compare the bytes sent and the input-token cost between the two approaches
- Enable citations (
citations.py) and confirm each answer names its page - Finish by
delete-ing the uploaded file
💡 Hint: The inline path re-sends the full base64 on every call; the Files API path sends the bytes once — that difference is exactly what you are measuring.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: The simplest way to put an image in front of Claude is to inline it as base64 in an image content block. Getting the block shape right is the foundation for every vision task that follows.
Your task: Read a PNG, base64-encode it, and ask Claude what it shows using an inline image content block.
Requirements:
- Needs a real API key to run
- The base64 payload has no embedded newlines
- Use an
imageblock with a base64 source and matchingmedia_type(e.g.image/png) - The image block precedes the text block in the same user message
- Print the model's text answer describing the image
💡 Hint: Encode the raw bytes with base64.standard_b64encode(...).decode() and make media_type match the actual file type.
Show solution
The canonical inline-image block (base64 must have no newlines):
import base64, anthropic
client = anthropic.Anthropic()
with open("chart.png", "rb") as f:
data = base64.standard_b64encode(f.read()).decode("utf-8")
resp = client.messages.create(
model="claude-opus-4-8", max_tokens=512,
messages=[{"role": "user", "content": [
{"type": "image", "source": {
"type": "base64", "media_type": "image/png", "data": data}},
{"type": "text", "text": "What does this chart show?"},
]}],
)
print(next(b.text for b in resp.content if b.type == "text"))
Inline base64 is the simplest path for a one-off image. The image block comes before the text block, and media_type must match the file.
Context: PDFs go through a document block rather than an image block, and the API enforces hard size and page limits. Knowing those limits tells you when inlining stops being viable.
Your task: Send a PDF as a base64 document block, ask a question about it, and note the size and page limits.
Requirements:
- Needs a real API key to run
- Use a
documentblock with a base64 source andapplication/pdfmedia type - The document block comes before the text block
- No beta header is needed for base64 PDF
- State the limits: 32 MB per request and 600 pages (100 on 200k-context models)
- Note that a large or reused file should instead go through the Files API
💡 Hint: The block mirrors the inline-image pattern — swap image for document and use the PDF media type.
Show solution
PDF uses a document block, placed before the text:
import base64, anthropic
client = anthropic.Anthropic()
with open("report.pdf", "rb") as f:
pdf = base64.standard_b64encode(f.read()).decode("utf-8")
resp = client.messages.create(
model="claude-opus-4-8", max_tokens=1024,
messages=[{"role": "user", "content": [
{"type": "document", "source": {
"type": "base64", "media_type": "application/pdf", "data": pdf}},
{"type": "text", "text": "What are the three key findings?"},
]}],
)
print(next(b.text for b in resp.content if b.type == "text"))
Base64 PDF has no beta header. Limits: 32 MB per request and 600 pages (100 on 200k-context models). For a bigger or reused file, upload it once with the Files API instead (next rung).
Context: When the same document is queried repeatedly, re-inlining its bytes on every call is wasteful. The Files API lets you upload once and reference the file by id, paying the transfer a single time.
Your task: Upload a PDF once, then ask three questions referencing it by file_id without re-uploading, and clean up afterward.
Requirements:
- Needs a real API key to run
- Upload via
client.beta.files.uploadand reference the returnedfile_id - Include the
files-api-2025-04-14beta header on upload and on each message call - Reference the document by
{"type": "file", "file_id": ...}rather than re-sending bytes - Delete the uploaded file at the end (files persist until deleted)
- Note that uploads/lists/deletes are free but the content still bills as input tokens each use
💡 Hint: Do the upload once outside the question loop; inside the loop only the question text and the file_id reference change.
Show solution
Upload → reference by file_id → clean up. Beta header required on both:
uploaded = client.beta.files.upload(
file=("contract.pdf", open("contract.pdf", "rb"), "application/pdf"),
)
for q in ["Termination clause?", "Payment schedule?", "Governing law?"]:
resp = client.beta.messages.create(
model="claude-opus-4-8", max_tokens=1024,
betas=["files-api-2025-04-14"], # required on upload AND here
messages=[{"role": "user", "content": [
{"type": "text", "text": q},
{"type": "document",
"source": {"type": "file", "file_id": uploaded.id}},
]}],
)
print(q, "->", next(b.text for b in resp.content if b.type == "text")[:60])
client.beta.files.delete(uploaded.id) # files persist until deleted
The Files API avoids re-uploading the same bytes for every question — you pay the upload once and reference file_id thereafter. Uploads/lists/deletes are free; the content is billed as input tokens each time it is used.
Context: Image token cost scales with pixel area, so an oversized image can quietly dominate a request's bill. Estimating the cost before the call lets you trade fidelity for price deliberately.
Your task: Write image_tokens(w, h) and image_cost(w, h) for Opus 4.8 input, then compare a 1568×1568 image against a 784×784 downsize.
Requirements:
- Pure offline estimator — no API key
- Estimate tokens as roughly
(width × height) / 750 - Cost is the estimated tokens at the Opus 4.8 input price
- Report the percentage saved by halving each edge
- Show that halving both edges cuts cost to about a quarter (~75% saved)
- Note when to keep full resolution: dense documents or fine detail
💡 Hint: Because area is the driver, halving width and height quarters the pixel count — compute both sizes and diff the costs.
Show solution
Offline estimator — decide fidelity vs cost before the call:
IN = 5.0 / 1e6 # $ per input token, Opus 4.8
def image_tokens(w, h):
return round(w * h / 750) # Anthropic's rough estimate
def image_cost(w, h):
return image_tokens(w, h) * IN
print(image_tokens(1568, 1568), f"${image_cost(1568,1568):.5f}") # ~3279 $0.01640
print(image_tokens(784, 784), f"${image_cost(784, 784):.5f}") # ~820 $0.00410
print(f"downsizing saves {100*(1-image_cost(784,784)/image_cost(1568,1568)):.0f}%") # 75%
Token cost scales with pixel area, so halving each edge quarters the cost. Downsize client-side when the extra resolution will not change the answer — but keep full resolution for dense documents or fine detail where it will.
Context: For auditable answers, each claim should point back to a locatable span in the source document. Enabling citations turns a document answer into cited blocks you can verify.
Your task: Enable citations on a document so each cited answer block carries a source span, and show both the request flag and how to read the response citations.
Requirements:
- Needs a real API key to run
- Set
citations: {"enabled": True}on the document block - Citations are all-or-none across documents in the request
- Read each text block's
citationsforcited_textand a location - Handle the location type: page for PDFs, character range for plain text
- Note that citations are incompatible with forcing a JSON
output_config.format
💡 Hint: Iterate the response content blocks; each text block exposes a citations list whose entries name the cited text and its page or char range.
Show solution
Set citations.enabled on the document; the response splits into cited blocks:
resp = client.messages.create(
model="claude-opus-4-8", max_tokens=1024,
messages=[{"role": "user", "content": [
{"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": pdf},
"title": "Q4 Report",
"citations": {"enabled": True}}, # all-or-none across documents
{"type": "text", "text": "What was Q4 revenue? Cite the source."},
]}],
)
for block in resp.content:
if block.type == "text":
print(block.text)
for c in (block.citations or []):
# PDF -> page_location; plain text -> char_location
print(" cite:", c.cited_text, "p", getattr(c, "start_page_number", None))
Citations make answers auditable: each cited text block carries cited_text plus a location (page for PDFs, char range for text). Note citations are incompatible with output_config.format — you cannot force JSON and cite at the same time.
Context: A platform serving many documents needs one rule that decides transport and rejects over-limit files before a request is ever built. Encoding that contract as a gate keeps hard limits from failing deep in the call.
Your task: Write ingest_decision(size_mb, pages, reuse_count) that returns the transport (inline vs Files API) or a rejection.
Requirements:
- Pure decision logic — no API key needed
- Reject documents over 32 MB and over 600 pages, fast, before building a request
- Route reused documents (reuse_count ≥ 2) to the Files API
- Route one-off documents to inline base64
- Rejections explain the limit hit and advise splitting or chunking
- Never silently truncate an over-limit document
💡 Hint: Check the hard limits first and return early on rejection; only then branch on reuse count to pick inline versus Files API.
Show solution
Encode the ingestion contract as a gate (pure logic, no API needed):
def ingest_decision(size_mb, pages, reuse_count):
if size_mb > 32:
return "REJECT — exceeds 32 MB request limit; split the document"
if pages > 600:
return "REJECT — exceeds 600-page limit; chunk by section"
if reuse_count >= 2:
return "Files API — upload once, reference by file_id (avoid re-upload)"
return "inline base64 — one-off use, simplest path"
print(ingest_decision(2, 40, 1)) # inline base64
print(ingest_decision(2, 40, 5)) # Files API (reused 5x)
print(ingest_decision(50, 40, 1)) # REJECT — too big
The contract fails fast on the hard limits (32 MB, 600 pages) and routes by reuse: one-off → inline, reused → Files API to stop paying repeated upload/transfer. Never silently truncate an over-limit document — reject and chunk it.
✓ Checkpoint — you can move on when you can…
- Send an image inline as a base64
imagecontent block. - Send a PDF as a
documentblock and do document Q&A. - Upload a file once and reference it by
file_idacross many calls. - Choose inline vs Files API by reuse count and file size.
- Enable citations and read the source location back from the response.
- Explain the token cost of files and the cleanup/quota discipline they demand.
Knowledge check check yourself
When should you use the Files API instead of inlining base64, and what discipline does the Files API require that inlining does not?
Show answer
file_id rather than re-sending bytes. It requires lifecycle discipline: uploaded files persist indefinitely against a 100 GB org quota until you explicitly call files.delete().Why can't you combine document citations with output_config.format in the same request, and what does enabling citations change about the response shape?
Show answer
citations splits the response into multiple text blocks where citing blocks carry a citations array with document_title, cited_text, and a location (page number for PDFs, character range for text), so answers become traceable to their source.