Project Story

Inspiration

Almost no banking app talks differently to a student with no credit history than to someone who already invests with discipline — everyone sees the same screen, with the same numbers changing color. Banorte's challenge proposed something different: an AI agent that doesn't just answer, but decides what interface to build based on what's actually happening with that person's money. That felt like the real interesting problem — not "another finance app," but one that recomposes itself on every tap.

We picked financial education as the domain because that's where "UI adaptability" matters the most: the same savings section should look like a congratulatory message with a chart for someone on track, and like an alert with a concrete action button for someone who isn't — never the same template with different numbers. If we got that right, the rest of the challenge (MCP, A2UI, architecture) followed almost as a consequence.

How we built it

We started with the two non-negotiables: MCP to expose real financial data from Azure PostgreSQL to the model as tools (never free text), and A2UI — our own render_ui protocol — for the agent to describe a screen as an array of blocks (kpi, chart, lista, tarjeta, boton, texto) instead of raw HTML or free-form layout. The visual design of every component is already solved; the agent only decides which ones to use, how many, and with what data — it never invents layout.

The project started as a mobile app (Expo/React Native), but halfway through we realized the real evaluation criterion — "the agent generates the UI" — never depended on it being mobile. We pivoted to a pure React web frontend, which gave back the engineering time we were spending on native-build particularities, and we reinvested it in the renderer and the agent, which are the pieces that actually get evaluated.

By design, we separated "the contract" from "who fulfills it": we defined a single Agente type with one function signature, and first built a rule-based agent (no LLM) that queries the same real MCP tools and decides the composition from the actual data — this let us build and test the entire system (frontend, database, protocol) without spending a single API credit. When it was time to wire up the real LLM (DeepSeek, via a manual tool-calling loop against its OpenAI-compatible API), the change was surgical: one new function fulfilling the same contract, with nothing else in the system touched. That same swappable Agente still works today as a fallback whenever the LLM isn't available.

Challenges we ran into

Most of our real bugs weren't in the agent's logic — they were in the seams between pieces, and almost all of them only showed up against real infrastructure, never in code reviewed in isolation:

  • A single extra prefix in the DB_HOST variable (a leftover jdbc:postgresql:// from another tool) took down the Azure connection completely — twice, once locally and again weeks later inside Railway's environment variables.
  • expo-secure-store doesn't exist on web — its own web module is literally an empty object. We found this while testing login in a browser, right as we were already planning the pivot to web, and it confirmed the decision was right.
  • Azure's firewall closed itself the moment the project owner changed networks — a reminder that "works on my machine" doesn't always survive "works in production."
  • Railway runs two processes in one container (backend/ and mcp/), which produced two subtle bugs: both were fighting over the same port until we split a fixed internal one from the dynamic PORT the platform assigns, and tsc was never copying our systemPrompt.md into dist/ — it compiles .ts, not loose files, so the system worked perfectly in development and crashed on boot in production.
  • Node 18 doesn't have the global crypto that the MCP SDK needs — only guaranteed from Node 20 on. On our laptops, running a newer version, this was invisible; on Railway's container, it was a ReferenceError on the very first request.
  • And once the real LLM was wired in, two logic bugs that only surfaced running the agent against all four demo profiles: an interest-rate formula that happened to match the annual rate by pure mathematical coincidence when there's only one debt, and the classic off-by-one-month bug when formatting a date (new Date('2026-09-01') parses as UTC midnight, and in a timezone behind UTC that renders as the previous month).

None of these bugs were found by reading code — every one of them showed up by actually running the system against the real database, with the four demo users. That's the pattern we ended up trusting more than any manual review.

What we learned

That the most important piece of the challenge — the component catalog and the render_ui contract — is worth designing before picking the LLM, not after: by keeping the agent swappable between fixed rules and a real model, we could validate the full protocol, the database, and the frontend without depending on an API key, and once we finally connected DeepSeek the change was a single swap, not a rewrite.

We also learned, once again, that local development hides more bugs than you'd think — the Node version, whether code runs against src/ or against dist/, a firewall that isn't yours. Every time something worked flawlessly locally and only failed in production, the cause was never the LLM or the agent's logic: it was an environment difference that local development simply couldn't show us. Now we know that's the first question to ask, not the last.

Share this project:

Updates