System architecture · as built, Phase 10

A hexagonal core with a streaming face

Python FastAPI backend + Next.js frontend, joined by the AG-UI protocol and CopilotKit. The backend follows ports & adapters: the agent and domain models sit in the middle, and everything replaceable — prompts, flights, hotels, persistence — plugs in from the edges.

System map

Every moving part, top to bottom

FrontendNext.js App Router · CopilotKit v2 · Tailwind v4 "Candy"
page.jsheader · sidebar · chat · map split-panel
use-trip-tools.jsregisters 8 card renderers + HITL tool
use-active-trip.jsthread id sync · history rehydration
Settings modalglobal profile editor
⬇ SSE streaming · AG-UI protocol ⬇
FastAPImain.py lifespan: telemetry → init_db → MCP clients
POST /api/copilotkitmerge prefs · upsert trip · run agent
/api/trips + /messageslist · resume · persist AG-UI history
/api/trips/{id}/background.imgserves the generated vibe image
/api/saved-scenariossave · list · delete bookmarks
/api/profileglobal preferences GET/PUT
/healthhealth check
PydanticAI agentagent.py · output: str · temp 0.2 · thinking off · Gemini 2.5 Flash
gather_preferenceslayered via merged_with()
find_cheapest_datesconcurrent ±3-day flex sampling
search_flightsKiwi MCP + client-side filters
search_accommodationsmax_price · min_rating
search_webgrounded research sub-agent
search_ground_transporttrain · bus · ferry routing
generate_scenarios2–3 validated, refinable scenarios
ask_clarifying_questionfrontend HITL — run pauses for the answer
set_background_imagefire-and-forget vibe image refresh
⬇ ports & adapters ⬇
Adapters & dataeverything swappable lives here
Kiwi.com MCPremote streamable HTTP · no key
Accommodations MCPlocal stdio · SerpApi Google Hotels
Gemini groundingresearch + fallback prices
Gemini image modelper-trip vibe backgrounds
SQLite (aiosqlite)trips · messages · profile · saved · backgrounds
File promptssystem + research markdown
LangfuseOTel spans per run
Backend

Ports & adapters, concretely

Each capability is a small interface (port) in backend/app/ports/ with one adapter implementation in backend/app/adapters/. The agent only ever sees the port.

CapabilityPortAdapterBacked by
PromptsPromptServicePortFilePromptServiceMarkdown files on disk
FlightsFlightServicePortKiwiMCPFlightServiceAdapterRemote Kiwi.com MCP (streamable HTTP)
AccommodationsAccommodationServicePortMCPAccommodationServiceAdapterLocal FastMCP stdio server → SerpApi Google Hotels
ResearchResearchServicePortGroundedResearchServiceresearch_agent.py sub-agent (Gemini grounding)
TripsTripRepositorySqliteTripRepositorySQLAlchemy async + aiosqlite
ProfileUserProfileRepositorySqliteUserProfileRepositorySingleton row in SQLite
Saved scenariosSavedScenarioRepositorySqliteSavedScenarioRepositoryScenario JSON + denormalized columns
Trip backgroundsTripBackgroundRepositorySqliteTripBackgroundRepositoryImage bytes + generation-lock status in SQLite
Why it matters: swapping Kiwi for another flight source, or SQLite for Postgres, means writing one new adapter — the agent, tools, and API never change.
Domain

The models the agent thinks in

🧳

TripRequest & UserPreferences

Destination, duration, month, budget, travelers — plus home city, direct-flights-only, transit modes, hotel class, vibe tags, currency. Preferences layer with merged_with(): global profile first, conversation on top.

🛫

Itinerary & Legs

Leg carries a TransportMode (flight / train / bus / ferry / car), times and fares. DaySummary gives each day a title and a time-blocked schedule of PlanItems.

⚖️

Scenario & scoring

Each Scenario bundles an itinerary with a CostBreakdown (transport / stay / grand total) and StressFactors (layovers, overnight travel, tight connections, total hours) → a 1–5 stress score .

✈️

FlightOption & FlightDateOption

Live results incl. a booking_link deep link and a routing-label airline (Kiwi exposes no carrier name). Date options power the cheapest-dates card.

🏨

AccommodationOption

Name, property type, nightly + total rate, currency, rating, reviews, hotel class, amenities, link, coordinates, and an estimated flag when values are approximate.

🗄️

ORM models

Trip (with a full AG-UI message_history JSON column), Message, UserProfile singleton, SavedScenario, and TripBackground (generated vibe image + a pending/ready/error lock) — all on a shared SQLAlchemy metadata.

Agent layer

One agent, two sub-agents, nine tools

The main agent orchestrates; a research sub-agent handles Google-grounded search (Gemini won't reliably combine grounding with function tools and structured output in a single call), and a second, purely fire-and-forget sub-agent paints each trip's vibe background image.

ToolWhat it doesData sourceFallback
gather_preferencesExtracts and persists traveler preferences to the global profileConversation
find_cheapest_datesSamples a month with concurrent ±3-day flex searches, keeps the cheapest fare per departure dateKiwi.com MCPsearch_web
search_flightsLive flight options; max_stops / time-of-day filters applied client-sideKiwi.com MCPsearch_web
search_accommodationsLive hotels/rentals with nightly + total rates, honoring max_price / min_ratingSerpApi Google Hotels MCPsearch_web
search_webGrounded destination research and the universal price fallbackGemini Google grounding
search_ground_transportTrain/bus/ferry routing: operators, times, duration, frequency, fare rangeResearch sub-agent
generate_scenariosRenders 2–3 validated scenarios; recomputes totals, enforces a complete day-by-day plan, supports targeted refinement by labelAgent-built
ask_clarifying_questionFrontend human-in-the-loop card — pauses the run until the traveler answersCopilotKit useHumanInTheLoop
set_background_imageOnly on an explicit request to change the vibe image — kicks off a forced regenerationImage sub-agent
Reliability contract: a source erroring or returning nothing never fabricates data — flights degrade to an empty option list, accommodations to available: false, and the agent falls back to search_web. For flights specifically, a transport/protocol failure (dead connection, timeout) raises KiwiTransportError in mcp_flight_service.py rather than silently returning []; the search_flights/find_cheapest_dates tools catch it and add a data_source_error: true flag, so a dead Kiwi connection stays distinguishable from Kiwi genuinely finding zero itineraries for a route.
🧠

System prompt

Dynamic, but injects only the current date (day granularity) so the KV-cache prefix stays stable. Encodes seasonality reasoning, multi-modal sequencing with transfer buffers, round-trip-by-default, stress scoring heuristics, manual-override rules, and the scenario-comparison contract.

📡

Observability

Langfuse v4 over OpenTelemetry: Agent.instrument_all() traces every run (LLM calls, tool inputs/outputs) grouped per trip via session_id. Fully no-op when keys are absent.

🖼️

Background-image sub-agent

image_agent.py mirrors the research sub-agent's isolation: a cheap scene_agent turns the conversation + vibe tags into a destination + scene prompt, a Gemini image model renders it, and the result is downscaled to WebP and stored — never on the main request path.

🧪

Eval suite

backend/evals/ runs the real agent + prompt headlessly against deterministic fixtures — 30 cases, 10 evaluators, an LLM-as-judge for soft criteria, and a live dashboard. Full reference: evals.html / evals.md.

Persistence

Five tables that make it feel personal

trips

One row per conversation thread. Carries the full AG-UI history — user/assistant/tool turns incl. tool calls + results — so generative-UI cards rehydrate on reload.

messages

Flattened plain-text turns (legacy/fallback), cascade-deleted with the trip.

user_profiles

Singleton of global preferences — loaded as the baseline for every run, editable in the settings modal.

saved_scenarios

Bookmarked scenarios: full Scenario JSON plus queryable columns (destination, total, stress, dates). trip_id is SET NULL so saves outlive a deleted conversation.

trip_backgrounds

One row per trip (cascade-deleted with it): generated image bytes + MIME type, the scene it came from, and a pending/ready/error status that doubles as a generation lock.

Frontend

Chat in the middle, context on both sides

Desktop shows a 3-pane split — trip sidebar · chat · live map. Below lg the chat goes full-width, the sidebar becomes an off-canvas drawer, and modals become bottom-sheets.

🃏

Generative-UI cards

preferences · clarify (HITL) · cheapest-dates · flights · accommodations · research · ground-transport · scenario-comparison. Loading states show an animated candy search-progress pill; between tool calls a pink status pill (chat-status.js) replaces the default loading dot.

🔍

Scenario detail modal

Price + stress gauge, cost-breakdown bars, stress-factor tiles, a per-direction travel timeline with layover/overnight/tight chips and booking links, an expandable day-by-day itinerary, and Save / View-on-map actions.

🗺️

Interactive map

Built on @vis.gl/react-google-maps: numbered stops, accommodation markers, mode-colored polylines (geodesic for flights), auto-fit bounds. Client-side geocoding with a localStorage cache; degrades to a friendly placeholder without a key.

🖼️

Trip vibe backdrop

trip-backdrop.js — a self-healing image that fades in only once the generated background loads, and stays invisible on a 404. Reused behind the sidebar row and the scenario-detail modal header.

Quality

Tested at every layer

🐍

pytest

Domain, dependency injection, persistence, agent, and REST routes via TestClient + dependency overrides against an isolated SQLite DB.

Vitest

Frontend unit tests for the itinerary helpers.

🎭

Playwright

End-to-end specs: API, persistence, saved scenarios, and settings flows.