# Maya

*The hard part was never writing the email. It was quoting a rate that traces back to a live source.*

**Category:** AI SYSTEMS / TRAVEL
**Status:** live
**Period:** May 2026 – September 2026
**Built by:** HM Ammar, Digital Architect & Head of IT, Kuala Lumpur

**601s** — of waiting before the portal's price report gave up

**Stack:** FastAPI, SQLAlchemy 2.0 async, PostgreSQL 16 + pgvector, Redis / RQ workers, Next.js 15, OpenAI + OpenRouter, Gmail API, Retell AI

## The challenge

Embassy Alliance is a B2B travel DMC in Malaysia and Singapore. Its partner agencies, mostly Russian-speaking, send a daily stream of enquiries about hotels, tours and transfers, and answering one meant opening the SAMO B2B portal, several Google Sheets and a stack of PDF and Slides price decks before anyone could write a line. The rates that mattered lived in a portal with no API. The complaint I kept hearing through the build was never speed but truthfulness: drafts that said we are checking availability instead of quoting a number, or quoted one nobody could trace back to a source.

## What I built

Maya reads each incoming email, classifies it, extracts party, dates and destinations, prices hotels live through the SAMO portal, pulls tours and transfers from the team's own sheets, and writes the reply in that agent's voice for a human to approve before sending. I built the backend on FastAPI and Postgres with RQ workers, and the command centre in Next.js. I keep the hard constraints in code rather than in the prompt: the portal is the only permitted source of a hotel rate, and a post-generation validator rejects any draft whose figures or omitted conditions disagree with the blocks it was assembled from. A Retell-hosted voice agent, launched by a human from the Voice Calls page, calls hotels for the answers the portal cannot give.

## The detail

### The wizard walk

The portal's price-list report never returns for this server, which I verified at a 601-second read timeout across every date format and both verbs. Prices come instead from a stateful tour wizard, which I bisected against the live portal into a walk of newTour, getTourList, getTourRouteList, wizardstep, showHotelForm and minprice. getTourList is the strange one: nothing in the portal's own wizard.js suggests the server depends on it, because in a browser it only repopulates a dropdown, but without it wizardstep returns 500.

### A 48 KB stall

Requests from the production server stalled at roughly 48 KB of response body and never resumed, with identical byte counts at 60, 120 and 300 second timeouts and nothing further at 600. Changing the server's IP was off the table, so I fixed it in code: jQuery AJAX headers now ride on every wizard call so the portal returns an 88-byte script instead of the half-megabyte SPA page, and a hard 250 KB cap turns an unbounded hang into a bounded timeout. The portal also tarpits repeated logins, so my adapter logs in once, reuses the cookie, and holds a per-username lock that stops a cold worker firing simultaneous logins.

### Guardrails in code

Every rule in the draft validator started life as a prompt instruction the model followed most of the time, which is the wrong standard for a price sent to a partner. The checks that can be decided from the data are now decided after generation and before storage, each one comparing the reply against a figure or a name that came from a source block. One detail earns the whole file: my money regex originally matched three-digit integer parts, so it read 1414 as 141 and 2124 as 212, silently disarming every check that compared a quoted figure to its source.

### Two scripts, one city

The same email came back from the extractor once as Kuala Lumpur and once as Куала Лумпур, and an unmapped destination means no hotel is priced at all. Rather than trust a free model to translate, I put both scripts in the region table and sorted it longest-first so the full city name wins before the short token. A related bug was subtler: the knowledge base files Langkawi hotels under their state, so two resorts failed in three milliseconds with could not map destination Kedah while the portal had them on file the whole time.

### Free models, routed

A day that burned through more API budget than I expected turned into a routing policy. I now reserve the expensive model for writing partner-facing replies, while sheet extraction, classification, entity extraction, hotel inference and style profiling run through OpenRouter's free models. My router refreshes the live free-model list so new ones are picked up automatically and rotates on failure with per-cause cooldowns, and a double-pass extraction plus a needs-review gate mean a weak free model can only ever raise a verify flag, never produce a wrong answer.

## The outcome

I built the drafter to either quote a rate traced back to the live portal or say plainly that the rate is being confirmed. There is no stored-rate, spreadsheet or estimated fallback anywhere in that path. Maya runs on the client's own Google Workspace domain, wired to real Gmail mailboxes with each agent seeing only their own, and to the team's sheets, PDF and Slides price decks and tiered B2B logins. A daily 05:00 Malaysia resync re-reads every source before the agents start work, and a ten-minute poll picks up sheet edits made during the day. I turned the throttling measurements into a reproducible one-curl bug report, written in English and Russian, asking the portal vendor for proper gateway access rather than a workaround.

> Deployed behind a Google Workspace sign-in. The link is held back pending the client's sign-off, since the same domain fronts internal tooling.

---

[All work](https://hmammar.world/) · [HTML version](https://hmammar.world/work/maya.html)
