A Travel Planning Agent Grounded in Real Places

Ask a model to plan three days in Lisbon and it will confidently recommend a restaurant that closed in 2019. Maps data fixes that.

Profile picture of Serply
Serply
An itinerary built from real map listings with coordinates

Travel is the domain where model hallucination is most obvious and most annoying. A plausible-sounding restaurant name, a neighbourhood that doesn’t exist, opening hours invented wholesale. The failure is specific: the model knows the shape of a Lisbon itinerary perfectly well, and fills the slots with fiction.

Grounding in real listings turns the model’s job from recall into arrangement, which it’s genuinely good at.

The maps endpoint

import os
import requests
from urllib.parse import quote_plus

API_KEY = os.environ["SERPLY_API_KEY"]


def places(query: str, location: str | None = None) -> list[dict]:
    headers = {"X-Api-Key": API_KEY}
    if location:
        headers["X-Proxy-Location"] = location
    resp = requests.get(
        f"https://api.serply.io/v1/maps/search/q={quote_plus(query)}",
        headers=headers,
        timeout=30,
    )
    resp.raise_for_status()
    return resp.json().get("places", [])


for p in places("seafood restaurant Alfama Lisbon"):
    print(p["name"], p.get("rating"), p.get("review_count"))
    print("  ", p.get("address"))

Results come back under places — not results — and each entry carries a lot: name, address and address_lines, district, latitude and longitude, rating and review_count, categories, phone and phone_e164, website and domain, opening_hours, timezone, google_maps_url, place_id, and a thumbnail. The response also includes result_count and parsed_at.

The coordinates and opening_hours are what make itinerary planning possible rather than decorative.

Gathering a candidate pool

from concurrent.futures import ThreadPoolExecutor


def gather(city: str, interests: list[str]) -> dict[str, list[dict]]:
    queries = {
        "food": [f"best restaurants {city}", f"local cuisine {city}"],
        "coffee": [f"specialty coffee {city}"],
        "sights": [f"top attractions {city}", f"museums {city}"],
    }
    for interest in interests:
        queries[interest] = [f"{interest} {city}"]

    flat = [(cat, q) for cat, qs in queries.items() for q in qs]
    with ThreadPoolExecutor(max_workers=6) as pool:
        results = list(pool.map(lambda item: places(item[1]), flat))

    out: dict[str, list[dict]] = {}
    seen: set = set()
    for (cat, _), found in zip(flat, results):
        for p in found:
            key = p.get("place_id") or (p.get("name"), p.get("address"))
            if key in seen:
                continue
            seen.add(key)
            out.setdefault(cat, []).append(p)
    return out

Deduplicating on place_id is the reliable path — the same venue surfaces under several queries with slightly different name formatting.

Filtering before the model sees anything

Give the model twenty good options, not two hundred raw ones. Its context is better spent on the arrangement problem:

def shortlist(items: list[dict], min_reviews: int = 40,
              min_rating: float = 4.0, limit: int = 12) -> list[dict]:
    scored = []
    for p in items:
        rating, reviews = p.get("rating"), p.get("review_count") or 0
        if rating is None or reviews < min_reviews or rating < min_rating:
            continue
        # Bayesian pull toward 4.2 so a 5.0 with 45 reviews doesn't top the list
        score = (60 * 4.2 + reviews * rating) / (60 + reviews)
        scored.append((score, p))

    scored.sort(key=lambda s: -s[0])
    return [p for _, p in scored[:limit]]

The review-count floor is the single most useful filter. Tourist-trap listings and genuinely great neighbourhood places both show 4.5 stars; the difference is whether 900 people or 12 people said so.

Geography is the hard constraint

An itinerary that sends someone across the city and back three times is technically valid and practically useless. Cluster by distance before the model plans:

import math


def haversine_km(a: dict, b: dict) -> float:
    lat1, lon1 = a["latitude"], a["longitude"]
    lat2, lon2 = b["latitude"], b["longitude"]
    p = math.pi / 180
    h = (0.5 - math.cos((lat2 - lat1) * p) / 2
         + math.cos(lat1 * p) * math.cos(lat2 * p) * (1 - math.cos((lon2 - lon1) * p)) / 2)
    return 12742 * math.asin(math.sqrt(h))


def cluster(items: list[dict], radius_km: float = 1.2) -> list[list[dict]]:
    """Simple greedy geographic clustering — good enough for walkable days."""
    pool = [p for p in items if p.get("latitude") and p.get("longitude")]
    clusters: list[list[dict]] = []

    while pool:
        seed = pool.pop(0)
        group = [seed]
        rest = []
        for p in pool:
            (group if haversine_km(seed, p) <= radius_km else rest).append(p)
        clusters.append(group)
        pool = rest

    return sorted(clusters, key=len, reverse=True)

Greedy clustering isn’t optimal and doesn’t need to be. What it produces is “these eight places are within a walk of each other,” which is exactly the constraint a day plan needs.

Planning within the constraints

import json
from anthropic import Anthropic

client = Anthropic()

SYSTEM = """You build day-by-day travel itineraries.

Absolute rules:
- Use ONLY venues from the provided list. Never add a place from your own
  knowledge, however well-known.
- Never state opening hours, prices, or booking requirements that aren't in the
  data. If someone needs to check, say "verify hours before going."
- Each day should stay within one geographic cluster. Do not send the traveller
  back and forth across the city.
- Include the website or Google Maps URL for every venue you name.
- If a category has no good options in the data, say so rather than substituting.

Return JSON:
{"days": [{"day": int, "area": str, "stops": [
  {"time": str, "name": str, "why": str, "url": str, "note": str}]}],
 "gaps": [str]}"""


def plan(city: str, days: int, interests: list[str]) -> dict:
    pool = gather(city, interests)
    payload = {
        "city": city,
        "days": days,
        "clusters": [
            [{"name": p["name"], "category": (p.get("categories") or [None])[0],
              "district": p.get("district"), "rating": p.get("rating"),
              "reviews": p.get("review_count"), "hours": p.get("opening_hours"),
              "url": p.get("website") or p.get("google_maps_url"),
              "lat": p.get("latitude"), "lon": p.get("longitude")}
             for p in group]
            for group in cluster([p for ps in pool.values() for p in shortlist(ps)])
        ][:days + 2],
    }

    msg = client.messages.create(
        model="claude-sonnet-4-5", max_tokens=4000, system=SYSTEM,
        messages=[{"role": "user", "content": json.dumps(payload, indent=2)}],
    )
    return json.loads(msg.content[0].text)

The gaps field is deliberate. Without somewhere to report a missing category, the model fills it — and a fabricated vegetarian restaurant is exactly the failure this whole pipeline exists to prevent.

Verify the output against the input

Never trust the constraint to hold just because you asked:

def verify(itinerary: dict, pool: dict[str, list[dict]]) -> list[str]:
    known = {p["name"].lower() for ps in pool.values() for p in ps}
    problems = []
    for day in itinerary.get("days", []):
        for stop in day.get("stops", []):
            if stop["name"].lower() not in known:
                problems.append(f"Day {day['day']}: '{stop['name']}' is not in the data")
    return problems

A set membership check, four lines, run on every response. It catches the one failure mode that would otherwise send someone to a restaurant that doesn’t exist.