Ask an agent for “a dashboard of my tech holdings with an earnings calendar”, and there are two broad ways to get one.
The first is to let the model write the interface: a React component, a block of HTML and CSS, maybe a little script for interactivity. It’s impressive in a demo. It’s also a frontend you didn’t write, rendering numbers you can’t trace, with behaviour nobody reviewed, regenerated slightly differently every time.
The second is to give the model a vocabulary. It doesn’t write the dashboard. It describes one, in a small typed language your own code knows how to render. I think the second is the right default for almost any product that people rely on, and this post is about what that language needs in order to hold up.
A closed spec, validated twice
The spec should be boring and strict. In Python I’d reach for Pydantic with extra="forbid" at every level, so a field the model invents is an error rather than something silently ignored:
class Panel(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str = Field(pattern=r"^[a-z][a-z0-9-]*$")
kind: PanelKind # a fixed enum: table, chart, notes, ...
at: GridSlot # x + w <= 12 on a 12-column grid
source: DataRef | None = None # where the data comes from
options: dict[str, Any] = {}
actions: list[ActionName] = [] # named behaviours the frontend implements
A fixed list of panel kinds. A grid with simple arithmetic rules. Ids that are safe to use as keys and anchors. Then validate it again in the frontend, against the same rules, and fail closed there too. Two validators sound redundant until one of them gets a bug.
Be forgiving where models are predictably bad and strict where they’re dangerous. Models are poor at grid arithmetic, so overlapping panels can simply be packed downward. They are also very willing to put style, className, onClick, href, src, or dangerouslySetInnerHTML into an options object if you let them. Forbid those keys outright. Styling belongs to the design system. Behaviour belongs to a short list of named actions — “refresh”, “open details”, “export” — that your frontend implements and your security review has seen.
Data by reference, never by value
This is the decision that matters most, and it’s easy to get wrong.
If a table panel carries its own rows, the model can put any numbers it likes in them, and they’ll render with exactly the same authority as real data. So a panel shouldn’t carry data at all. It carries a reference: which allowlisted data source to call, with which parameters.
{ "source": { "from": "holdings", "params": { "account": "primary" } } }
The frontend resolves that through the normal authenticated API, as the current user, with their permissions. The model chooses what to show. It never gets to supply the numbers. Reject parameters whose keys look like secrets — api_key, password, token, pin — because sooner or later a model will try to be helpful with one.
An invalid spec is a result, not an exception
The tool the agent uses to apply a spec will often receive something invalid. How it says so changes the agent’s behaviour a lot.
If an invalid spec raises an error, the agent tends to read it as “this tool is broken” and do something drastic: retry wholesale, switch approach, or apologise and stop. If the tool returns a normal result that says the change wasn’t applied and lists exactly what was wrong, the agent treats it as feedback and fixes the field.
return {
"applied": False,
"errors": [
{"path": "panels[2].at.w", "problem": "x + w must be <= 12"},
{"path": "panels[4].options.style", "problem": "styling is not allowed"},
],
"hint": "Fix these fields and resubmit once.",
}
The error messages themselves are part of the interface. A failure that repeats the entire rulebook every time is surprisingly hard for a model to act on. One that names the failing field, and where relevant lists the valid choices it actually has, usually gets fixed on the next attempt. Pair that with a dry-run tool so the agent can check a large change before applying it.
Every applied change should become a versioned snapshot. Restoring the view later should load the latest snapshot, not replay the conversation and hope the model makes the same choices again.
Sources have to be real
Research output — an earnings comparison, a timeline of events, a table of evidence — has a problem that layout doesn’t: every row is a claim.
I’d make every row point at a source, and make “source” mean something checkable. A URL is only acceptable if this run actually retrieved it. A document must be one the user saved. Anything else should be a receipt: the id of a successful tool call that belongs to this run, which the server looks up before accepting the reference. The model can’t cite a page it never opened, and a confident fake link fails validation instead of reaching the screen.
Numbers can go one step further. If a table cell claims “FY revenue, consolidated, in crore”, let the spec bind that cell to a structured value — symbol, period, metric, unit, and scope — and have the server check it. Then report the result honestly: how many cells were bound and verified, and which were left as unverified text. Labelling prose as unchecked is much better than letting a polished table imply that everything in it was checked.
Scope deserves a special mention in finance. A company’s standalone and consolidated figures are both real and not comparable. A comparison that quietly mixes them is wrong in a way no reviewer skimming the table will catch. Making scope a required field on a bound number is cheap and prevents a whole class of mistake.
The HTML escape hatch, fenced
Not everything fits a catalogue. Sooner or later someone wants a bespoke calculator or an unusual layout, and an “HTML panel” is the pragmatic answer. Treat it as a separate risk tier.
On the server, reject iframes, javascript: URLs, meta http-equiv, and remote scripts, styles, and images, and cap the size. On the client, strip every authored <script> and <style> and wrap what remains in a host-owned kit: your stylesheet, plus a small set of behaviours you wrote. Check outbound citation links against the run’s list of retrieved sources. Render it in an iframe with sandbox="allow-scripts" and without allow-same-origin, so even the kit’s own script runs in an opaque origin.
Here’s the failure mode this creates. Imagine the agent builds a scenario calculator: an input, a slider, and a result that should update as you drag. The model writes the calculation as a handler inside a <script> tag. The sanitiser strips it, as designed. The slider moves and the number never changes. Nothing errors. It just looks broken.
The tempting fix is to allow scripts. The better one is to move a small, bounded set of behaviours into the kit, driven by data attributes the model can write — a base value, a rate, a percentage — so 10,000 × 1.05 × 22% gets computed by code you own. The model describes the calculation. Your code performs it.
”Done” has to mean the same thing everywhere
One last, slightly embarrassing class of bug. An agent publishes an artifact, considers its job finished, and stops — while the user’s screen still shows an empty desk, because publishing and placing are separate steps and the model only did one. The run says “published”. The user sees nothing.
The fix is to make the state the model thinks it has reached the same state the user sees. If publishing should put the artifact on screen, publishing should do that. Don’t rely on the model remembering a second tool call.
Why this is model-agnostic
None of this depends on which model is driving. A smaller model produces more invalid specs and gets more repairable errors back. A stronger one gets it right the first time more often. Both render through the same components, with the same source checks, the same snapshots, and the same sanitiser. That’s the point. The contract is the thing you test and review. The model is just whoever happens to be writing to it this week. (The same idea applies to the agent loop itself — see the post on model-agnostic harnesses.)