← All writing

12 Nov 202522 minllmagentslanggraph🛠≥20min

Building a Production LangGraph Travel Agent

How I built a LangGraph travel assistant that searches flights, hotels, and activities in parallel and assembles budget-aware packages.

First published in Python in Plain English

Update, October 2026: Two parts of this post no longer work as written. Amadeus shut down its Self-Service APIs on July 17, 2026, so the flight, activity, and Amadeus hotel searches need another provider. LangGraph also pauses and resumes runs with built-in interrupts now, which replace the is_continuation workaround in Challenge 4. What changed in 2026 covers both, along with streaming, durable execution and timeouts, MCP servers, agent checkout, prompt injection, budget math, and evaluation. I also redrew the diagrams.

A chatbot can answer a travel question. Planning a trip takes more:

  • searching flights, hotels, and activities across several APIs at once
  • turning a request like “5-day trip to Tokyo for $3000” into structured parameters
  • collecting customer details partway through the conversation
  • combining the results into packages that fit the budget

The target was a request like “Plan a 7-day honeymoon to Bali from NYC, departing May 15, budget $5000”, answered with options a customer could book. This post walks through how I built it with LangGraph. The code is on GitHub: langgraph-travel-agent.

The agent's chat window from the 2025 demo: the user's trip request, a confirmation that the customer details were saved, and the start of the agent's plan with flight options.
Figure 1. The chat UI in the 2025 demo.

The stack:

  • LangGraph for workflow orchestration
  • Gemini 2.5 Flash as the LLM
  • Amadeus and Hotelbeds APIs for travel data
  • FastAPI for the async backend
  • React for a frontend that polls for results

How a request flows

Request flow of the 2025 design. A natural-language request goes to call_model_and_tools, which extracts a TravelPlan and converts locations to codes. If customer info is missing, the user fills a form, a human-in-the-loop step, and the run continues; in 2026, interrupt() replaces the continuation flag. Flights from Amadeus, hotels from Amadeus and Hotelbeds, and activities from Amadeus are searched in parallel; Amadeus Self-Service closed on July 17, 2026. synthesize_results parses the results, builds packages for full plans with a budget, writes the final response, and sends it to HubSpot. Request flow of the 2025 design. A natural-language request goes to call_model_and_tools, which extracts a TravelPlan and converts locations to codes. If customer info is missing, the user fills a form, a human-in-the-loop step, and the run continues; in 2026, interrupt() replaces the continuation flag. Flights from Amadeus, hotels from Amadeus and Hotelbeds, and activities from Amadeus are searched in parallel; Amadeus Self-Service closed on July 17, 2026. synthesize_results parses the results, builds packages for full plans with a budget, writes the final response, and sends it to HubSpot.
Figure 2. How a request moves through the 2025 graph. Teal notes mark what changed in 2026.

Five parts do most of the work:

  1. Human-in-the-loop (HITL): a customer-info form appears mid-conversation.
  2. Parallel tool calls: flight, hotel, and activity searches run at the same time.
  3. Two hotel providers: Amadeus and Hotelbeds are queried together.
  4. Packages: when the user gives a budget, the LLM builds Budget, Balanced, and Premium options.
  5. CRM handoff: the final plan goes to HubSpot.

Tools

Each tool is a LangChain @tool with typed inputs and outputs.

search_flights

  • converts place names to airport codes (Tokyo → NRT)
  • supports time windows such as morning or afternoon
  • keeps the three cheapest flights, then re-sorts them by the preferred time

search_and_compare_hotels

  • queries Amadeus (OAuth) and Hotelbeds (HMAC) in parallel
  • returns about 40% more hotels than one provider alone

search_activities_by_city

  • searches within 15 km of the city’s coordinates
  • drops results that have neither a price nor a description

The intent extracted from the request decides which tools run:

Intent routing. The LLM extracts a TravelPlan and detects one intent per request. full_plan runs search_flights, search_and_compare_hotels, and search_activities_by_city; flights_only runs search_flights; hotels_only runs search_and_compare_hotels, which queries Amadeus and Hotelbeds; activities_only runs search_activities_by_city. If the intent is full_plan and there is a budget, generate_travel_packages builds Budget, Balanced, and Premium packages before the final LLM response. Intent routing. The LLM extracts a TravelPlan and detects one intent per request. full_plan runs search_flights, search_and_compare_hotels, and search_activities_by_city; flights_only runs search_flights; hotels_only runs search_and_compare_hotels, which queries Amadeus and Hotelbeds; activities_only runs search_activities_by_city. If the intent is full_plan and there is a budget, generate_travel_packages builds Budget, Balanced, and Premium packages before the final LLM response.
Figure 3. The intent decides which tools run; full plans with a budget also get packages.
  • full_plan → flights, hotels, and activities
  • flights_only → flights
  • hotels_only → hotels
  • activities_only → activities

State

Field Type Purpose
messages List[AnyMessage] Full conversation history (auto-accumulated)
travel_plan TravelPlan Structured trip extracted by LLM (origin, destination, dates, budget, intent)
customer_info Dict User details from HITL form (name, email, phone, budget)
current_step str Workflow stage: "initial" → "collecting_info" → "synthesizing" → "complete"
form_to_display str UI trigger: "customer_info" signals frontend to show form
is_continuation bool Session flag: True after form submission to bypass re-collection
original_request str First user message preserved for CRM context

Challenge 1: From a sentence to a structured plan

Users write things like “I want to go to Tokyo next month.” The APIs need airport codes (NRT, HND), dates in YYYY-MM-DD, a numeric budget, and an intent. An LLM layer with Pydantic models fills that gap:

class TravelPlan(BaseModel):
    origin: Optional[str]
    destination: str
    departure_date: Optional[str]
    return_date: Optional[str]
    total_budget: Optional[float]
    user_intent: Literal["full_plan", "flights_only", "hotels_only", "activities_only"]
async def enhanced_travel_analysis(user_request: str) -> TravelPlan:
    analysis_prompt = f"""
    Extract structured trip information from: "{user_request}"
    Today's date: {datetime.now().strftime('%Y-%m-%d')}
    Output valid JSON matching: {TravelPlan.model_json_schema()}
    """
    response = await llm.ainvoke(analysis_prompt)
    return TravelPlan.model_validate_json(response.content)

Putting the JSON schema in the prompt improved extraction accuracy: the model sees exactly which fields it has to fill.

Challenge 2: One city, three location formats

“Tokyo” means NRT or HND to a flight API, TYO to a hotel API, and (35.676, 139.650) to an activity API. A second LLM call converts names to codes:

async def location_to_airport_code(location_name: str) -> str:
    """Convert 'Tokyo' → 'NRT'"""
    if len(location_name) == 3 and location_name.isupper():
        return location_name  # Already a code

    conversion_prompt = f"""
    Convert this location to main airport IATA code.
    Examples: "Seoul" → "ICN", "Paris" → "CDG"
    Location: "{location_name}"
    IATA Code:
    """

    response = await llm.ainvoke(conversion_prompt)
    return response.content.strip().upper()

Origin and destination are converted in parallel. This adds about 200 ms and removes a hand-maintained mapping table.

Challenge 3: Parallel tool calls

Called one after another, the three searches take about 12 seconds. Run together, they take as long as the slowest one:

Timeline. Run one by one, the flight search (4 seconds), hotel search (5 seconds), and activity search (3 seconds) take about 12 seconds. Run together with asyncio.gather(), they finish in about 5 seconds, the length of the slowest search. Timeline. Run one by one, the flight search (4 seconds), hotel search (5 seconds), and activity search (3 seconds) take about 12 seconds. Run together with asyncio.gather(), they finish in about 5 seconds, the length of the slowest search.
Figure 4. Sequential and parallel tool calls.

A LangGraph node runs them together with asyncio.gather():

async def call_model_node(state: TravelAgentState) -> dict:
    # Prepare all tool calls
    tasks = [
        search_flights.ainvoke({...}),
        search_and_compare_hotels.ainvoke({...}),
        search_activities_by_city.ainvoke({...})
    ]

    # Execute in parallel
    results = await asyncio.gather(*tasks, return_exceptions=True)
    # Convert to ToolMessages for LangGraph
    messages = []
    for result, tool_name in zip(results, tool_names):
        if isinstance(result, Exception):
            content = "[]"  # Handle failures gracefully
        else:
            content = json.dumps([item.model_dump() for item in result])
        messages.append(ToolMessage(
            content=content,
            name=tool_name,
            tool_call_id=f"call_{tool_name}"
        ))
    return {"messages": messages, "current_step": "synthesizing"}

The wait dropped from 12 seconds to about 5.

Challenge 4: Human-in-the-loop without losing state

The agent needs a name, email, and budget. Asking for everything up front is poor UX, and many agents cannot resume once they stop for input. Here the graph routes on a state field:

async def call_model_node(state: TravelAgentState) -> dict:
    # Check if we need customer info
    if (not state.get('customer_info') and
        state['current_step'] == "initial"):
        return {
            "current_step": "collecting_info",
            "form_to_display": "customer_info",
            "original_request": state['messages'][-1].content
        }

    # Otherwise, proceed with tools
    # ...
# Graph routing
workflow.add_conditional_edges(
    "call_model_and_tools",
    lambda state: state["current_step"],
    {
        "collecting_info": END,      # Pause for form
        "synthesizing": "synthesize_results",
        "complete": END
    }
)

The frontend shows the form when the status response asks for it, then resumes the run:

const response = await axios.get(`/chat/status/${taskId}`);
if (response.data.form_to_display === 'customer_info') {
    setShowCustomerForm(true);  // Display form
}

// After form submission
await axios.post('/chat/customer-info', {
    thread_id: threadId,
    customer_info: formData
});
// Resume with is_continuation=True
await axios.post('/chat', {
    message: originalRequest,
    thread_id: threadId,
    is_continuation: true  // Skip form check
});

The is_continuation flag matters. Without it, the resumed run asks for the form again and loops.

Challenge 5: Two hotel providers

Amadeus and Hotelbeds differ in authentication (OAuth vs HMAC), response schemas, and error behavior. Each sits behind its own function, and both run in parallel:

async def search_and_compare_hotels(
    city_code: str,
    check_in_date: str,
    check_out_date: str,
    adults: int = 1
) -> List[HotelOption]:

    # Convert location to city code
    actual_city = await location_to_city_code(city_code)
    # Search both providers in parallel
    results = await asyncio.gather(
        _search_amadeus_hotels(actual_city, check_in_date, check_out_date, adults),
        _search_hotelbeds_hotels(actual_city, check_in_date, check_out_date, adults)
    )
    # Combine results
    combined = []
    for provider_results in results:
        combined.extend(provider_results)
    return combined

Hotelbeds signs each request with a SHA-256 signature:

def _get_hotelbeds_headers():
    utc_timestamp = int(time.time())
    signature = hashlib.sha256(
        f"{api_key}{secret}{utc_timestamp}".encode()
    ).hexdigest()

    return {
        "Api-key": api_key,
        "X-Signature": signature,
        "Accept": "application/json"
    }

Adding Hotelbeds increased hotel coverage by about 40% over Amadeus alone.

Challenge 6: Packages that fit the budget

Five flights, eight hotels, and ten activities make 400 combinations, too many for one prompt. The agent sends a representative sample instead (the two cheapest, three mid-range, and two most expensive options) and asks the LLM for three tiers:

def _get_representative_options(options: List, max_items=7) -> List:
    """Sample cheapest, mid-range, and priciest options"""
    if len(options) <= max_items:
        return options

    options.sort(key=lambda x: float(x.price.split()[0]))
    return (
        options[:2] +           # Cheapest 2
        options[len(options)//2-1:len(options)//2+2] +  # Mid-range 3
        options[-2:]            # Most expensive 2
    )
async def generate_travel_packages(
    trip_plan: TravelPlan,
    all_options: Dict
) -> List[TravelPackage]:
    # Sample options to fit context window
    rep_flights = _get_representative_options(all_options['flights'])
    rep_hotels = _get_representative_options(all_options['hotels'])
    rep_activities = _get_representative_options(all_options['activities'], 10)

    generation_prompt = f"""
    Create 3 travel packages for budget ${trip_plan.total_budget}:

    Available options:
    - Flights: {json.dumps([f.model_dump() for f in rep_flights])}
    - Hotels: {json.dumps([h.model_dump() for h in rep_hotels])}
    - Activities: {json.dumps([a.model_dump() for a in rep_activities])}
    Requirements:
    - ONE flight, ONE hotel, 0-2 activities per package
    - Calculate: flight + (hotel × nights) + activities
    - Create Budget, Balanced, Premium tiers
    Output JSON array: {TravelPackage.model_json_schema()}
    """
    response = await llm.ainvoke(generation_prompt)
    return [TravelPackage.model_validate(p) for p in json.loads(response.content)]

Sampling cut prompt tokens by about 70% without hurting package quality.

Running it in production

Error handling

results = await asyncio.gather(*tasks, return_exceptions=True)

for result in results:
    if isinstance(result, Exception):
        print(f"Tool failed: {result}")
        content = "[]"  # Graceful degradation
    else:
        content = json.dumps([item.model_dump() for item in result])

A failed API returns an empty list instead of failing the whole request.

State persistence

InMemorySaver() is enough for development. Production needs Redis or Postgres.

from langgraph.checkpoint.memory import InMemorySaver

checkpointer = InMemorySaver()
graph = build_enhanced_graph(checkpointer)
# For production:
# from langgraph.checkpoint.redis import RedisSaver
# checkpointer = RedisSaver.from_conn_info(host="localhost", port=6379)

Background tasks and polling

A request takes 5–12 seconds, so the backend runs it as a background task and the frontend polls:

# Backend
@app.post("/chat")
async def start_chat_task(request: ChatRequest, background_tasks: BackgroundTasks):
    task_id = str(uuid.uuid4())
    jobs[task_id] = {"status": "running"}

    background_tasks.add_task(run_agent_in_background, task_id, request)
    return {"task_id": task_id}
@app.get("/chat/status/{task_id}")
async def get_task_status(task_id: str):
    return jobs.get(task_id, {"status": "not_found"})
// Frontend
const response = await axios.post('/chat', { message });
const taskId = response.data.task_id;

// Poll every 2.5 seconds
const interval = setInterval(async () => {
    const status = await axios.get(`/chat/status/${taskId}`);
    if (status.data.status === 'completed') {
        clearInterval(interval);
        displayResults(status.data.result);
    }
}, 2500);

What I learned

Keep nodes simple and branch with conditional edges

# BAD: Complex logic in node
async def node(state):
    if condition1:
        # do thing 1
    elif condition2:
        # do thing 2
    # ...
# GOOD: Simple node + conditional routing
async def node(state):
    return {"current_step": determine_step(state)}
workflow.add_conditional_edges(
    "node",
    lambda state: state["current_step"],
    {"step1": "node1", "step2": "node2"}
)

Track the conversation stage explicitly

Human-in-the-loop flows loop forever without a flag like is_continuation.

Validate LLM output with Pydantic

# Instead of parsing LLM output manually:
response = await llm.ainvoke(prompt)
extracted = TravelPlan.model_validate_json(response.content)

Pydantic handles validation, error messages, and type coercion.

Run API calls in parallel

Sequential calls were the largest source of latency. asyncio.gather() removed most of it.

Sample instead of sending everything

The prompt does not need all 50 hotels. A spread of cheap, mid-range, and expensive options gives the model enough to choose from and stays within the token budget.

Numbers

Response time:

  • analysis: 1–2 s
  • parallel tool calls: 3–8 s
  • package generation: 1–2 s
  • total: 5–12 s

Cost per query with Gemini 2.5 Flash is about $0.001 for analysis and $0.002 for package generation, roughly $0.003 in total.

What changed in 2026

I added this section in October 2026, eleven months after the original post. Figure 5 sketches how I would rebuild the agent with what LangGraph and the travel ecosystem offer now. It is a design, not the deployed system; the subsections below explain each part.

A sketch of the agent rebuilt with 2026 LangGraph, not the deployed 2025 system. A React UI uses useStream to stream progress, show the form on an interrupt, and resume the run. An Agent Server or FastAPI streams updates and custom events over SSE and stores checkpoints in Postgres with durability set to sync. The graph has six steps: collect customer info with interrupt() and resume with Command; analyze the request into a structured TravelPlan; search suppliers with Send, one branch per leg and provider, with per-node timeouts and retries; build packages with totals and budget checks in code; write the options with an LLM that reads supplier text and has no tools; and hand off only after approval. Suppliers are Duffel for flights and Hotelbeds for hotels, plus MCP servers from Booking.com, Expedia, and Kiwi.com through langchain.mcp, whose text is treated as untrusted. Checkout goes through UCP, ACP, or AP2 only after approval, and LangSmith holds traces and evaluations such as tau-squared-bench and TravelPlanner. A sketch of the agent rebuilt with 2026 LangGraph, not the deployed 2025 system. A React UI uses useStream to stream progress, show the form on an interrupt, and resume the run. An Agent Server or FastAPI streams updates and custom events over SSE and stores checkpoints in Postgres with durability set to sync. The graph has six steps: collect customer info with interrupt() and resume with Command; analyze the request into a structured TravelPlan; search suppliers with Send, one branch per leg and provider, with per-node timeouts and retries; build packages with totals and budget checks in code; write the options with an LLM that reads supplier text and has no tools; and hand off only after approval. Suppliers are Duffel for flights and Hotelbeds for hotels, plus MCP servers from Booking.com, Expedia, and Kiwi.com through langchain.mcp, whose text is treated as untrusted. Checkout goes through UCP, ACP, or AP2 only after approval, and LangSmith holds traces and evaluations such as tau-squared-bench and TravelPlanner.
Figure 5. A 2026 rebuild of the agent, sketched. The highlighted step reads untrusted supplier text and holds no tools.

Amadeus Self-Service is gone

In February 2026, Amadeus told developers it would stop accepting new registrations and decommission its Self-Service API portal for existing users on July 17, 2026. Enterprise customers keep access through the Amadeus Enterprise portal. The flight search, the Amadeus half of the hotel search, and the activity search in this post used Self-Service keys, so they stopped working on that date.

The design around them still holds. Each provider sits behind its own tool, a failure returns an empty list, and the hotel search merges providers. What changes is where the data comes from:

  • Hotels: Hotelbeds still documents the signed-request scheme shown above.
  • Booking.com: it runs a hosted MCP server for approved partners. The available tools depend on the affiliate account, and order creation isn’t included.
  • Expedia: it has announced a B2B MCP server for partners.
  • Google: it began testing agentic hotel booking in AI Mode in the US in August 2026 and plans to run it on its Universal Commerce Protocol (UCP). The first draft of UCP’s lodging-booking capability was published for feedback in September, and the hotel stays the merchant of record.
  • Flights: Duffel sells self-serve flight search and booking on a pay-as-you-go plan, at $3 per confirmed order plus 1% of the order value when you use its accreditation. Otherwise you need an enterprise or consolidator contract, or a search-only fare API.

Interrupts replace the continuation flag

LangGraph 1.0 (October 2025) made pausing for a human a first-class feature:

  1. A node calls interrupt().
  2. LangGraph saves the state through the checkpointer and waits.
  3. The client resumes the same thread with Command(resume=...).

The value passed to resume becomes the return value of interrupt():

from langgraph.types import Command, interrupt

async def collect_customer_info(state: TravelAgentState) -> dict:
    if state.get("customer_info"):
        return {}
    form = interrupt({"form": "customer_info"})  # the run pauses; the client shows the form
    return {"customer_info": form}
config = {"configurable": {"thread_id": thread_id}}
await graph.ainvoke({"messages": [("user", request)]}, config)  # stops at the form
await graph.ainvoke(Command(resume=form_data), config)          # continues the same run

This removes form_to_display, is_continuation, and original_request from the state. The paused run keeps the original request in its checkpoint, and there is no flag to forget. Two rules from the docs matter here:

  • On resume, the node runs again from its first line. Side effects such as the HubSpot call belong after the interrupt or in a later node.
  • Don’t put interrupt() inside a bare try/except, because it would catch the pause.

LangGraph 1.1 (March 2026) adds a typed return value. With version="v2", ainvoke returns a GraphOutput whose .interrupts field lists the pending pauses, instead of mixing them into the state under an __interrupt__ key. The status endpoint can pass the form request straight to the frontend:

result = await graph.ainvoke({"messages": [("user", request)]}, config, version="v2")
if result.interrupts:
    return {"status": "needs_input", "form": result.interrupts[0].value}
return {"status": "completed", "state": result.value}

LangChain 1.0 adds the same pause around tool calls. With HumanInTheLoopMiddleware, an agent built with create_agent stops before the tools you choose run, and a person can approve, edit, or reject the call. That fits the CRM handoff:

from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware

agent = create_agent(
    model=model,
    tools=[search_flights, search_and_compare_hotels, send_to_crm],
    middleware=[HumanInTheLoopMiddleware(interrupt_on={"send_to_crm": True})],
    checkpointer=checkpointer,
)
# Approve the pending call:
# agent.invoke(Command(resume={"decisions": [{"type": "approve"}]}), config)

Durable execution and a production checkpointer

LangGraph 0.6 added a durability setting that controls when checkpoints are written:

  • "exit" saves only when the run stops.
  • "async" writes in the background.
  • "sync" writes before each next step.

A booking flow should use "sync", so a crash can’t lose a step the user already confirmed. The LangGraph docs recommend Postgres for production:

from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver

async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
    await checkpointer.setup()  # first run only
    graph = build_enhanced_graph(checkpointer)
    await graph.ainvoke(inputs, config, durability="sync")

LangGraph 1.2 (May 2026) adds the controls a graph that waits on outside suppliers needs:

  • Per-node timeouts: a TimeoutPolicy on add_node stops a hung supplier call. LangGraph discards that attempt’s writes and hands it to the retry policy.
  • Error handlers: error_handler= runs after the last retry and can return a Command that routes to a fallback node, for example to release a held fare when a later step fails.
  • Graceful shutdown: RunControl.request_drain() stops a run after the current step and saves a resumable checkpoint, so a deploy doesn’t cut a booking in half.
from langgraph.types import RetryPolicy, TimeoutPolicy

workflow.add_node(
    "search_flights",
    search_flights_node,
    timeout=TimeoutPolicy(run_timeout=20, idle_timeout=8),
    retry_policy=RetryPolicy(max_attempts=2),
    error_handler=use_cached_fares,  # returns a Command to a fallback node
)

Timeouts work on async nodes only.

Stream progress instead of polling

The frontend above polls every 2.5 seconds. LangGraph can stream instead:

  • stream_mode="updates" emits each node’s state change as it finishes.
  • "messages" streams LLM tokens.
  • "custom" carries anything a tool writes through get_stream_writer(), such as “searching hotels…”.

Over server-sent events, the user sees each search arrive as it completes:

from fastapi.responses import StreamingResponse

@app.post("/chat/stream")
async def chat_stream(request: ChatRequest):
    config = {"configurable": {"thread_id": request.thread_id}}

    async def events():
        async for mode, chunk in graph.astream(
            {"messages": [("user", request.message)]}, config, stream_mode=["updates", "custom"]
        ):
            yield f"data: {json.dumps({'mode': mode, 'data': chunk}, default=str)}\n\n"

    return StreamingResponse(events(), media_type="text/event-stream")

LangGraph Platform, the hosted runtime, was renamed LangSmith Deployment in October 2025, and its API offers the same stream modes.

When the graph runs on LangGraph’s Agent Server (langgraph dev locally, or LangSmith Deployment), the useStream hook from @langchain/react replaces both the polling loop and the form flag on the frontend. It streams the run, exposes a pending interrupt, and resumes it:

import { useStream } from "@langchain/react";

const stream = useStream({ apiUrl: "http://localhost:2024", assistantId: "travel_agent" });
const pending = stream.interrupt?.value; // { form: "customer_info" } from interrupt()

// in the form's submit handler
stream.submit(null, { command: { resume: formData } });

Fan out with Send when the number of searches varies

asyncio.gather() is fine for a fixed set of three searches. A multi-city trip needs one flight search per leg, and the number of legs comes from the request. LangGraph’s Send creates one parallel branch per item, and a reducer on the state field merges the results:

import operator
from typing import Annotated

from langgraph.types import Send

class TravelAgentState(TypedDict):
    ...
    flights: Annotated[list, operator.add]  # branch results are concatenated

def plan_flight_searches(state: TravelAgentState):
    return [Send("search_leg", {"leg": leg}) for leg in state["travel_plan"].legs]

workflow.add_conditional_edges("call_model_and_tools", plan_flight_searches, ["search_leg"])

Here legs would be a new field on TravelPlan.

Do the budget math in code

Challenge 6 asks the LLM to add up flight, hotel, and activity prices and fit three tiers into the budget. Planning benchmarks show why that is fragile. In TravelPlanner (Xie et al., ICML 2024), GPT-4 produced a plan that met every constraint for 0.6% of the queries. When the LLM only translated the request into constraints and a solver searched for the plan, the success rate rose to 93.9%, and the solver could also say which constraint made an impossible request impossible (Hao et al., NAACL 2025).

This agent needs less than a solver. Five flights, eight hotels, and up to two of ten activities make about 2,200 combinations, which code can total, check against the budget, and sort in milliseconds. The LLM then only writes the descriptions:

from itertools import combinations

def package_total(flight, hotel, picks, nights):
    return flight.price + hotel.price_per_night * nights + sum(a.price for a in picks)

bundles = [()] + [(a,) for a in activities] + list(combinations(activities, 2))
options = sorted(
    ((package_total(f, h, picks, nights), f, h, picks) for f in flights for h in hotels for picks in bundles),
    key=lambda option: option[0],
)
affordable = [o for o in options if o[0] <= budget]
if affordable:
    tiers = {"Budget": affordable[0], "Balanced": affordable[len(affordable) // 2], "Premium": affordable[-1]}

Prices are parsed into numbers once, when the tool results come in. If nothing fits, the agent can report the cheapest total instead of inventing a package.

Keep supplier text away from the tools

Hotel and activity descriptions come from suppliers, and through MCP servers from third parties, so they can carry injected instructions. Beurer-Kellner et al. (2025) work through a booking assistant as one of their case studies. In their most robust design, a privileged LLM fixes the plan from the user’s request alone, and third-party content goes to a separate LLM that has no tools. Applied to this agent:

  • the step that reads supplier text and writes the packages runs without the CRM or booking tools
  • tools with side effects, such as the HubSpot call or a booking, stay behind HumanInTheLoopMiddleware
  • the customer’s details go only to the step that needs them

Models and cost

Gemini 2.5 Flash still runs for projects that already used it, but Google now points new projects to Gemini 3.8 Flash or Gemini 3.5 Flash-Lite. The cost figures above were measured with 2.5 Flash, so measure them again with the current model.

MCP servers and agent checkout

Travel companies now expose inventory to agents directly:

  • Kiwi.com: released a prototype MCP server for flight search in August 2025.
  • Expedia and Booking.com: launched apps inside ChatGPT on OpenAI’s Apps SDK, which is built on MCP.
  • Booking.com: also runs the partner MCP server mentioned above.

An agent like this one can call those servers instead of wrapping each REST API by hand. LangChain 1.4 (September 2026) builds the client into LangChain: MCPAdapter in langchain.mcp, in beta, replaces the separate langchain-mcp-adapters package and turns a server’s tools into LangChain tools.

from langchain.agents import create_agent
from langchain.mcp import MCPAdapter

async with MCPAdapter("https://partner.example.com/mcp") as adapter:
    supplier_tools = await adapter.list_tools()
agent = create_agent(model, supplier_tools + [send_to_crm], checkpointer=checkpointer)

Two details fit this agent. When a server asks the user a question in the middle of a call, the adapter turns it into a LangGraph interrupt, so the same pause-and-resume path handles it. Each tool also carries the server’s annotations, such as destructive_hint, which can decide which calls need approval.

MCP Apps, the first official MCP extension (January 2026), lets a server return an interactive UI that hosts such as Claude, ChatGPT, Goose, and VS Code render in a sandboxed frame. A customer form like the one in Challenge 4 can now ship with the server instead of with every frontend.

Checkout is following:

  • OpenAI and Stripe released the Agentic Commerce Protocol with Instant Checkout in ChatGPT in September 2025.
  • Google published the Agent Payments Protocol (AP2) the same month, and in January 2026 launched the Universal Commerce Protocol (UCP) with Shopify and large retailers. UCP works alongside AP2 and is now being extended to hotel booking.

The HubSpot handoff in this post ends at a lead. With these protocols, the same flow could end at a booking, with an approval step before any payment.

Evaluate against τ²-bench

τ²-bench (Barres et al., 2025) tests conversational agents in environments where both the agent and a simulated user can act on shared state. Its airline domain has 50 tasks carried over from the original τ-bench and is close to the booking and change requests this agent would handle. Amazon’s τ²-Bench-Verified fixes tasks whose expected actions did not match the policies, so check which version a reported score uses. For the planning side, TravelPlanner, described above, checks whether a plan respects every constraint in the request.

Where the LangChain stack went

LangChain 1.0’s create_agent runs on LangGraph, and the Deep Agents harness adds planning, a file system, and subagents for long, open-ended tasks. In August 2026 LangChain launched Managed Deep Agents, which bundles that harness with LangSmith Deployment, sandboxes, memory, and authentication. This agent follows a fixed workflow, which maps directly onto a LangGraph graph. The deep-agent stack targets open-ended work, such as researching and revising a multi-city itinerary over many steps.

Next steps, revisited

Two of the next steps below now have built-in support:

  • Caching repeated searches: node caching (cache_policy=CachePolicy(ttl=...) on add_node, with a cache passed to compile) returns the stored result for the same input until the TTL expires.
  • Learning from the packages users pick: LangGraph’s store keeps memory across threads, such as a traveler’s preferred hotel class, in memory or in Postgres.

Sources for this update

Next steps

  1. A Redis cache for repeated searches
  2. Learning from the packages users pick
  3. Follow-up requests such as “show me cheaper hotels”
  4. Car rentals, to cover the whole trip

The full implementation is on GitHub: langgraph-travel-agent.