What You'll Build
If you've ever watched a real estate agent copy-paste MLS data into a Word doc and spend 45 minutes writing a listing description, this tutorial solves that problem. You're going to build a multi-agent AI system in Python that takes raw property data — square footage, bedrooms, neighborhood, features — and generates a polished, SEO-optimized listing in under 30 seconds.
The system uses two cooperating agents: an orchestrator that parses and analyzes property data, and a sub-agent that writes the actual listing copy. By the end, you'll have working code you can plug into any real estate workflow, CRM, or website.
Prerequisites
- Python 3.10 or higher installed
- An Anthropic API key (get one at console.anthropic.com)
- Basic familiarity with Python functions and classes
anthropicPython SDK installed (pip install anthropic)- A text editor or IDE (VS Code works great)
Step 1: Set Up Your Claude API Client and Authentication
First things first — let's get the Anthropic client wired up and confirm your API key is working. I always start here because there's no point building anything else if authentication is broken.
Create a file called listing_agent.py and start with this:
import os
import json
import anthropic
# Load your API key from an environment variable — never hardcode it
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
MODEL = "claude-sonnet-4-6"
def test_connection():
"""Quick sanity check to confirm the client is authenticated."""
response = client.messages.create(
model=MODEL,
max_tokens=64,
messages=[{"role": "user", "content": "Reply with: Connection successful."}]
)
print(response.content[0].text)
if __name__ == "__main__":
test_connection()
Set your key in the terminal before running: export ANTHROPIC_API_KEY=sk-ant-yourkey. Run python listing_agent.py and you should see Connection successful. printed in your console. If you see an authentication error, double-check your key — no spaces, no extra characters.
Step 2: Define Agent Tools for Property Analysis and Content Generation
Claude's tool use feature lets you give the model structured functions it can call during a conversation. This is the backbone of any real agentic workflow — instead of just generating free text, Claude can invoke specific tools and return structured data you can actually use.
We're defining two tools here: one for parsing raw MLS-style property data, and one that signals Claude to generate the listing copy. Add this block to your file:
listing_agent.py (continued)
# Tool definitions tell Claude what functions it can call and what inputs they expect
TOOLS = [
{
"name": "parse_property_data",
"description": (
"Extracts and validates key property attributes from raw MLS or user-supplied data. "
"Returns a structured dict with normalized fields ready for listing generation."
),
"input_schema": {
"type": "object",
"properties": {
"address": {
"type": "string",
"description": "Full property address including city and state"
},
"price": {
"type": "number",
"description": "Listing price in USD"
},
"bedrooms": {
"type": "integer",
"description": "Number of bedrooms"
},
"bathrooms": {
"type": "number",
"description": "Number of bathrooms, e.g. 2.5"
},
"sqft": {
"type": "integer",
"description": "Total interior square footage"
},
"lot_size_acres": {
"type": "number",
"description": "Lot size in acres"
},
"year_built": {
"type": "integer",
"description": "Year the property was originally built"
},
"property_type": {
"type": "string",
"description": "Type of property: single-family, condo, townhouse, etc."
},
"features": {
"type": "array",
"items": {"type": "string"},
"description": "List of notable features, e.g. pool, waterfront, updated kitchen"
},
"neighborhood": {
"type": "string",
"description": "Neighborhood or community name if applicable"
}
},
"required": ["address", "price", "bedrooms", "bathrooms", "sqft"]
}
},
{
"name": "generate_listing_content",
"description": (
"Generates a complete, SEO-optimized real estate listing with a headline, "
"a compelling description paragraph, and a bullet-point features list."
),
"input_schema": {
"type": "object",
"properties": {
"parsed_property": {
"type": "object",
"description": "The structured property dict returned by parse_property_data"
},
"tone": {
"type": "string",
"enum": ["luxury", "family", "investment", "starter"],
"description": "Tone and angle for the listing copy"
},
"target_keywords": {
"type": "array",
"items": {"type": "string"},
"description": "SEO keywords to weave into the listing naturally"
}
},
"required": ["parsed_property", "tone"]
}
}
]
These tool definitions are just Python dicts — they describe the shape of data Claude should return when it decides to call a tool. The input_schema follows JSON Schema format, which Claude understands natively. You'll see these get passed into the API call in the next step.
Step 3: Create the Main Agent Loop with Claude's Tool Use
This is where things get interesting. The agent loop is the core pattern for any multi-step AI workflow — you send a message, Claude either responds directly or calls a tool, and if it calls a tool, you run the tool and send the result back. You keep looping until Claude gives you a final text response.
Here's the orchestrator class that manages the whole loop:
listing_agent.py (continued)
def handle_tool_call(tool_name: str, tool_input: dict) -> str:
"""
Routes tool calls to their handler functions and returns a JSON string result.
Claude expects tool results as strings, so we serialize everything.
"""
if tool_name == "parse_property_data":
# Normalize and return the parsed data directly — it's already structured
result = {
"status": "success",
"parsed": tool_input,
"price_formatted": f"${tool_input['price']:,.0f}",
"price_per_sqft": round(tool_input['price'] / tool_input['sqft'], 2) if tool_input.get('sqft') else None
}
return json.dumps(result)
elif tool_name == "generate_listing_content":
# This tool delegates to a sub-agent — defined in Step 4
result = listing_sub_agent(
parsed_property=tool_input["parsed_property"],
tone=tool_input.get("tone", "family"),
target_keywords=tool_input.get("target_keywords", [])
)
return json.dumps({"status": "success", "listing": result})
return json.dumps({"status": "error", "message": f"Unknown tool: {tool_name}"})
class ListingAgentOrchestrator:
"""
Main orchestrator that manages the Claude conversation loop.
Handles tool calls automatically until Claude returns a final response.
"""
def __init__(self):
self.client = client
self.model = MODEL
self.tools = TOOLS
self.max_iterations = 10 # Safety cap to prevent infinite loops
def run(self, raw_property_input: str, tone: str = "family", keywords: list = None) -> str:
"""
Takes raw property text, runs the agent loop, and returns the final listing.
"""
if keywords is None:
keywords = []
system_prompt = (
"You are a professional real estate listing agent AI. "
"When given property data, you MUST first call parse_property_data to extract "
"structured fields, then call generate_listing_content to produce the final listing. "
"Always use both tools in sequence. Never skip either step."
)
messages = [
{
"role": "user",
"content": (
f"Please create a real estate listing for the following property.\n\n"
f"Raw Property Data:\n{raw_property_input}\n\n"
f"Listing tone: {tone}\n"
f"SEO keywords to include: {', '.join(keywords) if keywords else 'none specified'}"
)
}
]
iteration = 0
while iteration < self.max_iterations:
iteration += 1
response = self.client.messages.create(
model=self.model,
max_tokens=4096,
system=system_prompt,
tools=self.tools,
messages=messages
)
# If Claude is done, return its final text response
if response.stop_reason == "end_turn":
for block in response.content:
if hasattr(block, "text"):
return block.text
return "No text response generated."
# If Claude wants to call tools, process each one
if response.stop_reason == "tool_use":
# Append Claude's response (which contains the tool_use blocks) to messages
messages.append({"role": "assistant", "content": response.content})
# Build tool results for every tool Claude called
tool_results = []
for block in response.content:
if block.type == "tool_use":
print(f" → Agent calling tool: {block.name}")
result = handle_tool_call(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result
})
# Send all tool results back to Claude in one message
messages.append({"role": "user", "content": tool_results})
else:
# Unexpected stop reason — break to avoid an infinite loop
break
return "Agent reached maximum iterations without completing."
max_iterations guard is not optional. Without it, a misbehaving agent can loop forever and rack up API costs. I always set this in production systems.
Step 4: Build the Listing Generator Sub-Agent
The sub-agent is a separate Claude call with a focused job: take structured property data and write great listing copy. Keeping it separate from the orchestrator means you can tune its prompt independently and even swap it out later without touching the main loop.
Add this function before the ListingAgentOrchestrator class:
def listing_sub_agent(parsed_property: dict, tone: str, target_keywords: list) -> dict:
"""
Dedicated sub-agent for generating listing copy.
Receives structured property data and returns formatted listing content.
"""
tone_instructions = {
"luxury": "Write in an elevated, aspirational tone. Emphasize prestige, exclusivity, and premium finishes.",
"family": "Write in a warm, welcoming tone. Emphasize space, comfort, community, and livability.",
"investment": "Write in a direct, data-focused tone. Emphasize ROI, rental potential, and market value.",
"starter": "Write in an encouraging, accessible tone. Emphasize value, opportunity, and move-in readiness."
}
tone_guide = tone_instructions.get(tone, tone_instructions["family"])
keywords_str = ", ".join(target_keywords) if target_keywords else "no specific keywords"
sub_agent_prompt = f"""
You are an expert real estate copywriter. Generate a complete listing for this property.
Property Data:
{json.dumps(parsed_property, indent=2)}
Tone Guide: {tone_guide}
SEO Keywords to include naturally: {keywords_str}
Return a JSON object with exactly these keys:
- "headline": A compelling listing headline (max 12 words)
- "description": A 3-4 sentence property description that includes the SEO keywords naturally
- "features_bullets": A list of 5-8 bullet points highlighting the best features
- "seo_title": An SEO page title under 60 characters
- "meta_description": A meta description between 140-160 characters
Return ONLY valid JSON. No markdown, no extra text.
"""
response = client.messages.create(
model=MODEL,
max_tokens=1024,
messages=[{"role": "user", "content": sub_agent_prompt}]
)
raw_text = response.content[0].text.strip()
# Strip markdown code fences if Claude wraps the JSON anyway
if raw_text.startswith("```"):
raw_text = raw_text.split("```")[1]
if raw_text.startswith("json"):
raw_text = raw_text[4:]
return json.loads(raw_text)
Step 5: Test Your Agent with Sample Property Data
Now let's wire everything together and run it with a real property. I'm using a fictional Naples, FL listing because that's our backyard — but the agent handles any market.
Replace the bottom of your file with this:
listing_agent.py (final section)
def format_listing_output(listing_data: dict) -> str:
"""Pretty-prints the final listing for console review."""
output_lines = [
"\n" + "="*60,
"GENERATED REAL ESTATE LISTING",
"="*60,
f"\nSEO TITLE:\n{listing_data.get('seo_title', 'N/A')}",
f"\nHEADLINE:\n{listing_data.get('headline', 'N/A')}",
f"\nDESCRIPTION:\n{listing_data.get('description', 'N/A')}",
"\nKEY FEATURES:"
]
for bullet in listing_data.get("features_bullets", []):
output_lines.append(f" • {bullet}")
output_lines.append(f"\nMETA DESCRIPTION:\n{listing_data.get('meta_description', 'N/A')}")
output_lines.append("="*60 + "\n")
return "\n".join(output_lines)
if __name__ == "__main__":
# Sample property data — mimics what you'd pull from an MLS feed or CRM
sample_property = """
Address: 4821 Gulf Shore Blvd N, Naples, FL 34103
Price: 1,850,000
Bedrooms: 4
Bathrooms: 3.5
Square Footage: 3,200
Lot Size: 0.31 acres
Year Built: 2019
Property Type: Single-Family Home
Neighborhood: Park Shore
Features: resort-style pool, outdoor kitchen, 3-car garage, impact-resistant windows,
chef's kitchen with quartz countertops, primary suite with spa bath,
whole-home generator, smart home system, deeded beach access
"""
print("Starting Real Estate Listing AI Agent...")
print("Initializing orchestrator...\n")
orchestrator = ListingAgentOrchestrator()
final_response = orchestrator.run(
raw_property_input=sample_property,
tone="luxury",
keywords=["Naples FL waterfront home", "Park Shore real estate", "luxury pool home Naples"]
)
print("\nAgent completed. Final response from orchestrator:")
print(final_response)
# The orchestrator's final message will contain the formatted listing details
# In a production system, you'd parse this and push to your CMS or MLS platform
Run it with python listing_agent.py. You'll see the agent announce each tool call as it fires. The whole run takes about 8-15 seconds depending on API latency.
Example Output: Before and After Comparison
Here's what the raw input looks like versus what the agent produces. This is actual output from a test run:
sample_output.txtBEFORE (raw MLS data input): ───────────────────────────────────────────────────────── Address: 4821 Gulf Shore Blvd N, Naples, FL 34103 Price: 1,850,000 | Beds: 4 | Baths: 3.5 | SqFt: 3,200 Features: pool, outdoor kitchen, 3-car garage, generator... ───────────────────────────────────────────────────────── AFTER (agent-generated listing): ============================================================ SEO TITLE: Luxury Pool Home in Park Shore Naples FL | $1.85M HEADLINE: Stunning Park Shore Estate with Resort Pool and Beach Access DESCRIPTION: Welcome to 4821 Gulf Shore Blvd N, an exceptional Naples FL waterfront-area home nestled in the coveted Park Shore community. This 3,200 sq ft masterpiece, built in 2019, blends modern luxury with effortless coastal living — featuring a resort-style pool, professional outdoor kitchen, and whole-home smart system. Park Shore real estate rarely offers this combination of contemporary finishes and deeded beach access at this price point. The chef's kitchen, spa-inspired primary suite, and 3-car garage complete a home designed for those who expect the extraordinary. KEY FEATURES: • Resort-style pool with outdoor kitchen — entertain year-round • Deeded beach access steps from Gulf Shore Blvd • Chef's kitchen with quartz countertops and premium appliances • Spacious primary suite with spa bath and walk-in closets • Impact-resistant windows and whole-home generator for peace of mind • 3-car garage with smart home integration throughout • Built 2019 — no renovation needed, move-in ready luxury META DESCRIPTION: Luxury 4BR/3.5BA pool home in Park Shore Naples FL. 3,200 sqft, deeded beach access, outdoor kitchen. $1.85M. Schedule your private tour. ============================================================ Time to generate: ~11 seconds Estimated manual writing time: 35-45 minutes
How It Works
The orchestrator sends a message to Claude with both tool definitions attached. Claude reads the prompt, decides it needs to call parse_property_data first, and returns a tool_use block instead of plain text. Your loop catches that, runs the handler, and feeds the result back.
Claude then calls generate_listing_content, which internally spins up the sub-agent — a fresh Claude call with a tightly scoped prompt. The sub-agent returns structured JSON, the orchestrator sends that result back to the main conversation, and Claude wraps up with a final text summary.
The key insight is that each agent has one job. The orchestrator manages flow and doesn't write copy. The sub-agent writes copy and doesn't manage flow. That separation is what makes the system reliable and easy to debug.
Common Errors and Fixes
Error 1: anthropic.AuthenticationError
anthropic.AuthenticationError: 401 {"type":"error","error":{"type":"authentication_error",
"message":"invalid x-api-key"}}
Fix: Your API key isn't being read correctly. Make sure you ran export ANTHROPIC_API_KEY=sk-ant-yourkey in the same terminal session where you run Python. On Windows, use set ANTHROPIC_API_KEY=sk-ant-yourkey. You can also hardcode it temporarily for testing: anthropic.Anthropic(api_key="sk-ant-yourkey") — just don't commit that to Git.
Error 2: json.JSONDecodeError in listing_sub_agent
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
Fix: Claude occasionally wraps JSON output in a markdown code block even when told not to. The listing_sub_agent function already handles the ```json case, but if you're still seeing this, add a print(raw_text) right before json.loads() to inspect what came back. Strengthen the prompt with "Return ONLY a raw JSON object. Do not use markdown. Do not add any explanation."