AAmerica Cruise Line
← Back to the voyage desk

Behind the desk

How this page works.

The voyage desk is not a search box with a chat wrapper. It decides what to look up, looks it up across four separate systems, and assembles one answer — or tells you it cannot. Here is that process, first in plain terms and then in detail.

Every number on this site is invented.Sailings, prices, availability and policies were written to be plausible so the system could be built and demonstrated. Many details are certainly wrong. Nothing here should be used to plan or book a real holiday.

General Overview

In plain terms

Ask a question the way you would ask a person — “can four of us get a verandah room on the Treasure next August, and what would it cost?” — and the desk works out what it needs to know before it answers.

That question alone needs four separate lookups: which sailings exist, what is still unsold, what a party of four actually pays, and what deposit secures it. Nobody wrote a script for that particular question. The desk reads it, picks the tools it needs, and chains them, carrying the sailing it found in the first step into the three that follow.

Where answers come from

Nothing is answered from memory. Every price, cabin count and policy detail in a reply came from a lookup made while you waited, against one of four systems — the sailing inventory, the pricing engine, guest records, and a library of policy and shipboard documents.

Those systems are deliberately kept apart, and none of them can query another. Joining inventory to pricing to your booking history is the desk’s job, not the database’s, which is exactly how it would work on a real reservations platform.

What it will not do

The most useful thing the desk does is stop. Ask about something the documents do not cover — onboard casinos, mobile roaming charges — and it is built to say so rather than reach for the nearest loosely related page and improvise.

That sounds obvious and is surprisingly hard. A search system will always return something; its five best results for an unanswerable question are just its five least-bad ones. Recognising that they are all bad, and returning nothing at all, is a specific piece of engineering described in the next section.

Technical Description

In detail

The request path

A question travels from the browser to a Next.js route handler, which streams it through to a FastAPI service over Server-Sent Events. That service runs a Google ADK agent driving Gemini, which holds fourteen tools: eleven reached over the Model Context Protocol, and three in-process retrieval functions.

browser
  └─ voyagedesk.ai              Vercel — pages, and route handlers that run server-side
      └─ POST /api/chat
          └─ POST /chat/stream  Render — voyage-api, public, FastAPI over SSE
              └─ ADK Runner     the model decides which tools to call, and when
                  ├─ McpToolset ──► Render — voyage-mcp, private network only
                  │                    ├─ inventory       Neon
                  │                    ├─ pricing         Neon
                  │                    └─ guests          Neon
                  └─ retrieval ───► voyage_vectors        Neon + pgvector

The browser never talks to the API. It only ever calls its own origin, and the route handler makes the onward request server-side, so the API address is not a public value and never reaches the page. Locally the same code runs unchanged against Postgres in Docker.

Tool activity is streamed to the browser as it happens, which is why the panel names each lookup while the answer is still being written. A tool called more than once in a turn shows what varied between the calls — three fare lookups for three stateroom categories, not one lookup reported three times.

Agentic retrieval, not a retrieval pipeline

There is no unconditional “search, then answer” step. An availability question goes straight to SQL and never touches the document store; a dress-code question goes only to the documents; a “what should we do in Nassau and what will it cost” question does both and joins the results. The model chooses, and that choice is what makes this agentic rather than a pipeline.

The four databases

DatabaseHolds
inventoryShips, stateroom categories, sailings, day-by-day itineraries, live cabin counts
pricingNightly rate basis, seasonal multipliers, age and berth brackets, fees, add-ons, deposit rules
guestsProfiles, preferences, travelling parties, past reservations
voyage_vectors58 policy, excursion and ship-experience documents, stored as embeddings

Four separate Postgres instances, not four schemas in one. No tool can join across them, so composition has to happen in the agent — a constraint kept on purpose, because it is the constraint a production deployment would have.

Why the SQL sits behind a protocol

The three transactional databases are reached only through a FastMCP server speaking the Model Context Protocol. That indirection buys a lifecycle: the data plane can move to its own service without the agent changing. Document retrieval deliberately does not go through it, because retrieval is part of the model’s reasoning loop rather than a system of record.

Retrieval runs two searches at once

Document search combines a dense vector search with Postgres full-text search. They fail differently, which is the point: embeddings catch paraphrase, where a guest says “somewhere quiet and indulgent” and the document says “adults-exclusive”. Keyword search catches the exact rare term — a venue name, “passport” — that embeddings blur into its neighbours.

The two result sets merge by Reciprocal Rank Fusion, which combines rankings rather than scores and so needs no calibration between two systems whose numbers mean different things.

How it decides to return nothing

Each arm applies relevance floors before fusion, and getting these right was the substantial work.

  • Keyword floors are based on inverse document frequency: matching one rare word clears them, matching one ubiquitous word like “deck” does not. Two thresholds are OR’d, because an absolute score is unreachable for a one-word question while a coverage ratio is too harsh on a long one.
  • Distance alone cannot work. Measured on this corpus, the nearest document to an unanswerable question sits about as close as the right answer to a real one — the two distributions overlap almost entirely. Tightening a distance threshold trades recall for abstention roughly one for one, and never separates them.
  • The shape of the neighbourhood does work. When the corpus can answer a question, the best match stands out from the rest. When it cannot, every candidate is equally mediocre. Comparing the closest document against the fifth-closest more than doubled the abstention rate at no cost to the answers.

An optional last pass sends the surviving passages to a small model and asks whether each one actually answers the question, rather than merely being about the subject. That is the only thing that reliably rejects a question sitting one hop from a real document — roaming charges next to the wifi policy — because one hop away is, geometrically, very close.

Money is never the model’s arithmetic

Fares are not linear: a seasonal multiplier, then age brackets crossed with berth slots where the first two guests pay full fare, then per-guest fees and taxes. All of it is computed in exact decimal arithmetic with half-up rounding, and the pricing tool returns a fully itemised breakdown so the model has nothing left to calculate. The agent is instructed never to do fare maths itself.

Where it runs

And what keeps it up

PieceRuns onReachable from
Pages and route handlersVercelThe internet
Agent and APIRenderThe internet
MCP serverRenderThe API only, over a private network
All four databasesNeonThe two services above

The MCP server is private on purpose. Its tools return guest profiles and reservation history and it has no authentication of its own, so on the open internet it would be an unauthenticated read of every guest record. It is also given only the three transactional database URLs — not the document store, not the model key — so there is less behind it if it is ever exposed.

Deploys are not done by hand

A merge to main runs the test suite against four throwaway Postgres instances, and the API will not deploy unless it passes. The release then applies any missing database schema and embeds any changed documents before the new version takes traffic; if that step fails, the previous version keeps serving.

One thing it deliberately does not do is migrate. It initialises a database that has never been set up and skips one that has, which is what makes running it on every deploy safe — and means a change to an existing table needs a real migration rather than a redeploy.

What it costs to leave open

There is no login, because the data is invented and a login would only make the demonstration harder to use. That makes cost, not privacy, the real exposure of a public agent: each turn is several model calls, and anyone can start one. Three limits bound it.

  • Twelve turns an hour per visitor, which stops one client looping the endpoint.
  • Three hundred turns a day in total, counted in Postgres rather than in memory — in memory it would reset on every deploy and every restart, making it a cap per process lifetime rather than per day. It fails open if the counter is unreachable, because a database blip should not take the desk down.
  • Ten model calls per turn, which caps the worst single question anyone can ask. Real answers use two to eight.

All three are application code, so none of them holds if the application has a bug. The provider-side quota on the model key is the one that does, and it is set.

A question, end to end

The real chain of calls

This is the actual sequence for “can 4 adults get a verandah room on the America Treasure next August, and what would it cost?” — each step feeding the next.

find_sailings                 ship + an August date range   →  TRE-2027-08-12
list_stateroom_categories     which categories sleep four
check_stateroom_availability  what is left on that sailing  →  4 verandah rooms
quote_cruise_fare             that sailing, category DOV    →  itemised total
quote_cruise_fare             again, category DFV           →  a second total
get_booking_rules             deposit terms for the category
get_booking_rules             again, with the fare total    →  the deposit in dollars

Seven calls across two databases, one answer — and that is a real trace, not an illustration. The first call returns the sailing identifier every later step depends on, which is why the order matters and why a fixed script would not have served.

The two repeats are each doing something. Pricing runs once per stateroom category because the tool prices one category at a time, so comparing two verandah types means asking twice. The deposit tool is called twice for a different reason: its fare argument is optional, and supplying it turns a deposit percentage into a dollar figure — so the model asked once to learn the terms, then again once it had a number to apply them to.

How well it works

Measured, not asserted

Retrieval is the part of a system like this that fails quietly — an answer reads just as fluently when the wrong passage was fetched. So quality is a number here: 96 labelled questions, 24 of which the documents genuinely cannot answer.

SearchRight firstFound at allNoiseSays “I don’t know”
Keyword only0.860.910.3925%
Meaning only0.930.960.3762%
Both, combined0.920.970.5221%

Combining both searches finds the right document 97% of the time. Abstention is the weakest column and the honest one: fusion returns something whenever either search does, so the keyword arm sets the ceiling. A regression gate fails the build if any of these slip.

Right now

Read live from the same health endpoint the desk uses, so this page cannot describe a system that is not running.

Checking…