Skip to contentAPILayer: production APIs with free plansExplore APILayer
Cover illustration: United Just Blocked AI Agents From Booking Flights. Here's the AI Travel Agent You Can Still Build
Favour Onuoha

Posted on · Updated on · 6 min read

United Just Blocked AI Agents From Booking Flights. Here's the AI Travel Agent You Can Still Build

On this page · 13 sections

On November 11, 2026, United Airlines said it will block unauthorized AI agents from booking, changing or paying for its tickets, whether on United's own site or through travel agencies. TechCrunch's write-up has the details. The reason is account security: an agent that books for you needs your password and your MileagePlus miles, and United doesn't want either handed to a bot.

The part that didn't make the headlines is what's still allowed. AI tools can search and compare flights. The traveller just has to complete the purchase. That leaves a useful, safe job for an AI travel agent: knowing where a flight is, whether it's late, and which gate to walk to.

So we built that half. It's a get_flight_status tool for a JavaScript agent, on the aviationstack API. It can't book anything, by design. Everything below ran on November 12, 2026, against live flights. The data had four surprises in it.

#What is the aviationstack API?

aviationstack serves flight data as JSON: real-time and historical flight status, airports, airlines, aircraft and schedules. For an agent, the endpoint that matters is flights. You give it a flight number and it returns the flight's status, departure and arrival airports, scheduled and actual times, delay, terminal, gate and, while the plane is in the air, a live position. There's a free plan to start with, and paid plans raise the request limits.

The same data can power a delay alert bot, a "where's my pickup" page for an airport transfer service, or a team travel dashboard.

#How Do You Look Up a Flight?

One request with flight_iata:

Bash
curl "https://api.aviationstack.com/v1/flights?access_key=YOUR_KEY&flight_iata=UA1&limit=3"

UA1 is United's San Francisco to Singapore flight. Here's the newest of the three records that came back, with the runway times and aircraft cut:

JSON
{
  "flight_date": "2026-11-11",
  "flight_status": "scheduled",
  "departure": {
    "airport": "San Francisco International",
    "timezone": "America/Los_Angeles",
    "iata": "SFO",
    "terminal": "3",
    "gate": "F13",
    "delay": 42,
    "scheduled": "2026-11-11T22:40:00+00:00",
    "actual": "2026-11-11T23:22:00+00:00"
  },
  "arrival": {
    "airport": "Singapore Changi",
    "timezone": "Asia/Singapore",
    "iata": "SIN",
    "scheduled": "2026-11-13T08:00:00+00:00",
    "estimated": null
  },
  "airline": { "name": "United Airlines", "iata": "UA" },
  "flight": { "number": "1", "iata": "UA1", "codeshared": null },
  "live": {
    "updated": "2026-11-12T17:32:38+00:00",
    "latitude": 18.1002,
    "longitude": 150.46,
    "altitude": 10972.8,
    "is_ground": false
  }
}

The pagination.total was 91. One flight number runs every day, so a query for UA1 returns many dates, newest first. Your tool has to pick one.

Look at that record closely, though. It doesn't add up.

#Why a Flight Over the Pacific Said "scheduled"

flight_status is "scheduled". But departure.actual says it left SFO 42 minutes late, and live puts it at 10,972 metres over the western Pacific, east of the Mariana Islands, with is_ground: false. The plane was obviously in the air. The status field just hadn't caught up.

A model reading that response will say "UA1 hasn't departed yet". That's the worst answer for someone about to drive to the airport to pick up a passenger. So our tool trusts the live position over the status: if there's a live block and the plane isn't on the ground, the status is "in the air".

#What Time Zone Are the Times In?

The times all end in +00:00, which reads as UTC. They aren't UTC. UA1 is scheduled to leave at 22:40 on November 11 and land at 08:00 on November 13. Read as UTC, that's a 33-hour flight. Read as local time (22:40 in San Francisco, 08:00 in Singapore), it's about 17 hours, which is right for that route.

So the times are local to each airport, with a misleading offset attached. If you let JavaScript parse them as real timestamps, every one is off by the airport's UTC offset. Our tool drops the fake offset and writes the zone out in words: "2026-11-11 22:40 local (America/Los_Angeles)". The model can't misread that, and it can repeat it to a traveller as is.

#Why BA117 Came Back as an American Airlines Flight

We asked for BA117, British Airways' Heathrow to New York flight. The first record that came back was AA6930. That's American's codeshare number for the same plane: American sells seats on it under its own number, and aviationstack returns those records too because they point at BA117.

It works the other way as well. UA8032 from San Francisco to Montreal is a United number on an Air Canada flight, and the record says so in flight.codeshared:

JSON
"flight": { "iata": "UA8032", "codeshared": { "airline_name": "air canada", "flight_iata": "ac756" } }

A traveller with a United ticket for UA8032 needs to look for AC756 on the departure board, at the Air Canada desk. That's genuinely useful for an agent to say. So the tool does two things. It prefers the record filed under the number the user asked for, and when that record is a codeshare, it reports the operating flight.

#The Tool

Bash
npm install @anthropic-ai/sdk zod
JavaScript
// flight-tool.mjs: flight status for an AI travel agent (aviationstack). Read-only on purpose.
import { betaZodTool } from "@anthropic-ai/sdk/helpers/beta/zod";
import { z } from "zod";

// aviationstack labels local airport times "+00:00". Drop the fake offset and say which zone it is.
const local = (t, tz) => (t ? `${t.slice(0, 16).replace("T", " ")} local (${tz})` : null);

export async function flightStatus(flight) {
  const url = new URL("https://api.aviationstack.com/v1/flights");
  url.searchParams.set("access_key", process.env.AVIATIONSTACK_API_KEY);
  const iata = flight.toUpperCase().replace(/\s+/g, "");
  url.searchParams.set("flight_iata", iata);
  url.searchParams.set("limit", "10");
  const res = await fetch(url);
  const body = await res.json();
  // Errors come as {error: {code}} or, for upstream rate limits, {error: "message"}.
  if (!res.ok) throw new Error(`aviationstack ${res.status}: ${body.error?.code ?? body.error}`);
  if (!body.data.length) return { found: false, flight };
  // The results include other airlines' codeshare records that point at this flight (BA117 came back as
  // AA6930 first). Prefer the record filed under the number asked for, then the newest date.
  const f = body.data.sort((a, b) =>
    (b.flight.iata === iata) - (a.flight.iata === iata) || b.flight_date.localeCompare(a.flight_date),
  )[0];
  // A live position beats a stale status: we saw "scheduled" on a flight over the Pacific.
  const airborne = f.live && !f.live.is_ground;
  const status = airborne ? "in the air" : f.flight_status;
  return {
    found: true,
    flight: f.flight.iata,
    // A codeshare (UA8032) is really another airline's flight (AC756): that's the one on the departure board.
    operated_by: f.flight.codeshared ? f.flight.codeshared.flight_iata.toUpperCase() : f.airline.name,
    date: f.flight_date,
    status,
    from: `${f.departure.airport} (${f.departure.iata})`,
    to: `${f.arrival.airport} (${f.arrival.iata})`,
    scheduled_departure: local(f.departure.scheduled, f.departure.timezone),
    actual_departure: local(f.departure.actual, f.departure.timezone),
    departure_delay_min: f.departure.delay,
    terminal: f.departure.terminal, gate: f.departure.gate,
    scheduled_arrival: local(f.arrival.scheduled, f.arrival.timezone),
    estimated_arrival: local(f.arrival.estimated, f.arrival.timezone),
  };
}

export const getFlightStatus = betaZodTool({
  name: "get_flight_status",
  description:
    "Look up a flight by its number (e.g. UA1, BA 117) and get its status, times, terminal and gate. Times are local to each airport. This tool can't book, change or cancel anything: send the traveller to the airline for that.",
  inputSchema: z.object({ flight: z.string().describe("Airline code plus number, e.g. UA1") }),
  run: async ({ flight }) => JSON.stringify(await flightStatus(flight)),
});

flightStatus is exported on its own so you can use it outside the agent too, in a delay alert job for example. The input is normalised with toUpperCase() and whitespace removed, because people and models write "ua 8032" and "BA 117".

#What the Tool Returned

We called run() directly with the inputs a model would send. UA1, written properly:

JSON
{"found":true,"flight":"UA1","operated_by":"United Airlines","date":"2026-11-11","status":"in the air","from":"San Francisco International (SFO)","to":"Singapore Changi (SIN)","scheduled_departure":"2026-11-11 22:40 local (America/Los_Angeles)","actual_departure":"2026-11-11 23:22 local (America/Los_Angeles)","departure_delay_min":42,"terminal":"3","gate":"F13","scheduled_arrival":"2026-11-13 08:00 local (Asia/Singapore)","estimated_arrival":null}

"ua 8032", lowercase with a space:

JSON
{"found":true,"flight":"UA8032","operated_by":"AC756","date":"2026-11-12","status":"in the air","from":"San Francisco International (SFO)","to":"Pierre Elliott Trudeau International (YUL)","scheduled_departure":"2026-11-12 08:25 local (America/Los_Angeles)","actual_departure":"2026-11-12 08:39 local (America/Los_Angeles)","departure_delay_min":14,"terminal":"2","gate":"D10","scheduled_arrival":"2026-11-12 16:46 local (America/Montreal)","estimated_arrival":"2026-11-12 16:37 local (America/Montreal)"}

BA117, now resolved to British Airways' own record instead of American's:

JSON
{"found":true,"flight":"BA117","operated_by":"British Airways","date":"2026-11-12","status":"landed","from":"Heathrow (LHR)","to":"John F Kennedy International (JFK)","scheduled_departure":"2026-11-12 08:20 local (Europe/London)","actual_departure":"2026-11-12 08:44 local (Europe/London)","departure_delay_min":24,"terminal":"5","gate":"B42","scheduled_arrival":"2026-11-12 11:20 local (America/New_York)","estimated_arrival":"2026-11-12 10:47 local (America/New_York)"}

And a flight number that doesn't exist, ZZ9999. aviationstack answers that with HTTP 200 and an empty data array, not an error, so the tool returns {"found":false,"flight":"ZZ9999"}. The model can then ask the user to check the number instead of inventing a flight.

#Running It in an Agent

The SDK's tool runner handles the loop: it sends the question, runs the tool when the model asks for it, and sends the result back.

JavaScript
// agent.mjs
import Anthropic from "@anthropic-ai/sdk";
import { getFlightStatus } from "./flight-tool.mjs";

const client = new Anthropic(); // reads ANTHROPIC_API_KEY

const message = await client.beta.messages.toolRunner({
  model: "claude-haiku-5-5",
  max_tokens: 2048,
  system:
    "You help travellers track flights. Use get_flight_status for every flight question and give times as local airport times. You can't book, change or cancel flights: point people to the airline for that.",
  tools: [getFlightStatus],
  messages: [{ role: "user", content: process.argv.slice(2).join(" ") }],
});
console.log(message.content.filter((b) => b.type === "text").map((b) => b.text).join(""));
Bash
node agent.mjs "My mum is on UA8032 to Montreal. Has she left yet, and when does she land?"

We use Haiku because looking up a flight and reading out a time doesn't need a frontier model, and travel questions come in bursts.

#Why the Agent Can't Book

This is the part United's announcement makes concrete. Searching and tracking are fine. Booking, changing and paying are where an agent needs your password, your card or your miles, and where a mistake costs real money. United's own statement warns about "unintended purchases, account changes, or use of MileagePlus miles".

So keep the two halves apart. Our tool only reads public flight data with our own API key, which never touches a traveller's account. The "can't book" line in the tool description and the system prompt isn't decoration: it tells the model what to say when someone asks it to change a seat. If you do add booking later, do it through a partner API the airline has approved, with the traveller confirming every purchase. Don't do it by giving a model someone's login.

#How to Stay Inside the Request Quota

Flight status questions repeat. "Has it left yet?" turns into "Is it still on time?" ten minutes later. Cache each flight's result for a few minutes in your server and most of those cost nothing. A status that's three minutes old is fine for a traveller.

Bursts are the other thing to plan for. When we tried the flightsFuture schedules endpoint during testing, it answered HTTP 429 with a body in a different shape from the usual errors:

JSON
{"error":"The upstream flight data provider is rate limiting requests. Please retry shortly."}

That's why the tool reads body.error?.code ?? body.error. Otherwise the error message would say "undefined", and the model would have nothing useful to tell the user. Space out calls in loops (we used 1.7 seconds between requests in our tests), and keep the key on the server where the tool runs.

#How to Vibe Code the Tool

If you hand this to an AI assistant, the four quirks have to be in the prompt. Otherwise you'll get a tool that passes the raw response through and tells your users a flying plane is still at the gate:

Write flight-tool.mjs exporting flightStatus(flight) and getFlightStatus, a betaZodTool from
"@anthropic-ai/sdk/helpers/beta/zod" with zod input { flight: string }. Call
https://api.aviationstack.com/v1/flights with flight_iata (uppercased, spaces removed), limit=10
and access_key from AVIATIONSTACK_API_KEY. Prefer the record whose flight.iata equals the request,
then the newest flight_date. If live exists and live.is_ground is false, status is "in the air",
otherwise flight_status. Times end in +00:00 but are LOCAL airport times: return them as
"YYYY-MM-DD HH:MM local (<timezone>)" and never parse them as UTC. If flight.codeshared is set,
operated_by is codeshared.flight_iata uppercased. Return {found:false} for an empty data array.
On errors, read body.error.code or body.error if it's a string.

Then check its work against live flights. Ask for a long-haul flight that's in the air right now and make sure the status says so. Ask for a known codeshare and check operated_by. And do the arithmetic on one departure and arrival time: if the flight comes out 16 hours longer than it should be, the assistant parsed local times as UTC.

#Things to Sort Out Before You Launch

Work out the quota for real traffic. Each "where's my flight" conversation might make three or four tool calls. A five-minute cache per flight number turns a busy morning of repeat questions into one request per flight.

Be honest about freshness. The live.updated timestamp says when the position was last seen. Pass it through if your users care about minutes, and have the agent say "as of" rather than "right now".

And keep the agent on the right side of the airline. United's policy allows searching and comparing. Ticketing, seat changes and miles belong to the traveller and the airline, so build your agent to send people there.

#Wrapping Up

The agent works because the tool does the careful part: it picks the right record, trusts the live position over a stale status, labels local times as local and names the operating airline. The model just has to read it out. Get a free key at aviationstack.com, and compare it with other flight APIs on its Public APIs page.

Happy building.