Investment Report Analysis Agent
Build a Python agent that reads investment PDFs and extracts financial insights.
Introduction
30 Second Summary
Earnings reports are packed with numbers, charts, and fine print that take hours to digest. Imagine having an analyst on call who can instantly pull out the key metrics and tell you whether a company is thriving or struggling.
In this project, you will build a command-line AI agent that reads investment report PDFs and extracts structured financial insights using Claude. You will feed it a real earnings report and get back organized metrics, an investment thesis, and the ability to ask follow-up questions.
What You'll Build
A Python script that accepts a PDF earnings report, sends it to Claude for analysis, and returns structured financial data through a conversational interface.
By the end of this project, you'll have:
- A working AI agent that extracts key financial metrics (revenue, net income, EPS, growth rates) from any PDF earnings report.
- A multi-turn conversational loop where you can ask follow-up questions about the report's contents.
- Structured JSON output that summarizes the investment thesis with bull and bear cases, ready to feed into a dashboard or database.
- Secret Mission: Add a comparison mode that analyzes two reports side-by-side and identifies trends.
Are there any prerequisites?
You need basic Python knowledge (variables, functions, loops). No AI or machine learning experience is required. The project walks you through everything from environment setup to building the full agent.
Set Up Your Environment
Before you can build an AI-powered analysis agent, you need a working Python environment and access to Claude's API.
In this step, you'll install Python, create an isolated project folder with its own dependencies, and connect to the Anthropic API so your script can talk to Claude.
In this step, get ready to:
- Install and verify Python 3.9+.
- Create a project directory with a virtual environment and install the Anthropic SDK.
- Set up your Anthropic Console account and API key.
Install Python 3.9+ and verify the installation
The Anthropic SDK requires Python 3.9 or higher. Let's confirm you have a compatible version installed.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check your Python version by running this command:
python3 --version
✔️ I see version 3.9 or higher
You're all set. Move on to creating your project directory below.
ⓧ I see an older version
Your system has an older Python that won't work with the Anthropic SDK. You need to install Python 3.9 or higher alongside it.
On macOS, the system Python is often stuck at an older version. Install a newer version using the official installer.
- Download the latest Python installer from python.org/downloads.
- Run the installer and follow the prompts.
- Close and reopen your terminal, then run python3 --version again to confirm the new version appears.
macOS still showing the old version?
macOS may continue pointing python3 to its built-in older version even after you install a newer one. Try running the full versioned binary (e.g. python3.12 --version) or use a version manager like pyenv to manage multiple Python installations.
ⓧ Command not found
Python isn't installed on your machine yet. Let's fix that.
- Download the latest Python installer from python.org/downloads.
- Run the installer. On Windows, make sure to check the box that says Add Python to PATH before clicking Install Now.
- Close and reopen your terminal after the installation finishes.
- Run python3 --version again to confirm you see version 3.9 or higher.
Still seeing command not found?
On Windows, try python --version (without the 3). Some Windows installations use python instead of python3 as the command name.
Help me troubleshoot my Python installation.
Create a project directory and install the Anthropic SDK
A virtual environment keeps this project's packages separate from your system Python. This prevents version conflicts with other projects on your machine.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir investment-agent
cd investment-agent
- Confirm the folder exists by running:
ls
The folder will be empty for now. That's expected.
- Create a virtual environment by running this command:
python3 -m venv .venv
- Activate the virtual environment by running this command:
macOS
source .venv/bin/activate
Windows
.venv\Scripts\activate
You should see (.venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.
- Install the Anthropic SDK by running this command:
pip install anthropic
What is the Anthropic SDK?
The anthropic package is the official Python library for communicating with Claude's API. It handles authentication, request formatting, and response parsing so you can focus on your analysis logic.
You should see output ending with a line like Successfully installed anthropic-... confirming the package was installed.
Seeing a permission error or pip not found?
Make sure you activated the virtual environment first. Your terminal prompt should start with (.venv). If it doesn't, re-run the activation command above.
Help me fix my pip install error.
Create an Anthropic account and set your API key
To call Claude's API, you need an API key from the Anthropic Console. The SDK automatically reads this key from an environment variable so you never have to hardcode it.
- Open console.anthropic.com in your browser.
- Create an account or sign in if you already have one.
- Click API Keys in the left sidebar.
- Click Create Key.
- Give your key a name like investment-agent and click Create Key.
- Copy the key immediately. You will not be able to see it again after closing the dialog.
Keep your API key safe
Never share your API key or commit it to version control. You'll store it as an environment variable in the next step, which keeps it out of your code.
- Back in your terminal (with the virtual environment still active), set the API key as an environment variable by running this command:
macOS
export ANTHROPIC_API_KEY="your-api-key-here"
Windows
$env:ANTHROPIC_API_KEY="your-api-key-here"
- Replace your-api-key-here with the key you just copied from the Anthropic Console.
Why an environment variable?
The Anthropic SDK automatically reads the ANTHROPIC_API_KEY environment variable when you create a client. This means your code never contains the actual key, keeping it safe if you share or publish your script.
- Verify the key is set by running this quick test:
python3 -c "import anthropic; print('SDK ready!')"
You should see SDK ready! printed in your terminal. This confirms the SDK is installed and importable.
Seeing ModuleNotFoundError?
This means your terminal is not using the virtual environment where you installed the package. Make sure you see (.venv) in your prompt. If not, re-run the activation command from the previous substep.
Help me fix my Anthropic SDK import error.
- Open your code editor in the project directory. Press Cmd+Space (macOS) or the Windows key (Windows) and type Cursor or VS Code to launch it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the investment-agent folder, then click Open.
Your environment is locked and loaded. Next up, you'll write the foundation of your analysis agent and send your first PDF to Claude.
Build the PDF Analysis Foundation
Your environment is ready. Now it is time to write the actual Python script that will power your investment analysis agent.
The foundation of this agent is a script that can load a PDF file, send it to Claude for analysis, and display the results. You will build this piece by piece, starting with the utility functions and finishing with a working end-to-end analysis call.
In this step, get ready to:
- Write a base script that initializes the Anthropic client and loads a PDF as base64.
- Craft a financial analysis system prompt for Claude.
- Send a PDF to Claude and display the raw analysis response.
Write the base script with PDF loading and client setup
Before your agent can analyze anything, it needs two core capabilities: loading a PDF file into a format the API can accept, and connecting to the Anthropic API.
- Create a new file called agent.py in your project directory.
- Add the following imports and utility functions to agent.py:
import anthropic
import base64
import json
import sys
import os
def load_pdf_as_base64(file_path):
"""Load a PDF file and return its base64-encoded content."""
with open(file_path, "rb") as f:
return base64.standard_b64encode(f.read()).decode("utf-8")
def create_client():
"""Create and return an Anthropic client."""
return anthropic.Anthropic()
What does this code do?
- The imports bring in the anthropic SDK, Python's base64 encoding library, and standard utilities for JSON, system args, and file paths.
- load_pdf_as_base64 reads a PDF file in binary mode and encodes it as a base64 string. This is the format Claude's API expects when you send a document.
- create_client initializes the Anthropic client. It automatically reads the ANTHROPIC_API_KEY environment variable you set in Step 1.
Why base64 instead of a file upload?
Claude's API accepts PDFs as base64-encoded document blocks sent directly in the message. This keeps your script self-contained in a single file with no external file management state to track.
- Save agent.py.
Craft the financial analysis system prompt
A system prompt tells Claude what role to play and how to behave. For financial analysis, you want Claude to act as an experienced investment analyst who extracts precise numbers and cites sources.
- In agent.py, add the following SYSTEM_PROMPT constant below the create_client function:
SYSTEM_PROMPT = """You are an expert investment analyst with deep experience in financial statement analysis.
When presented with an investment report or earnings document, you will:
1. Identify and extract key financial metrics including:
- Revenue (current period and YoY change)
- Net Income (current period and YoY change)
- Earnings Per Share (EPS)
- Operating Margin
- Free Cash Flow
- Any other notable metrics present in the report
2. Provide a concise summary of the company's financial performance.
3. When asked for a structured analysis, output valid JSON matching the requested schema.
4. Answer follow-up questions about the report with specific references to data points in the document.
Be precise with numbers. If a metric is not available in the report, indicate it as null rather than guessing.
Always cite the specific section or page where you found the data when possible."""
What makes this prompt effective?
- It assigns a specific persona (expert investment analyst) so Claude responds with domain-appropriate depth.
- It lists the exact metrics to extract, giving Claude a clear checklist to follow.
- It tells Claude to return null for missing data rather than guessing. This prevents hallucinated numbers in financial analysis.
- It requests source citations, which helps you verify the output against the original document.
- Save agent.py.
Send the PDF to Claude and display the response
Now you need the function that actually sends the PDF to Claude along with a prompt, and a main function that ties everything together into a runnable script.
- Add the analyze_pdf function below the SYSTEM_PROMPT constant:
def analyze_pdf(client, pdf_base64, prompt):
"""Send a PDF to Claude for analysis with a specific prompt."""
message = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
system=SYSTEM_PROMPT,
messages=[
{
"role": "user",
"content": [
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": pdf_base64,
},
},
{
"type": "text",
"text": prompt,
},
],
}
],
)
return message.content[0].text
What does this function do?
- It calls client.messages.create using the claude-sonnet-4-6 model, which has strong document understanding capabilities and a 1M token context window.
- The message content includes two blocks: a document block with the base64-encoded PDF, and a text block with your analysis prompt.
- Claude processes each page of the PDF as an image with extracted text, then responds based on both the visual layout and the text content.
- The function returns just the text of Claude's response for easy display.
- Add the main function and entry point below analyze_pdf:
def main():
if len(sys.argv) < 2:
print("Usage: python agent.py <path-to-pdf>")
print("Example: python agent.py earnings_report.pdf")
sys.exit(1)
pdf_path = sys.argv[1]
if not os.path.exists(pdf_path):
print(f"Error: File not found: {pdf_path}")
sys.exit(1)
if not pdf_path.lower().endswith(".pdf"):
print("Error: Please provide a PDF file.")
sys.exit(1)
print(f"Loading report: {pdf_path}")
pdf_base64 = load_pdf_as_base64(pdf_path)
print(f"PDF loaded ({len(pdf_base64) // 1024} KB encoded)")
client = create_client()
print("Connected to Claude API")
result = analyze_pdf(client, pdf_base64, "Please analyze this investment report and provide an overview of the key financial information.")
print(result)
if __name__ == "__main__":
main()
What does the main function do?
- It checks that the user provided a PDF file path as a command-line argument.
- It validates the file exists and has a .pdf extension before attempting to load it.
- It loads the PDF, creates the API client, sends the PDF to Claude with a general analysis prompt, and prints the response.
- The if __name__ == "__main__" guard ensures main() only runs when you execute the script directly.
- Save agent.py.
Where to find a sample PDF
You need a real earnings report PDF to test your agent. Visit any public company's investor relations page (Apple, Microsoft, Tesla) and download a recent quarterly earnings report. These are typically 10-30 pages and free to download.
- Download any publicly available quarterly earnings report PDF and save it in your project directory.
- Run your agent by executing this command (replace the filename with your downloaded PDF):
python agent.py [[PDF_FILENAME="your-report-filename.pdf"]]
- You should see output showing the PDF loading, a connection message, and then Claude's analysis of the report's key financial data.
Not seeing any output?
- If you see "Usage: python agent.py <path-to-pdf>", you forgot to include the PDF filename after the command.
- If you see "Error: File not found", make sure the PDF is in the same folder where you are running the command, or provide the full file path.
- If you get an authentication error, confirm your ANTHROPIC_API_KEY environment variable is still set in your current terminal session.
Help me debug my agent.py script
✔️ Awesome, I've got everything!
Great. Double check you have saved agent.py and that your test run produced Claude's analysis output.
ⓧ I'd like to double check the full code
Here is the complete agent.py file at the end of this step:
import anthropic
import base64
import json
import sys
import os
def load_pdf_as_base64(file_path):
"""Load a PDF file and return its base64-encoded content."""
with open(file_path, "rb") as f:
return base64.standard_b64encode(f.read()).decode("utf-8")
def create_client():
"""Create and return an Anthropic client."""
return anthropic.Anthropic()
SYSTEM_PROMPT = """You are an expert investment analyst with deep experience in financial statement analysis.
When presented with an investment report or earnings document, you will:
1. Identify and extract key financial metrics including:
- Revenue (current period and YoY change)
- Net Income (current period and YoY change)
- Earnings Per Share (EPS)
- Operating Margin
- Free Cash Flow
- Any other notable metrics present in the report
2. Provide a concise summary of the company's financial performance.
3. When asked for a structured analysis, output valid JSON matching the requested schema.
4. Answer follow-up questions about the report with specific references to data points in the document.
Be precise with numbers. If a metric is not available in the report, indicate it as null rather than guessing.
Always cite the specific section or page where you found the data when possible."""
def analyze_pdf(client, pdf_base64, prompt):
"""Send a PDF to Claude for analysis with a specific prompt."""
message = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
system=SYSTEM_PROMPT,
messages=[
{
"role": "user",
"content": [
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": pdf_base64,
},
},
{
"type": "text",
"text": prompt,
},
],
}
],
)
return message.content[0].text
def main():
if len(sys.argv) < 2:
print("Usage: python agent.py <path-to-pdf>")
print("Example: python agent.py earnings_report.pdf")
sys.exit(1)
pdf_path = sys.argv[1]
if not os.path.exists(pdf_path):
print(f"Error: File not found: {pdf_path}")
sys.exit(1)
if not pdf_path.lower().endswith(".pdf"):
print("Error: Please provide a PDF file.")
sys.exit(1)
print(f"Loading report: {pdf_path}")
pdf_base64 = load_pdf_as_base64(pdf_path)
print(f"PDF loaded ({len(pdf_base64) // 1024} KB encoded)")
client = create_client()
print("Connected to Claude API")
result = analyze_pdf(client, pdf_base64, "Please analyze this investment report and provide an overview of the key financial information.")
print(result)
if __name__ == "__main__":
main()
Your agent can now read a PDF and produce a raw financial overview. Next up, you will teach it to extract structured JSON data from the report so the output is machine-readable.
Extract Structured Financial Data
In the previous step, Claude analyzed your PDF and returned a free-form text overview. That's useful for reading, but if you want to feed financial insights into a dashboard or database, you need structured data.
In this step, you'll define a JSON schema that tells Claude exactly what metrics to extract and how to format them. The result is clean, parseable output instead of wall-of-text prose.
In this step, get ready to:
- Define a JSON schema for the financial metrics you want to extract.
- Write a prompt that requests structured output matching your schema.
- Parse the JSON response and display formatted results.
Define the extraction prompt with a JSON schema
When you ask an LLM for structured output, the trick is showing it the exact shape you expect. You'll define a prompt constant that includes the target JSON structure so Claude knows precisely what fields to fill.
- In agent.py, find the closing triple-quote of SYSTEM_PROMPT. Add the following EXTRACTION_PROMPT constant below it:
EXTRACTION_PROMPT = """Analyze this investment report and extract the key financial metrics.
Return your response as valid JSON with the following structure:
{
"company_name": "string",
"report_period": "string (e.g., 'Q1 2025', 'FY 2024')",
"metrics": {
"revenue": {"value": "string with currency", "yoy_change": "string percentage or null"},
"net_income": {"value": "string with currency", "yoy_change": "string percentage or null"},
"eps": {"value": "string", "yoy_change": "string percentage or null"},
"operating_margin": {"value": "string percentage or null"},
"free_cash_flow": {"value": "string with currency or null"}
},
"key_highlights": ["string", "string", "string"],
"risks_mentioned": ["string", "string"]
}
Return ONLY the JSON object, no additional text."""
What does this prompt do?
This prompt serves as a contract between your script and Claude. By showing the exact JSON structure you expect, Claude knows to return data in that shape rather than free-form text.
- The metrics object captures revenue, net income, EPS, operating margin, and free cash flow. Each includes a year-over-year change where available.
- The key_highlights and risks_mentioned arrays capture qualitative insights alongside the numbers.
- The "Return ONLY the JSON object" instruction prevents Claude from wrapping the JSON in explanatory text, which would break parsing.
You won't see a change yet. The next chunk updates main() to use this new prompt.
Update main to parse and display structured output
Now you'll swap out the generic analysis call for your new extraction prompt. You'll also add JSON parsing so the output is neatly formatted.
- In agent.py, scroll down to the main() function. Find these two lines at the bottom of the function:
result = analyze_pdf(client, pdf_base64, "Please analyze this investment report and provide an overview of the key financial information.")
print(result)
- Replace those two lines with the following code:
# Extract structured financial metrics from the report
print("\nExtracting structured metrics...")
result = analyze_pdf(client, pdf_base64, EXTRACTION_PROMPT)
try:
parsed = json.loads(result)
print(json.dumps(parsed, indent=2))
except json.JSONDecodeError:
print(result)
What does this code do?
- analyze_pdf(client, pdf_base64, EXTRACTION_PROMPT) sends the PDF to Claude with your structured extraction prompt instead of the generic one.
- json.loads(result) parses the response string into a Python dictionary. If Claude's response is valid JSON, this succeeds.
- json.dumps(parsed, indent=2) pretty-prints the parsed data with nice indentation so you can read it easily.
- The try/except block is a safety net. If Claude returns something that isn't valid JSON, the script prints the raw text instead of crashing.
- Save agent.py.
- Run your script again with the same PDF by running:
python agent.py [[PDF_PATH="your-report.pdf"]]
You should see neatly formatted JSON output with the company name, report period, financial metrics, highlights, and risks. Each metric includes a value and year-over-year change where available.
Seeing raw text instead of JSON?
- If Claude returns text instead of pure JSON, it may have wrapped the JSON in an explanation. Check that your EXTRACTION_PROMPT includes the line "Return ONLY the JSON object, no additional text." at the end.
- If you see a Python error about EXTRACTION_PROMPT not being defined, make sure the constant is defined above the analyze_pdf function, not inside it.
- If you get an API error, confirm your ANTHROPIC_API_KEY environment variable is still set in this terminal session.
Need more help? Help me debug why my extraction prompt isn't returning valid JSON
Why prompt-based extraction instead of tool use?
Claude supports a tool use feature that can guarantee JSON output in a specific schema. For this project, prompt-based extraction teaches you a more transferable skill. The same prompting technique works with any LLM, not just Claude.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here's the complete agent.py at this point:
import anthropic
import base64
import json
import sys
import os
def load_pdf_as_base64(file_path):
"""Load a PDF file and return its base64-encoded content."""
with open(file_path, "rb") as f:
return base64.standard_b64encode(f.read()).decode("utf-8")
def create_client():
"""Create and return an Anthropic client."""
return anthropic.Anthropic()
SYSTEM_PROMPT = """You are an expert investment analyst with deep experience in financial statement analysis.
When presented with an investment report or earnings document, you will:
1. Identify and extract key financial metrics including:
- Revenue (current period and YoY change)
- Net Income (current period and YoY change)
- Earnings Per Share (EPS)
- Operating Margin
- Free Cash Flow
- Any other notable metrics present in the report
2. Provide a concise summary of the company's financial performance.
3. When asked for a structured analysis, output valid JSON matching the requested schema.
4. Answer follow-up questions about the report with specific references to data points in the document.
Be precise with numbers. If a metric is not available in the report, indicate it as null rather than guessing.
Always cite the specific section or page where you found the data when possible."""
EXTRACTION_PROMPT = """Analyze this investment report and extract the key financial metrics.
Return your response as valid JSON with the following structure:
{
"company_name": "string",
"report_period": "string (e.g., 'Q1 2025', 'FY 2024')",
"metrics": {
"revenue": {"value": "string with currency", "yoy_change": "string percentage or null"},
"net_income": {"value": "string with currency", "yoy_change": "string percentage or null"},
"eps": {"value": "string", "yoy_change": "string percentage or null"},
"operating_margin": {"value": "string percentage or null"},
"free_cash_flow": {"value": "string with currency or null"}
},
"key_highlights": ["string", "string", "string"],
"risks_mentioned": ["string", "string"]
}
Return ONLY the JSON object, no additional text."""
def analyze_pdf(client, pdf_base64, prompt):
"""Send a PDF to Claude for analysis with a specific prompt."""
message = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
system=SYSTEM_PROMPT,
messages=[
{
"role": "user",
"content": [
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": pdf_base64,
},
},
{
"type": "text",
"text": prompt,
},
],
}
],
)
return message.content[0].text
def main():
if len(sys.argv) < 2:
print("Usage: python agent.py <path-to-pdf>")
print("Example: python agent.py earnings_report.pdf")
sys.exit(1)
pdf_path = sys.argv[1]
if not os.path.exists(pdf_path):
print(f"Error: File not found: {pdf_path}")
sys.exit(1)
if not pdf_path.lower().endswith(".pdf"):
print("Error: Please provide a PDF file.")
sys.exit(1)
print(f"Loading report: {pdf_path}")
pdf_base64 = load_pdf_as_base64(pdf_path)
print(f"PDF loaded ({len(pdf_base64) // 1024} KB encoded)")
client = create_client()
print("Connected to Claude API")
print("\nExtracting structured metrics...")
result = analyze_pdf(client, pdf_base64, EXTRACTION_PROMPT)
try:
parsed = json.loads(result)
print(json.dumps(parsed, indent=2))
except json.JSONDecodeError:
print(result)
if __name__ == "__main__":
main()
Your agent now extracts structured data from messy PDF reports. Next up, you'll build a conversational loop so you can ask follow-up questions about the report without re-uploading it each time.
Build the Conversational Follow-Up Loop
Your agent can extract structured financial metrics from a PDF in a single shot. That's useful, but real analysis is iterative.
In this step, you'll transform your agent from a one-shot extractor into an interactive analyst. You'll build a multi-turn conversation loop that remembers context, so you can dig deeper into the report with follow-up questions like "What were the risk factors?" or "How did margins change?"
In this step, get ready to:
- Build a multi-turn conversation function that maintains message history.
- Add special commands for structured extraction and clean exit.
- Connect the conversation loop to your main function.
Create the conversation initialization
A multi-turn conversation with an LLM works by sending the full message history with every request. Each new question includes all prior messages so the model has context for its answer.
- In agent.py, add the following run_conversation function directly above your main function:
def run_conversation(client, pdf_base64):
"""Run an interactive conversation about the report."""
# Initialize the conversation with the PDF and an overview request
messages = [
{
"role": "user",
"content": [
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": pdf_base64,
},
},
{
"type": "text",
"text": "I've uploaded an investment report. Please provide a brief overview of what this report covers, then I'll ask follow-up questions.",
},
],
}
]
# Send the initial request and get a report overview
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
system=SYSTEM_PROMPT,
messages=messages,
)
# Store the response in conversation history and display it
assistant_message = response.content[0].text
messages.append({"role": "assistant", "content": assistant_message})
print(f"\n{'='*60}")
print("REPORT OVERVIEW")
print(f"{'='*60}")
print(assistant_message)
What does this code do?
This function sets up the conversation by sending the PDF alongside an initial prompt asking for an overview.
- messages is a list that holds the entire conversation history. Every new request sends this full list to Claude so it remembers prior context.
- After the first API call, both the assistant's response and the original user message are stored in messages. This is how multi-turn conversations maintain context.
Why maintain message history?
LLMs are stateless. Each API call is independent. Without message history, Claude would have no idea what report you're asking about or what you discussed previously. By appending each exchange to the messages list, you simulate a continuous conversation.
- Save agent.py.
You won't see a change yet. The next chunk adds the interactive loop that makes this function usable.
Add the interactive question loop
The conversation initialization gives Claude context about the report. Now you need a loop that accepts follow-up questions, sends them with the full history, and prints each response.
- In agent.py, add the following code at the bottom of your run_conversation function, directly below the print(assistant_message) line:
while True:
print(f"\n{'\u2014'*60}")
user_input = input("\nAsk a question (or type 'metrics' for extraction, 'quit' to exit): ").strip()
if user_input.lower() == "quit":
print("\nExiting analysis session. Goodbye!")
break
elif user_input.lower() == "metrics":
# Reuse the structured extraction from the previous step
print("\nExtracting structured metrics...")
result = analyze_pdf(client, pdf_base64, EXTRACTION_PROMPT)
try:
parsed = json.loads(result)
print(json.dumps(parsed, indent=2))
except json.JSONDecodeError:
print(result)
continue
# Append the user's question to conversation history
messages.append({"role": "user", "content": user_input})
# Send the full conversation history to Claude
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
system=SYSTEM_PROMPT,
messages=messages,
)
# Store and display the response
assistant_message = response.content[0].text
messages.append({"role": "assistant", "content": assistant_message})
print(f"\n{assistant_message}")
What does this code do?
- The while True loop runs until the user types quit. Each iteration prompts for input and handles it.
- Typing metrics triggers the structured JSON extraction you built in the previous step, reusing your existing analyze_pdf function.
- Any other text is treated as a follow-up question. It gets appended to messages along with Claude's response, building up the conversation history over time.
- Save agent.py.
You won't see a change yet. The run_conversation function exists but nothing calls it. The next substep wires it into main.
Update main to use the conversation loop
Your main function currently calls analyze_pdf directly and prints JSON. You need to replace that with a call to your new conversational function.
- In agent.py, scroll down to your main function and find this block of code near the bottom:
print("\nExtracting structured metrics...")
result = analyze_pdf(client, pdf_base64, EXTRACTION_PROMPT)
try:
parsed = json.loads(result)
print(json.dumps(parsed, indent=2))
except json.JSONDecodeError:
print(result)
- Replace that entire block with this single line:
run_conversation(client, pdf_base64)
- Save agent.py and run your agent by running this command:
python agent.py earnings_report.pdf
You should see a REPORT OVERVIEW section followed by a prompt asking you to type a question. Try asking something like "What were the main risk factors?" or "How did revenue compare to last year?"
Type metrics to see the structured JSON extraction, or quit to exit the session.
Not seeing the conversation prompt?
- Make sure the run_conversation function is defined above main in your file. Python reads top-to-bottom, so functions must be defined before they are called.
- Confirm you deleted the old analyze_pdf call block completely and replaced it with run_conversation(client, pdf_base64).
- Check that your ANTHROPIC_API_KEY environment variable is still set in your terminal session.
Help me debug my conversation loop
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
import anthropic
import base64
import json
import sys
import os
def load_pdf_as_base64(file_path):
"""Load a PDF file and return its base64-encoded content."""
with open(file_path, "rb") as f:
return base64.standard_b64encode(f.read()).decode("utf-8")
def create_client():
"""Create and return an Anthropic client."""
return anthropic.Anthropic()
SYSTEM_PROMPT = """You are an expert investment analyst with deep experience in financial statement analysis.
When presented with an investment report or earnings document, you will:
1. Identify and extract key financial metrics including:
- Revenue (current period and YoY change)
- Net Income (current period and YoY change)
- Earnings Per Share (EPS)
- Operating Margin
- Free Cash Flow
- Any other notable metrics present in the report
2. Provide a concise summary of the company's financial performance.
3. When asked for a structured analysis, output valid JSON matching the requested schema.
4. Answer follow-up questions about the report with specific references to data points in the document.
Be precise with numbers. If a metric is not available in the report, indicate it as null rather than guessing.
Always cite the specific section or page where you found the data when possible."""
EXTRACTION_PROMPT = """Analyze this investment report and extract the key financial metrics.
Return your response as valid JSON with the following structure:
{
"company_name": "string",
"report_period": "string (e.g., 'Q1 2025', 'FY 2024')",
"metrics": {
"revenue": {"value": "string with currency", "yoy_change": "string percentage or null"},
"net_income": {"value": "string with currency", "yoy_change": "string percentage or null"},
"eps": {"value": "string", "yoy_change": "string percentage or null"},
"operating_margin": {"value": "string percentage or null"},
"free_cash_flow": {"value": "string with currency or null"}
},
"key_highlights": ["string", "string", "string"],
"risks_mentioned": ["string", "string"]
}
Return ONLY the JSON object, no additional text."""
def analyze_pdf(client, pdf_base64, prompt):
"""Send a PDF to Claude for analysis with a specific prompt."""
message = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
system=SYSTEM_PROMPT,
messages=[
{
"role": "user",
"content": [
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": pdf_base64,
},
},
{
"type": "text",
"text": prompt,
},
],
}
],
)
return message.content[0].text
def run_conversation(client, pdf_base64):
"""Run an interactive conversation about the report."""
messages = [
{
"role": "user",
"content": [
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": pdf_base64,
},
},
{
"type": "text",
"text": "I've uploaded an investment report. Please provide a brief overview of what this report covers, then I'll ask follow-up questions.",
},
],
}
]
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
system=SYSTEM_PROMPT,
messages=messages,
)
assistant_message = response.content[0].text
messages.append({"role": "assistant", "content": assistant_message})
print(f"\n{'='*60}")
print("REPORT OVERVIEW")
print(f"{'='*60}")
print(assistant_message)
while True:
print(f"\n{'\u2014'*60}")
user_input = input("\nAsk a question (or type 'metrics' for extraction, 'quit' to exit): ").strip()
if user_input.lower() == "quit":
print("\nExiting analysis session. Goodbye!")
break
elif user_input.lower() == "metrics":
print("\nExtracting structured metrics...")
result = analyze_pdf(client, pdf_base64, EXTRACTION_PROMPT)
try:
parsed = json.loads(result)
print(json.dumps(parsed, indent=2))
except json.JSONDecodeError:
print(result)
continue
messages.append({"role": "user", "content": user_input})
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
system=SYSTEM_PROMPT,
messages=messages,
)
assistant_message = response.content[0].text
messages.append({"role": "assistant", "content": assistant_message})
print(f"\n{assistant_message}")
def main():
if len(sys.argv) < 2:
print("Usage: python agent.py <path-to-pdf>")
print("Example: python agent.py earnings_report.pdf")
sys.exit(1)
pdf_path = sys.argv[1]
if not os.path.exists(pdf_path):
print(f"Error: File not found: {pdf_path}")
sys.exit(1)
if not pdf_path.lower().endswith(".pdf"):
print("Error: Please provide a PDF file.")
sys.exit(1)
print(f"Loading report: {pdf_path}")
pdf_base64 = load_pdf_as_base64(pdf_path)
print(f"PDF loaded ({len(pdf_base64) // 1024} KB encoded)")
client = create_client()
print("Connected to Claude API")
run_conversation(client, pdf_base64)
if __name__ == "__main__":
main()
Your agent now holds a real conversation. You can ask as many follow-up questions as you want, and Claude remembers everything discussed. Next up, you'll add a dedicated command that generates a complete investment thesis with bull and bear cases.
Generate the Investment Thesis
Your agent can now extract raw metrics and answer questions about a report. The next step is turning those numbers into something actionable.
In this step, you will add a dedicated command that produces a structured investment thesis. This includes bull and bear cases, a sentiment score, and a buy/hold/sell recommendation, all formatted as JSON that another system could consume.
In this step, get ready to:
- Define a thesis prompt that requests structured bull/bear case analysis.
- Wire up a new command in the conversation loop to trigger thesis generation.
- Test the full pipeline and see formatted JSON output.
Define the investment thesis prompt
The metrics extraction prompt asks Claude to pull out raw numbers. The thesis prompt goes further. It asks Claude to synthesize those numbers into an opinionated investment view with supporting arguments on both sides.
- In agent.py, find the closing triple-quote of EXTRACTION_PROMPT (the line that says Return ONLY the JSON object, no additional text.""").
- Add the following THESIS_PROMPT constant directly below it:
THESIS_PROMPT = """Based on your analysis of this investment report, generate a structured investment thesis.
Return your response as valid JSON with the following structure:
{
"company_name": "string",
"report_period": "string",
"summary": "string (2-3 sentence overview)",
"bull_case": {
"points": ["string", "string", "string"],
"confidence": "high/medium/low"
},
"bear_case": {
"points": ["string", "string", "string"],
"confidence": "high/medium/low"
},
"sentiment_score": "number from 1-10 (1=very bearish, 10=very bullish)",
"recommendation": "string (Buy/Hold/Sell with brief rationale)"
}
Return ONLY the JSON object, no additional text."""
What does this prompt do?
This prompt asks Claude to go beyond data extraction and produce an analytical opinion. The JSON schema captures both sides of the investment argument.
- bull_case lists reasons the stock might outperform, with a confidence level.
- bear_case lists risks and reasons for caution.
- sentiment_score gives a numeric 1-10 rating so downstream systems can sort or filter reports.
- recommendation provides a final Buy/Hold/Sell call with a brief rationale.
- Save agent.py.
Seeing a syntax error on the triple-quote?
Make sure the opening """ is on the same line as the = sign, and the closing """ sits on its own line with no extra indentation.
Help me fix a syntax error in my THESIS_PROMPT triple-quoted string.
Wire up the thesis command in the conversation loop
The prompt is ready, but the conversation loop does not know about it yet. You need to update the input prompt text and add a handler that triggers thesis generation when the user types thesis.
- In agent.py, scroll to the while True: loop inside run_conversation.
- Find this line:
user_input = input("\nAsk a question (or type 'metrics' for extraction, 'quit' to exit): ").strip()
- Replace it with this updated version that includes the thesis option:
user_input = input("\nAsk a question (or type 'metrics' for extraction, 'thesis' for investment thesis, 'quit' to exit): ").strip()
Now you need to add the handler that fires when the user types thesis. This goes right after the existing metrics handler.
- Find the elif user_input.lower() == "metrics": block. Locate the continue statement at the end of that block.
- Add the following code directly below that continue statement:
elif user_input.lower() == "thesis":
print("\nGenerating investment thesis...")
result = analyze_pdf(client, pdf_base64, THESIS_PROMPT)
try:
parsed = json.loads(result)
print(json.dumps(parsed, indent=2))
except json.JSONDecodeError:
print(result)
continue
What does this handler do?
When the user types thesis, the handler calls analyze_pdf with the new THESIS_PROMPT. Claude re-reads the PDF and produces an opinionated investment thesis.
The try/except block attempts to parse the response as JSON for pretty-printing. If Claude returns text that is not valid JSON, the raw response prints instead so you still see the output.
- Save agent.py.
Indentation errors after adding the elif block?
The new elif must line up exactly with the existing elif user_input.lower() == "metrics": above it (8 spaces of indentation). The body of the block needs 12 spaces.
Help me fix my indentation in the thesis elif block.
Test the investment thesis generation
Time to see the full pipeline in action. You will run the agent, let it analyze the report, then trigger the thesis command to see structured JSON output.
- Run the agent with your PDF by running this command:
python agent.py [[PDF_PATH="your-report.pdf"]]
- Wait for the report overview to appear.
- At the prompt, type thesis and press Enter.
You should see a JSON object printed with the company name, report period, bull/bear cases, a sentiment score from 1-10, and a recommendation.
What should the output look like?
The exact values depend on the report you provide. A typical output looks like a pretty-printed JSON object with fields like "sentiment_score": 7 and "recommendation": "Buy ...".
This structured format means a dashboard, database, or downstream script could parse this output directly without any manual reformatting.
- Type quit to exit the session when you are done exploring.
Seeing raw text instead of JSON?
Claude occasionally wraps the JSON in markdown code fences or adds commentary before it. The try/except block handles this gracefully by printing the raw text. The content is still valid analysis. If you want to retry for cleaner JSON, type thesis again.
Help me understand why my thesis command returns text instead of parsed JSON.
✔️ Awesome, I've got everything!
Great. Double check you have saved agent.py.
ⓧ I'd like to double check the full code
Here is the complete agent.py at this point:
import anthropic
import base64
import json
import sys
import os
def load_pdf_as_base64(file_path):
"""Load a PDF file and return its base64-encoded content."""
with open(file_path, "rb") as f:
return base64.standard_b64encode(f.read()).decode("utf-8")
def create_client():
"""Create and return an Anthropic client."""
return anthropic.Anthropic()
SYSTEM_PROMPT = """You are an expert investment analyst with deep experience in financial statement analysis.
When presented with an investment report or earnings document, you will:
1. Identify and extract key financial metrics including:
- Revenue (current period and YoY change)
- Net Income (current period and YoY change)
- Earnings Per Share (EPS)
- Operating Margin
- Free Cash Flow
- Any other notable metrics present in the report
2. Provide a concise summary of the company's financial performance.
3. When asked for a structured analysis, output valid JSON matching the requested schema.
4. Answer follow-up questions about the report with specific references to data points in the document.
Be precise with numbers. If a metric is not available in the report, indicate it as null rather than guessing.
Always cite the specific section or page where you found the data when possible."""
EXTRACTION_PROMPT = """Analyze this investment report and extract the key financial metrics.
Return your response as valid JSON with the following structure:
{
"company_name": "string",
"report_period": "string (e.g., 'Q1 2025', 'FY 2024')",
"metrics": {
"revenue": {"value": "string with currency", "yoy_change": "string percentage or null"},
"net_income": {"value": "string with currency", "yoy_change": "string percentage or null"},
"eps": {"value": "string", "yoy_change": "string percentage or null"},
"operating_margin": {"value": "string percentage or null"},
"free_cash_flow": {"value": "string with currency or null"}
},
"key_highlights": ["string", "string", "string"],
"risks_mentioned": ["string", "string"]
}
Return ONLY the JSON object, no additional text."""
THESIS_PROMPT = """Based on your analysis of this investment report, generate a structured investment thesis.
Return your response as valid JSON with the following structure:
{
"company_name": "string",
"report_period": "string",
"summary": "string (2-3 sentence overview)",
"bull_case": {
"points": ["string", "string", "string"],
"confidence": "high/medium/low"
},
"bear_case": {
"points": ["string", "string", "string"],
"confidence": "high/medium/low"
},
"sentiment_score": "number from 1-10 (1=very bearish, 10=very bullish)",
"recommendation": "string (Buy/Hold/Sell with brief rationale)"
}
Return ONLY the JSON object, no additional text."""
def analyze_pdf(client, pdf_base64, prompt):
"""Send a PDF to Claude for analysis with a specific prompt."""
message = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
system=SYSTEM_PROMPT,
messages=[
{
"role": "user",
"content": [
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": pdf_base64,
},
},
{
"type": "text",
"text": prompt,
},
],
}
],
)
return message.content[0].text
def run_conversation(client, pdf_base64):
"""Run an interactive conversation about the report."""
messages = [
{
"role": "user",
"content": [
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": pdf_base64,
},
},
{
"type": "text",
"text": "I've uploaded an investment report. Please provide a brief overview of what this report covers, then I'll ask follow-up questions.",
},
],
}
]
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
system=SYSTEM_PROMPT,
messages=messages,
)
assistant_message = response.content[0].text
messages.append({"role": "assistant", "content": assistant_message})
print(f"\n{'='*60}")
print("REPORT OVERVIEW")
print(f"{'='*60}")
print(assistant_message)
while True:
print(f"\n{'\u2014'*60}")
user_input = input("\nAsk a question (or type 'metrics' for extraction, 'thesis' for investment thesis, 'quit' to exit): ").strip()
if user_input.lower() == "quit":
print("\nExiting analysis session. Goodbye!")
break
elif user_input.lower() == "metrics":
print("\nExtracting structured metrics...")
result = analyze_pdf(client, pdf_base64, EXTRACTION_PROMPT)
try:
parsed = json.loads(result)
print(json.dumps(parsed, indent=2))
except json.JSONDecodeError:
print(result)
continue
elif user_input.lower() == "thesis":
print("\nGenerating investment thesis...")
result = analyze_pdf(client, pdf_base64, THESIS_PROMPT)
try:
parsed = json.loads(result)
print(json.dumps(parsed, indent=2))
except json.JSONDecodeError:
print(result)
continue
messages.append({"role": "user", "content": user_input})
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
system=SYSTEM_PROMPT,
messages=messages,
)
assistant_message = response.content[0].text
messages.append({"role": "assistant", "content": assistant_message})
print(f"\n{assistant_message}")
def main():
if len(sys.argv) < 2:
print("Usage: python agent.py <path-to-pdf>")
print("Example: python agent.py earnings_report.pdf")
sys.exit(1)
pdf_path = sys.argv[1]
if not os.path.exists(pdf_path):
print(f"Error: File not found: {pdf_path}")
sys.exit(1)
if not pdf_path.lower().endswith(".pdf"):
print("Error: Please provide a PDF file.")
sys.exit(1)
print(f"Loading report: {pdf_path}")
pdf_base64 = load_pdf_as_base64(pdf_path)
print(f"PDF loaded ({len(pdf_base64) // 1024} KB encoded)")
client = create_client()
print("Connected to Claude API")
run_conversation(client, pdf_base64)
if __name__ == "__main__":
main()
Your agent is now complete. It can load a PDF, extract raw metrics, answer free-form questions, and generate a structured investment thesis with bull/bear cases and a recommendation. All from a single Python script and the Claude API.
Secret mission
Add Report Comparison Mode
Your agent can analyze a single report, but real investment analysts compare reports side-by-side. Can you add a comparison mode that accepts two PDFs and identifies trends, improvements, and concerns between them?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
This project runs entirely locally and uses the Claude API on a pay-per-use basis. There are no ongoing costs when you are not actively running the script.
Resources you used:
- Project folder on your Desktop (contains agent.py, .venv, and any PDF files you analyzed).
- ANTHROPIC_API_KEY environment variable set in your terminal session.
- Anthropic API key stored in your Anthropic Console account.
Keep everything running
No action needed. Choose this if you plan to keep experimenting with your agent, analyzing more reports, or extending the comparison mode.
- Your project folder stays on your Desktop, ready for the next analysis session.
- Your API key remains active for future use.
- You only pay for API calls when you actively run the script.
Pause - I'll come back to this later
Close your terminal to clear the environment variable from your session. Your project files stay on disk for when you return.
- Close any terminal windows where you set ANTHROPIC_API_KEY. This clears the variable from that session.
- Your project folder and virtual environment remain on disk, ready to reactivate.
- When you return, reactivate your virtual environment and re-export the API key.
Delete - I don't want to use this again
Remove all project files and credentials to start completely fresh.
- Clear the environment variable from your current terminal session by running:
unset ANTHROPIC_API_KEY
- Delete your project folder from the Desktop by running:
rm -rf ~/Desktop/[[PROJECT_DIR="your project folder name"]]
- Revoke your API key in the Anthropic Console. Visit console.anthropic.com, navigate to your API Keys page, find the key you created for this project, and click the delete option to revoke it.
Windows users
Use this command instead to delete the project folder:
rmdir /s /q %USERPROFILE%\Desktop\[[PROJECT_DIR="your project folder name"]]
Nice Work!
Nice Work!
Nice work! You just built a fully functional investment report analysis agent powered by Claude. From a single PDF, your agent extracts structured financial data, generates investment theses, and holds a conversation about the report's contents.
You've learned how to:
- Built a PDF analysis pipeline that sends documents to Claude's API and extracts structured JSON output with key financial metrics.
- Created a multi-turn conversational interface that maintains message history so you can ask follow-up questions about any report.
- Generated structured investment theses with bull cases, bear cases, and sentiment scores that could feed into a dashboard or database.
- Secret Mission: Added a comparison mode that analyzes two reports side-by-side and identifies trends, improvements, and concerns.
Ready to quiz yourself?