A Lead-Gen Agent That Actually Verifies the Business Exists

Google Maps results carry addresses, ratings, categories, and websites. Wire them into an agent and you get qualified leads instead of a scraped phone list.

Profile picture of Serply
Serply
A map with pinned business locations feeding a qualification pipeline

Most “lead generation” automation produces a spreadsheet of businesses that closed two years ago. The data is scraped from a directory that was itself scraped from a directory, and nobody checked.

Maps data is different in a useful way: it’s what Google is currently showing to people searching for that business. If a place has a live listing with an address, a rating, and a website, it exists.

What comes back

Serply’s Maps endpoint takes a Google-style query string in the path and returns structured place records:

import os
import requests
from urllib.parse import quote_plus

API_KEY = os.environ["SERPLY_API_KEY"]


def find_places(query: str, num: int = 20) -> list[dict]:
    resp = requests.get(
        f"https://api.serply.io/v1/maps/search/q={quote_plus(query)}&num={num}",
        headers={"X-Api-Key": API_KEY},
        timeout=30,
    )
    resp.raise_for_status()
    return resp.json().get("places", [])


places = find_places("commercial HVAC contractors in Austin, TX")

Each record in places carries name, address, address_lines, website, domain, latitude, longitude, rating, review_count, categories, phone, timezone, opening_hours, and Google’s own place_id and data_id. The response also includes result_count and a metadata object describing the parse.

Two fields deserve care. review_count and phone come back as null on plenty of listings — Google doesn’t surface them consistently — so treat their absence as “unknown,” not “zero” or “no phone.” Filtering on review_count > 10 silently drops every listing where the count wasn’t rendered.

Qualifying

The point of an agent here isn’t finding places — that’s one API call. It’s deciding which ones are worth a human’s time.

def qualify(place: dict) -> tuple[bool, str]:
    """Cheap deterministic filters before spending any model tokens."""
    if not place.get("website"):
        return False, "no website"
    rating = place.get("rating")
    if rating is not None and rating < 3.5:
        return False, f"low rating ({rating})"
    cats = [c.lower() for c in place.get("categories", [])]
    if any("residential" in c for c in cats):
        return False, "residential focus"
    return True, "passes filters"


candidates = [p for p in places if qualify(p)[0]]

Run the deterministic filters first, always. Every place you eliminate with a comparison is one you don’t pay a model to evaluate, and rating thresholds don’t need an LLM.

Enrichment

For the survivors, read their site. A homepage tells you more about fit than any directory category:

def read_site(url: str) -> str | None:
    try:
        resp = requests.post(
            "https://api.serply.io/v1/request",
            headers={"X-Api-Key": API_KEY, "Content-Type": "application/json"},
            json={"url": url, "response_type": "markdown"},
            timeout=60,
        )
        resp.raise_for_status()
        return resp.text[:8000]
    except requests.RequestException:
        return None

Markdown mode returns the converted text as the response body, so read .text. That matters more here than usual: a contractor’s homepage is mostly navigation chrome and image tags, and markdown strips the noise that would otherwise eat your context window.

The scoring agent

from anthropic import Anthropic

client = Anthropic()

RUBRIC = """You score B2B sales leads for a commercial HVAC parts supplier.

Score 1-5 on fit. A 5 is a commercial contractor with multiple crews and an
explicit commercial services page. A 1 is a residential-only or single-operator shop.

Return JSON: {"score": int, "reason": str, "signals": [str]}
Base your answer only on the provided material. If the site says little, score low
and say so — do not infer from the company name."""


def score_lead(place: dict, site_text: str | None) -> dict:
    material = f"""Business: {place.get('name')}
Address: {place.get('address')}
Categories: {', '.join(place.get('categories', []))}
Rating: {place.get('rating')} ({place.get('review_count')} reviews)
Website: {place.get('website')}

Site content:
{site_text or '(could not fetch)'}"""

    msg = client.messages.create(
        model="claude-sonnet-4-5",
        max_tokens=500,
        system=RUBRIC,
        messages=[{"role": "user", "content": material}],
    )
    return json.loads(msg.content[0].text)

The last line of the rubric is the one that keeps this honest. Without it, a model handed a business called “Austin Commercial Air Systems” and an empty site will confidently score it a 5 based entirely on the name.

Putting it together

import json
from concurrent.futures import ThreadPoolExecutor

places = find_places("commercial HVAC contractors in Austin, TX", num=20)
candidates = [p for p in places if qualify(p)[0]]

with ThreadPoolExecutor(max_workers=5) as pool:
    sites = list(pool.map(lambda p: read_site(p["website"]), candidates))

leads = []
for place, site in zip(candidates, sites):
    result = score_lead(place, site)
    if result["score"] >= 4:
        leads.append({**place, **result})

leads.sort(key=lambda l: l["score"], reverse=True)
for lead in leads:
    print(f"{lead['score']}  {lead['name']:<40} {lead.get('phone') or '—'}")
    print(f"   {lead['reason']}")

Coverage

One Maps query returns one neighborhood’s worth of results. Real territory coverage means gridding: run the same query against several sub-areas and dedupe on place_id, which is stable across queries in a way that names and addresses are not.

areas = ["North Austin", "South Austin", "Round Rock, TX", "Cedar Park, TX"]
seen, all_places = set(), []
for area in areas:
    for p in find_places(f"commercial HVAC contractors in {area}"):
        if p.get("place_id") and p["place_id"] not in seen:
            seen.add(p["place_id"])
            all_places.append(p)

Expect meaningful overlap between adjacent areas — that’s the dedupe earning its keep, not a bug.