The model does not reply. It renders.

· 16 min read

Ask Coach AI in FUT Evolution to compare two strikers and you do not get four paragraphs describing their pace. You get this:

Kylian Mbappé

Kylian Mbappé

ST · 99

VS
PAC99 → 98SHO98 → 99PAS91 → 92DRI99 → 93DEF60 → 60PHY84 → 96
Mbappé
Haaland
Erling Haaland

Erling Haaland

ST · 98

PAC99 → 98SHO98 → 99PAS91 → 92DRI99 → 93DEF60 → 60PHY84 → 96
Mbappé
Haaland
Coins5.7M
Price
6.0MCoins
99
PAC
98
98
SHO
99
91
PAS
92
99
DRI
93
60
DEF
60
84
PHY
96
Skills
Weak Foot
Not a screenshot. This is the interface from a live production answer: I asked "Compare Mbappé and Haaland, who should I buy?" and lifted the rendered DOM straight from the page, radar chart, stat bars, and the market prices of that moment, 5.7M against 6.0M.

The model built that answer, and it is worth being precise about what "built" means, because the honest version is more interesting than the marketing version. No layout classifier decided this was a comparison question; the only classifier in front of the model filters trivial messages like greetings, and it never chooses a UI. What actually happened has three layers. The model read the question and called the player tools. A mandatory routing rule constrained the macro shape: two players means a comparison, no exceptions. And inside that constraint, the model composed everything else: the radar overlay, the price row, the stat bars, the order they appear in. The system owns the skeleton. The model owns the composition. Every number came out of my database mid-reply, not out of its memory.

This has been in production since the end of March, about five months of real users asking real questions. This post is the whole pipeline: how the model knows what is in my database, how the prompt routes every answer to a visual shape, and the part I care most about, what has to be true before you let a language model decide what your interface looks like.

The two obvious approaches, and why both lose

The first obvious approach is to let the model write markup. It knows HTML; ask for a comparison card and you will get one. You will also get inline styles that ignore your design system, layouts that collapse on mobile, and, eventually, whatever a hostile prompt convinces it to emit into your DOM. Model output rendered as markup is an injection surface with a friendly face.

The second obvious approach is to keep the model out of layout entirely: classify the question, pick a pre-built template, fill the slots. And to be fair to templates, this works, right up until the conversation gets conversational. A deterministic router can absolutely handle "compare these two." But requirements compound: compare these two, but prioritize chemistry, given my current squad, within my budget, and show whether prices are falling. Every new conversational dimension multiplies the routing states a human has to enumerate. Composition moves that combinatorial explosion into the model, while the rendering stays deterministic. That trade is the whole bet.

The way out is the same move that worked when I put an AI pipeline in charge of our Swift design system: constrain the vocabulary, not the creativity. Someone will point out that an LLM emitting a JSON tree for a renderer is just server-driven UI, and they are right about the mechanism. This is not a new rendering technique. The renderer is deliberately boring. What changed is who constructs the component tree: not application code mapping intent X to schema A, but a model reading conversational semantics, restricted to the same finite grammar. But the vocabulary is the last stage. Grounding comes first.

The pipeline, end to end

One user message goes through five stages before anything renders:

One message, five stages
The database sits beside the pipeline, never inside the model.
greetings skip the model players database read by tools
user message
1 · Classify
plain TypeScript, no LLM · picks the toolset
2 · Pre-fetch
players named in the message, fetched before the model runs
3 · One streaming LLM call
layered prompt · UI blocks inline in the stream
4 · Validate + repair
one schema judges every block · repair before reject
5 · Render
real design-system components
11 tools
One message, five stages. The database sits beside the pipeline, reached only through tools: pre-fetch reads it before the model runs, and the model's 11 tools read it mid-stream. Greetings never touch the model at all.

Two details in that flow do more work than their size suggests. The classifier means a "thanks coach" never costs a model call at all. And the pre-fetch means that by the time the model starts generating, the players the user just named are already in its context as data, so the common case streams without a single tool round-trip.

The model never knows my database. The tools do.

A language model has opinions about football players. It has stale ratings from its training data, prices from a dead market, and full confidence in both. So the ground rule of Coach AI is that the model is never the source of a single fact. It gets 11 function tools, and every one of them resolves against the live players database: search_players, get_player_detail, get_better_alternatives, get_chemistry_links, get_meta_tier_list, get_price_history, and the rest.

Two things about the tool layer took real iteration.

First, the tool descriptions are not documentation, they are steering. The description of search_players ends with: "This is usually enough to answer most questions. Only call get_player_detail if you need detailed sub-stats or playstyles." That sentence is cost control living inside the tool definition. Without it, the model chains a detail call after every search because more data feels diligent, and every extra round-trip is latency the user watches.

Second, users type "vieera" and "mbape". The search tool falls back to a fuzzy match inside the database when an exact search returns nothing, and the prompt tells the model to retry with a corrected spelling before giving up. Misspelled questions are the normal case, not the edge case.

And then there is the rule that makes the whole thing trustworthy, stated in the prompt as an absolute: never invent, guess, or modify player data. Every value in a template must be copied exactly from tool results or pre-fetched data, and a field that is not in the tool result gets omitted, never guessed.

Omit, not guess. A card with a missing price is honest. A card with a plausible invented price is a lie with good typography, and in a market app it is the kind of lie that costs the user coins.

But a prompt instruction is not a boundary. Models do not obey absolutes; they obey them almost always, and "almost" is exactly the word a trust rule cannot contain. So the same rule now exists as code. After generation, before anything renders, a fact-check pass rebuilds an index of every player the tools actually returned in that request and audits the emitted templates against it. A price that matches no platform price from the tool results is replaced with the tool's number. A rating that disagrees with the database is overwritten. Face stats are corrected field by field. A player id the tools never returned is counted and flagged, never invented around. The model chooses how a price is presented; it has lost the ability to choose what the price is. "Tools own the facts" stopped being a sentence in the prompt and became a pass with its own tests.

The prompt is a system, not a paragraph

The system prompt is assembled per request from text templates plus TypeScript conditionals, and each layer exists because of a failure I watched happen:

Layer What it does
Scope guard FUT only. One polite sentence and a redirect for anything else.
Banned vocabulary A list of last year's game terms the model must never use, because its training data confidently believes in mechanics that no longer exist.
User context Platform, budget, squad if imported (formation plus a summary line), learned preferences, recent market moves for the user's own players.
Pre-fetched data The players fetched before the call, so the model quotes instead of asking.
Reply protocol A fixed order: short text intro, then visual templates, then at most one call-to-action, then three suggested follow-ups. Always in that order.
Routing rules The shape of the answer is decided by cardinality, and it is mandatory.

That last layer is the one I would defend in an argument. The prompt does not say "consider using a visual template". It says: one player means a player card, two players means a comparison, three or more players anywhere in the answer means a picks grid, a formation question means the formation view, and stats must never be rendered as a markdown bullet list. Left to taste, the model drifts back to prose, because prose is what it was trained to produce. The routing rules are how the interface stays an interface at message ten thousand.

Here is that routing rule, rendered by the production components. One player in the answer, and the model must reach for player_card:

Here's the player card for Dembélé:

Ousmane Dembélé

Ousmane Dembélé

RW96
S TierExplosive
Coins850K
SM
WF

He's one of the most complete wingers in the game right now.

The `player_card` template rendered by the production React components, shown here with the component catalog's preview data.

A formation question, and it must reach for the formation view:

Here's the 4-2-3-1 formation layout:

Formation

4-2-3-1

NeuerMarceloVan DijkRamosHakimiKantéCasemiroNeymarDe BruyneSalahLewandowski
  • GKNeuer
  • LBMarcelo
  • CB1Van Dijk
  • CB2Ramos
  • RBHakimi
  • CDM1Kanté
  • CDM2Casemiro
  • LAMNeymar
  • CAMDe Bruyne
  • RAMSalah
  • STLewandowski

The 4-2-3-1 offers control through De Bruyne at CAM, shielded by the double pivot of Kanté and Casemiro.

The `formation` template: a full 4-2-3-1 laid out on a pitch, plus the position list. Same production components, catalog preview data.

Three or more players anywhere in the answer, and it must reach for the picks grid:

Here are the best strikers under 100K:

Best ST Under 100K

Budget: 100K

Ousmane Dembélé
1

Ousmane Dembélé

S
ST9685K
PAC
95
SHO
86
PAS
84
DRI
94
DEF
42
PHY
72
5SM4WFEXPLOSIVE
Mia Hamm
2

Mia Hamm

A
ST9392K
PAC
90
SHO
93
PAS
82
DRI
91
DEF
45
PHY
78
4SM4WFLENGTHY
Ousmane Dembélé
3

Ousmane Dembélé

S
RW9665K
PAC
95
SHO
86
PAS
84
DRI
94
DEF
42
PHY
72
5SM4WFEXPLOSIVE
The `budget_picks` template: ranked picks with prices, stat lines, and tier badges. Same production components, catalog preview data.

The protocol also caps upsells at one per reply, ever. A coach that nags twice in one answer is not a coach, it is a popup.

A closed vocabulary of blocks

So the model has real data and a required shape. Now the part that renders. The model never emits markup. For the common shapes it fills rich templates like player_card and player_comparison. For everything else it composes freely from a catalog of 20 primitives, and every primitive maps to a component the app already ships: glass_card, row, grid, stat_bar, comparison_bar, sparkline, price, player_image, radar_chart, and so on.

A composed block arrives inline in the streamed reply:

{
  "type": "glass_card",
  "props": { "title": "Mbappé vs Haaland" },
  "children": [
    { "type": "row", "children": [
      { "type": "player_image", "props": { "url": "...", "eaId": "231747" } },
      { "type": "player_image", "props": { "url": "...", "eaId": "239085" } }
    ]},
    { "type": "comparison_bar", "props": { "label": "Pace", "value1": 97, "value2": 89 } },
    { "type": "comparison_bar", "props": { "label": "Shooting", "value1": 90, "value2": 93 } },
    { "type": "price", "props": { "value": 1250000, "change_percent": -3.2 } }
  ]
}

And this is what composition looks like rendered, a card the model assembled from glass_card, row, player_image, stat bars, badges, and a price:

Ousmane Dembélé

Dembélé
PAC
95
SHO
86
PAS
84
DRI
94
DEF
42
PHY
72
5⭐ SM4⭐ WFExplosiveRW🪙 850K

Elite winger with game-changing pace and 5★ skill moves.

A DynUI composition rendered by the production components: a glass card composing a player image, stat rows, badges, and a price. Catalog preview data.

The renderer dispatches over those types, each case returning the real React component with the real design tokens. The model gets composition. It never gets pixels, colors, or CSS. It cannot draw anything my design system cannot already draw, which means its worst layout day is still on-brand.

A grammar needs semantics too, and I will be honest about how thin mine still are. The hard limits: nesting capped at depth 4, at most 12 blocks per payload, grid columns clamped between 1 and 4, and a grid holding comparison bars is forced to a single column because bars crushed side by side are unreadable. What does not exist yet is a cognitive-density rule: the model can still compose a valid card that is simply too busy. Valid but ugly is a smaller failure than invalid or false, which is why it is the one I have deferred, but it is a real gap, not a solved problem.

Accessibility follows the same logic as styling: it lives in the components, not in the model. The templates carry their labels and roles; the primitives expose real semantics, a stat bar is a progressbar with its value and range, a star rating announces "4 of 5" instead of rendering five mute icons, headings carry heading roles. The model cannot make the interface inaccessible for the same reason it cannot make it off-brand: it never touches markup. That is not a complete accessibility story, but it puts the work where it can be done once, in the component, instead of hoping a generative model remembers alt text ten thousand messages in.

Templates are molecules, not cages

The fair objection to everything above: if the rich answers come from a fixed set of templates, the model is not composing, it is picking. A player card, a comparison, a formation, chosen from a menu. That was true for longer than I would like, and the reason is embarrassing enough to be worth telling. When I audited the wiring for this post, I found that the catalog generator, the validator, and the compositional renderer all existed and all had tests, and the production prompt never mentioned any of them. The model had been living template-only, not because composition was forbidden, but because nobody had told it composition existed. The fence was built; the gate was never installed.

Fixing that properly meant more than injecting the catalog. Templates and primitives were two disconnected systems: a question either matched a template or fell into free composition, with nothing in between. So the templates became blocks. Each one is now registered in the same schema object as the primitives, tpl_player_card with name and eaId required, tpl_budget_picks with its picks array, six in all, and the renderer delegates each to the exact same React component the template path uses. When the answer is "two players side by side with a verdict underneath", the model no longer chooses between a pixel-perfect template and a hand-rolled approximation. It puts two tpl_player_card leaves in a grid and writes the verdict as a text block below them. Primitives are glue; templates are the payload.

Two templates deliberately did not make the jump. The import-your-squad and install-the-app calls to action stay outside the compositional vocabulary, because they are governed by session rules, shown once, never stacked, and a model free to place upsells inside layouts is a model that will.

The fence did not move. A template block passes through the same validator, the same repair counters, and the fact-check pass now walks composed trees exactly as it walks templates, so a fabricated price inside a nested card gets corrected against tool output no matter how deeply the model buried it. And the vocabulary now grows on evidence instead of taste: when the validator drops a block type it does not recognize, it logs the rejected name into the same telemetry column as the repairs. If the model keeps reaching for a table that does not exist, the log will say so, and that becomes the case for building one. The model proposes the next word in the grammar; production data seconds it; I still hold the pen.

One object is both the prompt and the law

The failure mode of every schema-shaped system is drift: the prompt describes one contract, the validator enforces another, and the model is graded against rules it was never shown. So there is exactly one source of truth, a typed catalog that declares each block's required props, optional props, and whether it takes children or an action.

From that one object, two things are generated. The first is the prompt catalog, a compact block of about 1,200 characters that tells the model what it may use:

glass_card(title) [children,action], row(spacing?,align?) [children],
stat_bar(label,value,max,color?), comparison_bar(label,value1,value2), ...

The second is the validator that judges what came back. Same object, same props, same flags. The prompt and the enforcement cannot disagree, because they are two views of one literal. When I merged progress_bar into stat_bar, the prompt lost it and the validator started rejecting it in the same commit, and a test now asserts the catalog does not contain it. Pruning the vocabulary is a one-line change, not an archaeology project.

Streaming without flicker

Coach AI streams. Text should appear token by token, but a UI block must not: nobody should watch half a card parse. The stream buffer makes that call per chunk. Plain text passes through immediately. The moment a [UI] tag opens, buffering starts, and the block is released only when it closes, as one chunk, already validated and repaired. The buffer even holds back a trailing partial prefix, because a chunk boundary can land in the middle of the tag itself, and a split tag would leak raw JSON to the screen.

So the user sees the sentence stream in, then the card lands whole. The model's plumbing never shows.

Repair beats reject

Here is the part I underestimated. The interesting engineering was never the happy path. It was deciding what to do when the model gets the format almost right, because a model that is 98% compliant is still wrong dozens of times a day at production volume, and refusing to render punishes the user for the model's slip.

Every rule in the repair layer is a failure I actually observed:

What the model did What the repair layer does
Put props at the top level instead of inside props: {} Lifts every known prop key into props, deletes the strays
Emitted raw JSON with no [UI] tags at all Detects bare blocks by their type field and wraps them
Concatenated two objects back to back, {...}{...} Splits them with balanced-brace parsing and renders both
Put comparison bars inside a multi-column grid, which crushes them Forces columns: 1 when a grid contains bars
Built a player card but forgot the tap action Injects openPlayer when a card contains a player_image with an id
Stopped generating mid-object Leaves truncated JSON as text; a repair must never invent content
Wrote JSON inside a markdown code fence as documentation Leaves it alone; a code example is not a render instruction

Those last two matter as much as the others. A repair layer that guesses is worse than one that rejects, so the rules only ever move or wrap what the model actually said, and anything ambiguous stays text. The user sees slightly awkward JSON once in a while instead of a hallucinated card.

Every one of these rules is pinned by tests, so a prompt rewrite or a model upgrade cannot silently regress a repair that a past failure paid for.

The uncomfortable question about a repair layer is what it becomes in two years: six rules today, forty-seven undocumented model-bug compatibilities later, a shadow parser nobody dares delete. The defense is observability. Every repair rule now increments a counter when it fires; the counts are logged per reply and stored with the interaction in the database. Every repair should have an observable reason to exist and a measurable reason to die: a rule that has not fired in months against the current model is not robustness, it is dead weight with test coverage.

I can offer proof that this is not paranoia, at my own expense. While auditing the layer for this post, I found that one repair, the grid-columns fix, had been writing its correction into a property the web renderer never read. It fired, it "worked", and it changed nothing on screen. A repair without telemetry can be dead code indefinitely, and the only reason I found it was a hostile reread of my own architecture.

The rules that are security, not style

Once the model controls the interface, its output is untrusted input, full stop. Three rules exist purely for that reason.

Unknown block types are dropped, not passed through. If tomorrow's model invents iframe_embed, the validator returns nothing and the text renders as text.

Nesting is capped at depth 4. An unbounded tree from a generative model is a rendering hazard, and no legitimate coaching card is five levels deep.

And every image URL the model emits goes through an allowlist before it reaches the renderer. Only https URLs on the app's own hosts are accepted. javascript:, data:, unknown CDNs, anything clever, all rejected before parsing. The model has never emitted a hostile URL. The allowlist is not there because it has. It is there because "the model has never done it" is not a security posture.

Rendering is one attack surface. Cost is the other, because a model with 11 database tools is a machine a crafted question can try to spin. Steering in tool descriptions is good engineering but it is persuasion, not enforcement, so the enforcement is dumb and numeric: at most 5 agent iterations per request, at most 4 tool calls per iteration, a 15-second timeout on every tool, 2,500 tokens on every completion, 12 blocks per UI payload, and a 60-second deadline that aborts the whole request server-side, whether or not the client is still listening. One honest gap remains: there is no per-user rate limit yet. It is next on the list, and naming it beats implying it is covered.

Swapping the model is a measurement, not a leap of faith

The system prompt names a model, which means one env var swaps the brain out from under everything above. What makes that survivable is a 100-prompt eval corpus that runs against a live build: greetings, comparisons, budget questions, squad imports, misspellings, off-topic bait, adversarial phrasing. Each case asserts structure: the right macro shape for the question, zero invalid blocks, templates where templates are mandatory, with a pass gate of 90 out of 100. It measures structure, not truth; truth is the fact-check pass's job. The corpus is hand-written, which is its current weakness: the obvious next step is replaying real production questions from the interaction log, so the eval distribution stops being my imagination of users and starts being users.

What exactly is the model trusted to do?

Strip everything above down to one question, the one a skeptical reviewer should ask: what, precisely, are you trusting the model with?

The fence, drawn precisely
Decisions belong to the model. Capabilities belong to the system.
The model decides
Selection
which blocks fit this answer
Composition
how they nest and flow
Explanation
the words around the UI
The model never touches
Factstools + fact-check pass
Components20 primitives + 6 template blocks
Propsone generated schema
Stylingdesign system tokens
URLshost allowlist
Depth, volume, costnumeric runtime caps
Failure policydeterministic repair, then text
The thesis of the whole system in one figure: the model controls the decisions and almost none of the capabilities. Everything in the lower table is deterministic code with tests.

Not facts, not components, not props, not styling, not URLs, not depth, not the failure policy. Selection, composition, explanation. That is the entire list, and the safest generative UI architecture I know how to build is exactly this shape: maximum decision surface, minimum capability surface.

The same lesson, one level up

My last post ended on the argument that when an AI writes your production Swift, the engineering is not the generation, it is the pipeline that proves the output. This system is that argument moved from build time to runtime, which is a harder room. There is no lap counter here, no human triggering a pipeline, no reviewer between the model and the user. The model emits an interface mid-conversation and it renders immediately, for a paying user, every time.

What makes that tolerable is the same discipline at every stage. Tools own the facts, so the model's stale confidence never reaches a card. The prompt routes answers to shapes, so the interface survives the model's pull toward prose. One schema is simultaneously the instruction and the enforcement. A repair layer absorbs the model's bad days without ever inventing on its behalf. An allowlist assumes the worst. And tests pin every rule, so the system's memory of past failures outlives any prompt.

One claim I am deliberately not making: I have production telemetry for latency, tool calls, and how often answers carry UI, and now for repairs and fact corrections, but I have not yet measured whether composed UI beats a plain text answer on engagement. Until that comparison exists, this post claims the architecture, not a conversion lift.

Do not trust the model. Build the thing that checks it, and let the model be brilliant inside the fence.