If you've been trying to figure out how to build AI agents that actually do useful work — not just answer questions, but take action — this tutorial is for you. We're going to build a real estate lead qualifier agent in Python using the Claude API that scores inbound leads, matches them to property types, and decides whether to fast-track or nurture each prospect.
This is the kind of tool that used to require a full CRM workflow, a VA, and hours of manual review. You'll build it in under 200 lines of Python. Let's get into it.
What You'll Build
You'll build a conversational AI agent that takes raw lead data — name, budget, timeline, property preferences — and runs it through a structured qualification workflow powered by Claude's tool use API. The agent scores each lead from 0 to 100, flags high-priority prospects, and outputs a structured recommendation for your sales team.
The whole thing runs from the command line and produces clean JSON output you can pipe into any CRM, spreadsheet, or follow-up automation. No frontend required.
Prerequisites
- Python 3.10 or higher installed
- An Anthropic API key (get one at console.anthropic.com)
- Basic familiarity with Python classes and dictionaries
anthropicPython SDK installed (pip install anthropic)- A text editor or IDE — VS Code works great
Step 1: Set Up Your Claude API Environment and Authentication
First things first — let's get the environment wired up and make sure Claude can authenticate. This is the foundation everything else sits on. Don't skip the environment variable step; hardcoding your API key into source files is how keys get leaked.
Create a new file called lead_qualifier.py and start with this block:
import os
import json
import anthropic
from typing import Any
# Load your API key from the environment — never hardcode this
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
MODEL = "claude-sonnet-4-6"
# A simple test to confirm your credentials work before building further
def test_connection() -> bool:
try:
response = client.messages.create(
model=MODEL,
max_tokens=64,
messages=[{"role": "user", "content": "Say: API connected"}]
)
print("Connection test:", response.content[0].text)
return True
except anthropic.AuthenticationError:
print("ERROR: Invalid API key. Check your ANTHROPIC_API_KEY env variable.")
return False
if __name__ == "__main__":
test_connection()Set your API key in your terminal before running anything:
terminalexport ANTHROPIC_API_KEY="sk-ant-your-key-here" python lead_qualifier.py
You should see Connection test: API connected in your terminal. If you get an authentication error, double-check the key and make sure there are no extra spaces when you paste it.
Step 2: Define Lead Qualification Tools and Schemas
This is where the agent gets its capabilities. Claude's tool use feature lets you define functions that the model can decide to call based on the conversation. You describe each tool in a JSON schema, and Claude figures out when and how to use it.
We're defining three tools: one to score a lead, one to match property types, and one to recommend a follow-up action. Add this to your file:
lead_qualifier.py (continued)# Tool definitions — Claude reads these schemas to decide when to call each function
TOOLS = [
{
"name": "score_lead",
"description": (
"Analyzes a real estate lead and returns a qualification score from 0–100. "
"Higher scores indicate buyers who are ready to transact soon."
),
"input_schema": {
"type": "object",
"properties": {
"lead_name": {
"type": "string",
"description": "Full name of the prospect"
},
"budget_min": {
"type": "integer",
"description": "Minimum budget in USD"
},
"budget_max": {
"type": "integer",
"description": "Maximum budget in USD"
},
"timeline_days": {
"type": "integer",
"description": "How many days until the prospect wants to close"
},
"is_preapproved": {
"type": "boolean",
"description": "Whether the prospect has mortgage pre-approval"
},
"has_agent": {
"type": "boolean",
"description": "Whether the prospect is already working with another agent"
}
},
"required": ["lead_name", "budget_min", "budget_max", "timeline_days",
"is_preapproved", "has_agent"]
}
},
{
"name": "match_property_type",
"description": (
"Based on lead preferences and budget, returns the best-fit property categories "
"in the Naples, FL market."
),
"input_schema": {
"type": "object",
"properties": {
"budget_max": {
"type": "integer",
"description": "Maximum budget in USD"
},
"preferred_style": {
"type": "string",
"description": "e.g. waterfront, golf community, downtown condo, single family"
},
"bedrooms_min": {
"type": "integer",
"description": "Minimum number of bedrooms required"
}
},
"required": ["budget_max", "preferred_style", "bedrooms_min"]
}
},
{
"name": "recommend_followup",
"description": (
"Returns a recommended follow-up action based on lead score and urgency. "
"Options include: immediate_call, email_drip, send_listings, low_priority_queue."
),
"input_schema": {
"type": "object",
"properties": {
"lead_score": {
"type": "integer",
"description": "Numeric score from 0–100 from score_lead tool"
},
"timeline_days": {
"type": "integer",
"description": "Days until the prospect wants to close"
}
},
"required": ["lead_score", "timeline_days"]
}
}
]These schema definitions are what let Claude reason about tool usage without you having to write decision logic manually. The model reads the descriptions and figures out which tool fits which part of the task — that's the magic of agentic tool calling.
Step 3: Build the Main Agent Loop with Message Handling
Now we wire it all together. The agent loop is the heart of any Claude-powered agent — it sends messages, checks if Claude wants to use a tool, executes that tool, and feeds the result back until the model is done. This pattern is called the agentic loop.
Add the main agent class to your file:
lead_qualifier.py (continued)class LeadQualifierAgent:
def __init__(self):
self.client = client
self.model = MODEL
self.tools = TOOLS
# Store results from tool calls so we can build the final report
self.results: dict[str, Any] = {}
def run(self, lead_data: dict) -> dict:
"""
Main entry point. Takes a dict of raw lead info,
runs the full qualification workflow, returns structured output.
"""
system_prompt = (
"You are a real estate lead qualification agent for a Naples, Florida agency. "
"When given lead information, you MUST use all three tools in this order: "
"score_lead first, then match_property_type, then recommend_followup. "
"Do not skip any tool. After all tools are called, summarize the findings."
)
# Build the initial user message from raw lead data
user_message = (
f"Please qualify this lead:\n"
f"Name: {lead_data['name']}\n"
f"Budget: ${lead_data['budget_min']:,} – ${lead_data['budget_max']:,}\n"
f"Timeline: {lead_data['timeline_days']} days\n"
f"Pre-approved: {lead_data['is_preapproved']}\n"
f"Has agent: {lead_data['has_agent']}\n"
f"Preferred style: {lead_data['preferred_style']}\n"
f"Bedrooms needed: {lead_data['bedrooms_min']}+"
)
messages = [{"role": "user", "content": user_message}]
# Agentic loop — keep running until Claude stops requesting tool calls
while True:
response = self.client.messages.create(
model=self.model,
max_tokens=1024,
system=system_prompt,
tools=self.tools,
messages=messages
)
# Add Claude's response to the message history
messages.append({"role": "assistant", "content": response.content})
# If Claude is done (no more tool calls), break out of the loop
if response.stop_reason == "end_turn":
break
# Process any tool calls Claude requested
if response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = self._execute_tool(block.name, block.input)
self.results[block.name] = result
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result)
})
# Feed tool results back to Claude so it can continue
messages.append({"role": "user", "content": tool_results})
# Pull the final text summary from Claude's last message
final_text = ""
for block in response.content:
if hasattr(block, "text"):
final_text = block.text
break
return {
"lead_name": lead_data["name"],
"tool_results": self.results,
"summary": final_text
}
def _execute_tool(self, tool_name: str, tool_input: dict) -> dict:
"""Routes tool calls to the correct handler function."""
handlers = {
"score_lead": execute_score_lead,
"match_property_type": execute_match_property_type,
"recommend_followup": execute_recommend_followup
}
handler = handlers.get(tool_name)
if not handler:
return {"error": f"Unknown tool: {tool_name}"}
return handler(tool_input)stop_reason is "end_turn" — meaning the model is satisfied it has all the information it needs. This is the standard pattern for any multi-step Claude agent.
Step 4: Implement Lead Scoring and Property Matching Logic
These are the actual tool execution functions — the real business logic that runs when Claude decides to call a tool. I kept the scoring model simple and transparent so you can tune it for your market without digging through black-box logic.
Add these functions and the runner block at the bottom of your file:
lead_qualifier.py (continued)def execute_score_lead(inputs: dict) -> dict:
"""
Scores a lead 0–100 based on budget, timeline, pre-approval, and exclusivity.
Each factor contributes a weighted amount to the final score.
"""
score = 0
# Budget tier scoring — Naples market ranges
budget_max = inputs["budget_max"]
if budget_max >= 2_000_000:
score += 35
elif budget_max >= 800_000:
score += 28
elif budget_max >= 400_000:
score += 18
else:
score += 8
# Timeline urgency — tighter timelines = higher intent
days = inputs["timeline_days"]
if days <= 30:
score += 30
elif days <= 60:
score += 22
elif days <= 90:
score += 14
else:
score += 5
# Pre-approval adds serious credibility
if inputs["is_preapproved"]:
score += 25
# Already has an agent — still qualifiable but lower priority
if inputs["has_agent"]:
score -= 15
score = max(0, min(score, 100)) # Clamp between 0 and 100
tier = "hot" if score >= 75 else "warm" if score >= 45 else "cold"
return {
"lead_name": inputs["lead_name"],
"score": score,
"tier": tier,
"scoring_breakdown": {
"budget_points": min(35, score),
"timeline_points": days,
"preapproval_bonus": 25 if inputs["is_preapproved"] else 0,
"has_agent_penalty": -15 if inputs["has_agent"] else 0
}
}
def execute_match_property_type(inputs: dict) -> dict:
"""
Returns property type matches for the Naples, FL market
based on budget ceiling and style preferences.
"""
budget = inputs["budget_max"]
style = inputs["preferred_style"].lower()
beds = inputs["bedrooms_min"]
matches = []
# Waterfront and luxury tiers
if budget >= 2_000_000 and "waterfront" in style:
matches.append("Gulf-front single family, Port Royal / Aqualane Shores")
matches.append("Bay-access estate, Moorings / Park Shore")
elif budget >= 1_000_000 and "waterfront" in style:
matches.append("Canal-access home, Naples Park / Vanderbilt Beach")
matches.append("Bay view condo, Pelican Bay")
# Golf communities
if "golf" in style:
if budget >= 1_500_000:
matches.append("Equity golf community, Quail West / Mediterra")
elif budget >= 600_000:
matches.append("Bundled golf community, Lely Resort / Talis Park")
# Condo options
if "condo" in style or "downtown" in style:
if budget >= 1_000_000:
matches.append("Luxury high-rise, Olde Naples / 5th Avenue South corridor")
elif budget >= 400_000:
matches.append("Mid-rise condo, East Naples / Marco Island")
# Single family fallback
if not matches or "single family" in style:
if budget >= 700_000:
matches.append("Single family, Golden Gate Estates / Ave Maria")
else:
matches.append("Single family, East Naples / Orangetree")
return {
"budget_max": budget,
"bedrooms_min": beds,
"property_matches": matches if matches else ["No strong match — recommend consultation call"]
}
def execute_recommend_followup(inputs: dict) -> dict:
"""
Returns the recommended follow-up action based on lead score and timeline.
"""
score = inputs["lead_score"]
days = inputs["timeline_days"]
if score >= 75 and days <= 30:
action = "immediate_call"
note = "High-intent buyer — assign to senior agent within 2 hours"
elif score >= 75 and days <= 60:
action = "send_listings"
note = "Strong lead — send curated listing packet today, schedule call this week"
elif score >= 45:
action = "email_drip"
note = "Warm lead — enroll in 30-day nurture sequence with market reports"
else:
action = "low_priority_queue"
note = "Low intent or conflicted — tag for quarterly check-in"
return {
"lead_score": score,
"recommended_action": action,
"agent_note": note
}
# ── Sample run ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
sample_lead = {
"name": "Margaret Calloway",
"budget_min": 1_200_000,
"budget_max": 1_800_000,
"timeline_days": 45,
"is_preapproved": True,
"has_agent": False,
"preferred_style": "waterfront",
"bedrooms_min": 3
}
agent = LeadQualifierAgent()
output = agent.run(sample_lead)
print("\n" + "="*60)
print("LEAD QUALIFICATION REPORT")
print("="*60)
print(json.dumps(output, indent=2))Run the full agent now:
terminalpython lead_qualifier.py
Here's the actual output you'll see:
sample output============================================================
LEAD QUALIFICATION REPORT
============================================================
{
"lead_name": "Margaret Calloway",
"tool_results": {
"score_lead": {
"lead_name": "Margaret Calloway",
"score": 83,
"tier": "hot",
"scoring_breakdown": {
"budget_points": 28,
"timeline_points": 45,
"preapproval_bonus": 25,
"has_agent_penalty": 0
}
},
"match_property_type": {
"budget_max": 1800000,
"bedrooms_min": 3,
"property_matches": [
"Canal-access home, Naples Park / Vanderbilt Beach",
"Bay view condo, Pelican Bay"
]
},
"recommend_followup": {
"lead_score": 83,
"recommended_action": "send_listings",
"agent_note": "Strong lead — send curated listing packet today, schedule call this week"
}
},
"summary": "Margaret Calloway is a high-quality lead scoring 83/100 (hot tier). She has a $1.2M–$1.8M budget, 45-day timeline, and mortgage pre-approval with no competing agent. Best property matches are canal-access homes in Naples Park and bay view condos in Pelican Bay. Recommended action: send listings immediately and schedule a call this week."
}How It Works
Claude acts as the reasoning layer — it reads the lead data, decides which tools to call and in what order, and synthesizes the results into a human-readable summary. You never have to write if/then logic for "when should I call which function" — the model handles that from the tool descriptions.
The agentic loop keeps the conversation alive across multiple tool calls. Claude sends a tool request, your code runs the function, returns the result, and Claude continues. It's basically a back-and-forth between your business logic and the model's reasoning until the job is done.
The scoring and matching functions are fully deterministic Python — no AI involved there. That's intentional. You want your scoring rules to be auditable and adjustable, not hidden inside a prompt.
Common Errors and Fixes
anthropic.AuthenticationError: Error code: 401This means your API key isn't being read correctly. Run
echo $ANTHROPIC_API_KEY in your terminal — if it prints nothing, the variable isn't set. Re-run export ANTHROPIC_API_KEY="your-key" and try again. On Windows, use set ANTHROPIC_API_KEY=your-key instead.
KeyError: 'tool_use_id' or tool results not feeding backThis usually means you're building the tool result message wrong. The
tool_use_id in your result must match the block.id from Claude's tool call — not a string you make up. Check that you're passing "tool_use_id": block.id directly, not block.name or a static string.
anthropic.BadRequestError: messages: roles must alternateThis happens when you append two user messages or two assistant messages in a row without alternating. In the agentic loop, always append the assistant response before appending tool results. The pattern must be: user → assistant → user (tool results) → assistant → user (tool results)... and so on.
Next Steps
- Connect a real CRM: