← All posts

Build an AI Travel Itinerary Planner That Uses Live Web Data

Ask a plain LLM to plan three days in Lisbon and you get a confident, well-formatted itinerary featuring a restaurant that closed in 2024, a museum that is shut on the day it scheduled your visit, and ticket prices from whenever the training data was frozen. The prose is great. The facts are stale.

That is not a model problem. It is a data problem. An itinerary is mostly perishable facts: opening hours, admission prices, whether a place still exists, what recent visitors say. The fix is an agent that loops web search and page fetches for every venue it recommends, then builds the day-by-day plan only from what it just read.

In this tutorial I will build that agent in about 100 lines of Python. If you want the general pattern behind it, I covered the plan-search-fetch-validate loop in how to build a web scraping AI agent. Here we apply it to a concrete product: a trip planner with citations.

The architecture in one paragraph

The model does three jobs: propose candidate venues, extract facts from fetched pages, and write the final itinerary. Deterministic code does everything else: running searches, fetching pages, tracking which URL each fact came from, and refusing to include any venue the agent could not verify. The model never states an opening time or price it did not just read on a live page. That single constraint is what separates this from the stale-Lisbon itinerary.

Step 1: turn the trip request into candidates

Start by asking the model for candidates, not an itinerary. Candidates are cheap to verify; a finished itinerary is not.

import anthropic, json, requests

client = anthropic.Anthropic()
LSC = {"x-api-key": "lsc_..."}

TRIP = "3 days in Lisbon in August, mid budget, food and history, no clubs"

plan = client.messages.create(
    model="claude-sonnet-4-5", max_tokens=800,
    messages=[{"role": "user", "content":
        f"Trip: {TRIP}\n"
        "Propose 12 candidate venues (sights, restaurants, activities). "
        'Return JSON only: {"candidates": [{"name": str, '
        '"kind": "sight|food|activity", "search_query": str}]}'}],
)
candidates = json.loads(plan.content[0].text)["candidates"]

Note the search_query field. The model knows that verifying "Time Out Market" means searching for its official page and recent reviews, so let it write the query. Twelve candidates for a three-day trip gives you slack, because some will fail verification and get dropped.

Step 2 and 3: the search plus fetch loop

For each candidate, search the web, then fetch the top results as clean markdown. This is the loop that replaces training data with live data:

def research(candidate):
    r = requests.get("https://api.link.sc/v1/search",
                     params={"q": candidate["search_query"]},
                     headers=LSC).json()
    pages = []
    for item in r["results"][:3]:
        f = requests.get("https://api.link.sc/v1/fetch",
                         params={"url": item["url"]}, headers=LSC)
        if f.ok:
            pages.append({"url": item["url"],
                          "content": f.json()["content"][:15000]})
    return pages

I use the link.sc search and fetch endpoints because they return markdown already stripped of navigation and cookie banners, which matters when you are feeding three pages per venue into a model. But the pattern is API-agnostic. What is not optional is fetching at all: venue sites are exactly the kind of JavaScript-heavy, bot-walled pages where naive requests.get returns an empty shell. I went through those failure modes in give your AI agent internet access.

Why three pages and not one? Because the facts live in different places. Hours and prices come from the official site. Whether the place is actually worth it comes from review pages. One source cannot give you both.

Step 4: extract facts with the URL attached

Now the model reads the fetched pages and fills a fixed schema. The rule that makes citations work: every fact must carry the URL of the page it came from, and missing facts stay null.

FACT_PROMPT = """From the pages below, extract facts about {name}.
Return JSON only:
{{"open_status": "open|closed|unknown",
  "hours": str or null, "hours_source": url or null,
  "price": str or null, "price_source": url or null,
  "review_summary": str or null, "review_source": url or null}}
Rules: use ONLY the page text. Null anything not stated.
Never reuse a URL for a fact that page does not contain."""

def extract_facts(candidate, pages):
    blob = "\n\n".join(f"URL: {p['url']}\n{p['content']}" for p in pages)
    msg = client.messages.create(
        model="claude-sonnet-4-5", max_tokens=600,
        messages=[{"role": "user",
                   "content": FACT_PROMPT.format(**candidate) + "\n\n" + blob}],
    )
    facts = json.loads(msg.content[0].text)
    facts["verified"] = facts["open_status"] == "open" and facts["hours"]
    return facts

Then code, not the model, decides who makes the cut:

verified = []
for c in candidates:
    facts = extract_facts(c, research(c))
    if facts["verified"]:
        verified.append({**c, **facts})

A venue with no findable hours gets dropped. That feels harsh until you remember the alternative is scheduling your user into a locked door. In my experience roughly two or three candidates out of twelve fail this check, which is exactly why we over-generated in step 1.

Step 5: assemble the itinerary from verified facts only

The final call gives the model nothing but the verified list and asks for a day-by-day plan with inline citations:

itinerary = client.messages.create(
    model="claude-sonnet-4-5", max_tokens=2000,
    messages=[{"role": "user", "content":
        f"Trip: {TRIP}\nVerified venues: {json.dumps(verified)}\n"
        "Write a day-by-day itinerary in markdown. Order each day by "
        "the venues' actual hours. After every hour, price, or review "
        "claim, cite its source URL in parentheses. Use ONLY these "
        "venues and ONLY these facts."}],
)
print(itinerary.content[0].text)

Because every fact arrived with a source URL, citations are just plumbing, not a prayer. The output reads like "Day 1, morning: Castelo de S. Jorge, opens 09:00 (castelodesaojorge.pt), 15 EUR (same source), visitors recommend arriving before the tour buses (theportugalist.com review roundup)." If you want to go deeper on making citations trustworthy rather than decorative, see how to make AI agents cite sources.

What this design gets right, and what it does not

The honest tradeoffs, in a table:

Concern This design A plain LLM itinerary
Opening hours Read from live pages, cited Training data, often stale
Prices Current, with source URL Frequently outdated
Closed venues Dropped at verification Recommended anyway
Cost per trip ~40 API calls, ~5 LLM calls 1 LLM call
Latency 1 to 2 minutes Seconds

The cost and latency column is real. You are trading a few cents and a minute of wall time for an itinerary you can actually walk out the door with. For a travel product that is an easy trade; users wait longer than that for a flight search.

Two limitations worth naming. Seasonal hours are only as fresh as the venue's own website, so cite the URL and let users click through. And this planner verifies facts, not taste: it will not know the "hidden gem" that has no web presence. For everything with a web page, though, the loop holds: search, fetch, extract with sources, and only then write the plan.


Get search and fetch APIs built for agents, with 500 free requests a month, at link.sc/register.