Galle, LK--:--5/6 sites live

Nora Labs: 11 Google ADK Agents, a Deterministic Fallback and a Reading Brain

How our Tourthon 2026 platform plans Sri Lankan trips with specialist agents on Vertex AI, 2-opt route optimisation, deterministic pricing — and never depends on the LLM being up.

28 Sept 20265 min readSelf Guide · Stage 6#Google ADK#Gemini#FastAPI#Cloud Run
Nora Labs

Sri Lanka sells tourism as packages: seven days, a fixed route, a driver you meet at the airport. For Tourthon 2026 — a tourism business idea competition — our team at Nora Software asked a different question: what if the package was assembled live, by agents, around one traveller?

Nora Labs is the answer we shipped. It's a FastAPI backend with 11 Google ADK agents running on Vertex AI, a seven-step traveller wizard, a reading library with an animated brain, a map-first supply explorer and an analyst studio. It's live on Cloud Run. This post is how it's built and why.

Traveller wizard, step 1

The traveller wizard: dates → taste → route → stays → driver → reading → done.


The shape of the system

SurfaceForWhat it does
/travellersLarge-type, seven-step wizard. State persists in SQLite, so closing the tab and coming back resumes exactly where you were.
/brainreadersAnimated neural-brain canvas over a 250-article library. Clicking a lobe fires that region and the librarian agent returns reading.
/supplyoperatorsMap-first explorer for 250 properties and 100 drivers. Click anywhere to search that exact point.
/studioanalystsDense dashboard: ranking, route optimisation, OTA links, pricing and audits side by side.

Behind them: 16 routers, 150 documented paths, 100 agentic operations, and a SQLite schema of 15 tables — destinations, articles, hotels, drivers, workflows, itineraries, itinerary_stops, ota_queries, price_snapshots, agent_runs and friends.

Operator studio itinerary


Decision 1 — Google ADK, not LangChain

LangChain's rapidly shifting abstractions bring breaking dependency changes and overhead. Google ADK gives a small, explicit loop — an agent, an instruction, a list of Python function tools — tuned for Gemini function calling. That mattered for one reason above all: every ADK agent is given the same Python functions the fallback engine calls directly. The search agent and the rule-based search share catalog.py; the itinerary agent and the rule-based planner share itinerary.py and geo.py. Behaviour stays consistent across engines because the code is literally the same.

The 11 agents: search, itinerary, research, ota, pricing, map, category, concierge, dispatch, librarian, and an orchestrator that hands off between them (/api/agents/orchestrator/plan-and-book runs the whole swarm end to end).

Decision 2 — the AI is optional

The competition proposal's risk table promised that core search, booking and QR verification would keep working if the AI service went down. We took that literally. There are three backends, selected by environment and reported at GET /api/meta/backend:

BackendConfigured byUsed when
Vertex AIGOOGLE_GENAI_USE_VERTEXAI=TRUE + project/locationOn GCP — credentials from the service account, no key in the image
Gemini APIGOOGLE_API_KEYLocal development
DeterministicneitherAlways available

An ADK call that fails — quota, model 404 in a region, network — is caught and answered by the deterministic engine, and the response says which engine served it. Every run is logged to agent_runs with engine and latency (/api/meta/stats). It means a model outage degrades answer quality, never availability.

Decision 3 — specialist agents, not one giant prompt

A single prompt asked to plan "8 days, heritage and hiking, LKR 280,000" will cheerfully schedule Colombo → Ella → Jaffna in one afternoon. Splitting the work fixes that: the itinerary agent never estimates travel time itself — it calls geo.py, which computes road-factored haversine distance (1.35× straight line at ~42 km/h, tuned for Sri Lankan roads), then improves the order with a nearest-neighbour seed plus 2-opt. The response includes optimizer_saved_km so you can see what the optimiser removed.

Wizard route step

Small domain rules live in code too. National parks carry no nightly rate and are never chosen as overnight stops — they're attached to the nearest lodging town as day trips (Minneriya from Habarana, Wilpattu from Kalpitiya), which is how those routes are actually travelled.

Decision 4 — deterministic pricing with its reasoning attached

Dynamic pricing is base rate × seasonal factor × occupancy demand × lead-time multiplier, using 14-day velocity. It's deterministic: the same destination and date always return the same rate, and the reasoning is returned alongside the number. An LLM is great at explaining a price; it is terrible at being the price.

The ota agent turns a sentence like "eco stay in Sinharaja for 3 nights, 2 adults, under LKR 12000 with breakfast" into structured parameters and builds Agoda and Booking.com search URLs. It never fetches, scrapes or claims live availability. Affiliate IDs come from NORA_AGODA_CID / NORA_BOOKING_AID.

Stays suggested per town


The reading brain

The /brain page is the most unusual surface. 250 articles — destination guides, seasonal timing, routes, operator playbooks — are generated from the same destination dataset by scripts/generate_articles.py, so every figure in an article matches the API. Clicking a lobe fires that region on a canvas, pulses travel the synapses, and the librarian agent recommends reading. Finishing an article earns XP, levels and a day streak.

Brain firing

The Tourthon category taxonomy is first-class data (categories.json), not documentation: every destination is tagged, search and itinerary generation filter by it, and /api/categories/agents/classify scores free text against all eight categories.


Running and shipping it

ExampleShell
docker compose up --build            # SQLite on a named volume
curl -s localhost:8000/api/meta/backend

Production is Cloud Run in us-central1, built by Cloud Build into Artifact Registry, with Vertex AI credentials from a runtime service account and a GCS bucket mounted at /data. The service is pinned to --max-instances=1 because SQLite has a single writer — the full pipeline and its trade-offs are in How I Ship 23 Projects.

OpenAPI explorer

Testing

ExampleShell
pytest tests -q                     # behavioural tests
python scripts/smoke.py             # walks all 155 route calls

The smoke script calls every documented route — a crude but effective regression net for a 150-path API.

What I'd change next

  • Move itineraries, workflows and article_events to Cloud SQL or Firestore so the service can scale past one instance.
  • Add google_search grounding to the research agent for live events and closures.
  • Replace generated OTA links with real partner APIs once there's a commercial agreement.

See the full Nora Labs case study →