MiNorte
Banking that generates the interface for the person asking. The same account renders differently for a 24-year-old on a tight budget than for his 78-year-old grandfather with low vision — same data, same truth, different screen.
Inspiration
Two people, one bank.
Carlos is 24 and just started his first job. He gets paid Thursday and by Monday he has no idea where it went. He opens his banking app, sees twelve product cards, and none of them answer his only question: can I spend or not?
Don Ernesto is 78, Carlos's grandfather. He collects his pension on the first of the month and wants to know exactly one thing: did the deposit land. But the type is small, the buttons are small, and the app's green and red look like the same color to him. So he calls his grandson to read it to him, or he goes to the branch.
Same bank. Same app. Only one of them can use it.
We started from a diagnosis that felt uncomfortable once we said it out loud: the problem isn't the information, it's that the screen was decided before either of them asked. The hierarchy was designed for an average user that neither of them is, and neither can reorder it. Accessibility ships as a settings toggle — bumping the font size enlarges the same dense layout. It doesn't simplify the content, doesn't change what comes first, and doesn't change how a number is encoded with color.
We also rejected the obvious shortcut. Bolting a chatbot on top makes it worse: a paragraph of prose is a downgrade from a table when you're reviewing expenses, and a model that freely draws screens inside a bank is precisely the thing nobody can authorize.
So the question became: can the interface be generated per person, while staying inside a contract a bank could actually audit?
What it does
You ask in Spanish, by voice or text. MiNorte interprets the intent, reads only your data through typed tools, picks one of thirteen allowed financial presentations, and builds a validated interface — which the client then renders through your accessibility profile.
The same question, "¿en qué se me fue la quincena?", returns four spending categories and a follow-up action for Carlos, and two categories at xl type, high contrast, 64 px touch targets, and nothing encoded by red-versus-green for Don Ernesto.
It also answers on WhatsApp, because Don Ernesto is never going to install an app.
How we built it
Three actors in three independently deployable repositories, and one contract shared by all of them: Google's A2UI protocol, v0.9.1, implemented as-is rather than as a homemade JSON format inspired by it.
A2UI v0.9.1 CONTRACT
catalogs · surface templates · action allowlist
╱ │ ╲
owns │ mirrors in Pydantic mirrors in Zod
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ MCP SERVER │─────▶│ AGENT │────────▶│ CLIENT │
│ FastMCP │ │ LangGraph │ │ Expo / RN │
│ │◀─────│ Cloud Run │◀────────│ │
└──────┬───────┘ └──────────────┘ action └──────────────┘
│ read-only role
▼
Supabase PostgreSQL
- MCP server (FastMCP, Python) is the source of truth: it defines the catalogs, the
surface templates served over
resources/read, and the action allowlist. It is the only actor that touches PostgreSQL, on aSELECT-only role, inside read-only transactions with a per-statement timeout. - Agent orchestrator (FastAPI + LangGraph on Cloud Run) verifies the Supabase bearer
token, derives
current_user_id, plans scoped reads, interprets what came back, selects an intent, and builds the surface with trusted Python builders. - Client (Expo 57 / React Native) re-validates every payload against its own Zod mirror, renders only registered components, owns the accessibility profile, and emits actions.
The part we're most proud of: A2UI persists through the entire process
A2UI isn't an output format we reach for at the end. It exists in six phases, and it is enforced in all six — including two that happen before any user speaks and one that happens after the UI is already on screen:
| Phase | Where A2UI lives | Fails closed as |
|---|---|---|
| 0 · Boot | Catalogs + templates validated at import time | The server won't start |
| 1 · Advertisement | _meta.ui.resourceUri on the tool definition |
Registration rejects duplicates |
| 2 · Retrieval | EmbeddedResource, application/a2ui+json, beside text and structured data |
Degrades to a2ui: null, data survives |
| 3 · Construction | Static template + updateDataModel from verified rows |
Explicit empty view, never an estimate |
| 4 · Render | createSurface → updateComponents → updateDataModel |
Visible recoverable error |
| 5 · Action | The five-field A2UI event coming back | Rejected before it reaches the graph |
Three consequences follow, and they are the whole thesis:
- The contract precedes the request. A template that would violate v0.9.1 doesn't become a runtime error for a user to discover — it prevents the server from booting.
- The model is outside the contract, not inside it. It selects an intent from a finite vocabulary and writes prose. It never sees, emits, or edits A2UI JSON, component names, IDs, styles, or protocol values. "The AI generated the UI" is true at the level of intent and false at the level of bytes — which is exactly the property a bank can authorize.
- The loop closes. The action a user triggers in phase 5 is checked against the same registered template that declared the button in phase 0. A component cannot dispatch an action its own surface never declared.
Totals are another small invariant we're glad we set early. The producer computes them, never the renderer:
$$\text{totalSpent} = \sum_{c \in C} v_c, \qquad v_c = \sum_{i \in T_c} a_i$$
where each visual category $c$ sums the database rows $T_c$ that collapse onto it. Because the headline number is computed upstream, a truncated list of categories can never silently change it.
What we learned
Taking the UI away from the model made it cheaper and safer. This surprised us. Once trusted code builds the interface, the model's job shrinks to intent plus prose — so we didn't need a frontier model, we needed a fast one. Safety and cost moved in the same direction, which almost never happens.
Twenty-six tool schemas in a prompt is not a context problem, it's a selection problem.
Sending everything every turn didn't just waste tokens; it degraded which tool got picked. We
replaced the model-facing tools/list with BM25 search — search_tools then call_tool, at
most five results — so the model binds two schemas per turn instead of twenty-six. Ranking a
tool description $t$ against a query $q$:
$$\text{score}(q,t) = \sum_{i=1}^{n} \text{IDF}(q_i)\cdot \frac{f(q_i,t)\,(k_1+1)}{f(q_i,t) + k_1!\left(1 - b + b\,\dfrac{|t|}{\text{avgdl}}\right)}$$
The lesson underneath: we put stopword filtering on the query, not the index, because Spanish financial phrasing ("deudas pendientes", "¿cuánto debo?") needs the index to stay literal.
A hosted MCP is not a local MCP. More on this below — it cost us a production outage.
Language models are unreliable at nested envelopes. Measured against
gemini-3.6-flash, roughly three calls in ten arrived malformed. The fix was not a better
prompt.
Accessibility belongs to the renderer, not the server. Personal preferences — font scale,
contrast, reduced motion, screen-reader state — never leave the device. The server emits
semantics (variant: "h1", never fontSize: 28) and the client maps them to accessible
native widgets. The server is told about a preference only when it changes what content or
structure to send. This is the decision the entire product rests on: because accessibility
is a rendering concern, one validated surface serves Carlos and his grandfather differently
without the server knowing anything personal about either of them.
One contract in three languages carries a tax. The canonical JSON Schema lives on MCP, mirrored by strict Pydantic in the agent and Zod in the client. They must stay identical in meaning, and nothing enforces that automatically.
Challenges we ran into
Progressive discovery broke production, and our tests said it was fine. After we hid the
non-model tools behind discovery, every hidden tool started answering Unknown tool and the
orchestrator lost user context on every single turn. The cause: FastMCP happily delegates
tools/call to unlisted tools, but our hosted deployment proxies the server and resolves
calls against the advertised catalog. Our offline suite passed because the in-memory test
client delegates like a direct server. We pinned the three tools the host must address by
name (select_rows, a2ui_action, a2ui_form) as advertised-but-app-only — so the model
still can't see or invoke them — and added a reachability guard that tests the advertised
catalog instead of the registry.
Normalizing model output at a trust boundary, carefully. For the ~30% of malformed
envelopes — envelope re-wrapped in itself, name one level too deep, the request wrapper
dropped or applied twice — each shape has exactly one reading that can validate. So we
normalize instead of paying a rejected round trip and a retry turn. End-to-end success went
from 4 of 8 to 10 of 10 live calls. The safety detail that made this acceptable: trusted
user scoping runs after normalization, never before, so a forgiving parser can't smuggle an
unscoped call through. A payload with no tool name anywhere has no valid reading and is left
for the server to refuse.
Latency we didn't expect. Loading MCP tool schemas cost between 0.9 s and 4.3 s of every
production turn. We added a bounded cache with a TTL (300 s default, 0 disables it so a
redeploy is picked up without a restart). Separately, static A2UI templates get their own LRU
cache keyed by server identity plus resource URI — single-flight reads, detached copies, and
it never stores updateDataModel or a single banking row.
A spec recommendation we chose not to fake. A2UI recommends negotiating catalogs from
client capabilities during MCP initialize. We inspected FastMCP 4.0.3's public APIs and
found no stable hook exposing session-scoped capabilities to typed handlers. We refused to
monkeypatch internals, so we ship a fixed catalog allowlist and documented the limitation
instead of claiming a capability we couldn't verify.
An import collision that only appeared in the hosted inspector. Our internal package was
named a2ui, and FastMCP's file-based loader temporarily puts src/supabase_mcp on the
import path — so from a2ui... resolved to our package during fastmcp inspect, producing
a circular import. Renaming it to a2ui_support made file-based and installed-package
imports equivalent.
IPv6. The direct Supabase database host is IPv6-only and simply fails to resolve on an IPv4-only network — like the venue's. Moving to the session pooler fixed it.
Two lanes, two security models. The WhatsApp channel identifies users by phone number and
reads Supabase directly through n8n, so it inherits neither the trusted current_user_id
scoping nor A2UI validation. We scoped it deliberately as a read-only demo channel rather
than pretend it shares the app's boundary.
Honest scope
Everything runs on seeded demo data, clearly labeled, with no real customer data anywhere
near the repositories. Four of the thirteen intents have a real view builder today
(financial-summary, transactions, spending-analysis, recurring-payments); the other
nine render an explicit empty view rather than a fabricated one. That's the intended
failure mode, but it's a gap in coverage, not a finished product.
What's next
- Generate the agent and client contracts from the canonical schema, so the three-way sync becomes a build step instead of a discipline.
- Point the WhatsApp flow's tools at the MCP server, giving both channels one identity model and one data boundary.
- Fill the remaining view builders on the same versioned contract.
Carlos understands his paycheck. Don Ernesto doesn't have to ask for help.
Built With
- cloud-run
- fastapi
- fastmcp
- langgraph
- react-native
- sklearn

Log in or sign up for Devpost to join the conversation.