Real estate agents write. A lot. Every listing needs a description — portal rules, buyer expectations, and the agent’s own marketing all demand it — and every photo set needs work: straightening, brightening, background cleanup, watermarks. For an agent closing a few properties a month across rent and sale listings, this is 10–20 hours of work nobody entered the business to do.

Both halves are now nearly free to produce. That combination — recurring need, real hours, cheap automation — is exactly what a monthly service is made of. This guide builds the pipeline: a listing’s facts and photos go in, a checked description and cleaned photos come out, for about $0.02 per listing in AI costs. Then, how to sell it as a retainer that survives month after month.

What the pipeline does

For each listing:

  1. Description. The agent’s fact sheet (or their existing half-written draft) goes to Gemini, which writes a portal-ready description in the agent’s voice — and a Python checker verifies every claim traces to the facts, because a made-up “24-hour security” line in a property ad is a lawsuit-shaped problem.
  2. Photos. Open-source rembg removes cluttered backgrounds for feature shots; Pillow handles the boring fixes portals care about: brightness, straightening, sizing to spec, watermark placement.
  3. Everything lands in one folder per listing, ready to upload.

The agent’s work drops to: fill in the fact sheet, glance at the output, upload.

What you need

ItemCostNotes
Python 3.10+$0Any computer; 8 GB RAM is enough for rembg
Gemini API key$0 to startaistudio.google.com → “Get API key”
google-genai, pydantic, rembg, pillow$0pip install (first rembg run downloads a ~170 MB model)
The agent’s fact-sheet habit$0A one-page template per listing — see below

Pricing as of October 2026 — verify at ai.google.dev/pricing:

  • Gemini 3.5 Flash-Lite (paid tier): $0.30 per 1M input tokens, $2.50 per 1M output tokens
  • Free tier: testing only, with invented listings. Real property details are client business data; paid tier only.

Cost per listing

  • Description: ~900 input tokens (facts + style + instructions), ~450 output → about $0.0015
  • Ten photos through rembg: $0 (runs locally on your CPU)
  • Total: roughly $0.002 per listing, call it $0.02 with re-runs and a generous photo set. Fifty listings a month is under $1.

Step 1: The fact sheet

Every claim in the description must come from this. Template the agent fills in (5 minutes per listing):

listing_id,type,facts,highlights,language
R-2041,rent_house,"2 bedrooms; 1 bathroom; carport 1 car; semi-furnished; 120m2 land 90m2 building; Jl. Kenanga, Bandung; 15 min to Pasteur toll; AC 2 units",near Cihampelas Walk; quiet gang,id
S-1187,shopfront,"5m frontage; 60m2; mezzanine; 3-phase electricity; busy one-way street; parking 2 cars",high visibility; food business ready,id

highlights is the agent’s own selling angle — the AI uses it, but never invents beyond it.

Step 2: The script

Save as listing.py. It writes the description with claim-checking, then processes every photo in the listing’s folder.

import argparse, csv, os, sys, time
from pathlib import Path

from google import genai
from google.genai import errors, types
from PIL import Image, ImageEnhance, ImageOps
from pydantic import BaseModel

MODEL = os.environ.get("LISTING_MODEL", "gemini-3.5-flash-lite")

class Description(BaseModel):
    title: str            # portal title <= 70 chars
    body: str             # 3-4 short paragraphs
    bullet_points: list[str]
    missing_info: list[str]  # what buyers will ask that the facts don't answer

DESC_INSTRUCTIONS = """You write property listings for portals ( Rumah123 / Lamudi style ).
Rules:
- Use ONLY facts and highlights provided. Never invent amenities, security, views,
  distances, or legal status. If a buyer would want a fact that's missing, list it
  in "missing_info" instead of guessing.
- Warm, concrete, no hype ("mewah megah eksklusif" spam is out). Match the agent's language.
- body: 3-4 short paragraphs. First paragraph = what and where. Last = practical next step.
- bullet_points: 4-6 factual bullets.
- title: property type + area + key spec, <= 70 characters.
"""

def write_description(client, row):
    prompt = f"""{DESC_INSTRUCTIONS}

FACTS: {row['facts']}
AGENT HIGHLIGHTS: {row['highlights']}
LANGUAGE: {row['language']}
TYPE: {row['type']}"""
    for attempt in range(3):
        try:
            resp = client.models.generate_content(
                model=MODEL, contents=prompt,
                config=types.GenerateContentConfig(
                    response_mime_type="application/json",
                    response_schema=Description,
                    thinking_config=types.ThinkingConfig(thinking_level="minimal"),
                    temperature=0.4,
                ),
            )
        except errors.APIError as e:
            print(f"  API error {e.code} (attempt {attempt+1})", file=sys.stderr)
            time.sleep(5 * (attempt + 1))
            continue
        if resp.parsed:
            return resp.parsed
    return None

def check(desc: Description, row) -> list[str]:
    """Every bullet must trace to the facts/highlights text."""
    source_words = {w.lower().strip(";,.") for src in (row["facts"], row["highlights"])
                    for w in src.replace(";", " ").split() if len(w) > 3}
    problems = []
    for b in desc.bullet_points:
        tokens = [t.lower().strip(";,.") for t in b.split() if len(t) > 3 and not t.isdigit()]
        if tokens and not any(t in source_words for t in tokens):
            problems.append(f"bullet not traceable: '{b[:40]}...'")
    if len(desc.title) > 70:
        problems.append("title too long")
    return problems

# ---- photos ---------------------------------------------------------------
def fix_photo(img: Image.Image) -> Image.Image:
    """The fixes portals and buyers actually notice."""
    img = ImageOps.exif_transpose(img)          # correct phone rotation
    img = ImageEnhance.Brightness(img).enhance(1.12)   # interiors are always dark
    img = ImageEnhance.Contrast(img).enhance(1.06)
    return img

def process_photos(client, listing_dir: Path):
    out = listing_dir / "processed"
    out.mkdir(exist_ok=True)
    for p in sorted(listing_dir.glob("*.jpg")) + sorted(listing_dir.glob("*.jpeg")) + \
             sorted(listing_dir.glob("*.png")):
        img = fix_photo(Image.open(p).convert("RGB"))
        name = p.stem.lower()
        if "front" in name or "facade" in name or "eksterior" in name:
            img.thumbnail((1600, 1600))
            img.save(out / p.name, quality=88)
            continue                            # keep the facade as-is: context sells
        if "room" in name or "interior" in name:
            # interior detail shots: drop the cluttered background for a clean cutout
            try:
                from rembg import remove
                cut = remove(img)
                cut.save(out / f"{p.stem}_cutout.png")
            except Exception as e:
                print(f"  rembg failed on {p.name}: {e}", file=sys.stderr)
        img.thumbnail((1600, 1600))
        img.save(out / p.name, quality=88)
        print(f"  photo: {p.name}")

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("facts_csv")
    ap.add_argument("--photos-dir", default="photos")   # photos/<listing_id>/*.jpg
    ap.add_argument("--out", default="descriptions.csv")
    args = ap.parse_args()

    client = genai.Client()   # reads GEMINI_API_KEY
    with open(args.facts_csv, newline="", encoding="utf-8") as f:
        rows = list(csv.DictReader(f))

    out_rows = []
    for row in rows:
        desc = write_description(client, row)
        if desc is None:
            out_rows.append({"listing_id": row["listing_id"], "status": "REVIEW",
                             "problems": "API failed", "title": "", "body": "", "bullets": ""})
            continue
        problems = check(desc, row)
        status = "REVIEW" if problems else "OK"
        out_rows.append({"listing_id": row["listing_id"], "status": status,
                         "problems": "; ".join(problems), "title": desc.title,
                         "body": desc.body, "bullets": " | ".join(desc.bullet_points),
                         "missing_info": "; ".join(desc.missing_info)})
        print(f"{status:6} {row['listing_id']} {desc.title[:50]}")
        pdir = Path(args.photos_dir) / row["listing_id"]
        if pdir.exists():
            process_photos(client, pdir)

    with open(args.out, "w", newline="", encoding="utf-8-sig") as f:
        w = csv.DictWriter(f, fieldnames=out_rows[0].keys())
        w.writeheader()
        w.writerows(out_rows)
    print(f"\n-> {args.out}; check missing_info: that's the agent's list of unanswered buyer questions")

if __name__ == "__main__":
    main()
python listing.py facts.csv

Run it on one real listing first and read the description as a buyer would. If it sounds like the agent, ship it. If it sounds like a brochure, tighten DESC_INSTRUCTIONS — the fix is in the instructions, never in rewriting by hand.

Honest cost breakdown

One agent, 40 listings/month, ~10 photos each:

ItemMonthly
Gemini descriptions, 40 × ~$0.002 + re-runs~$0.15
rembg + Pillow$0 (local CPU)
Your time: fact-sheet template setup, review, delivery3–5 h one-time + 1–2 h/month
Total cash costunder $1/month

The agent’s side: 10–20 hours of writing and photo fiddling becomes roughly 1–2 hours of fact sheets and uploads. That gap is what the retainer prices against.

How to Sell This as a Service

Real estate is a retainer business by nature — agents don’t have one listing, they have a flow, and they’ll happily pay monthly for a back office that never lets a listing go up thin.

The offer:

  1. First listing free (the demo). Take one of the agent’s current listings, run the pipeline, deliver the description plus cleaned photos within a day. Compare it live against what they posted. This converts.
  2. Monthly retainer: every new listing gets a description in the agent’s voice within 24 hours of the fact sheet, photos processed to portal spec, and a monthly report of the missing_info patterns across their listings (e.g. “you never specify electricity capacity — buyers ask every time”).

Pricing. Anchor on their hours and their deal size. A single commission dwarfs any plausible monthly fee, which means the real decision criterion for the agent is “does my listing go up faster and look better” — so price for easy yes. As a test zone: Rp 500,000–1,500,000/month for up to ~20 listings (Indonesian market), scaling with volume; international clients (US/UK agents) sustain $150–400/month for the same work. Validate against local rates and the agent’s actual listing volume; the first agent sets your real price.

Who to approach: agents who post often and thin — Instagram/portal listings with two blurry photos and one sentence. Property management offices with rental flows. Newer agents who need every listing to compete against established names.

What to promise: “Your listings up faster, described from your facts, photos cleaned to portal spec.” Never “AI-generated listings that sell faster” — you can’t promise sales outcomes, and an agent who feels oversold on listing #1 won’t be a client in month #2.

Reality check: what does NOT work

  • Inventing features. The claim check exists because a hallucinated “swimming pool” or “24-hour security” in a property ad isn’t a typo — it’s misrepresentation with the agent’s name on it. Never skip the check().
  • Fully AI-generated interiors. Generating or heavily “beautifying” rooms beyond brightness/contrast crosses from enhancement into misrepresentation, and portals increasingly ban it. Fix lighting and rotation; don’t repaint the house.
  • Over-cutting photos. rembg cutouts suit feature/room detail shots; the facade must stay real — buyers use it to judge the building, and a floating house on white looks like something is being hidden.
  • Skipping the agent’s voice. Deliver one sample description for approval before batch-running. An agent whose listings all suddenly sound the same will churn; one whose voice got cleaner stays.
  • Bad input photos. A phone photo taken against a bright window will still be a bad photo after processing. The service includes a one-page photo checklist (daylight, landscape, corner-to-corner, no mirrors-with-people).
  • Free tier for client listings. Property details and addresses are client business data. Paid tier, always.

Final checklist

  • Fact-sheet template agreed with the agent, one sample listing approved
  • Claim check passing on every bullet; missing_info delivered as homework
  • Facades untouched, interiors brightened, cutouts only for detail shots
  • Paid-tier key, budget alert set
  • Portal specs (image size, description length) verified for each portal used
  • Delivery SLA agreed: fact sheet in → output within 24 h

Do one listing for an agent you know, free, this week. When they ask “can you do this for everything I post?”, the retainer conversation has already started — and the pipeline behind it costs you about a dollar a month to run.