Flight Offers
A ranked list of bookable flights from a fare search, with local times, stops, fare and bags, the cheapest and fastest marked, and one to pick.
Experience · Shares patterns with Comparison Matrix, Flight Ticket
≈ 3,117 tokens per use (tool 1,631 · guidance 272 · typical payload 1,214)How token costs are estimated
Example: Flight Offers
Flights from Tel Aviv to Athens for the family in November
Tel Aviv to Athens
Thu, Nov 12 – Thu, Nov 19
When to use
- The user searched for flights and should compare a few bookable options and pick one: "flights to Athens for the family in November", "cheapest nonstop to Lisbon next week".
When not to use
- The user already booked. Use a Flight Ticket.
- The user asks about a flight in progress. Use Flight Status.
- The agent has no live fares. Search first; never invent prices or times.
- More than 10 options. Narrow the search (nonstop, time of day) first.
How the agent should use it
- Every price, time and fare rule comes from the fare search result.
priceis the total for all passengers in the search, taxes included. - Send times with the airport's offset and each airport's IANA timezone. Times show local to each airport, so a 21:30 departure from JFK reads 21:30 for every viewer, and an arrival on the next local day gets a "+1".
- Order
offersby what the user asked for (cheapest, fastest) and setsortto match. - Pass
search.passengersso the footer says who the prices are for: "Prices for 3 adults, 1 child, 1 infant". - Act only on a
selectresult, then confirm the fare before booking: prices change. - Lifecycle: fares are live, so set
fetchedAtandstaleAfterMs(about 10 minutes) on the call. When the provider holds the fares until a deadline, setexpiresAttoo. Use supersede when the agent re-runs the search with new dates or filters.
Interaction
| Element | Behavior |
|---|---|
| Offer | Rank, airline mark (its code on a tile, no logos), airline, stops and total price. One row per direction (side by side on a wide card): departure and arrival times in each airport's zone, the airport codes, and the door-to-door duration on a dashed line. Connections add "1 stop · LCA 1h 05m". One meta line: fare, bags, trip type, "Refundable" when true, and "2 seats left" when 5 or fewer |
| Cheapest, Fastest | Computed from the offers (total price; total travel time across both directions). Ties mark every tied offer; nothing is marked when there's one offer or all are equal. Text badges, not color |
| Sort | Price, Duration or Departure re-sorts the list locally, stable so ties keep the agent's order. Rank numbers follow the visible order. The active sort starts at sort; without it the agent's order stands and no sort is pressed |
| Choose | With selectable (the default) each offer is a radio card: click anywhere on it, or use the arrow keys. The chosen offer gets a ring and a check in place of its rank. Select flight sends it |
| Show more | With more than 2 offers, the first 2 show and the rest sit behind "Show N more". This is a documented exception to expanded-by-default: the top of a ranked list is the answer. A chosen offer stays visible when the list is collapsed. The list flows with the page; nothing scrolls inside the card |
While streaming, offers appear one at a time in the agent's order. Sort and the badges wait for the whole list, so nothing jumps.
The helpers in flight-offers-utils.ts (sortOffers, cheapestIds, fastestIds, legMinutes, layoverText, legDayOffset, pricesForText) are pure, so the server can reuse them, for example to check that an "it's the cheapest" reply matches the data.
Installation
npx shadcn@latest add https://your-site.com/r/flight-offers.json
Usage
import { tool } from "ai"
import { flightOffersTool } from "@/components/genui/flight-offers.schema"
const tools = {
// No execute(): the call is completed by the user's choice.
[flightOffersTool.name]: tool({
description: flightOffersTool.description,
inputSchema: flightOffersTool.inputSchema,
}),
}<FlightOffers
{...part.input}
status={part.state === "input-streaming" ? "streaming" : undefined}
result={part.state === "output-available" ? part.output : undefined}
onResult={(result) =>
addToolResult({ tool: "compare_flights", toolCallId: part.toolCallId, output: result })
}
/>The agent receives:
{ action: "select", offerId: "mr-412" }With LangGraph
Push it from a graph node with ui.push({ name: "compare_flights", props: { toolCallId, ...args } }, { message }), then interrupt() until the user's result arrives. Render the thread's UI messages with the Renderer.
Restoring from history
Pass the stored result back as result. The list renders read-only, with the chosen offer ringed and checked (shown even if it's past the first 5), and the footer shows "Selected Meridian Air · $1,486". Sorting still works. It never calls onResult again.
<FlightOffers
{...part.input}
result={part.state === "output-available" ? part.output : undefined}
/>Schema
| Prop | Type | Notes |
|---|---|---|
search | Search? | Titles the card ("Tel Aviv to Athens", dates) and says who the prices are for |
currency | string | ISO 4217, e.g. USD |
offers | Offer[] | Data-bound. 1–10, best first |
sort | "price" | "duration" | "departure"? | What offers is sorted by; marks that sort as active |
selectable | boolean | Default true: offers are radio cards with a Select flight button |
source | { label, url? }? | Data-bound. Where the fares came from, a small link in the footer |
Search
| Field | Type | Notes |
|---|---|---|
from, to | string | As the user said it: "Tel Aviv", "Athens" |
departDate | string | YYYY-MM-DD |
returnDate | string? | Omit for one way |
passengers | { adults, children?, infants? } | "Prices for 3 adults, 1 child, 1 infant" |
cabin | economy | premium | business | first? | Shown in the header unless economy |
Offer
| Field | Type | Notes |
|---|---|---|
id | string | Returned on select |
airline | { name, code } | The code is the airline's mark |
price | number | Data-bound. Total for all passengers |
fare | string? | "Basic", "Standard", "Flex" |
bags | { carryOn, checked }? | "No carry-on", "Carry-on + 1 checked bag" |
legs | { segments: Segment[] }[] | 1 leg = one way, 2 = round trip. 1–4 segments per leg; layovers are computed |
refundable | boolean? | "Refundable" in the meta line when true |
seatsLeft | number? | Shown when 5 or fewer |
Segment
| Field | Type | Notes |
|---|---|---|
flightNumber | string | Shown under the flight's times, and read in the leg summary |
operatedBy | string? | The airline that flies it when it isn't the seller. Shown as "Operated by …" |
from, to | { code, city?, timeZone } | IATA code, an optional city shown under it, and the IANA timezone |
departAt, arriveAt | string | ISO 8601 with the airport's offset. Without one, local time at the airport |
Host props: status (streaming, ready, submitting, selected), onResult, result and className.