Multimodal AI

Working with images, documents, audio, and generated media.

Introduction

Everything so far has been text in, text out. Real products rarely stay that clean: users upload receipts, scan contracts, record calls, and expect diagrams back. Modern models are multimodal, they accept images and documents alongside text, and dedicated models handle audio and image generation. This lesson shows what a single model can do natively (vision, document understanding), what has to be routed to a specialized service (speech, image generation), and how to wire them into one pipeline.

1. What Is Multimodal?

Modalities and their token cost

2. Vision

Sending images to the model

3. Documents

Native PDF understanding

4. Audio

Speech-to-text and back

5. Image Generation

Routing to an image model

6. Combined Pipeline

Chaining modalities together

7. Cost & Pitfalls

Tokens, latency, and PII

1. What Is Multimodal AI?

A multimodal model accepts more than one kind of input in the same request. Claude and Gemini, for example, take interleaved text and images (and PDFs) in a single message. Crucially, not every modality flows through one model: today Claude reads images and documents but does not hear audio or draw pictures, so a complete product often combines a reasoning model with specialized services.

Multimodal Inputs to One Model

Imagejpeg / pngDocumentPDFTextinstructionsModelClaude / GeminiOutputtext / structured JSON

Figure 1: Text, images, and documents can share a single request; the model reasons over all of them together

What an Image Actually Costs

Images are billed as tokens, and the count comes from the pixel dimensions, not the file size. Rather than guess, measure with client.messages.count_tokens(). Here is the same receipt photo sent at six sizes, with a 12-token text prompt subtracted out:

Long edgeFile sizeImage tokensRelative cost
400px3 KB1680.04x
800px10 KB6410.14x
1000px15 KB9750.21x
1500px31 KB2,2170.47x
2000px52 KB3,8910.82x
3000px115 KB4,7431.00x

Two things fall out of those numbers. Cost tracks area, so halving the long edge quarters the bill: dropping from 2000px to 1000px is a 75% saving. And the growth flattens at the top, because the API downscales very large uploads server-side. A 3000px image costs 4,743 tokens rather than the ~9,000 the area formula predicts, so sending a huge file buys you nothing except upload time.

Resize before you send. Pick the smallest size at which the detail you need is still legible, which for most receipts and forms is 800 to 1000px on the long edge. Note that this is not free: even 1000px is close to a thousand tokens per image, so a high-volume pipeline should treat image size as a real line item. See Lesson 18 for the rest of the cost picture.

2. Vision: Sending Images

You pass an image as a content block with a base64 (or URL) source, alongside a text block that says what to do with it. The model can then describe, classify, compare, extract text from, or reason about the image.

# vision.py - fetch an image, encode it, ask about it
import base64

import anthropic
import httpx

client = anthropic.Anthropic()

# Any reachable image works. Fetch it, then base64-encode the bytes.
url = "https://raw.githubusercontent.com/github/explore/main/topics/python/python.png"
image_data = base64.standard_b64encode(httpx.get(url).content).decode("utf-8")

message = client.messages.create(
    model="claude-sonnet-5",
    max_tokens=1024,
    messages=[{
        "role": "user",
        "content": [
            {
                "type": "image",
                "source": {
                    "type": "base64",
                    "media_type": "image/png",
                    "data": image_data,
                },
            },
            {"type": "text", "text": "Describe what this image shows in one sentence."},
        ],
    }],
)
print(next(b.text for b in message.content if b.type == "text"))
print(f"input tokens: {message.usage.input_tokens}")

# You can also hand the model a URL and skip the download:
#   {"type": "image", "source": {"type": "url", "url": url}}
# It is fetched server-side, so it only works if the host serves that
# request; some CDNs refuse it and the call fails with a 400.
Expected Output:
This image shows the official Python programming language logo, featuring two intertwined snake-like shapes in blue (top) and yellow (bottom), each with a small circular "eye," forming the recognizable symbol used to represent Python.
input tokens: 144
Describe & classify

Alt text, content moderation, product tagging

Extract text

Read handwriting, signs, screenshots, labels

UI to code

Turn a screenshot or mockup into HTML/JSX

Visual QA

Answer questions about charts, diagrams, photos

3. Document Understanding

Documents are the highest-value multimodal use case in business software: invoices, contracts, forms, statements. Claude accepts a PDF as a document content block and reads both its text layer and its rendered pages, so tables and even image-only scans work without a separate OCR step. Pair it with a schema through structured outputs and what comes back is a validated object rather than JSON you still have to check.

# extract_invoice.py - PDF in, validated Invoice object out
import base64
import json

import anthropic
from pydantic import BaseModel
from reportlab.lib.pagesizes import LETTER
from reportlab.pdfgen import canvas

client = anthropic.Anthropic()

# --- Build an invoice PDF so the example runs with no external file ---
ROWS = [("Espresso beans 1kg", 2, 18.50), ("Paper filters", 3, 4.25),
        ("Ceramic mug", 1, 14.00)]

c = canvas.Canvas("invoice.pdf", pagesize=LETTER)
c.setFont("Helvetica-Bold", 16)
c.drawString(72, 720, "Blue Bottle Coffee")
c.setFont("Helvetica", 11)
c.drawString(72, 690, "Invoice INV-2044        Date 2026-08-11")
y = 650
for description, qty, unit in ROWS:
    c.drawString(72, y, f"{description:<24} {qty:>3} x {unit:>6.2f} = {qty * unit:>7.2f}")
    y -= 20
c.drawString(72, y - 20, f"TOTAL {sum(q * u for _, q, u in ROWS):.2f}")
c.save()


class LineItem(BaseModel):
    description: str
    qty: int
    unit_price: float


class Invoice(BaseModel):
    invoice_number: str
    date: str
    vendor: str
    total: float
    line_items: list[LineItem]


with open("invoice.pdf", "rb") as f:
    pdf_data = base64.standard_b64encode(f.read()).decode("utf-8")

# messages.parse applies the schema to the model's output, so what comes
# back is a validated Invoice, not JSON you still have to check.
response = client.messages.parse(
    model="claude-sonnet-5",
    max_tokens=1024,
    messages=[{
        "role": "user",
        "content": [
            {
                "type": "document",
                "source": {
                    "type": "base64",
                    "media_type": "application/pdf",
                    "data": pdf_data,
                },
            },
            {"type": "text", "text": "Extract this invoice."},
        ],
    }],
    output_format=Invoice,
)

invoice = response.parsed_output
print(json.dumps(invoice.model_dump(), indent=2))

# Claude reads the PDF natively, both its text layer AND its rendered
# pages, so tables, columns, and image-only scans work without a
# separate OCR step.
Expected Output:
{
  "invoice_number": "INV-2044",
  "date": "2026-08-11",
  "vendor": "Blue Bottle Coffee",
  "total": 63.75,
  "line_items": [
    {
      "description": "Espresso beans 1kg",
      "qty": 2,
      "unit_price": 18.5
    },
    {
      "description": "Paper filters",
      "qty": 3,
      "unit_price": 4.25
    },
    {
      "description": "Ceramic mug",
      "qty": 1,
      "unit_price": 14.0
    }
  ]
}
"No OCR step" is literal. The same call works on a PDF with no text layer at all. Rendering this invoice to a flat image and wrapping that in a PDF, so that nothing is selectable, still returns INV-2044 and the correct total. Claude looks at the page, it does not just read the embedded text stream.

Structured Document Extraction

PDF / Scaninvoice, formVision Modelreads text + layoutJSONagainst your schemaValidatePydanticTrusted Recordon passretry on fail

Figure 2: Extraction is only trustworthy once the output is validated against a schema, retry on failure

4. Audio: Transcribe, Then Reason

Claude has no native audio input or output. Send an audio block and the API rejects it, listing the block types it does accept: text, image, document, and the tool and thinking variants. Nothing for sound. So the pattern is a two-step pipeline: transcribe speech to text with a dedicated speech-to-text (STT) service, then let Claude do what it is best at, reasoning over the text. For the reverse direction, a text-to-speech (TTS) service turns Claude's output back into audio.

# call_summary.py - synthesize a call, transcribe it, then reason over the text
# Claude has no audio input, so speech is transcribed first by a dedicated
# service and Claude reasons over the text.

import anthropic
from openai import OpenAI

audio_api = OpenAI()          # STT and TTS live here
claude = anthropic.Anthropic()

SCRIPT = ("Hi, this is Dana from Northwind. Our checkout page has been returning "
          "500 errors since this morning and we are losing sales. I will escalate "
          "this to engineering now and we will have a fix deployed by Friday.")

# 0. Manufacture a recording, so the example needs no audio file of your own.
#    This is also the text-to-speech direction: Claude's output can be spoken
#    the same way.
audio_api.audio.speech.create(
    model="gpt-4o-mini-tts", voice="alloy", input=SCRIPT,
).write_to_file("support_call.mp3")

# 1. Speech -> text
with open("support_call.mp3", "rb") as audio:
    transcript = audio_api.audio.transcriptions.create(
        model="gpt-4o-transcribe",   # whisper-1 still works; this is the current model
        file=audio,
    ).text
print(f"transcript: {transcript[:70]}...")

# 2. Text -> analysis (this is where Claude excels)
reply = claude.messages.create(
    model="claude-sonnet-5",
    max_tokens=512,
    messages=[{"role": "user", "content": (
        "Summarize this support call in one line, then list any commitments "
        f"made as short bullets. No headings.\n\n{transcript}"
    )}],
)
print(next(b.text for b in reply.content if b.type == "text"))
Expected Output:
transcript: Hi, this is Dana from Northwind. Our checkout page has been returning ...
Dana from Northwind reported checkout page 500 errors causing lost sales since this morning; support committed to escalating and fixing by Friday.

- Escalate the issue to engineering immediately
- Deploy a fix by Friday
Service options: for STT, OpenAI's gpt-4o-transcribe (andgpt-4o-transcribe-diarize when you need speaker labels), Deepgram, and Google Speech-to-Text; whisper-1 still works and is the name you will see in older code. For TTS, ElevenLabs and OpenAI's audio models. Worth knowing that the two-step split is a Claude constraint, not a universal one: OpenAI's gpt-audio and gpt-realtime families take audio directly, which is the better fit for live conversation. When the job is reasoning over a recording, transcribe-then-reason stays the simpler and cheaper shape.

5. Image Generation

Generating images is another job to route out: Claude cannot draw, so you dispatch that step to an image model (OpenAI's image API, Google Imagen, Stability, and others). The most effective pattern is a division of labor, Claude writes a precise image prompt, the image model renders it, and Claude's vision then reviews the result. That last step closes the loop: the same model that specified the image can judge whether it got one, which is what makes the pattern automatable.

# icon_pipeline.py - Claude writes the prompt, the image model renders, Claude reviews
# Claude cannot generate images. When a workflow needs one, route that step
# to an image model, exactly the hybrid routing pattern from the
# "Building AI Applications" lesson.

import base64

import anthropic
from openai import OpenAI

claude = anthropic.Anthropic()
image_client = OpenAI()

# 1. Claude turns a vague request into a detailed image prompt.
brief = "an icon for the shipping section of our app"
reply = claude.messages.create(
    model="claude-sonnet-5",
    max_tokens=200,
    messages=[{"role": "user", "content": (
        f"Write a single-sentence image generation prompt for: {brief}. "
        "Flat vector style, green on white. Return only the prompt."
    )}],
)
prompt = next(b.text for b in reply.content if b.type == "text").strip()
print(f"prompt: {prompt}")

# 2. The image model renders it.
result = image_client.images.generate(
    model="gpt-image-1.5",         # or Google Imagen, Stability, etc.
    prompt=prompt,
    size="1024x1024",
    quality="low",
)

# gpt-image models return base64 only; there is no response_format="url".
image_bytes = base64.b64decode(result.data[0].b64_json)
with open("box.png", "wb") as f:
    f.write(image_bytes)
print(f"saved box.png ({len(image_bytes) // 1024} KB)")

# 3. Claude's vision reviews the result and can request a revision.
review = claude.messages.create(
    model="claude-sonnet-5",
    max_tokens=200,
    messages=[{"role": "user", "content": [
        {"type": "image", "source": {
            "type": "base64", "media_type": "image/png",
            "data": base64.b64encode(image_bytes).decode("utf-8")}},
        {"type": "text", "text": (
            f"Does this image satisfy the brief '{brief}'? "
            "Answer in one sentence.")},
    ]}],
)
print(f"review: {next(b.text for b in review.content if b.type == 'text')}")
Expected Output:
prompt: A flat vector icon of a delivery truck in solid green on a white background, minimalist design, clean simple lines, no gradients, no shadows, centered composition, app icon style.
saved box.png (127 KB)
review: Yes, this green delivery truck icon with motion lines effectively conveys shipping/delivery and would work well for a shipping section icon.

6. A Combined Multimodal Pipeline

Real features chain modalities. Consider an expense app: a user photographs a receipt, and the system extracts the data, checks it against policy, and returns a tidy summary. One vision call feeds a reasoning call feeds a rendered output, each step using the model best suited to it.

Receipt-to-Report Pipeline

Receipt Photouser uploadExtractvision → JSONAnalyzepolicy checkSummarytext + chartstructured data

Figure 3: Each stage uses the right model, vision to read the receipt, reasoning to apply policy, generation for the summary

7. Cost, Latency & Pitfalls

Do
  • Resize/downsample images before sending, resolution drives token cost
  • Always validate extracted data against a schema before using it
  • Cache results, the same receipt should not be re-processed
  • Ask for structured JSON, not prose, when the output feeds code
Watch out for
  • Blurry or low-res images produce confidently wrong text
  • Images often contain PII (IDs, faces), handle and store them accordingly
  • Vision + document calls are slower; stream or show progress
  • Generated images can carry watermarks or licensing terms, check them
Uploaded media is user data. A receipt carries a card number, an ID scan carries everything, and a photo carries location metadata. The handling rules are the same ones in Lesson 15: strip what you do not need, keep it out of logs, and set a retention window before you ship rather than after.

Key Takeaways

  • Multimodal means mixed input in one request - Claude and Gemini take text, images, and PDFs together and reason over all of them.
  • Vision is a content block - pass an image (base64 or URL) plus a text instruction; use it to describe, extract, classify, or convert UI to code.
  • Document understanding is native - Claude reads a PDF's text and its rendered pages, so even a scan with no text layer extracts correctly; pair it with a schema and you get a validated object.
  • Audio is routed out - transcribe with an STT service, reason with Claude, and use TTS for spoken output.
  • Image generation is routed out - Claude writes the prompt, an image model renders it, Claude's vision reviews it.
  • Chain modalities per stage - use the right model at each step of a pipeline (extract, analyze, generate).
  • Image cost scales with area - measured with count_tokens, one receipt runs 168 tokens at 400px and 4,743 at 3000px; halving the long edge quarters the bill.
  • Mind latency and PII - validate outputs, cache repeat work, and treat uploaded media as sensitive data.