AI Lead Qualification Engine with Claude
Score and qualify sales leads using Claude AI with structured output.
Introduction
30 Second Summary
Every sales team gets flooded with leads, but only a handful are actually ready to buy. Figuring out which ones to pursue first usually means hours of manual research and gut instinct.
In this project, you will build an AI-powered lead qualification engine using Python and the Claude API. The whole pipeline runs in Google Colab, right in your browser, with zero software to install.
What You'll Build
Paste a batch of sales leads into your notebook, hit run, and watch each one get scored, ranked by priority, and matched with a personalized follow-up email you can send right away.
By the end of this project, you'll have:
- A lead scoring pipeline where you paste in lead data and instantly get each one rated 0-100 with a tier, clear reasoning, and a recommended next action.
- A ranked qualification report that groups leads by priority so you know exactly which prospects to call first.
- Personalized outreach emails drafted automatically for every hot and warm lead, ready to copy into your inbox.
- Secret Mission: A lead enrichment step that analyzes company context before scoring for smarter qualification decisions.
Are there any prerequisites?
You need a Google account to access Google Colab. No prior coding experience is required.
Before We Start
Before we start writing code, let's get clear on what you're building and why.
Set Up Google Colab and Your Claude API Key
To build a lead qualification engine, you need two things: a place to write Python code and a way to talk to Claude programmatically.
Google Colab gives you a free Python notebook that runs entirely in your browser. You'll pair it with a Claude API key from Anthropic to send lead data to Claude and get intelligent responses back.
In this step, get ready to:
- Set up a Google Colab notebook.
- Create an Anthropic Console account and generate a Claude API key.
- Test your connection to Claude with a simple API call.
Open Google Colab and create a new notebook
Google Colab is a free online tool from Google that lets you write and run Python code in your browser. It works like a document made up of code cells that you can run one at a time.
- Open your browser and go to colab.research.google.com.
- Sign in with your Google account if prompted.
- Click File in the top menu bar.
- Click New notebook to create a fresh notebook.
You should see a blank notebook with a single empty code cell. This is where you'll write all your Python code for this project.
What is a Colab notebook?
A Colab notebook is made up of cells. Each cell holds a chunk of code that you can run independently. You'll build your lead qualification engine one cell at a time, running each cell to see its output immediately.
Create your Anthropic account and generate an API key
To call Claude from your code, you need an API key. This key authenticates your requests so Anthropic knows who is making the call.
- Open a new browser tab and go to platform.claude.com.
- Click Sign Up and create your Anthropic Console account.
- Once you're signed in, click Settings in the left sidebar.
- Click API Keys.
- Click Create Key.
- Give your key a name (for example, lead-qualification) and click Create Key.
- Copy your API key and save it somewhere safe, like a notes app. You will need it in the next substep.
Keep your API key private
Your API key is like a password. Anyone with it can make API calls on your account. Never share it publicly or commit it to version control.
What about cost?
New Anthropic accounts include free credits to test the API. This entire project uses approximately 15,000-20,000 tokens total, which costs well under $0.50 at claude-sonnet-5 pricing ($2 per million input tokens, $10 per million output tokens).
Install the anthropic library and test your connection
Now that you have your API key, you'll install the Python library that talks to Claude and verify everything works with a test call.
- Switch back to your Google Colab notebook tab.
- Click on the first empty code cell.
- Install the anthropic library and set your API key by pasting the following code:
!pip install anthropic
import os
os.environ["ANTHROPIC_API_KEY"] = "your-api-key-here"
- Replace your-api-key-here with the API key you copied earlier. Keep the quotes around it.
What does this code do?
!pip install anthropic installs the Anthropic Python library so you can call the Claude API from your notebook. The ! prefix tells Colab to run this as a terminal command instead of Python code.
os.environ["ANTHROPIC_API_KEY"] sets an environment variable that the anthropic library reads automatically when connecting to Claude.
- Now, directly below the code you just pasted (in the same cell), add the following code to create a client and send a test message:
import anthropic
client = anthropic.Anthropic()
message = client.messages.create(
model="claude-sonnet-5",
max_tokens=256,
messages=[
{"role": "user", "content": "Say 'Connection successful!' and nothing else."}
]
)
print(message.content[0].text)
What does this code do?
anthropic.Anthropic() creates a client that handles authentication and communication with the Claude API. It automatically reads the ANTHROPIC_API_KEY environment variable you set above.
client.messages.create() sends a message to Claude using the claude-sonnet-5 model. The response comes back as a structured object, and message.content[0].text extracts the text from Claude's reply.
Before you run this cell, what do you think Claude will respond with?
- Click the Play button on the left side of the cell (or press Shift+Enter) to run the cell.
You should see Connection successful! printed below the cell. This confirms your API key is valid and Claude received your request.
Seeing an error?
If you see AuthenticationError, double-check that you pasted your API key correctly between the quotes. Make sure the quotes are straight quotes ("), not curly quotes.
If you see ModuleNotFoundError, make sure the !pip install anthropic line is at the very top of the cell and the entire cell ran to completion.
I'm getting an error connecting to Claude in Google Colab. Help me debug.
✔️ Awesome, I've got everything!
Great. Double check you've saved your notebook before moving on.
ⓧ I'd like to double check the full code
Your first cell should contain exactly this code (with your own API key in place of the placeholder):
!pip install anthropic
import os
os.environ["ANTHROPIC_API_KEY"] = "your-api-key-here"
import anthropic
client = anthropic.Anthropic()
message = client.messages.create(
model="claude-sonnet-5",
max_tokens=256,
messages=[
{"role": "user", "content": "Say 'Connection successful!' and nothing else."}
]
)
print(message.content[0].text)
Your environment is ready and Claude is listening. Next up, you'll create a dataset of sales leads and see how Claude analyzes them.
Create Your Lead Data and Talk to Claude
Your connection to Claude is live. Now it's time to give the AI something real to work with.
Before you can build a scoring engine, you need two things: sample leads that represent real sales scenarios, and proof that Claude can analyze them intelligently. In this step, you'll create both.
In this step, get ready to:
- Create a dataset of 10 sample leads covering hot prospects, lukewarm inquiries, and dead ends.
- Send your first lead to Claude and see it respond with an intelligent analysis.
Create your sample lead data
A lead qualification engine is only as good as the data you feed it. You need leads that span the full spectrum: eager buyers with budget and urgency, casual browsers, and people who aren't even real prospects.
You'll define 10 sample leads as CSV data directly in Python, simulating what a real CRM export looks like.
- Click + Code in the Colab toolbar to create a new code cell below your existing one.
- Paste the following code into the new cell:
import csv
import io
# Sample lead data representing real sales scenarios
csv_data = """name,company,title,industry,company_size,message,source
Maria Garcia,TechNova Solutions,VP of Operations,Technology,250,We need to streamline our inventory and sales processes across 3 offices in Mexico. Looking for an ERP solution with Spanish support.,Website Demo Request
James Chen,Chen's Coffee House,Owner,Food & Beverage,8,Just browsing your website. Might need something for tracking orders someday.,Organic Search
Ana Rodriguez,Grupo Financiero del Sur,Director of Digital Transformation,Financial Services,1200,Our current SAP implementation is too expensive. We need a more cost-effective ERP that handles accounting compliance for Mexico and Colombia. Budget approved for Q4.,Partner Referral
Carlos Mendez,Mendez Marketing,Freelancer,Marketing,1,I'm a student researching ERP systems for a class project. Can you send me some brochures?,Contact Form
Sofia Petrov,MedTech Innovations,CTO,Healthcare,450,We are evaluating ERP solutions for our manufacturing and distribution operations in Chile. Need integration with our existing lab management system. Timeline: 6 months.,Trade Show
Ricardo Blanco,Blanco Construction,Operations Manager,Construction,75,Interested in project management and accounting modules. Currently using spreadsheets for everything. Need to migrate before year end.,Website Demo Request
Laura Kim,GlobalEd Partners,CEO,Education,35,We run online courses across Latin America. Need a platform to manage student enrollments and payments. Not sure if Odoo is the right fit.,Organic Search
Diego Alvarez,Alvarez Auto Parts,Supply Chain Director,Automotive,600,Urgent: our current system crashed and we need a replacement ERP immediately. Multiple warehouses in Colombia and Mexico. Budget is not a concern.,Partner Referral
Patricia Wong,Artisan Crafts Co,Founder,Retail,3,Love your website design! Do you offer website building services?,Social Media
Fernando Torres,Industrial Soluciones SA,VP of Finance,Manufacturing,850,Looking to replace our legacy accounting system. Need multi-currency support for operations in Mexico Chile and Colombia. Currently evaluating 3 vendors including Odoo.,Competitor Comparison"""
# Parse CSV string into a list of dictionaries
leads = []
reader = csv.DictReader(io.StringIO(csv_data))
for row in reader:
leads.append(row)
# Display all loaded leads
print(f"Loaded {len(leads)} leads\n")
for i, lead in enumerate(leads):
print(f"{i+1}. {lead['name']:20s} | {lead['title']:35s} | {lead['company']}")
What does this code do?
- csv_data holds 10 leads as a CSV string with columns for name, company, title, industry, company size, their message, and how they found you.
- csv.DictReader parses that string into a list of dictionaries, so you can access fields like lead['name'] or lead['company'] directly.
- The leads cover the full spectrum: partner referrals with approved budgets, casual website visitors, and even a student just looking for brochures.
- Run the cell by clicking the play button on the left side of the cell, or by pressing Shift+Enter.
You should see a numbered list of all 10 leads printed below the cell, each showing their name, title, and company.
Not seeing 10 leads?
- If you see a SyntaxError, check that the triple quotes """ at the start and end of the CSV string are intact. Partial pastes can cut them off.
- If you see fewer than 10 leads, the CSV data may have been cut short during paste. Delete the cell contents and paste the code block again.
Still stuck? Help me debug why my CSV lead data is not loading correctly in Google Colab.
✔️ Awesome, I've got everything!
Great. Make sure you've saved your notebook by pressing Cmd+S (macOS) or Ctrl+S (Windows).
ⓧ I'd like to double check the full code
Here is the full code for Cell 2. Compare it against what you have in your notebook.
import csv
import io
csv_data = """name,company,title,industry,company_size,message,source
Maria Garcia,TechNova Solutions,VP of Operations,Technology,250,We need to streamline our inventory and sales processes across 3 offices in Mexico. Looking for an ERP solution with Spanish support.,Website Demo Request
James Chen,Chen's Coffee House,Owner,Food & Beverage,8,Just browsing your website. Might need something for tracking orders someday.,Organic Search
Ana Rodriguez,Grupo Financiero del Sur,Director of Digital Transformation,Financial Services,1200,Our current SAP implementation is too expensive. We need a more cost-effective ERP that handles accounting compliance for Mexico and Colombia. Budget approved for Q4.,Partner Referral
Carlos Mendez,Mendez Marketing,Freelancer,Marketing,1,I'm a student researching ERP systems for a class project. Can you send me some brochures?,Contact Form
Sofia Petrov,MedTech Innovations,CTO,Healthcare,450,We are evaluating ERP solutions for our manufacturing and distribution operations in Chile. Need integration with our existing lab management system. Timeline: 6 months.,Trade Show
Ricardo Blanco,Blanco Construction,Operations Manager,Construction,75,Interested in project management and accounting modules. Currently using spreadsheets for everything. Need to migrate before year end.,Website Demo Request
Laura Kim,GlobalEd Partners,CEO,Education,35,We run online courses across Latin America. Need a platform to manage student enrollments and payments. Not sure if Odoo is the right fit.,Organic Search
Diego Alvarez,Alvarez Auto Parts,Supply Chain Director,Automotive,600,Urgent: our current system crashed and we need a replacement ERP immediately. Multiple warehouses in Colombia and Mexico. Budget is not a concern.,Partner Referral
Patricia Wong,Artisan Crafts Co,Founder,Retail,3,Love your website design! Do you offer website building services?,Social Media
Fernando Torres,Industrial Soluciones SA,VP of Finance,Manufacturing,850,Looking to replace our legacy accounting system. Need multi-currency support for operations in Mexico Chile and Colombia. Currently evaluating 3 vendors including Odoo.,Competitor Comparison"""
leads = []
reader = csv.DictReader(io.StringIO(csv_data))
for row in reader:
leads.append(row)
print(f"Loaded {len(leads)} leads\n")
for i, lead in enumerate(leads):
print(f"{i+1}. {lead['name']:20s} | {lead['title']:35s} | {lead['company']}")
Send your first lead to Claude for analysis
You have data. Now let's see if Claude can actually understand it. You'll send one lead to Claude with a prompt asking for an open-ended analysis of that lead's fit for Odoo ERP.
- Click + Code to create another new code cell below Cell 2.
- Paste the following code into the new cell:
# Pick the first lead to analyze
lead = leads[0]
# Build a prompt with the lead's details
prompt = f"""Analyze this sales lead for an Odoo ERP partnership opportunity:
Name: {lead['name']}
Company: {lead['company']}
Title: {lead['title']}
Industry: {lead['industry']}
Company Size: {lead['company_size']} employees
Message: {lead['message']}
Source: {lead['source']}
What is your assessment of this lead's fit for Odoo ERP?"""
# Send the prompt to Claude
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=512,
messages=[{"role": "user", "content": prompt}]
)
# Print Claude's analysis
print(f"Claude's analysis of {lead['name']}:")
print(response.content[0].text)
What does this code do?
- leads[0] grabs the first lead in your list, Maria Garcia from TechNova Solutions.
- The f-string prompt injects all of Maria's details (name, company, title, industry, company size, message, source) into a question asking Claude to assess her fit for Odoo ERP.
- client.messages.create() sends the prompt to claude-sonnet-5 with a 512-token response limit, and response.content[0].text extracts Claude's text reply.
Before you run this cell, take a moment to think. Maria Garcia is a VP of Operations at a 250-person tech company, looking for an ERP to streamline inventory and sales across 3 offices in Mexico. Do you think Claude will rate her as a strong prospect for Odoo, or a weak one?
- Run the cell by pressing Shift+Enter.
Claude prints a detailed, free-text analysis of Maria Garcia's fit for Odoo ERP. It should pick up on her seniority, the company's size, the multi-office need, and the Spanish language requirement.
Why is this result exciting?
Claude didn't just echo back the data. It interpreted the signals. A VP title means decision-making authority. A 250-person company in tech fits the mid-market sweet spot. Three offices in Mexico means real operational complexity that an ERP solves.
This is already more useful than manually reading lead forms. The challenge is that this free-text response is great for a human to read, but hard for code to act on.
Seeing an error instead of an analysis?
- If you see a NameError for leads or client, make sure you ran Cell 1 (setup) and Cell 2 (lead data) before this cell. Colab cells must be run in order.
- If you see an authentication error, return to Cell 1 and confirm your API key is pasted correctly in place of your-api-key-here. Then re-run Cell 1 before running this cell again.
Still stuck? Help me debug why my Claude API call is failing when I try to analyze a lead in Google Colab.
✔️ Awesome, I've got everything!
Great. Make sure you've saved your notebook by pressing Cmd+S (macOS) or Ctrl+S (Windows).
ⓧ I'd like to double check the full code
Here is the full code for Cell 3. Compare it against what you have in your notebook.
lead = leads[0]
prompt = f"""Analyze this sales lead for an Odoo ERP partnership opportunity:
Name: {lead['name']}
Company: {lead['company']}
Title: {lead['title']}
Industry: {lead['industry']}
Company Size: {lead['company_size']} employees
Message: {lead['message']}
Source: {lead['source']}
What is your assessment of this lead's fit for Odoo ERP?"""
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=512,
messages=[{"role": "user", "content": prompt}]
)
print(f"Claude's analysis of {lead['name']}:")
print(response.content[0].text)
Claude clearly understands sales leads. But this free-text analysis is written for a human reader. Next up, you'll try asking Claude to return structured scores, and discover why free-text output breaks down when you need consistent, automatable results.
Try Scoring Leads with Plain Text
In Step 2, Claude gave you an intelligent analysis of your first lead. That proves Claude understands the data. Now you need something more actionable: numeric scores, tier classifications, and recommended next actions for every lead in your list.
The most intuitive approach is to ask Claude to return structured scoring data in plain text. In this step, you'll build a scoring prompt and test it on two leads.
In this step, get ready to:
- Write a scoring function that asks Claude for a score, tier, reasoning, and next action.
- Score two different leads and compare Claude's responses.
Write a scoring prompt
To turn Claude's freeform analysis into something actionable, you need specific data points for each lead: a numeric score, a tier, reasoning, and a recommended next action. You can wrap all of this into a reusable function.
- Create a new code cell in your Colab notebook below Cell 3 and add this function:
# Define a function that sends a lead to Claude for scoring
def score_with_plain_text(lead):
# Build a prompt asking Claude for structured scoring data
prompt = f"""Score this sales lead for an Odoo ERP partnership on a scale of 0-100.
Name: {lead['name']}
Company: {lead['company']}
Title: {lead['title']}
Industry: {lead['industry']}
Company Size: {lead['company_size']} employees
Message: {lead['message']}
Source: {lead['source']}
Return: a score (0-100), tier (hot/warm/cold), reasoning, and recommended next action."""
# Send the prompt to Claude and return the raw text
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=512,
messages=[{"role": "user", "content": prompt}]
)
return response.content[0].text
What does this function do?
- score_with_plain_text(lead) takes a single lead dictionary and builds a prompt that includes all of the lead's details.
- The prompt explicitly asks Claude to return four pieces of data: a score (0-100), a tier (hot/warm/cold), reasoning, and a recommended next action.
- The function returns Claude's raw text response so you can print and inspect it.
Score two leads and compare the output
Now let's test this function on two different leads to see what Claude returns. You'll score Maria Garcia (Lead 1) and Sofia Petrov (Lead 5).
- Below the function in the same cell, add these lines to score and print both leads:
# Score Lead 1 (Maria Garcia) and Lead 5 (Sofia Petrov)
print("--- Lead 1 Response ---")
print(score_with_plain_text(leads[0]))
print("\n\n--- Lead 5 Response ---")
print(score_with_plain_text(leads[4]))
print("\n--- Notice: the format is different each time. ---")
print("--- You cannot reliably extract scores from unstructured text. ---")
Before you run this cell, make a prediction. Both leads go through the same function with the same prompt. Do you think Claude will return both responses in the exact same format?
- Run the cell.
Look at the two responses. Even though you asked the same question in the same format, Claude structured its answers differently. One response might use bullet points while the other uses a paragraph. One might label the score as "Score:" while the other says "Lead Score" or embeds it in a sentence.
Why is this a problem?
This inconsistency is not a bug. LLMs generate text probabilistically, so the exact wording and structure change every time. That's fine for human reading, but it's a dealbreaker for automation.
If you wanted to sort leads by score or filter by tier, you would need to write fragile text-parsing code that breaks every time Claude changes its phrasing. You cannot build a reliable pipeline on top of unpredictable output.
Seeing an error instead of responses?
- If you see a NameError about client or leads, go back and run Cells 1 and 2 first. Colab cells must be run in order.
- If you see an authentication error, check that your API key is set correctly in Cell 1 and re-run it.
Help me debug my Claude API scoring call.
✔️ Awesome, I've got everything!
Great. Make sure the cell is saved in your notebook before moving on.
ⓧ I'd like to double check the full code
Here is the complete Cell 4. Make sure your cell matches this exactly:
def score_with_plain_text(lead):
prompt = f"""Score this sales lead for an Odoo ERP partnership on a scale of 0-100.
Name: {lead['name']}
Company: {lead['company']}
Title: {lead['title']}
Industry: {lead['industry']}
Company Size: {lead['company_size']} employees
Message: {lead['message']}
Source: {lead['source']}
Return: a score (0-100), tier (hot/warm/cold), reasoning, and recommended next action."""
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=512,
messages=[{"role": "user", "content": prompt}]
)
return response.content[0].text
print("--- Lead 1 Response ---")
print(score_with_plain_text(leads[0]))
print("\n\n--- Lead 5 Response ---")
print(score_with_plain_text(leads[4]))
print("\n--- Notice: the format is different each time. ---")
print("--- You cannot reliably extract scores from unstructured text. ---")
You just hit the core limitation of asking an LLM for structured data in plain text. The output looks reasonable to a human, but a program cannot reliably extract the score or tier from it. In the next step, you'll solve this with a Claude feature that guarantees consistent, machine-readable output every time.
Get Structured Scores with Tool Use
In Step 3, you saw the problem firsthand. Claude returned scores in a different format every time, making it impossible to extract data programmatically.
Claude's tool_use feature solves this by letting you define an exact schema that Claude must follow on every response. In this step, you will define that schema, score all 10 leads against your Ideal Customer Profile, and produce a sorted qualification report.
In this step, get ready to:
- Define a score_lead tool with an exact JSON schema for structured scoring output.
- Score all 10 leads using tool_use with your Ideal Customer Profile criteria.
- Display a qualification report grouped by tier.
Define the tool schema for lead scoring
When you ask Claude for plain text, it can format the answer however it likes. Tool use flips this around. You hand Claude a schema that describes the exact fields and types you expect, and Claude is forced to fill them in.
- Create a new code cell in your Colab notebook by clicking + Code at the top.
- Paste the following tool definition into the cell:
import json
score_lead_tool = {
"name": "score_lead",
"description": "Score and qualify a sales lead based on ICP fit for Odoo ERP partnerships in Latin America. Evaluate budget signals, decision-making authority, need/urgency, and timing.",
"input_schema": {
"type": "object",
"properties": {
"score": {
"type": "integer",
"description": "Lead quality score from 0 (no fit) to 100 (perfect fit)"
},
"tier": {
"type": "string",
"enum": ["hot", "warm", "cold", "disqualified"],
"description": "Lead classification tier"
},
"reasoning": {
"type": "string",
"description": "2-3 sentence explanation of why this lead received this score"
},
"next_action": {
"type": "string",
"enum": ["schedule_demo", "send_case_study", "enroll_nurture", "disqualify"],
"description": "Recommended next action for the sales team"
},
"missing_info": {
"type": "string",
"description": "Key information that would improve scoring accuracy"
}
},
"required": ["score", "tier", "reasoning", "next_action", "missing_info"]
}
}
What does this schema do?
The score_lead_tool dictionary tells Claude exactly what to return for every lead. Instead of free-text, Claude must fill in five specific fields.
- score is an integer from 0 to 100 representing overall lead quality.
- tier is locked to one of four values: hot, warm, cold, or disqualified. Claude cannot invent its own labels.
- next_action is also locked to an enum, so the recommended action is always one of four predictable values your sales team can act on.
Why tool_use instead of asking for JSON in the prompt?
You could ask Claude to "respond in JSON format," but there is no guarantee Claude will follow the exact field names, nesting, or types you need. Tool use enforces the schema at the API level, so the response always matches your definition.
Score all 10 leads using the tool
The tool schema defines the output shape. Now you need to tell Claude how to score. A system prompt encoding your Ideal Customer Profile (ICP) gives Claude the criteria for evaluating every lead consistently.
- Add the following system prompt below the tool definition in the same cell:
SYSTEM_PROMPT = """You are a senior sales qualification specialist for Odoo, an open-source ERP platform. You specialize in qualifying leads for the Latin American market (Mexico, Colombia, Chile).
## Ideal Customer Profile (ICP)
- Company size: 50-2000 employees (mid-market sweet spot)
- Industries: Manufacturing, Financial Services, Healthcare, Technology, Construction
- Decision makers: VP, Director, C-suite, or Owner with buying authority
- Need: Active pain point with current systems (manual processes, legacy ERP, spreadsheets)
- Budget: Explicit budget mention or company size suggesting budget capacity
- Timeline: Defined timeline or urgency signals
- Region: Operations in Latin America (Mexico, Colombia, Chile)
## Scoring Guidance
- 80-100 (hot): Strong ICP match, clear budget + timeline + authority -> schedule_demo
- 60-79 (warm): Partial match, 1-2 gaps in BANT criteria -> send_case_study
- 30-59 (cold): Weak match, significant gaps -> enroll_nurture
- 0-29 (disqualified): Not a fit, no commercial intent -> disqualify"""
What are these ICP and BANT criteria?
ICP (Ideal Customer Profile) describes the type of company that is the best fit for your product. The criteria here target mid-market companies in Latin America with active ERP pain points.
BANT stands for Budget, Authority, Need, and Timeline. It is a classic sales qualification framework. Each lead is evaluated against these four signals to determine how ready they are to buy.
With the schema and scoring criteria defined, you can now loop through all 10 leads and send each one to Claude for structured scoring.
- Add the scoring loop below the system prompt in the same cell:
scored_leads = []
for i, lead in enumerate(leads):
lead_text = f"""Name: {lead['name']}
Company: {lead['company']}
Title: {lead['title']}
Industry: {lead['industry']}
Company Size: {lead['company_size']} employees
Message: {lead['message']}
Source: {lead['source']}"""
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=512,
system=SYSTEM_PROMPT,
tools=[score_lead_tool],
tool_choice={"type": "any"},
messages=[{"role": "user", "content": f"Score this lead:\n\n{lead_text}"}]
)
What does tool_choice any do?
Setting tool_choice to {"type": "any"} forces Claude to call at least one of the provided tools. Since you only provided score_lead, Claude always fills in your schema instead of responding with plain text.
- Add the response processing code below the API call, still inside the for loop:
for block in response.content:
if block.type == "tool_use" and block.name == "score_lead":
result = block.input
scored_leads.append({
"name": lead["name"],
"company": lead["company"],
"title": lead["title"],
"score": result["score"],
"tier": result["tier"],
"reasoning": result["reasoning"],
"next_action": result["next_action"],
"missing_info": result["missing_info"]
})
print(f"{i+1:2d}. {lead['name']:20s} | Score: {result['score']:3d} | {result['tier']:12s} | {result['next_action']}")
print(f"\nScored {len(scored_leads)} leads successfully!")
How does the response processing work?
When Claude responds with a tool call, the response contains a tool_use block instead of plain text. You check block.type and block.name to find the right tool call, then extract the structured data from block.input.
The block.input is a Python dictionary that exactly matches the schema you defined. Every lead produces the same five fields, every time.
Before you run this cell, think about what you expect the output to look like. Will all 10 leads have the same format this time, or will the structure vary like it did in Step 3?
- Click the play button to run the cell. This takes about 30-60 seconds because it sends 10 separate API calls.
Each lead prints on one line with aligned columns showing name, score, tier, and recommended action. Every row follows the exact same format.
Seeing an error or missing leads?
- If you see NameError: name 'leads' is not defined, go back and run Cell 2 (the lead data cell) first. Colab cells must be run in order.
- If you see NameError: name 'client' is not defined, go back and run Cell 1 (the setup cell) first.
- If only some leads print, check that the for block in response.content loop is indented inside the for i, lead loop. Both the API call and the response processing must be inside the outer loop.
help me debug my tool_use scoring loop
✔️ Awesome, I've got everything!
Great. Confirm you see 10 scored leads with the final line reading Scored 10 leads successfully!.
ⓧ I'd like to double check the full code
Your cell should contain these four sections, in this order:
- The import json line and the score_lead_tool dictionary with five properties (score, tier, reasoning, next_action, missing_info).
- The SYSTEM_PROMPT string with the ICP criteria and scoring guidance.
- The for i, lead in enumerate(leads) loop that builds lead_text and calls client.messages.create with tools and tool_choice.
- The inner for block in response.content loop that extracts block.input, appends to scored_leads, and prints each row.
Scroll up through this substep and compare each code block against your cell. Make sure the indentation matches and all four sections appear in order.
Display the qualification report grouped by tier
You have 10 scored leads, but they are listed in the order they were processed. A qualification report is more useful when leads are sorted by score and grouped by tier, so you can see the hottest prospects at the top.
- Create a new code cell by clicking + Code.
- Paste the following report code into the cell:
scored_leads.sort(key=lambda x: x["score"], reverse=True)
print("=" * 70)
print("LEAD QUALIFICATION REPORT")
print("=" * 70)
for tier_name in ["hot", "warm", "cold", "disqualified"]:
tier_leads = [l for l in scored_leads if l["tier"] == tier_name]
if tier_leads:
print(f"\n--- {tier_name.upper()} LEADS ({len(tier_leads)}) ---")
for lead in tier_leads:
print(f" {lead['score']:3d} {lead['name']:20s} {lead['company']}")
print(f" {lead['reasoning']}")
print(f" Action: {lead['next_action']} | Missing: {lead['missing_info']}")
print()
What does this report code do?
- scored_leads.sort() sorts the list by score from highest to lowest, so the strongest leads appear first.
- The loop iterates through each tier in a fixed order (hot, warm, cold, disqualified) and filters the scored leads into groups.
- Each lead displays its score, name, reasoning, recommended action, and what information is still missing.
Before you run this cell, which tier do you think will have the most leads? Which leads from your dataset do you expect to land in the hot tier?
- Click the play button to run the cell.
You should see a formatted report with a header, then each tier section listing its leads with scores, reasoning, and next actions. Leads with strong BANT signals (like Ana Rodriguez and Diego Alvarez) should appear in the hot tier.
Report shows no leads or an empty list?
- If you see NameError: name 'scored_leads' is not defined, run the scoring cell (Cell 5) first. The report cell depends on scored_leads being populated.
- If the report prints headers but no leads under any tier, the scoring cell may have run without successfully appending results. Re-run the scoring cell and check that it prints all 10 rows.
help me debug my lead qualification report
✔️ Awesome, I've got everything!
Great. Confirm you see leads grouped under tier headings with scores, reasoning, and recommended actions.
ⓧ I'd like to double check the full code
Double check your report cell matches this code exactly:
scored_leads.sort(key=lambda x: x["score"], reverse=True)
print("=" * 70)
print("LEAD QUALIFICATION REPORT")
print("=" * 70)
for tier_name in ["hot", "warm", "cold", "disqualified"]:
tier_leads = [l for l in scored_leads if l["tier"] == tier_name]
if tier_leads:
print(f"\n--- {tier_name.upper()} LEADS ({len(tier_leads)}) ---")
for lead in tier_leads:
print(f" {lead['score']:3d} {lead['name']:20s} {lead['company']}")
print(f" {lead['reasoning']}")
print(f" Action: {lead['next_action']} | Missing: {lead['missing_info']}")
print()
Your qualification engine now scores every lead with structured, consistent data you can sort, filter, and act on. Next up, you will turn these scores into personalized outreach emails for your best prospects.
Generate Outreach Emails for Qualified Leads
In the last step, you built a scoring engine that grades every lead with consistent, structured data. Your qualification report now shows exactly which leads are hot, warm, cold, or disqualified.
A scored list tells your sales team who to focus on, but they still need to write individual follow-up emails for each prospect. In this step, you will send each qualified lead back to Claude to generate personalized outreach emails, completing the pipeline from raw data to ready-to-send messages.
In this step, get ready to:
- Filter your scored leads to only the hot and warm tiers.
- Generate a personalized outreach email for each qualified lead.
Filter your scored leads to hot and warm tiers
Your scored_leads list contains all 10 leads, including cold and disqualified ones with no commercial potential. Outreach emails should only go to leads that scored well enough to justify sales effort.
The hot tier leads showed strong ICP match with clear budget, timeline, and authority signals. The warm tier leads had partial matches with one or two gaps. Cold and disqualified leads would waste your sales team's time.
Why not email every lead?
Sending a generic email to a disqualified lead (like a student requesting brochures) erodes trust. Filtering first means every email Claude generates is worth sending.
Generate personalized outreach emails for qualified leads
For each qualified lead, you will send the lead's scoring context back to Claude with a new system prompt. This time Claude acts as a Channel Account Manager, writing a concise follow-up email that references the lead's specific company, pain point, and recommended action.
- Create a new code cell in your Colab notebook below Cell 6, then paste this code:
# Filter to only hot and warm leads
qualified = [l for l in scored_leads if l["tier"] in ["hot", "warm"]]
print(f"Generating outreach emails for {len(qualified)} qualified leads...\n")
for lead in qualified:
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=512,
system="You are a Channel Account Manager at Odoo, writing personalized outreach emails to qualified leads in Latin America. Write concise, professional emails that reference the lead's specific situation. Keep emails under 150 words. Include a subject line.",
messages=[{
"role": "user",
"content": f"""Write a follow-up email for this qualified lead:
Name: {lead['name']}
Company: {lead['company']}
Title: {lead['title']}
Score: {lead['score']} ({lead['tier']})
Reasoning: {lead['reasoning']}
Recommended Action: {lead['next_action']}"""
}]
)
# Display each email with lead context
print(f"{'=' * 50}")
print(f"TO: {lead['name']} ({lead['company']})")
print(f"Score: {lead['score']} | Tier: {lead['tier']}")
print(f"{'=' * 50}")
print(response.content[0].text)
print()
What does this code do?
The first line filters scored_leads down to only leads whose tier is hot or warm, storing them in a new list called qualified.
The loop sends each qualified lead to Claude with a different system prompt. Instead of the scoring specialist from Cell 5, Claude now role-plays as a Channel Account Manager crafting a follow-up email.
Each email prompt includes the lead's score, tier, reasoning, and recommended action. This context lets Claude write emails that reference the lead's exact situation rather than sending a generic template.
Before you run this cell, think about what you expect to see. How many emails do you think Claude will generate? Which leads from your qualification report were hot or warm?
- Run the cell by clicking the play button or pressing Shift+Enter.
The cell first prints how many qualified leads it found. Then, for each one, it displays a separator with the lead's name, company, score, and tier, followed by a personalized outreach email. Each email references the lead's specific company, title, pain point, and recommended action.
Why are these emails different from each other?
Each email prompt includes that lead's unique scoring context. A hot lead with an urgent system crash gets a different tone and call-to-action than a warm lead still evaluating vendors. The structured data from tool_use makes this possible.
Seeing an error or no emails?
- If the cell prints 0 qualified leads, your Cell 5 scoring may have classified all leads as cold or disqualified. Re-run Cell 5 first, then re-run Cell 6 to re-sort, then try Cell 7 again.
- If you see a rate limit error, wait 30 seconds and re-run the cell. The free tier has lower request limits.
- If you see an authentication error, re-run Cell 1 to re-set your API key.
Still stuck? Help me debug my outreach email generation cell. I'm getting an error when running the loop that sends qualified leads to Claude.
✔️ Awesome, I've got everything!
Great. Double check you see personalized emails printed for each hot and warm lead in your output.
ⓧ I'd like to double check the full code
Here is the complete code for Cell 7. Compare it against what you have:
qualified = [l for l in scored_leads if l["tier"] in ["hot", "warm"]]
print(f"Generating outreach emails for {len(qualified)} qualified leads...\n")
for lead in qualified:
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=512,
system="You are a Channel Account Manager at Odoo, writing personalized outreach emails to qualified leads in Latin America. Write concise, professional emails that reference the lead's specific situation. Keep emails under 150 words. Include a subject line.",
messages=[{
"role": "user",
"content": f"""Write a follow-up email for this qualified lead:
Name: {lead['name']}
Company: {lead['company']}
Title: {lead['title']}
Score: {lead['score']} ({lead['tier']})
Reasoning: {lead['reasoning']}
Recommended Action: {lead['next_action']}"""
}]
)
print(f"{'=' * 50}")
print(f"TO: {lead['name']} ({lead['company']})")
print(f"Score: {lead['score']} | Tier: {lead['tier']}")
print(f"{'=' * 50}")
print(response.content[0].text)
print()
Secret mission
Add Lead Enrichment Before Scoring
Your scoring engine works, but it only knows what the lead told you. Real sales teams research a company before qualifying it. Can you add an enrichment step that analyzes each company first, then feeds that context into the scoring prompt for smarter decisions?
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. The Claude API charges per token used, so you likely spent less than $0.50 on this project with no ongoing costs while your key is idle.
Resources you used:
- Claude API key (stored at platform.claude.com).
- Google Colab notebook (saved automatically in Google Drive).
Keep everything running
No action needed. Choose this if you plan to keep experimenting with the Claude API or want to reuse your notebook for future lead scoring.
- Your API key stays active and ready for future projects. It only costs money when you make API calls.
- Your Colab notebook stays in Google Drive at no cost.
- To prevent unexpected charges, set a spend limit at platform.claude.com. Navigate to Settings, then Billing. The Start tier has a $500/month spend cap.
Pause - I'll come back to this later
No action needed. API keys only cost money when used, so no charges accrue while your key is idle.
- Your API key remains valid and ready to use whenever you return.
- Your Colab notebook stays saved in Google Drive automatically.
- Your Anthropic Console account at platform.claude.com remains active at no cost.
Delete - I don't want to use this again
Remove your API key to ensure it cannot be used. Your Colab notebook can also be deleted from Google Drive.
- Go to platform.claude.com.
- Navigate to Settings, then API Keys.
- Find your API key and click Delete.
- To delete your notebook, open Google Drive, right-click the Colab notebook file, and select Move to trash.
Nice Work!
Nice Work!
You just built a complete AI-powered lead qualification pipeline that scores, ranks, and writes outreach emails for your sales leads. From raw CSV data to ready-to-send follow-ups, your notebook handles the entire inbound-to-outbound workflow in seconds.
You learned how to:
- Discovered why plain text AI output breaks automation and used Claude's tool_use feature to get guaranteed structured output with consistent scores, tiers, and reasoning every time.
- Built a lead qualification engine that scores 10 leads against your Ideal Customer Profile and produces a ranked report grouped by hot, warm, cold, and disqualified tiers.
- Generated personalized outreach emails for every qualified lead, completing a full inbound-to-outbound sales pipeline that runs entirely in your browser.
- Secret Mission: Added a lead enrichment step that chains multiple AI calls to analyze company context before scoring, producing smarter qualification decisions.
Ready to quiz yourself?