google.com

search-flights

Installation

Adds this website's skill for your agents

 

Summary

Search Google Flights for one-way or round-trip itineraries between two airports on given dates and return the cheapest options with airline, flight numbers, total duration, stops, depart/arrive times, and a booking link. Read-only — never books.

SKILL.md
286 lines

Google Flights Search

Purpose

Given an origin, a destination, one date (one-way) or two dates (round-trip), and a passenger / cabin config, return the cheapest itineraries with airline, flight numbers, total duration, stops, depart/arrive times, and a booking link. Read-only — never click Select flight, Book, or any continuation that leaves the results screen.

When to Use

  • "What's the cheapest flight from SFO to JFK on 2026-06-15?"
  • Comparing fares across dates / airports for a trip-planner agent.
  • Bulk price monitoring on a fixed route (driven by the same tfs= URL day-over-day).
  • Any flow that needs a price + itinerary snapshot. Booking is a separate skill — Google Flights routes the actual purchase to airline-direct or OTA partners, none of which this skill touches.

Workflow

Google does not publish a consumer flight search API. The official Travel Partner API (developers.google.com/travel/flights) is for airlines/OTAs to push inventory to Google — it does not return search results. The reliable path is to drive the consumer site https://www.google.com/travel/flights with a deep-link URL that pre-applies all search parameters via the tfs= (base64-protobuf) query string, so no form interaction is required.

A residential-proxy + Browserbase-Verified session is mandatory. From data-center IPs Google serves the /sorry/index 429 interstitial ("Our systems have detected unusual traffic from your computer network") within 1–2 requests on /travel/flights* — verified at runtime (HTTP 429, Content-Type: text/html, captcha widget served inline).

1. Build the tfs deep-link

Construct the URL:

https://www.google.com/travel/flights?tfs=<BASE64_PROTOBUF>&hl=en&curr=USD&gl=us&tfu=EgQIABABIgA

The tfs value is a base64-URL-encoded protobuf with this schema (verified against open-source decoders):

message Airport     { string airport = 2; }                          // 3-letter IATA, uppercase
message FlightData  { string date = 2;                               // YYYY-MM-DD
                      Airport from_flight = 13;
                      Airport to_flight   = 14;
                      optional int32 max_stops = 5;                   // 0 = nonstop
                      repeated string airlines = 6; }                 // 2-letter IATA or alliance
enum Seat           { ECONOMY=1; PREMIUM_ECONOMY=2; BUSINESS=3; FIRST=4; }
enum Trip           { ROUND_TRIP=1; ONE_WAY=2; MULTI_CITY=3; }
enum Passenger      { ADULT=1; CHILD=2; INFANT_IN_SEAT=3; INFANT_ON_LAP=4; }
message Info        { repeated FlightData data = 3;                  // 1 leg for one-way, 2 for round-trip
                      Seat seat = 9;
                      repeated Passenger passengers = 8;             // one enum entry per passenger
                      Trip trip = 19; }

Wire-format rule: passengers is repeated and unpacked — three adults + one infant-on-lap = four entries [ADULT, ADULT, ADULT, INFANT_ON_LAP], not a single scalar count. Total passengers must be ≤ 9. infants_on_lap must be ≤ adults.

The trailing constants — hl=en, curr=USD, gl=us, tfu=EgQIABABIgA — are required. Drop any of them and Google often redirects to a "popular destinations" landing page that ignores the tfs payload.

Easier fallback when you don't want to encode protobuf: use the natural-language q= deep-link. The intent-parser handles ~95% of routes cleanly:

https://www.google.com/travel/flights?q=Flights+from+SFO+to+JFK+on+2026-06-15+through+2026-06-22&hl=en&curr=USD&gl=us

Verify after navigation that the resolved URL contains tfs= (intent-parser succeeded) — if it lands on /explore instead, the parser couldn't disambiguate and you must fall back to building the tfs directly.

2. Open in a stealth + residential-proxy session

sid=$(browse cloud sessions create --keep-alive --verified --proxies | jq -r .id)
export BROWSE_SESSION="$sid"
browse open "$URL" --remote
browse wait load --remote
browse wait timeout 3500 --remote   # itinerary cards stream in 2–4s after `load`

Anything short of --verified --proxies gets either the /sorry/index 429 or an empty itinerary list with a "Something went wrong" toast.

3. Dismiss the consent / cookie banner if present

On a fresh session (no SOCS cookie) Google overlays a consent dialog that swallows clicks on result rows.

browse snapshot --remote
# Look for a `button: "Reject all"` or `button: "Accept all"` ref — click it.
browse click "<ref>" --remote
browse wait timeout 1000 --remote

If you reuse a persisted Browserbase context, you can pre-seed the consent state once (CONSENT=PENDING+987; SOCS=<base64>) and skip this step on every subsequent run.

4. Switch the sort to "Cheapest" (the prompt explicitly asks for cheapest)

The default landing tab is "Best", which is Google's price-vs-duration heuristic, not strictly the cheapest. Click the "Cheapest" tab:

browse snapshot --remote
# Find: tab "Cheapest"  →  click its ref
browse click "<ref>" --remote
browse wait timeout 2500 --remote   # the list re-sorts in place; no navigation

Equivalent URL-only path: append &sort=2 (SerpAPI-style sort codes — 1=top, 2=price, 3=depart, 4=arrive, 5=duration, 6=emissions). Google honors this param on the consumer page.

5. Read the itinerary cards

browse get html body --remote > /tmp/flights.html

The result list is a sequence of <li> items inside two parent containers (one per tab/section):

  • div[jsname="IWWDBc"] — "Best flights" / top section
  • div[jsname="YdtKid"] — "Other flights" section

Within each ul.Rk10dc li row:

FieldSelector
Airline namediv.sSHqwe.tPgKwe.ogfYpf span (first)
Depart / arrive timesspan.mv1WYe div (nodes 0 and 1)
Next-day arrival flag (+1, +2)span.bOzv6.bOzv6 next to arrival time
Total durationdiv.gvkrdb.AdWm1c.tPgKwe.ogfYpf ("5 hr 32 min")
Stopsdiv.EfT7Ae.AdWm1c.tPgKwe span.ogfYpf ("Nonstop" / "1 stop")
Layover detaildiv.c8rWCd.tPgKwe.ogfYpf ("1 hr 45 min LAX")
Pricediv.YMlIz.FpEdX span ("$412")
CO₂ediv.O7CXue.tPgKwe ("312 kg CO2e")
Card "details" expanderbutton[aria-label*="more details"]

Flight numbers (e.g. DL 412) are not in the card-collapsed view. You have to expand each card — click button[aria-label*="more details"] — which renders an inner detail panel containing each segment's Airline + flight number + aircraft + seat pitch + on-time stats. Read span.sSHqwe span:nth-of-type(2) inside each segment block to get the AA 1234-style identifier. Budget 1.5–3 seconds per card for the detail-render to settle before the next snapshot.

6. Construct booking links

There is no clean per-itinerary "deep link to airline" available before the user picks a card — Google routes booking through /travel/flights/booking which is Disallow: in robots.txt and serves Akamai-blocked responses to non-browser HTTP. The two practical options:

  1. Canonical Google Flights detail URL — keep the current page URL and append the itinerary's itinerary-key parameter from data-iid attribute on the row (<li data-iid="...">). Format: https://www.google.com/travel/flights/booking?tfs=<original>&iid=<row data-iid>. Reaches the booking-redirect page when followed in-browser. Use this when you need a single URL the user can paste.
  2. Click + capture the outbound URL — click button[aria-label*="Select flight"] on the card, then browse get url immediately after navigation. Google bounces to either an airline-direct URL or a Google-hosted broker page. Carries an ?gclid= tracking param. Higher-fidelity but costs one navigation per card.

Default to option (1) for read-only price-comparison flows. Use option (2) only when the consumer explicitly needs the merchant-direct URL.

7. Pull deeper itinerary detail via the embedded JS blob (optional, faster than per-card expansion)

The full structured itinerary list (including flight numbers, aircraft types, layover cities, codeshares) is embedded in the initial HTML as a nested-list blob inside <script class="ds:1">…AF_initDataCallback({…, data: [ …deeply nested arrays… ], …})…</script>. Regex: /^.*?\{.*?data:(\[.*\]).*}/. Parse the captured group as JSON, then index by the positional paths below (verified against open-source decoders):

  • Itinerary root list: data[0] is "best", data[1] is "other"
  • Per itinerary: flights[], layovers[], travel_time (minutes), departure_airport, arrival_airport, itinerary_summary.flights (e.g. "DL 412, DL 1735"), itinerary_summary.price (dollars, integer)
  • Per flight segment (itinerary.flights[i]): operator [2], departure_airport [3], arrival_airport [5], departure_time [8] ([hour, minute]), arrival_time [10] ([hour, minute]), travel_time [11] (minutes), seat_pitch_short [14], codeshares [15], aircraft [17], departure_date [20], arrival_date [21], airline_code [22][0], airline_name [22][3], flight_number [22][1]

Reading the blob is one extraction per page vs. N expand-clicks in the DOM path, but the indices are positional and Google has rotated them at least once historically — always pin the parser against the latest version of wooldox/google-flights-ts/src/decoder.ts (or the upstream Python AWeirdDev/flights) before trusting them blindly.

8. Release the session

browse cloud sessions update "$sid" --status REQUEST_RELEASE

Fast-path fallback: direct HTTP, no browser

If you have a clean residential IP and just need a quick price snapshot, you can sometimes skip the browser entirely:

curl -sS "https://www.google.com/travel/flights?tfs=<b64>&hl=en&curr=USD&tfu=EgQIABABIgA" \
  -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..." \
  -H "Cookie: CONSENT=PENDING+987; SOCS=CAESHAgBEhJnd3NfMjAyMzA4MTAtMF9SQzIaAmRlIAEaBgiAo_CmBg" \
  -H "Accept-Language: en-US,en;q=0.5"

The 200-OK HTML body contains both the cheerio-parsable cards (selectors above) and the script.ds:1 JS blob. Expect this path to fail most of the time from cloud IPs — Google returns /sorry/index 429 or a redirect to a generic landing page. Use only as an opportunistic optimization with a graceful fallback to the browser path on any non-200 response or any response containing <title>Sorry...</title>.

Site-Specific Gotchas

  • READ-ONLY. Never click Select flight, Book, or any time-slot ref on the result card. The skill terminates at the results screen.
  • --verified --proxies is mandatory. Bare and --proxies-only sessions get the /sorry/index 429 captcha interstitial within 1–2 requests on /travel/flights*. Verified at iteration time via direct browse cloud fetch of /sorry/index returning HTTP 429 with the canonical "Our systems have detected unusual traffic" body.
  • The page is >1 MB of HTML — confirmed: browse cloud fetch https://www.google.com/travel/flights returns 502 The response body exceeded the maximum allowed size of 1MB. Use a browser session to handle large responses. Don't try to use Browserbase's Fetch API for the search page; you must drive a session.
  • robots.txt explicitly disallows the booking funnelDisallow: /travel/flights/booking, /travel/flights/search, /travel/flights/s/, /travel/search, /travel/clk. The main /travel/flights landing is the only allowed entry point. Skill stays on /travel/flights?tfs=... and never navigates into a Disallow-ed subpath.
  • No public consumer search API. developers.google.com/travel/flights is a partner API for airlines/OTAs to push inventory to Google. It does not return search results. Don't waste time looking for a JSON endpoint — the consumer site is the only surface.
  • tfs is base64-URL-encoded protobuf, not JSON. Decoders that try to JSON-parse it fail silently and produce blank pages. Verified protobuf schema in step 1; reference encoders: wooldox/google-flights-ts (TS, hand-rolled), AWeirdDev/flights (Python, upstream).
  • passengers is repeated-unpacked, not a count. Two adults = [ADULT, ADULT], not 2. Mis-encoding shows up as "1 traveler" on the rendered page regardless of intent.
  • Default tab is "Best", not "Cheapest". The prompt asks for cheapest — explicitly click the "Cheapest" tab (or append &sort=2 to the URL) before reading rows, otherwise the top results are Google's "best value" heuristic, which can be $50–$200 above true cheapest on a US transcon.
  • Flight numbers are not in the collapsed card. Either expand each card (1.5–3s wait per expand) or parse the script.ds:1 JS blob (one parse per page). The blob path is ~10× faster but positionally indexed — see step 7 caveat.
  • Next-day-arrival marker is easy to miss. The arrival cell shows 11:50 PM with a tiny +1 superscript when the flight lands the next day. Selector: span.bOzv6.bOzv6. Drop this and you'll mis-compute total_duration_minutes by 1440.
  • Consent dialog is a click-trap. Fresh sessions get the EU-style "Before you continue" overlay (even on US IPs). It captures clicks on row buttons silently. Always check for and dismiss it before clicking anything else.
  • q= intent-parser can rewrite the URL. Useful (you can skip protobuf encoding) but unpredictable — ambiguous airport names or odd date phrasings can land on /explore instead of running a search. Always inspect the post-navigation URL for tfs=; fall back to manually building the protobuf when it's missing.
  • Prices are cached, not live. A given tfs payload can return a stale price for hours. For high-fidelity pricing, append &curr=USD&hl=en (forces currency / locale) and treat the result as "Google's last-seen price", not "current airline price". Surface the page's <title> "Updated …" timestamp when emitting output.
  • HTML selectors are obfuscated and rotate quarterly. jsname="IWWDBc" and ul.Rk10dc are stable across at least 18 months of open-source community usage but have changed in the past. When a parse drops to zero rows, refresh selectors from wooldox/google-flights-ts/src/core.ts (parseResponse) or the upstream AWeirdDev/flights library — both track Google's churn.
  • Booking page is Akamai-blocked from cookieless HTTP. Don't try to follow /travel/flights/booking URLs out-of-band — they only render in-session after a Select flight click. For consumer-facing booking URLs, prefer attaching the canonical Google Flights URL with the iid (see step 6 option 1).
  • Trace capture from Vercel Sandbox is unavailable. The sandbox's DNS resolver returns REFUSED for connect.usw2.browserbase.com and all other connect.*.browserbase.com hosts (verified across us-west-2, us-east-1, eu-central-1, ap-southeast-1). The Browserbase Fetch API at api.browserbase.com works, but CDP/Playwright sessions cannot be driven from the sandbox itself. The skill is designed to run from a Browserbase-connected client (laptop, CI runner, or a host whose egress is unfiltered) — this caveat is environment-specific, not a property of Google Flights.

Expected Output

Two outcome shapes:

// Success
{
  "success": true,
  "origin": "SFO",
  "destination": "JFK",
  "depart_date": "2026-06-15",
  "return_date": "2026-06-22",
  "trip_type": "round-trip",
  "passengers": { "adults": 1, "children": 0, "infants_in_seat": 0, "infants_on_lap": 0 },
  "cabin": "economy",
  "sort": "cheapest",
  "price_currency": "USD",
  "results": [
    {
      "rank": 1,
      "price": 412,
      "airlines": ["Delta"],
      "flight_numbers": ["DL 412", "DL 1735"],
      "stops": 0,
      "total_duration_minutes": 332,
      "outbound": {
        "depart_time_local": "2026-06-15T07:30-07:00",
        "arrive_time_local": "2026-06-15T15:50-04:00",
        "depart_airport": "SFO",
        "arrive_airport": "JFK",
        "duration_minutes": 320,
        "segments": [
          { "flight_number": "DL 412", "depart": "07:30", "arrive": "15:50",
            "depart_airport": "SFO", "arrive_airport": "JFK",
            "aircraft": "Boeing 757", "operator": "Delta Air Lines", "duration_minutes": 320, "layover_minutes": 0 }
        ]
      },
      "return": {
        "depart_time_local": "2026-06-22T18:10-04:00",
        "arrive_time_local": "2026-06-22T21:35-07:00",
        "depart_airport": "JFK",
        "arrive_airport": "SFO",
        "duration_minutes": 385,
        "segments": [ /* same shape */ ]
      },
      "co2e_kg": 312,
      "co2e_vs_typical_pct": -8,
      "booking_url": "https://www.google.com/travel/flights/booking?tfs=<original>&iid=<row data-iid>",
      "last_updated": "2026-05-18T18:42:00Z"
    }
  ]
}

// Anti-bot wall (Sorry page) — emit when the post-navigation URL contains /sorry/ or the page title starts with "Sorry"
{
  "success": false,
  "error_reasoning": "Google served the /sorry/index 429 anti-bot interstitial. Retry with a fresh --verified --proxies session, or rotate the proxy pool. The IP currently in use is on Google's traffic-anomaly list."
}

If the route has no flights on the requested date — valid empty result, not a failure: { "success": true, ..., "results": [] } plus the page's empty-state text in an info field ("No flights available for these dates. Try nearby dates.").