- Published on
Teaching a Dashboard to Listen: An AI Agent for a City Transit Platform
- Authors

- Name
- Shuqi Wang
This July I spent a month interning at Pair City, a mobility-as-a-service (MaaS) company, working from their Shenzhen office on their city-level public transit big data platform. My task: build an AI agent so that transit operators could talk to a bus operations dashboard in natural language — "show me routes where operating speed dropped below 25 km/h on June 27" — and have the right analysis panel open itself, with the right parameters filled in.
This post covers the architecture and what I learned making an LLM behave in a real product. (I signed an NDA, so no internal code or data here — just the pipeline and the design decisions, which are the interesting part anyway.)
Demo
The core idea: the AI never touches the page
The naive version of "AI agent for a dashboard" is scary: give the model access to the DOM, or let it call component methods directly, and hope it doesn't hallucinate a click. I didn't do that, and the whole design falls out of one principle:
The LLM outputs intent. The frontend owns execution.
The model (running on Coze, a bot platform) is never allowed to output routes, function names, or page targets. It can only emit a small structured payload — an intent_id from a whitelist, a confidence score, and typed parameters. Everything that actually touches the page lives in local frontend code:
User input
→ Chat window (custom, streaming)
→ LLM (Coze bot + knowledge base + API tools)
→ Stream parser (extracts hidden JSON / link tokens from the reply)
→ Command builder + parameter validator (schema-checked)
→ Command executor
→ Adapter layer (per-page, exposes a few safe local functions)
→ The page's original Vue query flow
If the model outputs an intent that isn't on the whitelist, or a date in the wrong format, or a speed of 900 km/h — the validator rejects it and nothing happens. The mapping from intent to actual page function exists only in a local route map. A hallucination can produce a strange sentence, but it cannot produce a strange page state.
Three output forms, not one
Early on I realized "chat with the dashboard" is actually three different products, and forcing them into one behavior makes all three worse. The agent classifies every reply into one of three forms:
| Form | Example question | What happens |
|---|---|---|
| QnA | "What does ridership intensity mean?" | Plain answer. No page action. |
| Action | "Open the operating speed analysis for June 27" | One whitelisted panel opens automatically, parameters applied. |
| Links | "Give me a summary of that day, with entry points" | A written report with inline clickable links; nothing opens until the user clicks. |
The subtle one is Links. For reports and analysis, auto-jumping is wrong — the user wants to read first, then choose. So the model embeds link tokens in the report body ([[label|intent|params]]), the frontend renders them as clickable text, and a click runs through the same validation and adapter chain as an auto-action. Same safety, different trigger.
Data queries, interestingly, turned out not to be a fourth form. The model can call business APIs as tools to fetch real numbers, but that's a content source for any of the three forms — fetching data is not the same as acting on the page. Keeping that distinction clean fixed a whole class of confused behaviors.
The knowledge base is the real program
The part I underestimated: most of the engineering wasn't in the code, it was in teaching the model the world it lives in. I ended up maintaining a layered knowledge base of ten Markdown files, each with a single responsibility:
- Persona and boundaries — who the agent is, what it refuses, how it decides QnA vs Action vs Links
- Page structure — what's on the dashboard and where
- Domain knowledge — what each transit KPI actually means (this is a real domain: ridership intensity, cost recovery, peak-hour fleet…)
- Action rules / Link rules / API rules — the three "function call" contracts, one file each
- Worked scenario examples — full end-to-end examples, including edge cases
The discipline that made it work: every rule has exactly one home. When the intent whitelist changed, I changed one file and one route map, and everything downstream — prompt, docs, tests — was updated against that single source. Duplicated rules in an LLM knowledge base are worse than duplicated code, because the model happily retrieves the stale copy and you can't set a breakpoint on it.
I also learned to keep the runtime prompt thin. My first instinct was to stuff every rule into the per-request prompt. That bloated every call and fought with the knowledge base. The final split: knowledge base carries the business rules, and the frontend injects only runtime guardrails — the current whitelist, "hide the JSON", "one intent per reply", "don't leak your reasoning."
Working in a two-person office
The non-technical side was its own lesson. It was a two-person office — me and the founder — so I reported directly, daily, to someone who expects conclusions first.
That changed how I communicate. I started writing every dev log "conclusion first": what state the thing is in, then what I did, then risks, then tomorrow. I learned to demo the working thing before explaining the architecture, and to never claim more than what's wired up — saying "all KPIs are supported" when eight are is a quick way to lose trust in a small team. The progress decks I made deliberately opened with what the system doesn't do: the AI doesn't click your page, doesn't invent data, doesn't act outside the whitelist.
If I compress the month into two lessons:
- An LLM agent is mostly a systems problem. The model is maybe 20% of it; the parser, validator, adapter, state scoping, and test suite are the product.
- Write for the person after you. I spent the last week turning everything into handoff docs and a tutorial for adding new intents, since the system has to keep running after I leave.
Next month I start my master's at HKUST(GZ). This was a good way to spend the summer before it.