← Back to Blog

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
  • anthropic Python SDK installed (pip install anthropic)
  • A text editor or IDE (VS Code works great)
📦 Full Source Code Note: The complete, working code for this project is broken into steps below. Each step builds on the last, so by Step 5 you'll have the entire system running. If you want to skip ahead, all the snippets fit together into one file — I'll show you exactly how at the end.

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:

listing_agent.py
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."
⚠️ Important: The 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:

listing_agent.py (continued)
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.txt
BEFORE (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."