Inspiration

Most travel AI aims at the traveler and tries to replace the advisor: generate an itinerary, book it, done. Talk to advisors and the actual bottleneck is somewhere else entirely. They can already find good hotels. What costs them the afternoon is the last mile — the one fact the website doesn't publish, the one detail that decides the booking, the thing you can only get by picking up the phone.

For this client the whole trip hinged on a boring question: the couple lands at 9:00 a.m. on their anniversary, four hours before check-in, with luggage. Can the hotel store it securely? No booking site answers that. An advisor calls.

So we built the copilot for the part that isn't glamorous: research an advisor can defend in front of a client, with every fact carrying where it came from.

What it does

One linear flow, one screen per step:

Advisor brief → three curated candidates → exactly one decisive unanswered question → a disclosed voice call to a consenting demo participant → a re-ranked recommendation → a Chase-to-Hyatt payment comparison → an editable client proposal.

  • Candidates are real. Each of the three slots is filled from live Stay22 inventory — one property per requested neighbourhood (NoMad, Flatiron, Chelsea).
  • Ranking is arithmetic, not vibes. Fixed published weights — preference 35 / budget 30 / location 20 / verification 15 — with budget as a pass/fail gate. No LLM decides a rank, so the advisor can explain any ordering to a client.
  • One question, and only one. The app picks the single unanswered fact that would actually move the ranking, and the call carries that question alone.
  • The call is disclosed. The agent identifies itself as automated and gates on consent before asking anything. Recording is off.
  • The evidence changes the answer. The transcript renders in place, the shortlist re-ranks, and a before/after diff shows the explicit ▲/▼ rank movement and the rationale line that changed.
  • Points are severed from quality. The Chase-to-Hyatt comparison lives in its own dashed "Appendix A" sheet stamped Does not affect ranking, so a weak hotel can never rank first because it had a good redemption.
  • It ends in a document. An editable proposal the advisor sends, carrying the price basis and its caveats.

What it explicitly is not: no booking, no points transfer, no credentials, no financial advice — and no affordance that looks like one.

How we built it

Next.js 15 App Router, React 19, TypeScript strict, plain global CSS. No database — session state is a useReducer in the browser. It runs offline with zero environment variables.

Pure domain layer. Every product rule is a pure function in src/domain/ — brief validation, ranking, question selection, points comparison, proposal builder. Rules live in code that can be read and checked, not in a prompt.

Live inventory (Stay22). One search per neighbourhood against /v2/accommodations, cached in-process for 30 minutes. Three rules do the real work:

  1. Neighbourhood comes from the returned locality, not the search radius. The three areas overlap, so a radius search anchored on Flatiron returns mostly NoMad addresses. Anything that can't be positively classified returns Other rather than guessing — ZIP 10003 spans Union Square, which is not Flatiron — with a 700 m straight-line cap from the anchor as a second guard.
  2. Provider totals are pre-tax; the budget gate needs all-in. We gross up by 14.75% NYC lodging tax plus ~$3.50/room/night, label the result Estimated, and feed that to the gate. Comparing a pre-tax total to an all-in ceiling would let a hotel pass a gate it actually fails.
  3. Selection maximises spend under the ceiling. Of the in-area properties at or under the client's estimated all-in ceiling, the one closest to it wins — the advisor is spending a stated budget, not minimising cost. Runners-up are shown in the methodology note.

Voice (ElevenLabs + Twilio). The browser never supplies a phone number. It sends { hotelId, question } and nothing else; the destination comes from server env only. Then, in order: an approved-question validator (one ?, required luggage/secure/9 a.m./arrival-day intent, quiet-room wording blocked, plus a word allowlist — the advisor can rephrase, but cannot smuggle in a second question or a new topic); the same validation repeated server-side; a route with a timing-safe bearer check, hotel-ID allowlist, single-flight lock, 30-second cooldown and replay-key TTL; and an adapter with a bounded prompt, automated-assistant disclosure, consent gate, recording disabled and a 120-second lifecycle timeout.

The interface is an advisor's dossier: warm paper stock, ledger hairlines, folio numbering, and monospace reserved exclusively for provenance — origins, timestamps, scores, labels — so evidence metadata is always typographically distinguishable from the claims it supports. A persistent standing record keeps the client's constraints, the current lead and the verification state on screen at every step. Light and dark both ship.

Challenges we ran into

Keeping simulated evidence from ever reading as real hotel policy. This is the whole ballgame. The demo call reaches a consenting participant, not a hotel — so every rendered result carries "controlled simulation" / "not real hotel policy" language, and when a slot holds a real property the question and call sheets escalate: the call does not reach that hotel, the answer is not its policy, it must not be repeated to a client as a statement from the property. That's the highest-risk state in the app and the copy is deliberately blunt about it.

Two axes that everyone wants to collapse into one. Origin (where a fact came from: Demo data, Public source, Controlled voice demo, Live property call, Estimated) and outcome (what the answer was: Confirmed in simulation, Conditional, Declined, No answer) are separate, always. And No answer is unknown, not a no — it scores 0 but is never presented as a refusal.

Going live broke the ranking in a way that only showed up in the numbers. Once real properties filled the slots, the fictional fixtures kept out-ranking them. neighborhood was being set to the raw provider address ("851 Avenue of the Americas, New York US, 10001"), which never matches a preference like "Manhattan" — so every live candidate scored 10 on location where a fixture scored 20. In a partly-live run, the invented hotel led the shortlist at 85 while two real, in-budget properties sat below it at 75 and 64. Fixing it meant carrying the named locality ("Flatiron, Manhattan") and moving the street address to a display-only field. Live candidates now score 85 / 74 / 74, and the real property leads.

Real properties can't inherit invented prose. Carrying the fixture's "anniversary-friendly boutique atmosphere" onto a real hotel's name would be a fabricated claim about a real business. So descriptive fields are derived from live signals — rating, star class, cancellation policy, supplier spread, budget headroom — and a guest score with zero underlying reviews is reported as unknown, not as a good score. Live slots also carry no award comparison, because invented award pricing may not be attached to a real property; payment guidance degrades to cash-only with a stated reason.

The evidence was being skipped. Both the evidence and fallback paths advanced the step, so the transcript rendered on a sheet the advisor had already navigated past — the call appeared to produce nothing. Now the result unlocks the next step but leaves the advisor on the verification sheet to actually read it.

Accomplishments that we're proud of

  • Every external dependency has a demonstrated fallback, and the full flow completes offline. With no credentials the voice route returns 503, the UI reports a transport failure, and a labeled recorded fallback carries the demo to the end. Any Stay22 slot without a usable live result keeps its prepared fixture.
  • Hotel IDs are stable slots, never derived from live data. The voice route allowlists one hotel ID, and that guarantee had to survive an inventory swap — so live results fill a slot's fields while its ID never moves.
  • A partly-live run is labeled "Mixed sources" rather than letting one label stand for a mixed set of records. A real property may never carry a Demo data chip.
  • We deleted a feature for being confusing. The per-factor stacked score bar looked impressive and clarified nothing; the weights are stated in prose in the methodology note instead, and ranking stays fully explainable without the chart.

What we learned

Trust is plumbing, not tone. You don't earn it with a disclaimer at the bottom — you earn it by making the data model incapable of the lie. Separate axes so origin can't be laundered into outcome. Keep points out of the quality score so a redemption can't buy a rank. Derive copy from live signals so no invented sentence can attach to a real business. Every one of those is a schema decision, and each of them ruled out a whole class of plausible-sounding false statements.

The corollary: going live is a correctness problem, not an integration problem. Swapping fixtures for the Stay22 API was an afternoon. Making the results true — tax-inclusive budgets, honest neighbourhood classification, unknown ratings staying unknown — was the rest of the work.

What's next for TripDesk AI

  • A live-demo definition of done: real credentials, a controlled destination, a consenting participant, and a timed rehearsal — none of which is recorded yet.
  • Tests against the domain layer. It was written as pure functions specifically so they can be added directly; the rebuild ships without them.
  • Multi-instance call safety. The route's single-flight lock, cooldown and replay-key tracking are module-level in-memory state — correct for one process, not across serverless instances. Those guarantees need to move to shared state, with deployment-level access control on top of the bearer token.
  • More than one question. The architecture supports a question queue; the product deliberately ships with exactly one, because an unbounded call is a very different safety problem.
  • Verified award comparisons for live properties, which requires exact-date availability and comparable cash terms retrieved with one paired timestamp — today anything less is stamped Illustrative—not availability.

Built With

Share this project:

Updates