Multimodal AI
The whole course so far is text-in, text-out. But real systems read images, documents with layout, and audio. This part adds those input types step by step: how a vision model actually "sees," how to send images to Claude, building document-intelligence and multimodal-RAG pipelines, and where audio fits. Basic → advanced, with runnable code and diagrams.
- an Anthropic API key (
ANTHROPIC_API_KEY) +pip install anthropic
Learning objectives
- Understand how a vision-language model turns pixels into tokens the LLM can reason over.
- Send images to Claude (base64 & URL) and ask questions about them.
- Extract structured data from documents (invoices, forms, screenshots).
- Build multimodal RAG — retrieve over images + text.
- Know where audio (speech-to-text) slots into a pipeline.
1 · How a vision model "sees" basic essential
Start with the confusion that trips up almost everyone new to this. You paste a screenshot into a chat window, the model describes it perfectly, and it feels like the LLM grew eyes. So the first time your image bill is ten times what you expected, or a "simple" photo question is inexplicably slow, it makes no sense — a picture is just one attachment, right? The mistake is imagining the model looks at pixels the way you do. It doesn't. Understanding what really happens is what turns those cost and latency surprises into things you can predict and control.
Here's the analogy that fixes it: the model can only read one language — tokens — the same numbered chunks it uses for words. It has no separate "eyes." So before an image can be reasoned about, it has to be translated into that same token language, exactly like translating a foreign document into English before an English-only reader can understand it. The translator is a separate piece of machinery bolted in front of the language model, and the length of the translation is what you pay for.
Concretely, that translator does three things. First it chops the image into a grid of small square patches (think of laying a sheet of graph paper over the picture). Second, an encoder — a vision transformer — turns each patch into a vector of numbers, and crucially projects those vectors into the same embedding space the LLM uses for text. Third, the LLM simply reads that combined stream: image-patch tokens sitting shoulder-to-shoulder with your text tokens, one sequence, attended over jointly. That "same space" projection is the whole trick — it's why a text model can suddenly answer questions about a chart without being retrained from scratch.
Now the practical consequence, which is the whole reason this section is first. A picture doesn't become one token — it becomes many, often hundreds to thousands, because every patch is a token. More patches means more tokens means more money and more time. That gives you three levers you now understand rather than guess at:
- Image size drives cost. A bigger image is more patches is more tokens is a bigger bill. Resizing a huge photo down to what the task actually needs can cut the token count dramatically for the same answer — bigger is not sharper eyes, just a longer translation.
- Detail you don't need is money you don't spend. If you only need to read a total off a receipt, a modest resolution is plenty; reserve high resolution for genuinely fine print or dense diagrams.
- Latency tracks tokens too. Those image tokens sit in the same sequence the model processes, so a heavy image slows the response the same way a very long prompt would.
Keep that mental model — patches become tokens in text's space — as you read the diagram below; every cost and design choice in the rest of this part flows from it.
A vision-language model doesn't process pixels directly with the LLM. An image encoder (a vision transformer) splits the image into patches, turns each patch into a vector, and projects those into the same embedding space as text tokens. The LLM then attends over image-patch tokens and text tokens together — one stream, two modalities.
This picture answers a beginner's first question: how does a text model look at a picture at all? The trick is that the image is turned into the same kind of tokens the model already uses for words, so it can read pixels and text together.
- Start on the left: the image is chopped into a grid of small squares called patches (the little boxes labelled image patches).
- The encoder box in the middle is a vision model that turns each patch into a vector of numbers — an embedding — that lives in the model's token space.
- On the right, those become
img tok(image tokens) that sit right next to thetext tok(your words). Follow the arrows left to right. - The LLM at the far right then reads image tokens and text tokens as one single stream — that's why it can answer questions about a picture.
In short: Because a picture becomes many tokens (often hundreds to thousands), images cost real money and add latency. Bigger image = more patches = more tokens = higher bill.
2 · Sending an image to Claude basic → intermediate essential
You already know how to make a text call — so the hurdle here is smaller than it looks, but it's exactly where beginners freeze. Your first instinct is to reach for some special "vision endpoint" or a separate upload API, and you go hunting for one that doesn't exist. Then you try the obvious thing — jam the file path or the raw image bytes into the content string — and get a cryptic error, because a message body is JSON and JSON is text; raw binary has no place in it. The good news: sending an image is the same call you already make, with one new shape to learn.
Here's the mental model. Think of the message content not as a single sentence but as an envelope you can put several cards into — a list of "blocks." A normal text call happens to hold one card (your question). A vision call holds two: an image block and a text block. That's the entire conceptual leap — content becomes a list — and once it clicks, multimodal stops feeling exotic.
The image block itself needs two things the model can't guess. First, the bytes of the picture, rewritten as text — that's what base64 does: it re-expresses raw binary as a string of safe letters and digits so it can ride inside JSON. (You aren't compressing or changing the image, just spelling it out in an alphabet text can carry.) Second, a media type like image/png or image/jpeg, which tells the model how to decode those bytes back into a picture. Getting the media type wrong — announcing a JPEG as PNG — is the single most common beginner failure and can get the image rejected outright, so it must match the real file.
You have two ways to supply the picture, and the choice is a genuine trade-off:
- base64 bytes — you read the file, encode it, and embed it directly in the request. Works for local files and private images the model's servers can't reach; the cost is a larger request body.
- a URL — you pass a link and let the model fetch the image itself. Lighter request, but the URL must be publicly reachable, which rules out anything behind a login or on your laptop.
One more thing the envelope model buys you: you can drop several image cards into one message and ask the model to compare them ("which of these two screenshots has the error?"). And because you now know from section 1 that each image is really many tokens, you'll want to size images sensibly before encoding — the recipe below is deliberately minimal so the one new idea, content is a list of blocks, stands out clearly.
Images go in a message's content as an image block — either base64-encoded bytes or a URL — alongside a text block asking your question.
content holds two blocks — an image block and your text question — and the image block's source is filled either from base64 bytes or a public URL.
Read it top-to-bottom: the content field at the top fans out into the blocks it contains, and the image block below fans out again into the two ways to supply the picture.
- The top box (
content: [ … ]) is the one new idea —contentis a list, not a single string. The blue arrows show it holding more than one card. - The middle row is those cards: a blue image block and an amber text block carrying your question. Sending both together is what makes the call multimodal.
- The green bottom row is the choice inside the image block's
source: base64 bytes (works for local and private files) or a url (lighter request, but the link must be publicly reachable).
In short: A vision request is your usual text call with content turned into a list of blocks — one image (base64 or URL) plus one text question.
pythonimport base64, anthropic
client = anthropic.Anthropic()
with open("chart.png", "rb") as f:
data = base64.standard_b64encode(f.read()).decode()
msg = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {
"type": "base64", "media_type": "image/png", "data": data}},
{"type": "text", "text": "What trend does this chart show? Give the peak value."},
],
}],
)
print(msg.content[0].text)
# URL form: {"type":"image","source":{"type":"url","url":"https://..."}}
This is the core recipe for asking Claude about a picture on your computer. There is only one new idea versus a normal text call: you read the image file, turn its raw bytes into text with base64, and drop it into the message as an image block.
open("chart.png", "rb")opens the file in binary read mode ("rb") because an image is raw bytes, not letters.base64.standard_b64encode(...).decode()is the key step: it converts those bytes into a plain-text string of safe characters, because the API sends everything as text (JSON). base64 is just "bytes written as letters".- Inside
messages, thecontentis a list of blocks. The first block has"type": "image"and carries the base64dataplus itsmedia_type(hereimage/pngso Claude knows the format). - The second block is a normal
textblock with your question. Sending the image and the question together is what makes this multimodal. - The last comment shows the alternative: instead of base64 bytes you can pass a
"url"and let Claude fetch the image itself.
What the output means: msg.content[0].text prints Claude's answer in words — e.g. a sentence describing the chart's trend and naming its peak value.
Try this: Swap in your own .png and change the question text. If your file is a JPEG, remember to change media_type to "image/jpeg" to match.
3 · Document intelligence — structured extraction advanced
Picture the pipeline that has ruined more engineering weekends than almost any other. The task sounds trivial: read the total off a stack of invoices. So you bolt on an OCR library, get back a wall of text, and start writing regexes to fish out the invoice number and the amount. It works on the ten samples you tested. Then vendor #11 puts the total in the top-right instead of the bottom, vendor #12 uses "Amount Due" instead of "Total," a scan comes in rotated, and your regexes quietly return the wrong number — or nothing. This is the classic OCR-plus-regex trap: brittle because it treats a layout problem as a string problem.
The reframe that makes this section the highest-value one in the whole part: a vision model doesn't need you to describe where the total lives, because it reads the document the way a person does — seeing that a number near the words "Total Due" in the bottom-right is the total, regardless of the exact pixels. So instead of extracting text and then hunting through it, you ask the model to look at the page and hand you the fields directly.
But "hand you the fields" hides the real trick, and it's worth slowing down for. If you just ask "what's the invoice number and total?" you get back a sentence — prose you'd have to parse, which is the regex nightmare all over again, one layer up. The fix is to pair vision with a schema: you declare, up front, the exact shape you expect — an object with invoice_no, date, total, and a list of line items, each with its own types — and force the reply to match it. Think of the schema as a form with labelled, typed boxes: the model must fill in this box with text, that one with a number. You don't get a paragraph; you get a validated object.
Why the validation matters so much here: total declared as a number isn't decoration — it's a contract. If the model returns something that can't be a number, the parse fails loudly instead of silently feeding "$1,2O0" (with a letter O) into your accounting system. That is the difference between a demo and something you'd trust with real invoices. Two habits fall out of this:
- Model the document's real structure. Nest schemas the way the paper nests — an invoice contains a list of line items — so one call returns the whole thing already organized, not a flat blob you reshape afterward.
- Treat the schema as your source of truth. Need a new field like currency or tax? Add it to the schema and the extraction follows; you shape the output by editing the contract, not by writing more parsing code.
This is a genuine paradigm shift from the old pipeline, and the diagram below draws it as one clean arrow — picture in, typed object out — replacing the fragile OCR + regex chain entirely for semi-structured documents.
The highest-value multimodal use case: turn a messy document (invoice, form, receipt, screenshot) into structured data. Combine a vision message with structured output (A6) so you get a validated object, not prose.
This diagram shows the single most useful multimodal job: turning a messy document (an invoice, form, or receipt photo) into clean structured data your code can trust — not a paragraph you'd have to parse by hand.
- Left: the raw
invoice— an image or PDF page. Unstructured; a human can read it but a program can't reliably. - Middle:
vision + schemameans you send the image to the vision model and tell it the exact shape you want back (the schema). - Right: out comes a tidy object like
{invoice_no, date, total, line_items[]}— named fields with correct types, ready to store in a database.
In short: Read it left to right as "picture in, structured object out." This one pattern replaces brittle old OCR + regex pipelines for semi-structured documents.
pythonfrom pydantic import BaseModel
class LineItem(BaseModel):
description: str
qty: int
amount: float
class Invoice(BaseModel):
invoice_no: str
date: str
total: float
items: list[LineItem]
# messages.parse() forces schema-valid output (see Ch 2 / A6)
# resp = client.messages.parse(
# model="claude-opus-4-8", max_tokens=2048, output_format=Invoice,
# messages=[{"role":"user","content":[image_block, {"type":"text",
# "text":"Extract this invoice into the schema."}]}])
# invoice = resp # a validated Invoice object
This is the code behind that diagram. You describe the shape of the data you want using Pydantic classes, then ask the model to fill it in from the image. Pydantic is your contract: the result is guaranteed to match, or it errors — no guessing.
class LineItem(BaseModel)declares one row of the invoice: adescription(text), aqty(whole number), and anamount(decimal). The type after each name is the promise.class Invoice(BaseModel)is the whole document: an invoice number, a date, a total, anditems: list[LineItem]— a list of the rows above. Schemas nest like this to mirror real documents.- The commented-out call shows the payoff:
client.messages.parse(..., output_format=Invoice, ...)sends the image block plus a short instruction, and the.parsemethod forces the reply to fit yourInvoiceshape. - What you get back is a real, validated
InvoicePython object — you can readinvoice.totaldirectly instead of hunting through text.
What the output means: No console output here — the lines after # are commented out so the snippet is safe to read. Live, it would hand you a filled-in Invoice object with every field typed and checked.
Try this: Add a currency: str field to Invoice. The model will start returning it too — you shape the output just by editing the class.
4 · Multimodal RAG advanced
Here's the wall you hit the moment your knowledge base isn't pure text. You built RAG in Chapter 3, it retrieves passages beautifully, and then a user asks "which diagram shows the network topology?" or "find the slide with the Q3 revenue chart." Your retriever is helpless — it can only compare the text of the query against the text of your chunks, and a diagram isn't text. The valuable answer is sitting right there in your corpus as an image, and standard RAG can't reach it. Multimodal RAG is how you close that gap.
To see the fix, remember what retrieval actually is: turning things into vectors — points in a high-dimensional space — so that "similar in meaning" becomes "close together," and finding relevant items becomes finding nearby points. Text RAG only works because both the query and the chunks land in the same space, so distance is meaningful. The whole challenge with images is getting a picture and a text query into a comparable space at all. There are two honest ways to do it, and choosing between them is a real engineering decision:
- Caption-then-embed — the pragmatic route. You first ask a vision model to describe each image in rich text (a caption), then embed and store that caption like any other text chunk. You've converted the image problem into a text problem, so it works with any text vector store you already run — nothing new to buy. The catch: retrieval quality is only as good as the caption. If the caption says "a bar chart" but the query needs the exact peak value, and the captioner didn't mention it, the match fails. So you write captions for search — deliberately dense with the contents, on-image text, and numbers people might query.
- Native multimodal embeddings — the powerful route. A model trained to place images and text in one shared vector space (the CLIP family works this way) lets a text query match an image directly, no caption in between. Nothing is lost in translation to words, but you take on a dependency: a multimodal embedder and a store that holds its vectors.
One subtlety that separates a working system from a toy: even with caption-then-embed, the caption is only for finding the right image — at answer time you feed the actual image back to the model, not its caption. The caption is an index card in the drawer; once you've located the right card you pull the real document. That two-phase move — retrieve on a cheap text proxy, then reason over the true pixels — is what keeps answers grounded in what's really in the picture rather than in a lossy summary of it.
Standard RAG (Ch 3) retrieves text. Multimodal RAG retrieves images too — e.g. "find the diagram that answers this." Two common designs:
- Caption-then-embed: use a vision model to describe each image in text, embed the caption, retrieve as normal text. Simple, works with any text vector store.
- Native multimodal embeddings: a model (e.g. CLIP-style) embeds images and text into one shared vector space, so a text query can directly match an image. More powerful, needs a multimodal embedder.
The dashed line splits two designs; read each side into the shared bottom box. The goal on both sides is the same: get the query and the images into one comparable vector space so "similar" becomes "close together".
- Left (A · caption-then-embed): a vision model describes the image in words, that caption is embedded like any text chunk and stored in an ordinary text vector DB — you turned an image problem into a text problem, so nothing new to buy.
- Right (B · native multimodal embeddings): a CLIP-style model drops both the image and the text query into one shared vector space, so a text query matches an image directly — no caption in between, but you take on a multimodal embedder.
- The purple box at the bottom is the payoff both routes share: you retrieve the top-k matches, then feed the actual image back to the LLM — the caption or the vector was only for finding it, so the answer stays grounded in the real pixels.
In short: A converts pixels to text first (cheap, works anywhere); B embeds pixels and text jointly (more powerful, needs a special embedder) — both retrieve, then reason over the true image.
Setup to run this snippet
class _Any:
'''stands in for any undefined demo value; supports call/attr/index/
iteration and basic arithmetic (as 0.7) so demo snippets run.'''
def __call__(self, *a, **k): return _Any()
def __getattr__(self, k): return _Any()
def __getitem__(self, k): return _Any()
def __iter__(self): return iter([])
def __len__(self): return 0
def __contains__(self, o): return True
def __enter__(self, *a): return _Any()
def __exit__(self, *a): return False
def __float__(self): return 0.7
def __int__(self): return 1
def __lt__(self, o): return True
def __gt__(self, o): return False
def __le__(self, o): return True
def __ge__(self, o): return False
def __add__(self, o): return o
def __radd__(self, o): return o
def __bool__(self): return True
def __repr__(self): return 'demo'
def __str__(self): return 'demo'
def call_vision(*a, **k): # demo stub
return _Any()python# 1. For each image, generate a rich caption with the vision model
def caption(image_block):
# ask: "Describe this image in detail for search: contents, text, chart values"
return call_vision(image_block, "Describe for retrieval...")
# 2. Embed the caption (A4/A7) and store with a pointer back to the image
# vector_db.add(embed(caption(img)), metadata={"image_path": path})
# 3. At query time: retrieve top-k captions, then feed the ACTUAL images
# to the model for the final grounded answer.
This is the simplest way to make image search work with tools you already have. The idea: you can't easily search pictures, but you can search text — so first turn each image into a written description (a caption), then do ordinary text RAG over the captions.
- Step 1 —
caption(image_block)asks the vision model to describe an image in detail (its contents, any text, chart values). That description is plain text. - Step 2 (the comment) — you embed that caption into a vector and store it in a vector DB, keeping a
metadata={"image_path": path}pointer back to the original picture. - Step 3 (the comment) — at query time you retrieve the top matching captions, then feed the actual images (not just the captions) to the model for the final answer, so it reasons over the real pixels.
What the output means: This is a skeleton, not a runnable program (call_vision is a demo stub), so nothing prints. It shows the flow: caption → embed → retrieve → answer.
Try this: Compare with the alternative in the text above — native multimodal embeddings (CLIP-style) put images and text in one shared space, skipping the caption step.
5 · Audio & the full pipeline intermediate
A voice assistant sounds like it must be some fundamentally new kind of AI — and that assumption is exactly what over-complicates people's designs. You imagine you need an "audio model" that ingests sound and speaks back, and you go looking to rebuild your whole stack around it. For the common case, that's the wrong picture, and it leads to architectures far hairier than the job requires. The reality is almost anticlimactic: the same text LLM you've used all course sits at the centre, untouched, with a converter on each side.
The key fact to internalize is that a language model, at heart, still thinks in text tokens — the same currency from section 1. It doesn't natively hear. So audio has to be turned into text on the way in and, if you want a spoken reply, back into audio on the way out. Two small translators bookend the pipeline you already own:
- ASR / STT (speech-to-text) on the way in — Automatic Speech Recognition, e.g. a model like Whisper, converts the spoken audio into a plain-text transcript. From that instant on, it's just text, and everything you learned about prompting, RAG, and agents applies with zero changes.
- TTS (text-to-speech) on the way out — optional — turns the model's text answer back into a voice. If your product only needs to read voice (a meeting-notes summarizer), you skip this entirely; you add it only when you need to speak back.
Think of the LLM as a brilliant colleague who only reads and writes notes: a receptionist (ASR) transcribes what callers say into notes, your colleague reasons over the notes exactly as always, and a narrator (TTS) reads the written reply aloud. The colleague's job never changed — you just added a transcriber and a narrator at the doors. This "bookend" framing is powerful because it tells you where every hard problem lives: transcription accuracy and speech quality are the audio pieces, while correctness, grounding, and tool use remain the text pieces you already know how to build and test.
Where this mental model quietly breaks — and it's worth flagging. Bookending is right for turn-based tasks (transcribe a recording, answer, maybe speak). It starts to strain for truly real-time, low-latency conversation — think interruptions, back-channel "mm-hm," barge-in — where the round trip of transcribe → think → synthesize adds noticeable lag, and newer end-to-end speech systems blur the three boxes into one. For nearly everything you'll build first, though, the pipeline below is the correct and simplest answer; reach for the fancier stuff only when latency, not capability, becomes the bottleneck.
LLMs are text models, so audio enters via a speech-to-text (ASR) step first (e.g. Whisper), then the transcript flows through the normal text pipeline; a text-to-speech step can voice the reply. The LLM sits in the middle.
This diagram shows where audio fits. LLMs only understand text, so voice has to be converted to text on the way in and (optionally) back to voice on the way out. The model you already know sits unchanged in the middle.
- Follow the arrows left to right:
🎤 audiocomes in first. ASR (STT)means Automatic Speech Recognition / Speech-To-Text (e.g. Whisper) — it turns the spoken words into a plain text transcript.LLM (text)in the centre is your normal text pipeline — everything from earlier chapters works here unchanged, because it's just text now.TTS(Text-To-Speech) turns the model's text reply back into🔊 replyaudio, if you want a spoken answer.
In short: Audio just bookends the text pipeline: transcribe first, do your usual LLM work in the middle, synthesize speech last. No new LLM magic — two extra converters.
🎯 Interview practice interview
The interview questions this topic gets asked — worked, with code. For the full pattern catalog see A9 · Big Tech AI-engineering patterns.
Vision + structured output beats OCR+regex for semi-structured docs.
python# PIPELINE: image/PDF -> vision model with a Pydantic schema -> validated object
# SCALE: batch pages; cache by document hash; fall back to human review on low confidence
# COST: images cost tokens (hundreds-thousands each) -> resize, budget
These three comment lines are a whiteboard answer to a common interview question: "design a system to pull data out of invoices/PDFs." Each line names one axis an interviewer probes — the core approach, how it scales, and what it costs.
- PIPELINE — the core design: image/PDF goes to a vision model with a Pydantic schema, and you get a validated object out. This is the same idea as section 3 above.
- SCALE — how to handle volume: process pages in batches,
cache by document hashso you never re-pay for the same file, and route low-confidence results to a human reviewer instead of trusting them blindly. - COST — the money reality: images cost tokens (hundreds to thousands each), so resize images and budget deliberately.
Try this: In an interview, say the headline first — "vision + structured output beats OCR+regex" — then walk these three axes. Naming scale and cost trade-offs is what senior looks like.
Images ride in the message content as a base64 (or URL) image block next to your question.
pythonimport base64
data = base64.standard_b64encode(open("chart.png","rb").read()).decode()
content = [
{"type":"image","source":{"type":"base64",
"media_type":"image/png","data":data}},
{"type":"text","text":"What is the peak value in this chart?"},
]
A tight, from-memory version of "send an image to the model" — the kind of snippet you might whiteboard. It's the same recipe as the first lab, squeezed to the essentials.
base64.standard_b64encode(open("chart.png","rb").read()).decode()does the whole read-and-encode in one line: open the file as bytes, base64-encode them, decode to a text string.contentis the list of two blocks again: animageblock carrying the base64dataand itsmedia_type, then atextblock with the question.- That's the entire pattern — everything else (the
client.messages.createcall) is identical to a normal text request.
Try this: If you can reproduce these two blocks from memory — image block + text block in a content list — you can send any picture to the model. That's the whole interview answer.
Checkpoint advanced
- Explain how images become tokens and why they cost more.
- Send base64/URL images to Claude and ask questions about them.
- Extract structured data from a document with vision + a Pydantic schema.
- Design multimodal RAG (caption-then-embed or native embeddings).
- Place ASR/TTS correctly around the text LLM pipeline.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: An image content block must declare the correct media_type, and a JPEG announced as PNG can be rejected. Deriving the MIME type from the filename is the small, offline first step.
Your task: Write media_type_for(path) that returns the right MIME string for the image types the model supports (PNG/JPEG/WebP/GIF) and raises on anything else. Fully runnable offline.
Requirements:
- Map each supported extension to its MIME string (stdlib
osonly) - Both
.jpgand.jpegmap toimage/jpeg - Lowercase the extension so
chart.PNGresolves correctly - Raise
ValueErroron an unsupported type such as.pdf - Show a couple of successful lookups and one rejection
💡 Hint: Split the extension with os.path.splitext, normalize case, and look it up in a small dict of the supported types.
Show solution
Runnable, stdlib only:
import os
SUPPORTED = {
".png": "image/png",
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
}
def media_type_for(path):
ext = os.path.splitext(path)[1].lower()
if ext not in SUPPORTED:
raise ValueError(f"unsupported image type: {ext!r}")
return SUPPORTED[ext]
print(media_type_for("chart.PNG")) # image/png
print(media_type_for("scan.jpeg")) # image/jpeg
try:
media_type_for("notes.pdf")
except ValueError as e:
print(e) # unsupported image type: '.pdf'
Matching media_type to the actual bytes is what lets the model decode the image;
a JPEG sent as image/png can be rejected.
Context: Sending a local image means reading its bytes, base64-encoding them, and shaping the exact content-block dict the SDK expects — all doable offline before any live call.
Your task: Write image_block(path) that reads a file's bytes, base64-encodes them, and returns the SDK's image content-block dict, then verify it round-trips by decoding back.
Requirements:
- Read the file in binary and base64-encode with
base64(stdlib) - Return the block shape
{"type": "image", "source": {"type": "base64", "media_type": ..., "data": ...}} - Reuse the media-type lookup so the block declares the correct MIME
- Demonstrate offline: write raw bytes, encode, then decode the data field to prove the round-trip
- Note this is the exact shape handed to the messages API — only the final call differs
💡 Hint: The only difference from the live call is that this dict later becomes one entry in the message's content list.
Show solution
Runnable offline (creates a tiny file, encodes it, decodes back to prove it round-trips):
import base64, os
def media_type_for(path):
return {"png":"image/png","jpg":"image/jpeg","jpeg":"image/jpeg"}[
os.path.splitext(path)[1].lower().lstrip(".")]
def image_block(path):
with open(path, "rb") as f:
data = base64.standard_b64encode(f.read()).decode()
return {"type": "image",
"source": {"type": "base64",
"media_type": media_type_for(path),
"data": data}}
# --- demo: write raw bytes, encode, verify round-trip ---
with open("tiny.png", "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n demo bytes")
blk = image_block("tiny.png")
print(blk["source"]["media_type"]) # image/png
print(base64.b64decode(blk["source"]["data"])[:4]) # b'\x89PNG'
This is the exact shape the message API consumes; the only difference from the live call is
that you hand this block to client.messages.create.
Context: The core vision recipe is one idea beyond a text call: the message content is a list of blocks — an image block plus a text block. This rung needs an API key and network.
Your task: Combine the encode step with a real vision call: send an image block plus a text question and print the model's answer.
Requirements:
- Base64-encode a local image and place it in an image content block
- Send a message whose
contentlist holds the image block plus a text question - Read the answer from the first content block of the response
- Requires
ANTHROPIC_API_KEYand theanthropicpackage (network needed) - The offline encode block from the previous rung is the runnable half
💡 Hint: The whole novelty over a text call is that content is a list of blocks rather than a single string.
Show solution
Needs an API key (ANTHROPIC_API_KEY) and the anthropic
package. This is the lesson's core recipe, verbatim in shape:
import base64, anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from env
with open("chart.png", "rb") as f:
data = base64.standard_b64encode(f.read()).decode()
msg = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {
"type": "base64", "media_type": "image/png", "data": data}},
{"type": "text", "text": "What trend does this chart show? Give the peak value."},
],
}],
)
print(msg.content[0].text)
Only one idea beyond a text call: the content is a list of blocks — an image
block plus a text block. msg.content[0].text is the answer.
Context: Images cost tokens roughly by area, so a huge image quietly inflates your bill and latency. Budgeting the cost before you send is the practical discipline — bigger is not better.
Your task: Write approx_image_tokens(w, h) using a simple tile heuristic and a maybe_downscale(w, h, budget) that decides whether to shrink a large image before sending. Runnable offline.
Requirements:
- Estimate cost by tiling: ceil(w/tile) × ceil(h/tile) × tokens-per-tile
- Use
mathonly; note the exact numbers vary by provider and the point is budgeting maybe_downscalereturns as-is when the estimate fits the budget- When over budget, scale dimensions down (square-root of the ratio) and re-estimate
- Show a large image getting downscaled and a small one sent unchanged
💡 Hint: Scaling both dimensions by sqrt(budget/estimate) shrinks the tile count roughly to the budget in one step.
Show solution
Runnable, stdlib only. Uses a simple, documented-style tile heuristic (exact numbers vary by provider; the point is budgeting before you pay):
import math
TILE = 512 # px per tile edge
TOKENS_PER_TILE = 170
def approx_image_tokens(w, h):
tiles = math.ceil(w / TILE) * math.ceil(h / TILE)
return tiles * TOKENS_PER_TILE
def maybe_downscale(w, h, budget=700):
est = approx_image_tokens(w, h)
if est <= budget:
return (w, h, est, "send as-is")
# scale so estimated tokens fit the budget
scale = math.sqrt(budget / est)
nw, nh = int(w * scale), int(h * scale)
return (nw, nh, approx_image_tokens(nw, nh), "downscaled")
print(approx_image_tokens(512, 512)) # 170
print(maybe_downscale(2048, 2048)) # (..., 'downscaled') -- big image trimmed
print(maybe_downscale(400, 300)) # (400, 300, 170, 'send as-is')
Bigger ≠ better: more patches = more tokens = higher bill and latency. Budgeting up front is the practical discipline the lesson calls out.
Context: The multimodal RAG pattern is caption-then-embed: turn each image into a text caption, then embed the caption into a vector store. Because the index is text, it works today with any text vector DB.
Your task: Model the caption-then-embed pipeline offline with a fake captioner and a deterministic embedder, keeping the real vision call swappable, and retrieve the best-matching image for a text query.
Requirements:
- A
caption()stub stands in where a real vision call would go - A deterministic
embed()(e.g. bag-of-words) turns caption text into a vector - A
cosine()similarity ranks captions against the query vector - Build an index of (image, caption-embedding) pairs and return the closest image for a query
- Explain that swapping
caption()for a vision call andembed()for a real model makes it production-ready
💡 Hint: Because images are represented by their caption embeddings, retrieval is just text-vs-text cosine over the captions.
Show solution
Runnable offline. The caption() is a stub where a vision call would go; the
embed/search half is a real, tiny cosine index:
import math
def caption(image_path):
# OFFLINE STUB. Real version: client.messages.create(...) on the image,
# then use msg.content[0].text as the caption.
return {"invoice.png": "invoice total 42 dollars acme",
"chart.png": "line chart sales peak q3"}[image_path]
def embed(text): # toy bag-of-words vector (deterministic)
vocab = ["invoice","total","chart","sales","peak","acme"]
return [text.count(w) for w in vocab]
def cosine(a, b):
dot = sum(x*y for x, y in zip(a, b))
na = math.sqrt(sum(x*x for x in a)); nb = math.sqrt(sum(y*y for y in b))
return dot / (na*nb) if na and nb else 0.0
# index images by their captions
index = [(p, embed(caption(p))) for p in ["invoice.png", "chart.png"]]
q = embed("what was the sales peak")
best = max(index, key=lambda it: cosine(q, it[1]))
print("best match:", best[0]) # chart.png
Because images are embedded via their captions, this works today with any text vector
DB — swap caption() for a real vision call and embed() for your embedding
model.
Context: The highest-value multimodal use case turns a messy invoice image into a validated object, not prose. The trust comes from schema validation, not from the model's free-form text.
Your task: Combine a vision message with structured output to extract invoice fields, then validate the returned JSON against a schema and reject on missing or mistyped fields. The live call needs an API key; the validation half runs offline.
Requirements:
- The vision call asks for JSON with specific keys (vendor, total, currency) and JSON only
- Parse the model text as JSON with the stdlib
jsonmodule - A
validate_invoice()checks every required key is present and correctly typed - Raise on a missing field (e.g.
total) and show a valid object passing - Requires
ANTHROPIC_API_KEYfor extraction; the schema check is runnable offline - Frame the rule: never trust free-form model text as data — validate before it enters your system
💡 Hint: Keep a small required-fields map of name → expected type and check presence and isinstance before returning the object.
Show solution
The vision call needs an API key; the schema/validation logic below it is runnable offline and is what makes the result trustworthy:
import base64, json, anthropic # SDK part needs an API key
def extract_invoice(path):
client = anthropic.Anthropic()
with open(path, "rb") as f:
data = base64.standard_b64encode(f.read()).decode()
msg = client.messages.create(
model="claude-opus-4-8", max_tokens=1024,
messages=[{"role": "user", "content": [
{"type": "image", "source": {"type": "base64",
"media_type": "image/png", "data": data}},
{"type": "text", "text":
"Extract JSON with keys: vendor (str), total (number), "
"currency (str). Reply with JSON only."},
]}],
)
return json.loads(msg.content[0].text)
# ---- offline: validate whatever came back (runs as-is) ----
REQUIRED = {"vendor": str, "total": (int, float), "currency": str}
def validate_invoice(obj):
for key, typ in REQUIRED.items():
if key not in obj:
raise ValueError(f"missing field: {key}")
if not isinstance(obj[key], typ):
raise TypeError(f"{key} has wrong type")
return obj
print(validate_invoice({"vendor": "Acme", "total": 42.0, "currency": "USD"}))
try:
validate_invoice({"vendor": "Acme"})
except ValueError as e:
print("rejected:", e) # rejected: missing field: total
Design note: never trust free-form model text as data — validate against a schema and fail on missing/mistyped fields before it enters your system (invoices flow to ledgers).
Knowledge check check yourself
According to the lesson, how does a vision-language model "see" an image, and what practical cost consequence follows from that mechanism?
Show answer
For turning invoices/PDFs into structured data, the lesson recommends vision + a Pydantic schema over OCR-plus-regex. What does pairing vision with a schema (via messages.parse) buy you?
Show answer
{invoice_no, date, total, items[]}) instead of prose you'd parse by hand — the schema is a contract, so the reply must match or it errors. This replaces brittle OCR+regex pipelines for semi-structured documents and lets you read invoice.total directly.