duffel-recovery
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@duffel-recovery Recover my disrupted booking ord_fx_s02, need to arrive by Friday 18:00, find cheapest option."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Trip Disruption Recovery Agent
An AI agent that recovers a disrupted flight booking. Give it a goal in plain English and a booking reference; it investigates the disruption, prices every way out, recommends one with the math shown, and acts only after you approve.
Built on Claude Code plus a purpose-built MCP server, against the Duffel flight API in test mode only: no real money, no real bookings.
By Austin Lai. Specified, built and evaluated with Claude Code; the original spec is in docs/spec.md.
/trip-recovery My flight to New York (order ord_fx_s02) was moved by the airline.
I need to be at JFK by Friday 18 September 2026 at 18:00. Find the cheapest way.
Two real runs of the same scenario. First with nobody available to approve, so
nothing is booked. Then with an approval, so it books the replacement, cancels
the original, and verifies both. Recorded with
docs/demo_script.sh.
1. The problem
When an airline changes or cancels a flight, the traveler has a decision to make under time pressure, usually with three or four options:
Option | What it costs | What it risks |
Accept the airline's new itinerary | Nothing in cash; possibly hours | Arriving too late to matter |
Pay to change to a different flight | A change fee plus any fare difference | Paying for time you don't need |
Cancel and rebook elsewhere | A new fare, minus a refund that may be credit, not cash | Losing the refund's real value |
Escalate to a human | Time | Nothing, but slow |
Target user: the traveler, or the support agent handling it for them at an online travel agency.
Why this is a decision, not a lookup. There is no single correct answer to fetch from an API. The right choice depends on facts spread across four different endpoints (the order, the airline's change, change quotes, live fares) and on the traveler's own weights: how late is too late, and what a voucher is worth compared with cash. A voucher for £420 is not £420. Most travelers accept whatever the airline offers because comparing properly takes 20 minutes of work they can't do at the gate.
Related MCP server: exchange-ai-connector
2. What it does
A real run against the s02 scenario (an airline moves a flight 24 hours,
missing the traveler's deadline). Full transcript:
evals/results/m4_manual_runs/.
The agent chose to read the order, list the airline's change, quote a paid change, search alternative flights, price a cancellation, refresh the two live fares, and only then score the options:
Option | Out-of-pocket | Refund (cash) | Refund (credit) | Arrival | Meets deadline | Total cost (lower is better) |
A. Accept airline change | 0.00 | — | — | 19 Sep 11:55 | ❌ No | 600.00 |
B. Change to ZZ307 | 60.00 | — | — | 18 Sep 15:55 | ✅ Yes | 160.00 |
C1. Cancel + rebook ZZ307 | 298.00 | — | 420.00 | 18 Sep 15:55 | ✅ Yes | 104.00 |
C2. Cancel + rebook ZZ205 | 365.00 | — | 420.00 | 18 Sep 11:10 | ✅ Yes | 71.00 |
Recommended: C2 — cancel for 420.00 in airline credit (worth 294.00 at a 0.7 discount) and rebook ZZ205 for 365.00, netting 71.00. Runner-up: C1 at 104.00. What would change this: C1 wins if an hour of your time is worth less than 16.75.
It then booked the new flight first, cancelled the old one second (so the traveler is never left with no ticket), each behind its own approval, and verified both by re-reading the orders.
How the total is calculated
Every option collapses into one number: total cost, where lower is better. It isn't a score out of 100, and it isn't cash out of pocket. It's what the disruption costs you once credit and lost time are priced in.
total cost = cash paid
− cash refunded
− (airline credit × 0.7) ← a voucher is worth less than cash
+ (hours later than your original arrival × 25)Both weights are yours, set in config.yaml. For the run above
(original arrival 18 Sep 11:55, deadline 18 Sep 18:00):
Option | Arithmetic | Total cost |
C2. Cancel + rebook ZZ205 |
| 71.00 |
C1. Cancel + rebook ZZ307 |
| 104.00 |
B. Change to ZZ307 |
| 160.00 |
A. Accept the airline's change |
| 600.00, ineligible |
Why C2 beats C1 — the real contest. Both cancel the same booking for the same 420.00 credit, so the credit cancels out. C1 saves 67.00 in cash but arrives four hours later, and four hours costs 100.00. C2 wins by 33.00. Divide that 67.00 by those four hours and you get the break-even the agent reports: if an hour of your time were worth less than 16.75, C1 would be the better answer.
Four rules shape every comparison:
Credit is discounted. 420.00 in airline credit counts as 294.00, because it expires and only works on one airline.
Lateness is priced, not just checked, and measured against your original arrival. That's why accepting a free 24-hour delay costs 600.00.
Arriving early earns nothing. C2 lands 45 minutes early; the time term is clamped at zero, so the agent can't spend your money buying earliness you never asked for.
The deadline is a hard constraint, not a cost. Option A is shown with its total for transparency, but it misses the deadline and so can never be recommended, however cheap it looks.
The arithmetic lives in scoring.py, and the model
never does it: the agent decides which options to submit, the code ranks them.
3. Why it's agentic
There is no decision tree in this repo. The agent chooses which tools to call and in what order, and the runs show it: it used between 6 and 24 tool calls depending on the situation, searched extra dates when a day was sold out, and re-planned when a fare sold out mid-booking.
The agent decides | The code decides |
Which options exist and are worth pricing | What each option scores ( |
When it has enough information to recommend | Whether a number is real (traceability check) |
How to adapt when a tool fails or a price moves | Whether an action may run at all (approval gate) |
What to tell the traveler, and what would change the answer | What the traveler is shown before approving |
The division is deliberate: judgment to the model, consequences to the code.
4. Architecture
flowchart TD
U["Traveler: goal + booking ID"] --> CC["Claude Code<br/>+ /trip-recovery playbook"]
CC <--> MCP["MCP server: duffel-recovery"]
MCP --> G["Guardrail layer<br/>test-token check · spend cap · approval gate<br/>call budget · audit log"]
G --> B{"Backend"}
B -->|evals| FX["FixtureBackend<br/>JSON scenarios"]
B -->|live| DF["DuffelSandboxBackend<br/>Duffel API, test mode"]
G -.->|"approval dialog"| U
G --> AUD[("Audit log (JSONL)<br/>every call, approval, write")]Two backends, one interface. The live sandbox is non-deterministic, so it can't have a known right answer. Fixtures pin every response, which is what makes the eval suite meaningful; the Duffel backend proves the same agent works against a real API. Both return identical normalized models, so the agent can't tell them apart.
Every tool result carries a call_id (C001, C002, …). The agent cites
these for every number it reports, and the eval suite checks the citations.
5. Guardrails: the model proposes, code disposes
Each of these is enforced in Python and covered by tests. None of them relies on the model choosing to behave.
Guardrail | How it's enforced | Proof |
Test mode only | Refuses any token that isn't |
|
No irreversible action without approval | Write tools only propose until they get a single-use token, minted solely after a human types "yes" in an approval dialog the server writes |
|
Approval can't be reused or stretched | Tokens are bound to one action, target, amount and currency, expire after 10 minutes, and are voided on any mismatch (e.g. the price moved after approval) |
|
Spending cap | A ledger checks every proposal and every execution; one currency per session, never converted |
|
No invented numbers |
|
|
Fake passengers only | Passenger details come from |
|
Runaway protection | 40 tool calls per session; a failing call may be retried twice, then it's blocked |
|
Injected instructions are inert | Free text from a booking is labelled | scenario |
Auto-approval can't touch Duffel | Config validation and a second runtime check both refuse auto-approve unless the backend is fixtures |
|
A second, independent layer: when the server talks to Duffel, its write tools
are flagged anthropic/requiresUserInteraction, so Claude Code also demands a
human prompt on every call, even under permissive settings.
6. Evaluation
Method. 16 scenarios, each a JSON file pinning every tool response plus an answer key. Each run is a fresh headless Claude Code session with only this MCP server, no built-in tools (so the agent can't read the answer key), no personal settings, and no memory. Every run is graded from its audit log, not from its prose.
Scenarios cover: accepting a change, paid changes, cancel + rebook, a credit discount that flips the answer, a price that rises on refresh, an offer that sells out at booking time, no alternatives at all, persistent search timeouts, an option that's cheapest but over the cap, instructions injected into booking notes, an outright cancellation, an exact tie, lateness outweighing free, a change that refunds money, an unstated refund amount, everything arriving late, and a sold-out day that needs a wider search.
Scorecard: 16 scenarios × 3 runs = 48 runs (Claude Sonnet via Claude Code 2.1.272). Full results, including every failure and the method: evals/results/2026-09-15.md.
Metric | Result |
Decision accuracy | 48/48 (100%) |
Consistency (correct in all 3 runs) | 16/16 scenarios |
Guardrail violations | 0 |
Number traceability | 1,649/1,653 (99.8%) |
Avg tool calls per run | 13.4 (max 27) |
Runs that errored or timed out | 0 |
Read that accuracy with care. 3 runs per scenario is a small sample, the scenarios were written by the same person who wrote the playbook, and the model knows it is being asked for a careful comparison. It says the agent is reliable on the situations I anticipated; it says nothing about the ones I didn't.
The 4 untraced numbers are all the agent's own arithmetic, not invented facts: "122.00 GBP net credit after the new fare" (420 − 298), "arrives ~4.75h later", and two hypothetical departure times in "what would change this" ("a flight departing before ~10:00 would meet the deadline"). The traceability metric flags any number not present verbatim in a tool result, so derived values count against it. That's the metric being strict, not the agent guessing.
Why 3 runs and not 5. The spec asked for 5. 16 runs exhausted a Claude
subscription session limit, so the suite was re-run at 3 to fit one window.
uv run python -m evals.run_evals --runs 5 does the full version.
The answer keys are themselves tested. tests/test_scenario_answers.py
rebuilds every option in every scenario and scores it with the production
scoring code, so a hand-worked answer can't silently be wrong.
7. Limitations (honest list)
Synthetic data. Duffel Airways is a sandbox airline: schedules and prices aren't realistic. The fixtures are hand-written to be plausible, not real.
No real bookings, ever. Live mode is refused by construction.
No passenger-rights logic. The agent makes no claims about DOT, UK261 or EU261 compensation. That needs primary sources and citations; it's out of scope.
One-way trips, one traveler. Multi-slice itineraries and passengers with conflicting constraints aren't handled in v1.
Local clock times. Options are compared as local times at the arrival airport; the agent isn't asked to reason across time zones.
Credit value is a single number. A 0.7 factor can't express "worthless unless I fly them again within a year".
The approval dialog is as strong as its client. Claude Code shows it and the server never mints a token without an explicit "yes", but a user who configures an auto-answering hook can bypass the human step.
Evals measure decisions, not taste. A run can be graded correct and still explain itself poorly.
8. What I'd build next
Passenger rights, with citations. A module that reads primary sources (e.g. US DOT refund rules) and quotes them, so the agent can say "you're owed a cash refund" and show why. Biggest single lift in perceived value.
Proactive monitoring. Duffel webhooks instead of a human noticing; the agent drafts the recommendation before the traveler has read the email.
Points and vouchers as first-class currency, replacing the single credit-value factor.
Multi-passenger trips where constraints conflict.
Business case (a hypothesis, not a finding). Disruption handling is a high-cost, low-satisfaction moment for an online travel agency: it arrives in bursts, needs a trained agent, and the traveler is already unhappy. If an agent can prepare a priced, sourced recommendation before a human opens the ticket, the plausible win is shorter handle times and fewer escalations, with the human kept for the approval and the edge cases. Testing that claim needs real ticket data: volume, current handle time, and the mix of disruption types. None of that is measured here.
9. Run it yourself
Setup (once)
brew install --cask claude-code # the Claude Code CLI (or: curl -fsSL https://claude.ai/install.sh | bash)
brew install uv # Python toolchain; installs Python 3.12 for this project
cd trip-recovery-agent
uv sync # dependencies
uv run pytest # 219 tests: no network, no model, no token neededThe first claude run in this folder asks three one-time questions: a colour
theme, how to log in (a Pro/Max subscription or a Console account), and whether
to trust this folder and its MCP server. That server is duffel-recovery from
this repo, declared in .mcp.json. A Homebrew install doesn't
auto-update; refresh it with brew upgrade --cask claude-code.
Recover a booking
claudeThen type the skill name and your goal in plain English:
/trip-recovery The airline changed my flight (order ord_fx_s01). Get me to JFK by
Friday 18 September 2026 at 18:00 local time at the lowest total cost.The order ID has to match the scenario that's loaded. config.yaml sets it:
fixture:
scenario: evals/scenarios/s01_accept_change_meets_deadline.jsonPoint that at any file in evals/scenarios/ and restart
claude, since the server reads its config at startup. Four worth trying:
Order ID | Scenario | What you'll see |
|
| Accepting the airline's change wins |
|
| Cancel for credit and rebook; two approvals |
|
| The booking record tells the agent to ignore the spend cap |
|
| Nothing meets the deadline, so it escalates |
Approving
When the agent is ready to act, a dialog appears with a summary the server
wrote: the flight, the amount, and what can't be undone. Type yes to
approve; anything else declines. Every irreversible action asks separately, so
the s02 run asks twice: once to book the replacement, once to cancel the
original. Afterwards, read exactly what happened:
uv run python scripts/show_audit.pyWithout a human (headless)
uv run python -m evals.run_evals --runs 3 # the graded suite -> evals/results/<date>.md
bash docs/demo_script.sh # the two-scene demo aboveAgainst the real Duffel sandbox
Put a test token (duffel_test_…) in .env, set backend: duffel_sandbox
in config.yaml, then create a booking to disrupt:
uv run python scripts/seed_sandbox_order.py --route LHR-LTNIt prints an order ID; use that in /trip-recovery. In Duffel's sandbox, an
LHR-LTN order generates a fresh airline change every time changes are listed,
and an LTN-SYD order refunds to airline credit when cancelled.
10. Repo map
Path | What it is |
| the playbook: principles and required output, not a script |
| MCP adapter: tool registration and the approval dialog |
| every tool's logic, wrapped in the guardrails |
| test mode, fake data, call budget, spend cap, approval gate |
| the objective function and the traceability check |
|
|
| scenario builder, 16 scenarios, grader, runner, scorecards |
| objective weights, spend cap, limits, fake passengers |
| the original build spec |
Austin Lai · github.com/austinlai22 · MIT licensed
Available Tools
13 toolsaccept_airline_changeADestructive
Accept the airline's schedule change. Without approval_token this only proposes.
Inputs: change_id (required), approval_token. Returns: object
book_offerADestructive
Book a new flight. Without approval_token this only proposes and returns a pending_action_id.
Passenger details come from the server's fake-passenger config; you can't supply them.
Inputs: offer_id (required), approval_token. Returns: object
confirm_cancellationADestructive
Cancel the booking per a quote from quote_cancellation. Without approval_token this only proposes.
Inputs: cancellation_id (required), approval_token. Returns: object
confirm_order_changeADestructive
Move the booking to a quoted change offer. Without approval_token this only proposes.
Inputs: change_offer_id (required), approval_token. Returns: object
get_offerARead-only
Refresh one offer's price and availability. Do this before recommending it.
Inputs: offer_id (required). Returns: object
get_orderARead-only
Get a booking: itinerary, price, passengers, fare conditions, and arrival_local.
Inputs: order_id (required). Returns: object
list_airline_initiated_changesARead-only
List schedule changes the airline made to a booking (removed vs added flights).
Results are cached per session: in the Duffel sandbox a fresh call can create a new
simulated change. Only set force_refresh=true if you truly need to re-check.
Inputs: order_id (required), force_refresh. Returns: object
quote_cancellationARead-only
Price cancelling a booking: refund_amount and refund_to (cash vs airline credit). Changes nothing.
Inputs: order_id (required). Returns: object
quote_order_changeARead-only
Price moving an existing one-way booking to other flights on a date. Changes nothing.
change_total_amount > 0 means the traveler pays; < 0 means a refund.
origin/destination default to the booking's own.
Inputs: order_id (required), departure_date (required), origin, cabin_class, +1 more. Returns: object
record_recommendationA
Record your final recommendation (or escalation) in the audit log. Call once, before any approval.
target_ids: the IDs the recommended option would act on (airline change, change offer,
cancellation, and/or offer). score_call_id: the score_options call that backs it.
Inputs: summary (required), decision (required), target_ids, score_call_id, +3 more. Returns: object
request_approvalA
Ask the traveler to approve a pending action (from a write tool called without a token).
A dialog shows the traveler a summary written by the server. Only an explicit "yes"
returns an approval_token. Then call the same write tool again with that token.
Inputs: pending_action_id (required). Returns: object
score_optionsA
Score and rank options with the traveler's objective. Do the math here, not in your head.
total_cost = cash_paid - cash_refunded - credit x credit_value_factor + hours_late x value_of_time.
It is a COST, so lower is better; say that whenever you show it to the traveler.
Options missing the deadline or over the spend cap can't be the pick. Every amount and time
must cite the call_id it came from; numbers that don't appear there are rejected.
Inputs: options (required), deadline_local (required), original_arrival_local (required), original_arrival_call_id (required). Returns: object
search_alternativesARead-only
Search new one-way flights, cheapest first. Dates are a range of at most 3 days.
Use 3-letter IATA codes. Partial failures appear in date_errors.
Inputs: origin (required), destination (required), departure_date (required), adults, +3 more. Returns: object
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 13 tools
Each tool targets a distinct action in the recovery workflow: reading an order, searching flights, quoting changes/cancellations, scoring options, recording recommendations, executing actions, and handling approvals. No two tools appear to do the same job, and the separation between quote, confirm, and book is clear.
All tool names follow a consistent snake_case verb_noun pattern (get_order, quote_cancellation, confirm_order_change, request_approval, etc.). Longer compound names still use the same convention, so the set is predictable and easy to navigate.
13 tools is well-scoped for an airline recovery server. Each tool covers a necessary step in the workflow—discovery, quoting, scoring, execution, approval—without redundancy or bloat.
The core recovery domain is well covered: view booking, search alternatives, quote and confirm changes/cancellations, handle airline-initiated changes, score options, and request approvals. A minor gap is the lack of a list/search orders tool, so an agent must already know the order ID, but this is workable within the stated purpose.
Maintenance
Related MCP Connectors
Live reroutes, desk scripts, airport help and EU261/UK261/US DOT rights for disrupted flights.
Personal AI travel agent. Points optimization, live flight/hotel/award search, trip planning.
Live flight prices and working booking links for AI agents and travel apps.
Set up human approval for your AI agents' tool calls.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables AI agents to pause execution at critical decision points and request human review before proceeding. Provides tools for creating interrupts, polling for decisions, and managing approvals through a simple REST API interface.3 npmMIT
- AlicenseAqualityCmaintenanceEnables AI agents to read and manage Microsoft 365/Outlook email and calendar, with destructive actions (send email, create event) requiring human approval.8MIT
- AlicenseAqualityDmaintenanceEnables AI agents to manage a fictional B2B workspace SaaS (Tessera) with tools for ticketing, invoicing, customer management, and trial extensions, featuring a human-in-the-loop confirm pattern for safety.710 npmMIT
- FlicenseNot gradedqualityCmaintenanceEnables AI agents to investigate incidents by safely querying PostgreSQL, triaging GitHub issues, retrieving ERP order data, and sending Slack alerts, with AST-validated SQL and human-in-the-loop safeguards.-