Build a PDF Assistant
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:
- checks that the bytes are a PDF before accepting them,
- builds the document block once, with a cache marker,
- answers each question by sending the same document block followed by the question,
- 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
- 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. - 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.
- 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.
- 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.