Build a PDF Assistant

Su Suriya Ravichandran Updated 10 Nov 2026
4 min read ·Lesson 195 of 200

The Goal

A PDF assistant lets a person ask questions about a document, such as a policy, a contract, or a report, and receive answers that come from that document. It is a practical product: many organizations hold their knowledge in PDFs, and staff spend time searching them. The assistant does the searching and reading, and returns answers the reader can check.

The project brings together three earlier lessons. The PDF is sent as a document block (Lesson 114), the document is marked for caching so that repeated questions are cheap (Lesson 124), and the file is checked before anything is sent (Lesson 111). The result is an object that is created once for a document and answers any number of questions about it.


What You Will Build

A PdfAssistant class that:

  1. checks that the bytes are a PDF before accepting them,
  2. builds the document block once, with a cache marker,
  3. answers each question by sending the same document block followed by the question,
  4. returns the answer text, which the reader can compare with the document.

Project Structure

pdf_assistant/
├── assistant.py    # the PdfAssistant class
└── demo.py         # a stand-in client that answers from a fixed set of facts

Step 1: Accepting a Document

The assistant accepts only bytes that begin with the PDF signature. The check happens in the constructor, so an invalid document is refused before any question can be asked of it, and the error explains the problem:

"""assistant.py: answer questions about one PDF, sending the document once and caching it."""

import base64

MODEL = "claude-sonnet-5-5"
PDF_SIGNATURE = b"%PDF-"


class PdfAssistant:
    def __init__(self, client, pdf_bytes: bytes, title: str):
        if not pdf_bytes.startswith(PDF_SIGNATURE):
            raise ValueError(f"{title} is not a PDF: its contents do not start with %PDF-")
        self.client = client
        self.document = {
            "type": "document",
            "title": title,
            "source": {
                "type": "base64",
                "media_type": "application/pdf",
                "data": base64.b64encode(pdf_bytes).decode("ascii"),
            },
            "cache_control": {"type": "ephemeral"},
        }

The document block is built once and stored. Every question reuses the same object, so the document's bytes are identical in every request, which is the condition for a cache hit (Lesson 121).


Step 2: Answering a Question

Each question is sent as a user message that contains the document first and the question second. The document comes first because the cache covers everything before its marker, and the question is the part that changes:

    def ask(self, question: str) -> str:
        response = self.client.messages.create(
            model=MODEL,
            max_tokens=400,
            messages=[{
                "role": "user",
                "content": [self.document, {"type": "text", "text": question}],
            }],
        )
        return "".join(block.text for block in response.content if block.type == "text").strip()

The instruction to answer only from the document can be added to the question text, or to a system prompt that the reader sees as policy. The answer should be compared with the document by the reader, because a model answer about a PDF is only as reliable as its check.


Step 3: A Demonstration

The stand-in client answers from a fixed set of questions, and it reports whether the document carried a cache marker, so the demonstration shows the caching is in place:

"""demo.py: ask two questions about a document, with a stand-in client."""

from types import SimpleNamespace

from assistant import PdfAssistant

FACTS = {
    "What is the refund window?": "14 days from purchase.",
    "Who approves refunds?": "The support lead.",
}


class FactsClient:
    def __init__(self):
        self.messages = self

    def create(self, **request):
        content = request["messages"][0]["content"]
        question = content[-1]["text"]
        cached = "cache_control" in content[0]
        answer = FACTS[question] + f" (document cached: {cached})"
        return SimpleNamespace(content=[SimpleNamespace(type="text", text=answer)])


if __name__ == "__main__":
    pdf = b"%PDF-1.4\n% refund policy, 2026 edition\n"
    assistant = PdfAssistant(FactsClient(), pdf, title="Refund policy")
    print("pdf check: ok")
    for question in FACTS:
        print(f"Q: {question} A: {assistant.ask(question)}")

Output:

pdf check: ok
Q: What is the refund window? A: 14 days from purchase. (document cached: True)
Q: Who approves refunds? A: The support lead. (document cached: True)

How It Works

  1. The signature check passes, because the demonstration document begins with %PDF-. A text file in its place would raise the error from the constructor before any question.
  2. The stand-in reads the first content block, which is the document, and records whether it carries a cache marker. This is the check that the caching is set up correctly.
  3. Both questions use the same document object, so the second request sends identical document bytes, which is what allows the second request to read the cache.
  4. The question is the last block, so the part that changes sits outside the cached prefix, as the design requires.

Going Live

With the SDK, the assistant is built as PdfAssistant(Anthropic(), pdf_bytes, title="Refund policy"), and questions are asked the same way. The first question writes the document to the cache, and later questions within the cache lifetime read it. For a document that many people ask about, the savings are large, because the document is the most expensive part of each request.


Limits and Checks

A PDF has limits on size and pages that are set by the platform, and the documentation's current figures should be checked before a deployment relies on them. The assistant should also enforce its own size limit before encoding, since encoding enlarges the data (Lesson 113). Answers should cite pages or sections where possible, so that a reader can verify the answer quickly. Lesson 157 showed how citations link an answer to the passage it came from, and a PDF assistant built for real use should enable them.

0 Comments

Reviewed before they appear

No comments yet.

Anthropic SDK Using Python
Anthropic SDK Intro Claude API Intro SDK Requirements Install Anthropic SDK Upgrade the SDK Create an API Key Set API Keys Use Environment Variables Import Anthropic Create a Client Send Your First Message Choose a Claude Model Set Max Tokens Read a Response Print Response Text Understand Message Blocks Use System Prompts Send User Messages Set Temperature Handle Stop Reasons Messages API Intro Create Messages Use Message Roles Send Conversation History Continue a Conversation Build a Chat Loop Reset Chat History Use Multiple Turns Inspect Message IDs Read Usage Data Write Clear Prompts Set a System Prompt Add Context to Prompts Use Prompt Templates Add Examples to Prompts Request JSON Output Use XML Tags Control Response Length Improve Prompt Quality Avoid Prompt Injection Read Text Blocks Read Content Blocks Check Stop Reasons Count Input Tokens Count Output Tokens Count Tokens Before Sending Limit Output Tokens Use Stop Sequences Read Request IDs Parse API Responses Streaming Intro Stream Text Output Use Stream Events Use Stream Helpers Get Final Messages Stream Async Responses Handle Stream Errors Build a Live Chat Stream Long Responses Close Streams Safely Build a Chatbot Keep Chat History Manage Chat Sessions Add Chat Memory Build a CLI Chatbot Build a Web Chatbot Use Flask with Claude Use FastAPI with Claude Return JSON from an API Add Chat Error Messages Async SDK Intro Create an Async Client Send Async Messages Use asyncio Run Parallel Requests Stream Async Text Handle Async Errors Set Async Timeouts Close Async Clients Choose Sync or Async Tool Use Intro Define a Tool Create Tool Schemas Send Tools to Claude Read Tool Calls Run Python Tools Return Tool Results Continue After Tool Use Control Tool Choice Handle Tool Errors Use the beta_tool Decorator Create a Tool Runner Add Tool Docstrings Validate Tool Inputs Use Multiple Tools Limit Tool Calls Log Tool Activity Build a Weather Tool Build a Search Tool Build a Calculator Tool Structured Output Intro Request JSON Data Validate JSON Output Use Pydantic Models Handle Invalid JSON Extract Fields from Text Generate Typed Data Use Image URLs Send Base64 Images Read PDFs with Claude Use Output Schemas Build Data Extraction Test Structured Responses Send Image Inputs Upload Files Use File IDs Process Text Files Summarize Documents Extract Table Data Handle File Errors Read Cache Usage Reduce Repeated Input Costs Debug Cache Misses Prompt Caching Intro Enable Prompt Caching Cache System Prompts Cache Long Context Test Cache Performance Use Cache Breakpoints Cache Best Practices Message Batches Intro Create a Message Batch Set Batch IDs Check Batch Status List Message Batches Get Batch Results Process Batch Outputs Handle Failed Batch Items Use Batches for Bulk Tasks Compare Batches and Requests Handle API Errors Handle Rate Limits Retry Failed Requests Set Request Timeouts Handle Network Errors Use Exponential Backoff Build Reliable API Calls Use Extended Thinking Log API Errors Track Request IDs Paginate API Results Set Thinking Budgets Read Thinking Blocks Use Tool Search Use Web Search Tools Use Computer Tools Use Citations Use Token Counting Read Raw Responses Set Custom Headers Use Claude on AWS Bedrock Use Claude on Vertex AI Use Claude on Microsoft Foundry Configure Cloud Credentials Choose a Cloud Provider Compare API Integrations Configure Provider Clients Handle Cloud Errors Use Cloud SDK Extras Deploy Cloud Apps Protect API Keys Use a .env File Ignore Secrets in Git Validate User Input Reduce Prompt Injection Limit Tool Permissions Avoid Unsafe Tool Calls Sanitize Tool Results Set Usage Limits Review Security Logs Test SDK Functions Mock API Responses Test Error Handling Test Streaming Code Measure Token Usage Track API Costs Configure Logging Deploy with Docker Deploy a FastAPI App Monitor Production Apps Build a Claude Chatbot Build a Text Summarizer Build a Code Explainer Build a JSON Extractor Build a PDF Assistant Build a Streaming Chat App Build a Tool-Calling Agent Build a Batch Processor Build a Research Assistant Build a Claude API Dashboard
Ask about this post
AI Ask about this post

Ask questions about Build a PDF Assistant and get answers drawn from it.

Signed-in readers only.