Chat With Your PDF

Build a local Streamlit chatbot that answers questions about an uploaded PDF.

Introduction

30 Second Summary

A useful answer can be buried across dozens of pages. Finding it gets harder when the detail sits inside a table or chart.

In this project, you will build a local web chatbot with Python and Streamlit that answers questions about one uploaded PDF using Claude. The chat keeps earlier messages visible so follow-up questions stay connected to the same document.

What You'll Build

Your browser will show a local Chat With Your PDF app where an uploaded document becomes a conversation with concise answers in chat bubbles.

By the end of this project, you'll have:

  • PDF questions: Ask about text, tables, charts, or images in one uploaded document.
  • Grounded answers: Get responses tied to the document. Unsupported questions receive a clear message that the answer was not found.
  • Continuous chat: Ask follow-up questions with earlier messages visible. Replacing the PDF automatically clears the old conversation.
  • Secret Mission: Add a one-click action that asks Claude for a concise summary of the uploaded PDF.

Are there any prerequisites?

You need a Mac, internet access, a standard unencrypted PDF, and Claude API credit. You should already have Visual Studio Code plus an Anthropic API account.

Before We Start

A focused test document keeps your first PDF chatbot useful and manageable. Before the hands-on work begins, lock in which unencrypted PDF you want to question and why its answers matter to you.

Prepare Your Mac and Python Project

Your PDF chatbot needs a predictable Python toolchain. A system runtime or unrelated packages can interfere with the app before you write its first line.

Command Line Tools for Xcode prepares your Mac for software builds. An isolated virtual environment plus package pinning keeps every project library on its tested release.

In this step, get ready to:
  • Prepare the Mac toolchain for Python 3.15.1.
  • Create an isolated .venv inside ~/Projects/chat-with-your-pdf.
  • Install exact dependencies for a successful Streamlit launch.
Prepare the Mac toolchain

Apple's developer tools provide build support that Python packages can rely on. You will verify that support before installing the project runtime.

  • Check the active developer directory by running this command in Terminal:
xcode-select --print-path

What Does This Command Do?

The command asks macOS for its active developer directory. A printed path confirms that the Command Line Tools are configured.

✔️ I see a developer path

Your Command Line Tools are configured. Continue to the Python check below.

ⓧ No developer directory is configured

Apple provides an installer for the missing tools. The next command opens that installer.

  • Open the Command Line Tools installer by running this command:
xcode-select --install

What Does This Command Do?

The command asks macOS to open Apple's Command Line Tools installer. The installer handles the system-level setup.

The installation can take a few minutes while macOS downloads the tools. A quiet progress window during that time is expected.

  • Complete Apple's installer.
  • Confirm the active developer directory by rerunning the check:
xcode-select --print-path

What Does This Command Do?

This second check confirms that macOS now has an active developer directory. You should see a path in Terminal.

Python 3.15.1 is the runtime used throughout this project. A version check separates an exact installation from an older runtime or a missing command.

  • Check the available Python 3 runtime by running this command:
python3 --version

What Does This Command Do?

The command prints the release used by the python3 executable. The result determines which installation path you need.

✔️ I see version 3.15.1

Python 3.15.1 is available. Continue to the certificate setup below.

ⓧ I see an older version

Your current Python 3 command points to an older runtime. The standard Python.org installer adds the required release.

The installer adds Python 3.15.1 without using the older system runtime for this project.

ⓧ Command not found

Python 3 is unavailable from this Terminal session. The standard Python.org installer adds the required runtime.

The installer makes the Python 3 runtime available for the project commands that follow.

The standard Python installer includes a certificate setup command. Running it prepares secure connections for package downloads.

  • Press Cmd+Space to open macOS search.
  • Type Finder into the search bar.
  • Press Enter to open Finder.
  • Select Applications in the Finder sidebar.
  • Open the Python 3.15 folder.
  • Double-click Install Certificates.command.

A Terminal window should report that the certificate setup completed. Python can now establish secure connections when downloading packages.

Before the final version check, what release do you expect Terminal to report?

  • Confirm the installed Python release by running this command:
python3 --version

What Does This Command Do?

This check reads the version from the Python 3 executable now available in Terminal. It confirms that the installation path is ready for the project environment.

Terminal should report Python 3.15.1.

Still Seeing the Wrong Python Version?

Close Terminal after the installer finishes. Open a fresh Terminal session before running the version check again.

Confirm that you completed the Python 3.15.1 macOS installer from Python.org.

Ask for targeted help with diagnosing the Python installation.

Create the project environment

The project folder gives every file one predictable home. Opening that folder in Visual Studio Code keeps the editor focused on the same environment as Terminal.

  • Create ~/Projects/chat-with-your-pdf by running these commands:
mkdir -p ~/Projects/chat-with-your-pdf && cd ~/Projects/chat-with-your-pdf

What Do These Commands Do?

The first command creates the chat-with-your-pdf folder inside ~/Projects. The second command moves Terminal into that folder.

Your Terminal prompt should now end with chat-with-your-pdf.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open system search.
  • Type Visual Studio Code into the search bar.
  • Press Enter to open Visual Studio Code.
  • Select File in the menu bar.
  • Select Open Folder....
  • Select the Projects folder inside your home folder.
  • Select the chat-with-your-pdf folder.
  • Select Open.

Why Can You Trust This Folder?

Visual Studio Code may ask whether you trust the folder's authors. You created this empty folder yourself, so it contains no downloaded code.

  • Select Yes, I trust the authors if the Workspace Trust dialog appears.

The Explorer sidebar should show chat-with-your-pdf as the open folder.

A project-local environment stores its own Python executable. Packages installed there stay separate from unrelated Python projects on your Mac.

  • Create the project-local environment from the Terminal session inside chat-with-your-pdf by running this command:
python3 -m venv .venv

What Does This Command Do?

Python's environment module creates an isolated interpreter inside .venv. Future package installations can stay inside this project.

The .venv folder should now appear in Visual Studio Code's Explorer sidebar.

  • Activate the new environment by running this command:
source .venv/bin/activate

What Does This Command Do?

The activation script points this Terminal session at the interpreter inside .venv. Package commands now target the isolated environment.

Your Terminal prompt should begin with (.venv).

  • Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) to open the Command Palette in Visual Studio Code.
  • Type Python: Select Interpreter.
  • Select the interpreter whose path ends with .venv/bin/python.

Visual Studio Code's Status Bar should show the .venv interpreter. The editor now uses the same project-local Python as Terminal.

Cannot Select the Environment?

Confirm that .venv appears inside the chat-with-your-pdf folder. Reopen the interpreter list after Visual Studio Code finishes detecting environments.

Ask for targeted help with selecting the project interpreter.

Install the pinned dependencies

The requirements.txt file records the exact library releases used by the finished chatbot. Exact pins keep future installations aligned with this walkthrough.

  • Select the New File button in Visual Studio Code's Explorer toolbar.
  • Name the file requirements.txt.
  • Fill requirements.txt with the exact pins below:
anthropic==2.3.0
streamlit==1.66.1
python-dotenv==1.2.4

What Do These Pins Provide?

  • The Anthropic Python SDK sends requests to Claude.
  • Streamlit builds the browser interface from Python.
  • python-dotenv loads local environment variables from a file.
  • Each == pin selects the tested package release.
  • Press Cmd+S (macOS) or Ctrl+S (Windows) to save requirements.txt.

✔️ Awesome, I've got everything!

Your dependency file is saved with all three exact pins.

ⓧ I'd like to double check the full code

anthropic==2.3.0
streamlit==1.66.1
python-dotenv==1.2.4

The activated environment keeps these libraries inside .venv. Installing from the saved file applies every pin in one command.

  • Install the project dependencies by running this command in the activated Terminal:
pip install -r requirements.txt

What Does This Command Do?

The command reads each line from requirements.txt. It installs those exact releases into the active .venv environment.

The installation output should finish with the three pinned packages available in the local environment.

Package Installation Failed?

Confirm that your Terminal prompt begins with (.venv). An inactive environment can send the installation to a different Python runtime.

Check your internet connection before retrying the package download.

Ask for targeted help with diagnosing the package installation.

Before you launch the final check, what do you expect the browser to display?

  • Launch Streamlit's example app by running this command:
python -m streamlit hello

What Does This Command Do?

The active Python interpreter starts Streamlit's built-in example app. A successful browser page proves that the runtime plus the installed package work together.

Your browser should open a Streamlit example app. That's the local Python foundation working from the isolated environment.

  • Return to Terminal after capturing the working example.
  • Press Ctrl+C to stop the Streamlit server.

Terminal should return to the (.venv) prompt. Your local toolchain is ready for the first visible version of the PDF chat interface.

Build the Visible Chat Shell

Your virtual environment can already run Streamlit. That gives the PDF chatbot a reliable local foundation.

A browser-based chat shell gives that foundation a visible upload-to-question loop. You will build the interface first so each interaction can be tested in the browser.

In this step, get ready to:
  • Create app.py with the page heading and PDF upload control.
  • Keep the chat hidden until one PDF is available.
  • Connect the chat input to visible user and assistant messages.
Create the upload view

The first screen needs to explain the app before it asks the learner for a document. The upload control creates the entry point for every later question.

  • In the file list on the left side of Visual Studio Code, select the new-file icon.

A filename field opens in the file list.

  • Type app.py into the filename field.
  • Press Enter.

The new app.py file opens in the editor.

  • Add the page copy and upload control to app.py by pasting this code:
import streamlit as st

st.write("# Chat With Your PDF")
st.write(
    "Upload one PDF, then ask questions about its text, tables, charts, or images."
)

uploaded_file = st.file_uploader(
    "Upload a PDF",
    type="pdf",
    max_upload_size=10,
)

What Does This Code Do?

  • The import streamlit as st line makes Streamlit's interface methods available through st.
  • The two st.write() calls display the app title and its upload instructions.
  • The st.file_uploader() call accepts one PDF through uploaded_file with a project limit of 10 MB.
  • Save app.py.
  • Launch the local app from the activated terminal by running this command:
python -m streamlit run app.py

What Does This Command Do?

The command runs app.py through Streamlit in the active virtual environment. The terminal remains occupied while the local server is running.

Your first app screen is live. You'll see the Chat With Your PDF heading with instructions and a PDF upload control.

App Does Not Open?

Confirm that the terminal prompt still shows the active .venv environment. Check that app.py is saved inside ~/Projects/chat-with-your-pdf.

Ask for targeted help with debugging the local Streamlit launch.

Pause the app until a PDF arrives

A question only has meaning once the app has a document to work with. An upload gate stops the script before it reaches the chat interface.

  • In app.py, find the uploaded_file = st.file_uploader( block.
  • Place the cursor below that block's closing parenthesis.
  • Add the upload gate by pasting this code:
if uploaded_file is None:
    st.write("Upload a PDF to begin.")
    st.stop()

How Does the Upload Gate Work?

  • The None check detects when no PDF has been uploaded.
  • The first indented line tells the learner what the app needs.
  • The st.stop() call ends the current script run before the chat interface loads.
  • Save app.py.
  • Return to the browser tab running the app.
  • Refresh the browser page.

The document gate is working. You'll see Upload a PDF to begin. below the upload control.

Upload Instruction Missing?

Check that both lines beneath the if statement use the same indentation. Confirm that the entire upload gate sits below the uploader block.

Ask for help with fixing the upload gate.

Add and test the echo chat

A chat input captures the question submitted from the browser. Separate message containers make the exchange look like a conversation.

  • In app.py, find the st.stop() line.
  • Place the cursor below that line at the left edge of the editor.
  • Add the chat flow by pasting this code:
question = st.chat_input("Ask a question about the PDF")

if question:
    with st.chat_message("user"):
        st.write(question)

    with st.chat_message("assistant"):
        st.write(f"Echo: {question}")

What Does the Chat Flow Do?

  • The st.chat_input() call stores submitted text in question.
  • The first st.chat_message() container displays the submitted question as a user message.
  • The second chat container adds Echo: before the same question to create a temporary assistant response.
  • Save app.py.
  • Return to the browser tab running the app.
  • Upload one standard unencrypted test PDF through the upload control.

The upload instruction disappears. You'll see the chat box beneath the upload control.

Before you submit the question, what kind of answer do you expect from the response line you just added?

  • Enter What is this document about? into the chat box.
  • Press Enter.

You'll see your question in a user chat bubble. An assistant bubble displays Echo: What is this document about?.

Your upload-to-chat loop is working. The page now responds to both a PDF selection and a submitted question.

The assistant only mirrors your words because it has no reasoning or document analysis yet. This visible limitation is the intended result of the first chat shell.

Chat Box or Echo Missing?

Confirm that the chat code begins at the left edge below the upload gate. Code indented beneath st.stop() cannot run after a PDF is available.

Check that the two with st.chat_message() blocks contain their matching st.write() lines.

Ask for help with debugging the missing chat interface.

✔️ Awesome, I've got everything!

Your saved app.py now contains the upload gate and the complete echo chat flow.

ⓧ I'd like to double check the full code

import streamlit as st

st.write("# Chat With Your PDF")
st.write(
    "Upload one PDF, then ask questions about its text, tables, charts, or images."
)

uploaded_file = st.file_uploader(
    "Upload a PDF",
    type="pdf",
    max_upload_size=10,
)

if uploaded_file is None:
    st.write("Upload a PDF to begin.")
    st.stop()

question = st.chat_input("Ask a question about the PDF")

if question:
    with st.chat_message("user"):
        st.write(question)

    with st.chat_message("assistant"):
        st.write(f"Echo: {question}")

The visible chat shell is complete. Next, you'll connect it to Claude so the assistant can generate a real response.

Connect Claude

Your Streamlit upload-and-echo shell already proves that questions can move through the interface. Now your app needs a model that can generate useful responses.

This step connects the interface to Claude Haiku 5.5. You will prove that the model responds through your app before testing the limit of this first connection.

In this step, get ready to:
  • Store your Anthropic API key outside app.py.
  • Initialize the Anthropic client when the app starts.
  • Test the Claude connection with general questions and document-specific questions.
Protect the API key

An environment variable stores configuration outside your Python source. python-dotenv reads that configuration from a local .env file.

Your API key is a live credential. Keeping it outside app.py prevents it from becoming part of the application code.

  • Use the new-file control in the Visual Studio Code file sidebar to create .env inside ~/Projects/chat-with-your-pdf.
  • Add the expected environment variable by pasting this template into .env:
ANTHROPIC_API_KEY=your-api-key-here

What Does This Setting Do?

The line names the ANTHROPIC_API_KEY environment variable that the Anthropic Python SDK reads. The placeholder shows where your real key belongs.

  • Replace your-api-key-here with your real Anthropic API key.
  • Save .env.

You should see .env in the Visual Studio Code file sidebar. The file now holds the credential locally.

  • Use the new-file control in the Visual Studio Code file sidebar to create .gitignore inside ~/Projects/chat-with-your-pdf.
  • Protect the project files by pasting these rules into .gitignore:
.env
.venv/
__pycache__/

What Do These Rules Protect?

  • The .env rule keeps your live API key out of Git tracking.
  • The .venv/ rule excludes the project environment and its installed packages.
  • The __pycache__/ rule excludes generated Python cache files.
  • Save .gitignore.

You should now see both configuration files beside app.py in the file sidebar. Your credential stays separate from the Python source.

Cannot Find the New Files?

  • Confirm that Visual Studio Code still has ~/Projects/chat-with-your-pdf open.
  • Check that both filenames begin with a full stop.
  • Save each file before continuing.

Ask for help with checking the local API key files.

Initialize the Anthropic client

The Anthropic Python SDK gives your Python app a client for sending requests to Claude. The model constant keeps the selected model ID in one place.

  • In app.py, replace the existing import section at the top of the file with this code:
import anthropic
import streamlit as st
from dotenv import load_dotenv

MODEL = "claude-haiku-5-5"

What Does This Code Set Up?

  • The anthropic import provides the API client used by the app.
  • The load_dotenv import provides the function that reads .env.
  • The MODEL constant stores the exact claude-haiku-5-5 model ID.
  • Save app.py.
  • Return to the browser tab running your local app.

You should still see the Chat With Your PDF heading and the PDF uploader. This confirms that the new imports load successfully.

Did the App Stop Loading?

  • Confirm that each import matches the code block exactly.
  • Check that the Terminal prompt still shows the active .venv environment.
  • Confirm that requirements.txt still contains the pinned Anthropic and python-dotenv packages.

Ask for help with diagnosing the import failure.

Loading .env before creating the client makes the API key available during startup. A guarded initialization gives you a readable message if that setup is incomplete.

  • In app.py, locate the heading code immediately below MODEL.
  • Replace that heading code with this expanded startup section:
load_dotenv()

st.write("# Chat With Your PDF")
st.write(
    "Upload one PDF, then ask questions about its text, tables, charts, or images."
)

try:
    client = anthropic.Anthropic()
except Exception:
    st.write(
        "Add ANTHROPIC_API_KEY=your-api-key-here to a .env file, "
        "replace the placeholder, and rerun the app."
    )
    st.stop()

How Does Startup Work?

  • The load_dotenv() call loads values from the local .env file.
  • The anthropic.Anthropic() call creates the client used for Claude requests.
  • The except block displays a readable setup message when client initialization fails.
  • The st.stop() call prevents the remaining app from running without a client.
  • Save app.py.
  • Return to the browser tab running your local app.

You should see the heading and PDF uploader again. Your app can now initialize the Anthropic client from the local key.

Seeing the API Key Message?

  • Confirm that .env sits beside app.py.
  • Confirm that the variable name is exactly ANTHROPIC_API_KEY.
  • Replace the placeholder with your real key if it is still present.

Ask for help with diagnosing the client initialization.

Replace the echo with Claude

The Messages API accepts a user message and returns response content blocks. Your app can collect every text block for display inside the assistant chat bubble.

  • In app.py, locate the assistant block that ends with st.write(f"Echo: {question}").
  • Replace that assistant block with this Claude request:
    with st.chat_message("assistant"):
        response = client.messages.create(
            model=MODEL,
            max_tokens=800,
            messages=[{"role": "user", "content": question}],
        )
        answer = "\n".join(
            block.text for block in response.content if block.type == "text"
        ).strip()
        st.write(answer or "Claude returned no text response.")

What Does This Request Do?

  • The client.messages.create() call sends the typed question to Claude.
  • The MODEL constant selects claude-haiku-5-5.
  • The max_tokens value limits the generated response to 800 tokens.
  • The text-block filter joins the returned text into one answer for Streamlit.
  • Save app.py.
  • Return to the browser tab running your local app.
  • Select your test PDF in Upload a PDF if the uploader is empty.

Each submitted question now uses Claude API credit. Keep this check to two short prompts.

Before you submit the first question, do you think the assistant will still repeat your words?

  • Enter What is 2 + 2? in the chat input.
  • Press Enter to submit the question.

You should see your question inside a user chat bubble. The assistant bubble should contain an AI-generated answer identifying 4.

That is the Claude connection working. Your local interface can now render returned model text inside the assistant bubble.

  • Choose a question about one uncommon detail in your uploaded PDF.

Before you submit it, do you expect Claude to use that PDF-specific detail?

  • Enter your document-specific question in the chat input.
  • Press Enter to submit the question.

The response does not reliably use the selected PDF detail. The request currently contains only your typed question.

That shortfall is intentional. The uploader and the Claude request remain separate paths at this point.

Did the Claude Request Fail?

  • Confirm that .env contains your real key without extra spaces.
  • Confirm that your Anthropic account has API credit available.
  • Check your internet connection before submitting the question again.

Ask for help with diagnosing the Claude request.

✔️ Awesome, I've got everything!

Great. Double-check that app.py, .env, and .gitignore are saved.

ⓧ I'd like to double check the full code

Compare your Python file with the complete version below:

import anthropic
import streamlit as st
from dotenv import load_dotenv

MODEL = "claude-haiku-5-5"

load_dotenv()

st.write("# Chat With Your PDF")
st.write(
    "Upload one PDF, then ask questions about its text, tables, charts, or images."
)

try:
    client = anthropic.Anthropic()
except Exception:
    st.write(
        "Add ANTHROPIC_API_KEY=your-api-key-here to a .env file, "
        "replace the placeholder, and rerun the app."
    )
    st.stop()

uploaded_file = st.file_uploader(
    "Upload a PDF",
    type="pdf",
    max_upload_size=10,
)

if uploaded_file is None:
    st.write("Upload a PDF to begin.")
    st.stop()

question = st.chat_input("Ask a question about the PDF")

if question:
    with st.chat_message("user"):
        st.write(question)

    with st.chat_message("assistant"):
        response = client.messages.create(
            model=MODEL,
            max_tokens=800,
            messages=[{"role": "user", "content": question}],
        )
        answer = "\n".join(
            block.text for block in response.content if block.type == "text"
        ).strip()
        st.write(answer or "Claude returned no text response.")

Keep your real key in place. Use this template only to confirm the environment variable name:

ANTHROPIC_API_KEY=your-api-key-here

Compare your ignore rules with this complete file:

.env
.venv/
__pycache__/

Your pinned dependencies should remain unchanged:

anthropic==2.3.0
streamlit==1.66.1
python-dotenv==1.2.4

Your app now reaches Claude and displays generated answers. Next, you will place the uploaded PDF inside each request so the answers come from the selected document.

Ground Every Answer in the PDF

Claude currently answers general questions from the typed message alone. The uploaded PDF remains outside the API request.

This step turns the file bytes into a native document block. A grounding prompt then limits every answer to evidence from that PDF.

In this step, get ready to:
  • Encode the uploaded PDF bytes as Base64 text.
  • Send a native document block before the question.
  • Ground each answer in the PDF.
Prepare Base64 support

Streamlit keeps the uploaded PDF as raw bytes. Base64 converts those bytes into text that can travel inside the document source.

  • Return to Terminal from earlier if the app is still running.
  • Stop the Streamlit server by pressing Ctrl+C.
  • Switch back to app.py in Visual Studio Code.
  • Replace the import block at the top with this code:
import base64

import anthropic
import streamlit as st
from dotenv import load_dotenv

Why Add Base64?

The base64 module converts the uploaded PDF bytes into encoded bytes. The app later decodes that result into a UTF-8 string for the document block.

  • Save app.py.

Before you restart the app, do you expect the new import block to load cleanly?

  • Restart the local app by running this command in the activated environment:
python -m streamlit run app.py

What Does This Command Do?

The command launches app.py through the Streamlit module in your active environment. It also starts the local web server used by the browser app.

Your browser should open the Chat With Your PDF app. You should see the PDF uploader without an import error.

App Does Not Restart?

Check that import base64 appears before the other imports. Confirm that the Terminal prompt still shows the active .venv.

Ask for targeted help with diagnosing the restart failure.

Send the PDF as a document

The Anthropic Messages API accepts a PDF through a native document block. Placing that block before the question gives Claude the document before it reads the changing text.

  • In app.py, add the first part of ask_claude() directly above load_dotenv() by pasting this code:
def ask_claude(client, pdf_bytes, question):
    pdf_data = base64.standard_b64encode(pdf_bytes).decode("utf-8")

    response = client.messages.create(
        model=MODEL,
        max_tokens=800,
        messages=[
            {
                "role": "user",
                "content": [
                    {
                        "type": "document",
                        "source": {
                            "type": "base64",
                            "media_type": "application/pdf",
                            "data": pdf_data,
                        },
                    },
                    {"type": "text", "text": question},
                ],
            }
        ],
    )

What Does This Request Do?

  • The pdf_bytes parameter receives the uploaded file contents.
  • The pdf_data value holds the Base64 text sent to Claude.
  • The document block identifies the source as an application/pdf file.
  • The text block places the learner's question after the PDF.
  • Complete ask_claude() by adding this code directly below the response call:
    answer = "\n".join(
        block.text for block in response.content if block.type == "text"
    ).strip()
    return answer or "Claude returned no text response."

How Is the Answer Extracted?

The response can contain different block types. This code joins every text block into one answer for the assistant chat bubble.

  • Save app.py.
  • Return to the browser after Streamlit reruns the saved file.

You should still see the app title with the PDF uploader. This confirms that the complete helper loads without a syntax error.

  • Inside if question:, find the assistant chat block.
  • Replace the entire assistant chat block with this code:
    with st.chat_message("assistant"):
        answer = ask_claude(client, uploaded_file.getvalue(), question)
        st.write(answer)

How Does the PDF Reach the Helper?

The uploaded_file.getvalue() call reads the complete PDF as bytes. The helper receives those bytes with the current question.

  • Save app.py.
  • Return to the browser after Streamlit reruns the app.
  • Upload your test PDF again if the uploader is empty.

Before you submit a document-specific question, do you expect the answer to use details that Claude could not access in the previous step?

  • Enter a question about a fact that appears on a specific page of your PDF.
  • Press Enter to submit the question.

Claude should answer with information from the uploaded PDF. The response should reflect the document instead of relying only on general knowledge.

Claude Cannot Read the PDF?

Confirm that the uploaded file is a standard PDF. Password-protected files cannot use this request path.

Check that the document block comes before the text block. Confirm that uploaded_file.getvalue() is passed into ask_claude().

Ask for focused help with debugging the PDF document block.

Ground and verify every answer

A system prompt defines the source Claude must use. It also gives the app one consistent response for questions the PDF cannot answer.

  • In app.py, replace the MODEL line with this constants block:
MODEL = "claude-haiku-5-5"
SYSTEM_PROMPT = (
    "Answer using only the uploaded PDF. "
    "If the answer is not in the PDF, say: I couldn't find that in the PDF. "
    "Keep the answer concise and mention relevant page numbers when possible."
)

What Does the Grounding Prompt Control?

The prompt confines answers to the uploaded PDF. It also requests concise responses with page references when possible.

An unsupported question receives the same clear fallback sentence. That makes the grounding boundary visible to the learner.

  • Inside ask_claude(), find the max_tokens=800 line.
  • Replace that line plus the following messages=[ line with this code:
        max_tokens=800,
        system=SYSTEM_PROMPT,
        messages=[

Why Is System a Top-Level Value?

The Messages API accepts system beside the model settings. Every request from ask_claude() now uses the same grounding rules.

  • Save app.py.

✔️ Awesome, I've got everything!

  • Confirm that app.py is saved.

ⓧ I'd like to double check the full code

import base64

import anthropic
import streamlit as st
from dotenv import load_dotenv

MODEL = "claude-haiku-5-5"
SYSTEM_PROMPT = (
    "Answer using only the uploaded PDF. "
    "If the answer is not in the PDF, say: I couldn't find that in the PDF. "
    "Keep the answer concise and mention relevant page numbers when possible."
)


def ask_claude(client, pdf_bytes, question):
    pdf_data = base64.standard_b64encode(pdf_bytes).decode("utf-8")

    response = client.messages.create(
        model=MODEL,
        max_tokens=800,
        system=SYSTEM_PROMPT,
        messages=[
            {
                "role": "user",
                "content": [
                    {
                        "type": "document",
                        "source": {
                            "type": "base64",
                            "media_type": "application/pdf",
                            "data": pdf_data,
                        },
                    },
                    {"type": "text", "text": question},
                ],
            }
        ],
    )

    answer = "\n".join(
        block.text for block in response.content if block.type == "text"
    ).strip()
    return answer or "Claude returned no text response."


load_dotenv()

st.write("# Chat With Your PDF")
st.write(
    "Upload one PDF, then ask questions about its text, tables, charts, or images."
)

try:
    client = anthropic.Anthropic()
except Exception:
    st.write(
        "Add ANTHROPIC_API_KEY=your-api-key-here to a .env file, "
        "replace the placeholder, and rerun the app."
    )
    st.stop()

uploaded_file = st.file_uploader(
    "Upload a PDF",
    type="pdf",
    max_upload_size=10,
)

if uploaded_file is None:
    st.write("Upload a PDF to begin.")
    st.stop()

question = st.chat_input("Ask a question about the PDF")

if question:
    with st.chat_message("user"):
        st.write(question)

    with st.chat_message("assistant"):
        answer = ask_claude(client, uploaded_file.getvalue(), question)
        st.write(answer)
  • Return to Terminal from earlier.
  • Stop the running server by pressing Ctrl+C.

Before you run the final check, do you expect the completed app to restart with the grounded request path?

  • Restart the completed app by running this command:
python -m streamlit run app.py

What Does This Final Run Prove?

The restart loads the completed helper plus the grounding prompt from a clean run. A successful browser page confirms that the final file parses correctly.

Your browser should reopen the Chat With Your PDF app. You should see the PDF uploader ready for the final checks.

  • Upload your test PDF.

Before you ask about the document, do you expect the response to rely on a fact from the selected PDF?

  • Enter a question whose answer is clearly stated in the PDF.
  • Press Enter to submit the question.

Claude should answer from the document. The response may include a relevant page number.

Before you submit an unrelated question, what should the grounding prompt make Claude say?

  • Enter a question about a topic that does not appear anywhere in your PDF.
  • Press Enter to submit the unrelated question.

You should see I couldn't find that in the PDF. This proves that the app now rejects unsupported answers.

The Unrelated Question Gets an Answer?

Confirm that system=SYSTEM_PROMPT appears inside client.messages.create(). Check the fallback sentence in SYSTEM_PROMPT for missing text.

Ask for targeted help with diagnosing the grounding behavior.

Your chatbot now treats the uploaded PDF as its only source. Next, you will preserve follow-up context while reducing repeated document processing.

Preserve History and Cache the PDF

Your grounded PDF answers now use Claude successfully. The conversation still disappears when Streamlit rebuilds the interface.

Session State gives each message a durable home during the current browser session. It also lets the app send earlier exchanges back to Claude as context.

Prompt caching marks the repeated PDF block for temporary reuse. Rapid follow-up questions can reuse that stable document prefix.

In this step, get ready to:
  • Build a transcript from the visible conversation.
  • Preserve messages until the uploaded PDF changes.
  • Cache the PDF block during rapid follow-up requests.
Build a cached conversation request

The request function currently receives one question. A transcript lets Claude see the latest question within the conversation that led to it.

  • In app.py, find the opening of ask_claude() through the line that creates pdf_data.
  • Replace that function opening with this code:
def ask_claude(client, pdf_bytes, messages):
    transcript = "\n\n".join(
        f"{message['role'].title()}: {message['content']}" for message in messages
    )
    pdf_data = base64.standard_b64encode(pdf_bytes).decode("utf-8")

How Does the Transcript Work?

  • The messages parameter receives every stored user message plus every stored assistant response.
  • The join operation turns those dictionaries into readable conversation lines.
  • The PDF encoding remains stable for every question about the same upload.
  • In the same function, find the PDF document block.
  • Replace that document block with this cached version:
{
    "type": "document",
    "source": {
        "type": "base64",
        "media_type": "application/pdf",
        "data": pdf_data,
    },
    "cache_control": {"type": "ephemeral"},
},

Why Cache the Document Block?

The PDF stays at the beginning of each request. The ephemeral cache marker identifies that stable document prefix for temporary reuse.

The transcript can keep changing without moving the repeated PDF content.

  • Find the text block that sends question directly to Claude.
  • Replace that text block with this transcript prompt:
{
    "type": "text",
    "text": (
        "Here is the conversation so far:\n\n"
        f"{transcript}\n\n"
        "Answer the latest user question."
    ),
},

What Does the New Text Block Do?

The text block sends the changing transcript after the cached PDF. Its final sentence directs Claude to answer the newest user message.

Store and replay chat history

Streamlit reruns the script after each interaction. The app needs to rebuild every earlier chat bubble from Session State during each rerun.

The uploaded file name plus its size creates a simple PDF identity. A changed identity clears the old conversation before the new document receives questions.

  • In app.py, find the block beginning with if uploaded_file is None:.
  • Add this state setup immediately below that block:
pdf_id = f"{uploaded_file.name}:{uploaded_file.size}"
if "pdf_id" not in st.session_state or st.session_state.pdf_id != pdf_id:
    st.session_state.pdf_id = pdf_id
    st.session_state.messages = []

if "messages" not in st.session_state:
    st.session_state.messages = []

for message in st.session_state.messages:
    with st.chat_message(message["role"]):
        st.write(message["content"])

How Does the State Setup Work?

  • The pdf_id value combines the current file name with its byte size.
  • A changed identity replaces the old message list with an empty list.
  • The replay loop rebuilds one chat container for every stored message.
  • Find the existing question = st.chat_input("Ask a question about the PDF") line near the bottom of app.py.
  • Replace that line plus the complete if question: block with this request flow:
question = st.chat_input("Ask a question about the PDF")

if question:
    st.session_state.messages.append({"role": "user", "content": question})
    with st.chat_message("user"):
        st.write(question)

    with st.chat_message("assistant"):
        with st.spinner("Reading the PDF..."):
            try:
                answer = ask_claude(
                    client,
                    uploaded_file.getvalue(),
                    st.session_state.messages,
                )
            except Exception as error:
                answer = f"Request failed: {error}"
        st.write(answer)

    st.session_state.messages.append({"role": "assistant", "content": answer})

What Happens During Each Question?

  • The app stores the user message before calling Claude.
  • The spinner makes the active PDF request visible.
  • The exception path turns a failed request into a readable chat response.
  • The app stores the assistant response for the next rerun.

Before you save, do you expect the first exchange to remain visible after a follow-up question triggers another rerun?

  • Save app.py.
  • Return to the browser tab running the app.
  • Upload your testing PDF again if Streamlit asks for it.
  • Ask one question whose answer appears in the PDF.
  • Ask a follow-up question that refers to the first answer.

You should see both user questions plus both assistant responses. All four chat bubbles remain visible after the follow-up rerun.

Chat History Disappearing?

Check that both message dictionaries use the exact keys role and content.

Confirm that the replay loop appears before st.chat_input(). Ask for help with debugging Streamlit Session State.

Verify the complete conversation flow

Your final check covers continuity plus document boundaries. It also confirms that request failures remain visible inside the chat interface.

✔️ Awesome, I've got everything!

Your saved file includes transcript context, Session State history, PDF reset logic, request feedback, plus ephemeral caching.

ⓧ I'd like to double check the full code

import base64

import anthropic
import streamlit as st
from dotenv import load_dotenv

MODEL = "claude-haiku-5-5"
SYSTEM_PROMPT = (
    "Answer using only the uploaded PDF. "
    "If the answer is not in the PDF, say: I couldn't find that in the PDF. "
    "Keep the answer concise and mention relevant page numbers when possible."
)


def ask_claude(client, pdf_bytes, messages):
    transcript = "\n\n".join(
        f"{message['role'].title()}: {message['content']}" for message in messages
    )
    pdf_data = base64.standard_b64encode(pdf_bytes).decode("utf-8")

    response = client.messages.create(
        model=MODEL,
        max_tokens=800,
        system=SYSTEM_PROMPT,
        messages=[
            {
                "role": "user",
                "content": [
                    {
                        "type": "document",
                        "source": {
                            "type": "base64",
                            "media_type": "application/pdf",
                            "data": pdf_data,
                        },
                        "cache_control": {"type": "ephemeral"},
                    },
                    {
                        "type": "text",
                        "text": (
                            "Here is the conversation so far:\n\n"
                            f"{transcript}\n\n"
                            "Answer the latest user question."
                        ),
                    },
                ],
            }
        ],
    )

    answer = "\n".join(
        block.text for block in response.content if block.type == "text"
    ).strip()
    return answer or "Claude returned no text response."


load_dotenv()

st.write("# Chat With Your PDF")
st.write(
    "Upload one PDF, then ask questions about its text, tables, charts, or images."
)

try:
    client = anthropic.Anthropic()
except Exception:
    st.write(
        "Add ANTHROPIC_API_KEY=your-api-key-here to a .env file, "
        "replace the placeholder, and rerun the app."
    )
    st.stop()

uploaded_file = st.file_uploader(
    "Upload a PDF",
    type="pdf",
    max_upload_size=10,
)

if uploaded_file is None:
    st.write("Upload a PDF to begin.")
    st.stop()

pdf_id = f"{uploaded_file.name}:{uploaded_file.size}"
if "pdf_id" not in st.session_state or st.session_state.pdf_id != pdf_id:
    st.session_state.pdf_id = pdf_id
    st.session_state.messages = []

if "messages" not in st.session_state:
    st.session_state.messages = []

for message in st.session_state.messages:
    with st.chat_message(message["role"]):
        st.write(message["content"])

question = st.chat_input("Ask a question about the PDF")

if question:
    st.session_state.messages.append({"role": "user", "content": question})
    with st.chat_message("user"):
        st.write(question)

    with st.chat_message("assistant"):
        with st.spinner("Reading the PDF..."):
            try:
                answer = ask_claude(
                    client,
                    uploaded_file.getvalue(),
                    st.session_state.messages,
                )
            except Exception as error:
                answer = f"Request failed: {error}"
        st.write(answer)

    st.session_state.messages.append({"role": "assistant", "content": answer})

What Should You Compare?

Compare each section with your saved app.py. Pay close attention to indentation inside ask_claude() plus the final if question: block.

  • Switch back to the Terminal panel from earlier.
  • Stop the running app with Ctrl+C.
  • Restart the completed app by running this command:
python -m streamlit run app.py

What Does This Command Do?

Python launches Streamlit from the active .venv. Streamlit runs the completed app.py interface in your browser.

Before you test the full flow, do you think replacing only the uploaded PDF can leave any old chat bubbles behind?

  • Upload your first testing PDF in the browser.
  • Ask a question whose answer is clearly present in that PDF.
  • Ask a follow-up question that relies on the first response.
  • Ask a question unrelated to the uploaded PDF.
  • Replace the upload with a different PDF.

Before replacement, you should see three user bubbles plus three assistant bubbles. The follow-up response should use the earlier exchange as context.

The unrelated question should return I couldn't find that in the PDF. The replacement PDF should clear every bubble from the previous document.

You have now proven that the chatbot remembers one document's conversation without carrying that context into another document.

Old Messages Still Visible?

Confirm that pdf_id uses both uploaded_file.name plus uploaded_file.size.

Check that a changed PDF identity assigns an empty list to st.session_state.messages. Ask for help with debugging the PDF reset logic.

Your local chatbot now preserves follow-up context while keeping separate PDFs isolated. Next, you can extend the same grounded request path through an optional challenge.

Secret mission

Add a One-Click PDF Summary

Add a summary button that sends a fixed prompt through the existing grounded PDF request path. Prove the summary stays in chat history before asking another ordinary question.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep the project ready, pause it for later, or delete it from your Mac. Keeping the local files creates no ongoing charge because paid Claude API usage begins only when the app sends a request.

Cost warning

Each new question or summary sends a paid PDF request. Large PDFs or long conversations use more tokens.

The ephemeral cache marker can reduce repeated PDF processing during rapid follow-up questions. Stopping the app prevents new requests.

Resources you used:

  • The local ~/Projects/chat-with-your-pdf folder containing app.py, requirements.txt, and .gitignore.
  • The repository-local .venv virtual environment containing the pinned Python packages.
  • The local .env file containing your Anthropic API key.

There is no uploaded cloud PDF to clean up because the app sends the document directly with each request.

Keep everything running

No action needed. Choose this if you are still testing or extending your PDF chatbot.

  • Keep the Streamlit server running while you continue testing.
  • Keep the ~/Projects/chat-with-your-pdf folder on your Mac.
  • Keep .env private because it contains your API key.

Your project remains ready for more questions. The idle app does not send a paid request until you submit a question or click the summary button.

Pause - I'll come back to this later

Shut down the local process to free its memory. Your code and Python environment remain on your Mac.

  • Switch back to Terminal from earlier.
  • Press Ctrl+C to stop the Streamlit server.
  • Leave the active virtual environment by running this command:
deactivate

What does deactivate do?

This command returns Terminal to your normal Python environment. The project files and installed packages stay inside .venv for your next session.

  • Close Visual Studio Code.

The Streamlit process is stopped. The Terminal prompt no longer shows .venv.

Delete - I don't want to use this again

Deleting the folder is permanent on this Mac. Your Anthropic account and API key remain unchanged.

  • Return to Terminal from earlier.
  • Press Ctrl+C if the Streamlit server is still running.
  • Remove the entire local project by running these commands:
deactivate
cd ~/Projects
rm -rf chat-with-your-pdf

What do these commands remove?

  • The first line leaves the active virtual environment.
  • The second line moves Terminal outside the project folder.
  • The final line removes the project code, the local environment, and the local copy of your API key.

A successful deletion returns you to the Terminal prompt. The ~/Projects/chat-with-your-pdf folder and every file inside it are now gone.

Project folder still present?

  • Press Ctrl+C to stop the Streamlit server if Terminal is still busy.
  • Check that the folder name is exactly chat-with-your-pdf.
  • Run the cleanup commands again.
  • Help me diagnose why this local project folder was not deleted.

Nice Work!

Nice Work!

You did it! Your local PDF chatbot now answers from an uploaded document. Its conversation stays visible across follow-up questions.

You've learned how to:

  • Build a local PDF chatbot with Streamlit. Use it to question text or visual content in the browser.
  • Protect your Claude API key with .env plus .gitignore. Send each PDF as a native Base64 document block to produce grounded answers.
  • Preserve follow-up context with Session State. Reset the visible history when the PDF changes. Reduce repeated document processing with prompt caching.
  • Secret Mission: Add a one-click PDF summary that reuses ask_claude(). Confirm typed questions still work after the summary.

Think you can explain how the pieces work? Get ready to quiz yourself.