Артыкул апублікаваны на англійскай мове.
Stop Letting LLMs Write HTML: Semantic JSON Components for AI Reports
Why a team moved AI-generated reports from raw HTML to a constrained JSON vocabulary rendered by deterministic code, and how semantic components beat layout primitives.
Letting a language model write the final HTML for your product is a fine way to start and a poor way to continue. Consider a team building AI-generated customer insight reports. The first version was easy: hand the analysis to an LLM and ask for HTML. A few months later the pipeline had dozens of steps, enormous piles of generated markup and styling, and prompts that even their authors struggled to maintain.
A better prompt didn't fix it, and neither did a bigger model. What worked was taking freedom away. The model stopped producing the interface and started producing structured JSON built from a small set of semantic components, while ordinary code handled rendering. The outcome is more predictable, cheaper to run and easier to evolve.
Why reports existed at all
The product began as a chat. People opened the app, asked about their customer feedback, and the model dug up relevant insights. Technically that worked. In practice it put a lot of weight on the user, who had to know what to ask, what data existed, how to phrase a useful question and which topics deserved attention. Power users coped. Many others stalled, and the answers weren't the issue. They just didn't know what to ask.
So the team moved from pull to push. A data owner defines an analysis once and the results are delivered automatically. Email came first and suited short weekly summaries.
Trouble began when analyses grew richer. Long emails are hard to scan, tables break on small screens, and detailed sections make a message feel crowded. Past a certain point, email is the wrong container.
Hence HTML reports. The email became the notification and the report became the destination. A web page has room for sections, charts, cards, rankings and supporting detail, and it's easy to share. The pipeline was simple:
Customer data
│
▼
AI analysis workflow
│
▼
Generated HTML + CSS
│
▼
Hosted report
│
▼
Email with link
For the first few reports it worked surprisingly well.
Raw HTML makes a great prototype
The first implementation was basic: pass the model some data, show an example of the desired output, and ask for HTML. That's attractive early on. Models write HTML well, and almost no infrastructure is needed. Changing a layout, adding a section or trying a new card type often means editing a prompt and not building front-end components. For small reports that flexibility was valuable.
Growth exposed the weakness. One large report workflow reached around 40 steps. About 30 fetched or prepared data, and roughly 10 existed only to generate and assemble HTML. A single report could contain hundreds of kilobytes of markup and CSS.
At that size, problems fed each other. The model had to follow a complicated visual specification, write huge responses and stay consistent across many sections. Tiny prompt edits changed layouts in unexpected ways, and the sheer volume of generated markup made everything needlessly expensive.
The obvious workaround was to cut the page into smaller jobs:
Analysis
│
├── Generate sections 1–2 ─┐
├── Generate sections 3–4 ─┤
├── Generate sections 5–6 ─┼──► Combine HTML ─► Report
├── Generate sections 7–8 ─┤
└── Generate sections 9–10 ┘
That fixed the immediate model-side trouble. Calls got smaller, there was less context to reason over, and output limits stopped being a threat.
It created a new problem, though. Someone now had to know which prompt produced which part of the page, how the fragments fitted together, which styles belonged where, and how a change in one part affected the others. Maintaining a report meant understanding a distributed HTML factory made of prompts.
It wasn't a prompt problem
Treating this as a prompting issue would have been easy: sharper instructions, better examples, a newer model for more consistent markup. None of that would have touched the real cause, which was the level of abstraction.
One extreme gives the model total freedom to emit arbitrary HTML and CSS:
Data → LLM → arbitrary HTML/CSS
That's maximum flexibility and almost no predictability.
The opposite extreme is a fixed template where the model only fills in content:
Data → LLM → fixed fields → hard-coded template
That's efficient and predictable, and it doesn't fit either, because different reports really do need different structures. One has five sections, another ten. Some want charts, some rankings, some long text, some grids of quantitative insights.
What was needed sat in the middle. The model should compose a report without having to implement it. That distinction shaped the new design.
Lessons from email and Slack
The same issue exists outside AI. Email developers have lived with it for years. Reliable HTML email is notoriously hard, because mail clients support different and often ancient slices of HTML and CSS. Frameworks like MJML respond with a higher-level language. You describe the structure and let the framework emit the ugly markup:
<mj-section>
<mj-column>
<mj-text>Hello world</mj-text>
</mj-column>
</mj-section>
Slack's Block Kit follows a similar idea. Apps don't send Slack raw HTML. They send structured JSON describing sections, text, images, buttons and other elements, and Slack decides how they look.
That was close to what the team needed. Instead of asking the model for something like this:
Create a blue card with a 24px title, two chips below it,
the percentage on the right and 16px padding.
they wanted it to produce something like this:
{
"type": "QuantifiedInsight",
"title": "Delivery complaints increased",
"memoryCount": 184,
"share": 0.27
}
Responsibility shifts completely. The model describes what the content means, and the renderer works out the visual implementation.
Why JSON suits models
Next came the question of what the report language should look like. An XML format in the style of MJML was possible, but JSON won, and for a reason that had less to do with developer taste than with models. Current LLM APIs are very good at emitting structured JSON, and JSON Schema can describe the expected shape precisely. That provides a clear contract and lets you validate output before rendering anything.
A simplified definition of the component above:
{
"type": "object",
"properties": {
"type": {
"const": "QuantifiedInsight"
},
"title": {
"type": "string"
},
"memoryCount": {
"type": "integer",
"minimum": 0
},
"share": {
"type": "number",
"minimum": 0,
"maximum": 1
}
},
"required": [
"type",
"title",
"memoryCount",
"share"
]
}
Freedom remains, but it's bounded. To produce this kind of insight, the model must supply a title, a count and a percentage, and the count can't be negative while the percentage must fall between zero and one.
Earlier, the prompt amounted to "here's the data and some design advice, now build the page." The new one reads "here's the data, and here are the building blocks you're allowed to use; describe the report with them." That system is much easier to control.
Adaptive Cards as a base
Inventing the whole format from scratch wasn't appealing. Slack's Block Kit was a useful reference but belongs to Slack's ecosystem. Microsoft's Adaptive Cards fit better: an open, declarative JSON model with existing ideas for text, containers, columns, tables and images, plus rendering libraries to build on. Crucially, it could be extended with custom higher-level elements.
The layers ended up like this:
AI-controlled
│
▼
Customer data ─► Analysis ─► Report JSON
│
─────────────────────────────┼────────────────────────────
│
Code-controlled
▼
Validation
│
▼
Deterministic renderer
│
▼
HTML
That line is the key design decision. Anything above it can be generative. Anything below it should be deterministic: identical JSON must produce an identical result every time.
Generic primitives were still too loose
Initially the model got fairly generic UI building blocks. It could create a container, add text, insert columns and place elements in them. It worked technically.
Before long, though, the team saw it had simply rebuilt the old problem a layer up. When the model keeps choosing column counts, the position of a percentage, chip order and the arrangement of parts, inconsistency has lots of room to creep in. A report can fail even though its JSON is perfectly valid.
Picture a card showing an insight with an absolute count and a percentage. A generic version might be:
{
"type": "InsightCard",
"chips": [
{
"label": "27%",
"style": "secondary"
},
{
"label": "184 memories",
"style": "primary"
}
]
}
The model could swap the chips, duplicate one, choose the wrong style or drop one entirely. None of those options gives users anything useful.
So the component became semantic:
{
"type": "QuantifiedInsight",
"title": "Delivery complaints increased",
"memoryCount": 184,
"share": 0.27
}
Gone is the array of arbitrary chips. There's now a memory count and a share, and the renderer knows how to present them and in what order.
This small step captures a major lesson: semantic components beat generic layout primitives. A ColumnSet says how something is arranged. A QuantifiedInsight says what something means. The closer the vocabulary sits to the product's own language, the less implementation work is left for the model.
Rendering becomes normal software
A nice side effect is that rendering stops being an AI problem. A simplified TypeScript version:
function renderElement(element: ReportElement) {
switch (element.type) {
case 'QuantifiedInsight':
return renderQuantifiedInsight({
title: element.title,
count: element.memoryCount,
percentage: element.share,
});
case 'Timeline':
return renderTimeline(element);
case 'Chart':
return renderChart(element);
default:
return renderStandardAdaptiveCardElement(element);
}
}
A real implementation might still use Adaptive Cards underneath, with the custom element eventually translated into containers, text blocks and columns. The model never needs to know.
That gives a clean abstraction boundary. If every quantified insight later needs different spacing, fonts or colors, you change the renderer once. You don't edit several prompts and hope the model obeys the new instruction every time. The system behaves like regular software again.
Designing the vocabulary
A design question remains: how flexible should each component be? One bad extreme is a single component with dozens of optional properties:
{
"type": "EverythingCard",
"icon": "...",
"rank": 1,
"percentage": 0.27,
"image": "...",
"timeline": true,
"compact": false
}
The other is a brand-new component for each tiny visual variation.
The trade-off resembles designing React components. Sometimes two views are one component with a few props. Other times they're different concepts and should stay apart. Meaning is the best tiebreaker: if two blocks convey the same idea, a constrained property may do, and if they convey different things they probably deserve separate components. The goal isn't the fewest components. It's a vocabulary the model can use dependably.
Less freedom, more useful flexibility
This sounds backwards at first. The model can no longer generate arbitrary HTML or CSS, can't freely decide how each element is laid out, and some components have shapes it can't alter. Yet the whole system became more flexible.
The reason is that useful flexibility differs from freedom of implementation. Users care whether a report can hold the right sections, insights, data and visual forms. They don't care whether a percentage is built from one <div> or three nested <span> elements. That kind of freedom adds variability and no value.
With it removed, the model can safely get more say over decisions that matter: which sections are relevant, which components express an insight best, what content goes where and how many components a section needs. What it can't do is reinvent each component's implementation on every run. That's a healthier split of duties.
The pattern generalizes
The lesson isn't really about HTML. It's about where generative AI belongs inside a software system.
For a prototype, letting the model emit the final artifact is powerful. You move fast, skip infrastructure and learn what the product needs. Raw generation was right at that stage. The trouble starts when the prototype becomes part of a repeatable system, because direct generation turns harder to control than it's worth. A good next step is to raise the model one level in the architecture:
- Don't ask it to build the interface. Ask it to describe the interface.
- Don't ask it to assemble the final document. Ask it for the document's semantic structure.
- Don't ask it to settle low-level details. Give it a small language covering the choices you want it to make.
The general pattern:
Natural language
│
▼
AI
│
▼
Small semantic language
│
▼
Deterministic software
│
▼
Final artifact
Once you work this way, many questions stop being prompt engineering and become software design again. Which concepts should the model be able to express? Where is flexibility really needed? What can be removed? How can the schema rule out invalid or undesirable states? Where does the line between model decisions and code decisions belong? These are familiar engineering problems, and generative AI doesn't erase them. It makes picking good abstractions matter even more.
What's next
The vocabulary is still being refined. The first report has moved to the new approach, and it's being compared with the original before more workflows migrate. The gradual pace is deliberate: a component system built around one report is probably overfitted, while one that works across several real reports starts to become a platform. Some components will likely vanish, others get simpler, and new semantic elements will emerge.
The direction is clear, though. The model decides what to say and which building blocks to use. Software decides exactly how those blocks behave and render. That boundary turned a growing tangle of HTML prompts into something close to ordinary software. A good rule of thumb: spend the model on choices that need intelligence, and spend code on choices that need certainty.