What You'll Build
If you've ever tried to manually qualify real estate leads — checking credit, scoring motivation, estimating property value — you know how much time that eats up. By the end of this tutorial, you'll have a working Python agent that takes a raw lead and runs it through property lookup, credit scoring, and lead qualification tools automatically using the Claude API.
The agent uses Claude's native tool use to decide which tools to call, in what order, and when it has enough information to return a final lead score. You'll end up with a production-ready class you can drop into any CRM pipeline or webhook workflow.
The complete working code for this tutorial is broken into steps below — each snippet builds on the last. By Step 4, you'll have the entire agent assembled and ready to run. Copy each section in order and you'll have a fully functional lead qualifier.
Prerequisites
- Python 3.9 or higher installed
- An Anthropic API key (console.anthropic.com)
anthropicPython SDK installed (pip install anthropic)- Basic familiarity with Python classes and dictionaries
- Optional:
python-dotenvfor managing your API key (pip install python-dotenv)
Step 1: Initialize the Claude Client and Define the Agent Class
Start by setting up your project structure and initializing the Anthropic client. I keep the client and model name on the class so it's easy to swap models later without hunting through the code.
This class will eventually hold the tools, the agent loop, and the scoring logic. For now, we're just getting the foundation in place.
lead_qualifier_agent.pyimport os
import json
import anthropic
from dotenv import load_dotenv
load_dotenv()
class LeadQualifierAgent:
"""
A real estate lead qualification agent powered by Claude.
Uses tool use to look up property data, run credit checks,
and produce a scored lead report automatically.
"""
def __init__(self):
self.client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
self.model = "claude-sonnet-4-6"
self.max_iterations = 10 # Prevents runaway agent loops
def run(self, lead_data: dict) -> dict:
"""
Entry point. Pass in raw lead data, get back a qualified lead report.
lead_data should include: name, email, phone, property_address, budget
"""
print(f"\n🏠 Qualifying lead: {lead_data.get('name', 'Unknown')}")
return self._agent_loop(lead_data)The max_iterations guard is important. Without it, a misbehaving tool or an unexpected Claude response can spin the loop forever and rack up API costs. Ten iterations is more than enough for a three-tool agent.
Step 2: Create Lead Qualification Tool Definitions
Claude's tool use works by reading a list of tool definitions you provide and deciding which one to call based on the conversation. Each definition is a JSON schema that describes what the tool does and what arguments it needs.
We're defining three tools: a property lookup, a credit check, and a lead scorer. In a real system these would hit actual APIs — here I'm using realistic mock functions so you can run this locally without external dependencies.
lead_qualifier_agent.py (continued — add these methods to the class) def _get_tool_definitions(self) -> list:
"""Returns the tool schema Claude uses to decide what to call."""
return [
{
"name": "property_lookup",
"description": (
"Looks up property details for a given address including "
"estimated market value, square footage, year built, "
"and recent comparable sales in the area."
),
"input_schema": {
"type": "object",
"properties": {
"address": {
"type": "string",
"description": "Full street address of the property"
},
"zip_code": {
"type": "string",
"description": "ZIP code of the property"
}
},
"required": ["address"]
}
},
{
"name": "credit_check",
"description": (
"Runs a soft credit check on a lead using their name and email. "
"Returns estimated credit score range, debt-to-income ratio, "
"and pre-qualification status."
),
"input_schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Full name of the lead"
},
"email": {
"type": "string",
"description": "Email address of the lead"
},
"stated_income": {
"type": "number",
"description": "Annual income stated by the lead in USD"
}
},
"required": ["name", "email"]
}
},
{
"name": "calculate_lead_score",
"description": (
"Calculates a final lead score from 0-100 based on property data, "
"credit data, budget alignment, and motivation indicators. "
"Call this AFTER running property_lookup and credit_check."
),
"input_schema": {
"type": "object",
"properties": {
"credit_score_range": {
"type": "string",
"description": "Credit score range returned from credit_check"
},
"pre_qualified": {
"type": "boolean",
"description": "Whether the lead pre-qualifies for financing"
},
"property_value": {
"type": "number",
"description": "Estimated property value from property_lookup"
},
"stated_budget": {
"type": "number",
"description": "Budget the lead stated they have available"
},
"motivation_level": {
"type": "string",
"enum": ["low", "medium", "high"],
"description": "Lead motivation assessed from conversation context"
}
},
"required": [
"credit_score_range",
"pre_qualified",
"property_value",
"stated_budget",
"motivation_level"
]
}
}
]
# ── Mock tool implementations ──────────────────────────────────────────
def _property_lookup(self, address: str, zip_code: str = "34102") -> dict:
"""
Mock property lookup. Replace with Zillow, Attom, or MLS API calls
in production.
"""
return {
"address": address,
"zip_code": zip_code,
"estimated_value": 875000,
"square_footage": 2340,
"year_built": 2004,
"bedrooms": 4,
"bathrooms": 3,
"days_on_market": 22,
"price_per_sqft": 374,
"comparable_sales": [
{"address": "142 Pelican Bay Blvd", "sale_price": 860000, "days_ago": 18},
{"address": "390 Gulf Shore Dr", "sale_price": 910000, "days_ago": 31},
],
"market_trend": "appreciating",
"status": "success"
}
def _credit_check(
self,
name: str,
email: str,
stated_income: float = 0.0
) -> dict:
"""
Mock credit check. Replace with Experian, TransUnion, or lending
partner API in production.
"""
return {
"name": name,
"credit_score_range": "720-759",
"rating": "Good",
"debt_to_income_ratio": 0.31,
"pre_qualified": True,
"max_loan_estimate": 780000,
"stated_income": stated_income,
"flags": [],
"status": "success"
}
def _calculate_lead_score(
self,
credit_score_range: str,
pre_qualified: bool,
property_value: float,
stated_budget: float,
motivation_level: str
) -> dict:
"""
Scores a lead 0-100 based on four weighted factors.
Weights: credit (30), pre-qual (25), budget fit (25), motivation (20)
"""
score = 0
# Credit score component (30 pts max)
low_score = int(credit_score_range.split("-")[0])
if low_score >= 760:
score += 30
elif low_score >= 720:
score += 24
elif low_score >= 660:
score += 15
else:
score += 5
# Pre-qualification component (25 pts)
if pre_qualified:
score += 25
# Budget alignment component (25 pts)
if stated_budget > 0:
ratio = stated_budget / property_value
if ratio >= 0.95:
score += 25
elif ratio >= 0.80:
score += 18
elif ratio >= 0.60:
score += 10
else:
score += 3
# Motivation component (20 pts)
motivation_scores = {"high": 20, "medium": 12, "low": 4}
score += motivation_scores.get(motivation_level, 0)
# Determine tier label
if score >= 80:
tier = "HOT"
elif score >= 60:
tier = "WARM"
elif score >= 40:
tier = "COOL"
else:
tier = "COLD"
return {
"lead_score": score,
"tier": tier,
"pre_qualified": pre_qualified,
"recommended_action": (
"Schedule showing immediately" if tier == "HOT"
else "Add to nurture sequence" if tier == "WARM"
else "Low priority follow-up"
),
"status": "success"
}The three mock methods —
_property_lookup, _credit_check, and _calculate_lead_score — are the only things you need to replace for production. The tool definitions and agent loop don't change at all. That's the whole point of designing it this way.
Step 3: Build the Agent Loop with Tool Use Handling
This is where the agent actually runs. The loop sends a message to Claude, checks whether Claude wants to use a tool, executes that tool, and feeds the result back. It keeps going until Claude returns a final text response with no tool calls.
The pattern here is standard for any multi-tool Claude agent — once you understand it for this project, you can reuse it almost verbatim for any other agentic workflow.
lead_qualifier_agent.py (continued — add to the class) def _dispatch_tool(self, tool_name: str, tool_input: dict) -> str:
"""Routes a tool call from Claude to the correct Python method."""
print(f" 🔧 Calling tool: {tool_name}")
print(f" Input: {json.dumps(tool_input, indent=2)}")
if tool_name == "property_lookup":
result = self._property_lookup(**tool_input)
elif tool_name == "credit_check":
result = self._credit_check(**tool_input)
elif tool_name == "calculate_lead_score":
result = self._calculate_lead_score(**tool_input)
else:
result = {"error": f"Unknown tool: {tool_name}", "status": "error"}
print(f" Result: {json.dumps(result, indent=2)}\n")
return json.dumps(result)
def _agent_loop(self, lead_data: dict) -> dict:
"""
Core agent loop. Sends lead data to Claude, handles tool calls,
and returns the final qualified lead report.
"""
# Build the initial prompt with all lead context
system_prompt = """You are a real estate lead qualification specialist.
Your job is to evaluate incoming leads using the available tools and produce
a detailed qualification report.
Always follow this sequence:
1. Run property_lookup for the lead's property address
2. Run credit_check using the lead's name and email
3. Run calculate_lead_score with results from steps 1 and 2
4. Return a concise qualification summary with the lead score, tier,
recommended action, and key risk factors.
Be thorough but efficient. Don't call tools more than once unless you
get an error response."""
user_message = f"""Please qualify this real estate lead:
Name: {lead_data.get('name')}
Email: {lead_data.get('email')}
Phone: {lead_data.get('phone', 'Not provided')}
Property Address: {lead_data.get('property_address')}
Stated Budget: ${lead_data.get('budget', 0):,}
Annual Income: ${lead_data.get('annual_income', 0):,}
Motivation Notes: {lead_data.get('motivation_notes', 'None provided')}
Timeline: {lead_data.get('timeline', 'Not specified')}"""
messages = [{"role": "user", "content": user_message}]
iteration = 0
final_report = {}
while iteration < self.max_iterations:
iteration += 1
print(f"\n── Agent iteration {iteration} ──")
response = self.client.messages.create(
model=self.model,
max_tokens=4096,
system=system_prompt,
tools=self._get_tool_definitions(),
messages=messages
)
print(f" Stop reason: {response.stop_reason}")
# If Claude is done, extract the final text response
if response.stop_reason == "end_turn":
for block in response.content:
if hasattr(block, "text"):
final_report["summary"] = block.text
break
# If Claude wants to use tools, execute each one
if response.stop_reason == "tool_use":
# Add Claude's response (including tool calls) to message history
messages.append({
"role": "assistant",
"content": response.content
})
# Process every tool call in this response
tool_results = []
for block in response.content:
if block.type == "tool_use":
tool_result = self._dispatch_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": tool_result
})
# Feed all tool results back to Claude in one message
messages.append({
"role": "user",
"content": tool_results
})
else:
# Unexpected stop reason — break to avoid infinite loop
print(f" ⚠️ Unexpected stop reason: {response.stop_reason}")
break
final_report["lead_name"] = lead_data.get("name")
final_report["iterations"] = iteration
return final_reportNotice that tool results all go back to Claude in a single message as a list. That's the correct pattern — don't send one message per tool result, or you'll break the conversation structure and get confusing responses.
Step 4: Add the Property Valuation and Lead Score Logic — Then Run It
Now we wire everything together with a main block so you can run the file directly. This is also where you'd swap in real lead data from your CRM or webhook payload.
lead_qualifier_agent.py (continued — add at the bottom of the file, outside the class)def main():
agent = LeadQualifierAgent()
# Sample lead — in production this comes from your CRM or form webhook
sample_lead = {
"name": "Marco Delgado",
"email": "[email protected]",
"phone": "(239) 555-0182",
"property_address": "527 Tarpon Bay Road, Naples FL",
"budget": 850000,
"annual_income": 210000,
"motivation_notes": "Relocating from Chicago, closing on current home in 45 days, cash buyer",
"timeline": "60 days"
}
report = agent.run(sample_lead)
print("\n" + "="*60)
print("FINAL QUALIFICATION REPORT")
print("="*60)
print(f"Lead: {report.get('lead_name')}")
print(f"Agent iterations used: {report.get('iterations')}")
print("\n── Summary ──")
print(report.get("summary", "No summary generated"))
print("="*60)
if __name__ == "__main__":
main()Run it with python lead_qualifier_agent.py and you should see the agent working through the tools step by step. Here's what a real output looks like:
🏠 Qualifying lead: Marco Delgado
── Agent iteration 1 ──
Stop reason: tool_use
🔧 Calling tool: property_lookup
Input: {
"address": "527 Tarpon Bay Road, Naples FL",
"zip_code": "34102"
}
Result: {
"address": "527 Tarpon Bay Road, Naples FL",
"estimated_value": 875000,
"square_footage": 2340,
"year_built": 2004,
"days_on_market": 22,
"market_trend": "appreciating",
"status": "success"
}
── Agent iteration 2 ──
Stop reason: tool_use
🔧 Calling tool: credit_check
Input: {
"name": "Marco Delgado",
"email": "[email protected]",
"stated_income": 210000
}
Result: {
"credit_score_range": "720-759",
"pre_qualified": true,
"max_loan_estimate": 780000,
"debt_to_income_ratio": 0.31,
"status": "success"
}
── Agent iteration 3 ──
Stop reason: tool_use
🔧 Calling tool: calculate_lead_score
Input: {
"credit_score_range": "720-759",
"pre_qualified": true,
"property_value": 875000,
"stated_budget": 850000,
"motivation_level": "high"
}
Result: {
"lead_score": 87,
"tier": "HOT",
"recommended_action": "Schedule showing immediately",
"status": "success"
}
── Agent iteration 4 ──
Stop reason: end_turn
============================================================
FINAL QUALIFICATION REPORT
============================================================
Lead: Marco Delgado
Agent iterations used: 4
── Summary ──
**Lead Qualification Report — Marco Delgado**
**Lead Score: 87/100 — HOT 🔥**
**Property:** 527 Tarpon Bay Road, Naples FL
Estimated value $875,000 on an appreciating market. 22 days on market
with strong comparable sales. Budget of $850,000 is a 97% match to
current valuation — well within negotiating range.
**Credit Profile:** Score range 720-759 (Good). Pre-qualified up to
$780,000. DTI of 0.31 is healthy. No flags on file.
**Motivation:** HIGH — relocating from Chicago with a firm 60-day
timeline and existing home under contract. Cash buyer capability
significantly reduces financing risk.
**Recommended Action:** Schedule a showing immediately. Lead is
motivated, pre-qualified, and timeline-constrained. Priority contact
within 24 hours recommended.
**Risk Factors:** None significant. Budget slightly below list but
motivation and cash position give strong negotiating leverage.
============================================================How It Works
When you call agent.run(), it builds a prompt with all the lead data and sends it to Claude along with the three tool definitions. Claude reads the definitions and decides it needs to call property_lookup first — it doesn't know the property value yet and needs it for scoring.
After getting the property result, Claude calls credit_check, then uses both results to call calculate_lead_score. Each tool result goes back into the conversation so Claude has full context when it writes the final summary.
The loop only stops when Claude's stop_reason is end_turn, meaning it decided it had everything it needed and wrote a final response without requesting any more tools. The whole thing is stateless — every call to run() starts a fresh conversation.
Common Errors and Fixes
Error 1: AuthenticationError — Invalid API key
anthropic.AuthenticationError: 401 {"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}This almost always means your .env