Travel Ruter is the only project in the house with a real production stack: FastAPI, PostGIS, React, Mapbox, Stripe, an MCP tool layer, a PydanticAI orchestrator that streams over a WebSocket, forty pytest files, a frontend with Vitest and Playwright, a compose file that brings the whole thing up on a laptop or a NAS. It is also still a private GitHub repository. Both of those sentences are true.
It is a planner, not an agency. The marketplace sells digital itineraries — documents you can edit — and is legally explicit that it does not sell reservations, lodging, transport, tickets, or managed trips. That sentence exists because the first drafts of the UI made a purchase look like a booking, and that is a kind of lie you cannot unprint.
This issue is not a product tour. It is the failure that taught us to stop asking the network for permission to show a day.
The problem
A planner that “thinks like a local” has to know, or pretend to know, a pile of things that do not live in one database: whether a kitchen is open on a Monday, how long the walk actually is after the hill, whether the last bus is a rumour, whether the hotel rate on screen is a test-environment ghost. Models will invent the opening hours. Routing APIs will disagree with each other by twelve minutes. Hotel search in a sandbox will return rooms that were never on sale.
The user does not care which microservice is at fault. They care that the day they are looking at is either usable in the next ten seconds or a spinner they will close.
We wanted two things that fight: a map and a schedule that appear immediately, and a plan that is not fiction. You cannot have both if every pane is a live request. You also cannot have both if you freeze a plan on day one and never admit it aged.
Scheduling is the hard problem hiding under the travel dressing. Not “AI itinerary” as a genre. The constraint-satisfaction of a Tuesday that has to include a museum with last entry at 16:00, a walk that Mapbox thinks is twelve minutes and OpenRouteService thinks is nineteen, and a model that would like to have lunch in a place that is closed.
The constraints
- The itinerary is a document. It has timestamps. It can be shown without a network. It is allowed to be slightly wrong in a labelled way. It is not allowed to be a blank map.
- Live data is a sprinkle. Mapbox tiles, OpenRouteService road ETAs, Google transit where we have it, Amadeus hotel search — background, after first paint, written onto the document when they arrive.
- Honesty at the decision. Buying and publishing say, in the interface, that only digital planning content is sold. No package-travel costume.
- Free tiers or it does not ship. Mapbox, OpenRouteService, and friends have free allowances we actually stay inside for personal use. A planner that requires a sales call to demo is not a tool we will keep.
- Runs anywhere Docker runs. No managed mystery box. Compose up. That is the product operations story, and it is why this one graduated and the others have not.
The orchestrator talks to an MCP server of travel tools: destinations, trips, routes, schedules, stays. Multi-model on purpose — Claude, GPT, Gemini — because a single provider’s outage should not blank a Tuesday. Streaming over WebSocket because a three-paragraph plan that arrives at once feels like a report, and a plan that arrives as it is thought feels like a desk.
What we tried first
The first planner was a gentleman. On open, it asked the world for the truth: geocode the stay, fetch the tiles, compute every leg, search hotels, then render. In the office, on fibre, it was almost pretty.
On a train, it was a white map and a spinner. OpenRouteService’s daily cap is real. Amadeus in test is not a hotel. The model, waiting on tools, padded. The user (us) killed the tab and opened a notes app.
We then tried the opposite overcorrection: generate the whole day from the model with no tools. That is how you get a restaurant that closed in 2019 and a walking time that assumes the city is flat. Locals do not hallucinate the tram. Models do, cheerfully.
A third attempt cached nothing but the tiles, still blocked the schedule on routing, and displayed hotel rates without a captured-at time. People (we) treated every number as live. When a rate vanished at checkout — not that we sell checkout; when a rate vanished from the sandbox — the plan felt like a trick. Trust is a scheduling primitive. We had spent it.
There was also a UI failure that belongs in this issue because it is the same sin: marketplace copy that could be read as booking a trip. Legal review is not a vibe. We rewrote the purchase and publish flows until they were boring. Boring is the correct register for money.
The turn
Stop asking live APIs to be the spine of the document.
The itinerary, including a coarse day plan and the last known geometries, lives in Postgres/PostGIS. Opening a trip reads that. The map paints. The list of hours paints. This is the snapshot at t₀. Then, and only then, a background pass asks Mapbox, OpenRouteService, and friends whether anything important moved. When a live ETA arrives it is written onto the document with a time, and the UI is allowed to mark it as live. When the pass fails, the snapshot remains. A slightly stale Tuesday is a plan. A spinner is not.1
1
The model is no longer asked to be the city. It is asked to propose against the document, and the tools are asked to ground the proposal. If a tool is down, the proposal stays a proposal. We would rather print “we could not confirm walking time” than invent one. We do not always succeed at that sentence — see the open questions — but it is the rule.
This is the same shape we later used, in a different costume, for agent jobs in Issue 03: do not block the first useful frame on the slowest dependency. It is also the shape of every offline-first lecture we had already heard and somehow declined to apply because the map felt like it needed to be “real.”
The artifact
A running stack. Not a mockup. Frontend in React with Mapbox GL; backend in FastAPI with async SQLAlchemy; orchestrator in PydanticAI; MCP tools for the travel verbs; Stripe for the marketplace; PostGIS for the bits of the earth we have actually stored. Tests at both ends. Compose for laptop, VPS, Pi, NAS.
FIG. 1 is the cache path: snapshot first, live APIs after, UI language split between ink (snapshot) and rust (live or late). The Amadeus box is outlined in rust on purpose. Hotel data from a test environment is the cleanest example we have of a number that looks live and is not.
What graduated is the tool we use to plan with. What did not graduate is a public product with customers. There are no user counts on this page because there is nothing honest to print. The marketplace exists in the codebase and in the billing docs. It is not a storefront we will pretend is busy.
Started as Challenge 01. Now a tool. Member pricing, when the pass opens, will apply to this first.
The scheduling engine is the part we are least proud of, which is why it belongs in print. A day is a list of blocks with locations, windows, and travel between them. The orchestrator proposes the blocks. PostGIS already knows the points. Routing fills the edges. When a window is violated — last entry at 16:00, walk 19 minutes, it is 15:50 — the honest behaviour is to say so and offer to slide lunch. The current behaviour is often to emit a confident paragraph. We have tests for the geometry. We do not yet have tests for the paragraph, and Issue 03 is partly about why that sentence is harder than it sounds.
Compose-up is the other artifact people skip past. The whole stack — database, API, orchestrator, MCP, frontend — is one file. That is not infrastructure poetry; it is how two people keep a planner they can still run in a year. Managed services would have made the first demo faster and the third year impossible. We picked the third year.
What’s still open
The language for stale data. Black versus rust is a start and a failure. People assume a time on a screen is a live prediction. We do not yet have a way to show a confidence interval on a walking time or a kitchen’s Monday hours without turning the pane into a terms-of-service.
Upstream caps. OpenRouteService’s two thousand requests a day is plenty for one household and not plenty if a model gets into a retry loop. The orchestrator can waste a day’s routing on a single over-eager plan. We have guards. We have also burned a day.
The model still invents. Grounding reduced it. It did not remove it. A local’s knowledge is not a tool we can MCP yet.
The repo is private. That is a publication failure of a different kind. This issue is the public record until the repository is.
Scheduling remains hard. The planner is better than a blank note. It is not a local. We named the challenge after the thing we have not achieved, which is either honesty or branding. We are going to keep treating it as honesty.