What the model cannot do: six things a generated screen must never be allowed to decide
A check that runs after generation can be argued with in a review meeting. A field that does not exist in the wire format cannot. This article publishes the format.

The prototype always works. Someone on the iOS team gets a model to emit a JSON description of a screen, the app renders it natively, and for about a week it is the most interesting thing in the repo. Then it goes to a review, and someone from security asks a question nobody has a written answer to: what, exactly, can this thing do?
Most published advice on LLM guardrails for generated UI answers that question with a filter: a validator that inspects the string the model produced, decides whether it looks acceptable, and cleans it up if not. Applied to a chat transcript, that is reasonable. Applied to something your app is about to render as an interface, it inverts the burden of proof. You are now enumerating every bad output rather than enumerating the good ones, and the enumeration is never finished.
There is a stricter frame available, and it is the one worth arguing for in that meeting. Rather than asking what the model should be prevented from emitting, define a contract so narrow that the dangerous outputs have nowhere to live — then reject anything outside it. This article lists six of those constraints, states each as an impossibility, and publishes the schema and grammar behind them so you can check the claims instead of believing them. If the underlying mechanism is new to you, start with how a generated screen reaches an iOS device and come back.
The short answer: LLM guardrails for generated UI are a contract, not a filter
A guardrail that runs after generation inspects a string and decides whether to allow it. A contract decides what a valid string can be before the model writes one. The six constraints below are enforced structurally: the model cannot author style, invent a layout, render an undeclared action, reach a write endpoint, see a credential, or have invalid output repaired.
The distinction matters because of who carries the burden. A filter has to recognise every bad output, and it degrades quietly — the day a model emits a shape nobody anticipated, the filter passes it. A contract has to describe every good output, which is a finite, reviewable, checked-in artifact. You can read it. You can diff it. Someone in a review meeting can point at the line that makes their objection impossible.
This is what separates useful guardrails for AI generated interfaces from the general-purpose kind. The general-purpose kind asks whether a string is toxic, leaks personal data, or parses as JSON. Those questions still matter and they are well covered elsewhere. None of them tells you whether the thing about to appear on a customer's phone is a screen your team would have shipped.
The useful question is not "what should the model be prevented from doing?" It is "what does the format make it unable to say?"
Each section below names one constraint, explains how it is enforced, and ends with how you could verify it in someone else's system rather than taking their word for it. The examples come from Uzori's shipped wire format, read-only-ui/v1.
1. It cannot author style
The model does not choose colours, fonts, spacing, corner radii, or shadows, because the wire format has no field for any of them. Search all 23 record types in the schema for a visual token and there is nothing to find. Every object definition in the file carries additionalProperties: false, so a model that invents a color key does not get it ignored — it produces an invalid record.
The nearest thing to a visual decision the model gets is semantic. A metric can carry a tone of neutral, positive or negative; the model states that a number reads as bad news, and the app decides what bad news looks like. That indirection is the whole trick. Meaning travels over the wire; appearance stays on the device.
Style also never travels in the other direction. The presentation stage's input schema has exactly six top-level keys and theme is not among them, so the model is not shown the design system it is not allowed to author:
{
"type": "object",
"additionalProperties": false,
"required": [
"protocolVersion", "runtime", "request",
"context", "groundedData", "allowedInteractions"
],
"properties": {
"protocolVersion": { "const": "read-only-ui/v1" },
"runtime": { "$ref": "#/$defs/runtime" },
"request": { "oneOf": [
{ "$ref": "#/$defs/userMessageRequest" },
{ "$ref": "#/$defs/selectionRequest" }
] },
"context": { "$ref": "#/$defs/context" },
"groundedData": { "$ref": "#/$defs/groundedData" },
"allowedInteractions": { "$ref": "#/$defs/allowedInteractions" }
}
}How you would check it. Ask for the wire schema and grep it for color, font, spacing, radius and shadow. If any of them appears — even as an optional enum of approved brand values — the model is authoring style, and your design system now has a contributor who does not attend design review.
2. It cannot invent a layout
There is no view tree in the format. A model that could emit arbitrary nesting could emit a stack inside a card inside a carousel that no designer ever laid out, and no amount of per-node validation would tell you whether the result was a screen. So the format has no nesting to emit.
What it has instead is a state machine. A screen is a flat sequence of records, and which record may follow which is fixed in advance. Groups open, take items, and close explicitly; they never nest. Here is the grammar, in full, minus the per-record byte limits:
{
"protocolVersion": "read-only-ui/v1",
"initialState": "waiting",
"terminalState": "committed",
"serverOnlyRecords": ["screen_abort"],
"states": {
"waiting": { "screen_start": "screen" },
"screen": {
"narrative": "screen", "facts": "screen", "metrics": "screen",
"callout": "screen",
"collection_start": "collection",
"comparison_start": "comparison_subjects",
"steps_start": "steps",
"locations_start": "locations",
"media_start": "media",
"handoff_bar": "handoff",
"screen_commit": "committed"
},
"collection": { "collection_item": "collection", "collection_end": "screen" },
"comparison_subjects": {
"comparison_subject": "comparison_subjects",
"comparison_row": "comparison_rows"
},
"comparison_rows": { "comparison_row": "comparison_rows", "comparison_end": "screen" },
"steps": { "step_item": "steps", "steps_end": "screen" },
"locations": { "location_item": "locations", "locations_end": "screen" },
"media": { "media_item": "media", "media_end": "screen" },
"handoff": { "screen_commit": "committed" },
"committed": {}
},
"constraints": {
"exactlyOneScreenStart": true,
"exactlyOneScreenCommit": true,
"recordsAfterCommit": 0,
"minimumTopLevelContentRecords": 1,
"maximumCollectionItemsPerCollection": 20,
"minimumComparisonSubjectsPerComparison": 2,
"maximumComparisonSubjectsPerComparison": 3,
"comparisonRowValueCountMustEqualSubjectCount": true,
"maximumCollectionsPerScreen": 3,
"maximumComparisonsPerScreen": 1,
"maximumStepsBlocksPerScreen": 2,
"maximumHandoffBarsPerScreen": 1,
"groupsMayNest": false,
"blankLinesAllowed": false,
"maximumLineBytes": 16384
}
}Read the constraints block as a design review that already happened. Two or three comparison subjects, never four. One comparison per screen. Twenty collection items at most. A model that wants an eleven-column comparison does not get a squashed one; it gets a rejected record.
How you would check it. Ask whether the format has a nesting rule. If the answer is a component tree with a children array, layout is a runtime output rather than a design decision, and every screen is a new one.
3. It cannot render an action you did not declare
Buttons are the part of a generated screen that actually does something, so they get the narrowest definition in the format. The model cannot emit a label, a URL, a deep link or a payload. It emits a reference to an action the host application already registered, and nothing else:
{
"handoffBar": {
"type": "object",
"additionalProperties": false,
"required": ["type", "actions"],
"properties": {
"type": { "const": "handoff_bar" },
"actions": {
"type": "array",
"minItems": 1, "maxItems": 2, "uniqueItems": true,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["actionRef"],
"properties": {
"actionRef": { "type": "string", "minLength": 1, "maxLength": 160 }
}
}
}
}
}
}Three independent layers have to agree before that reference becomes a tappable control. The wire schema permits only the reference. The server rejects a reference that was not in the interactions it supplied for this turn. Then the iOS client checks the reference against the handoffs the host app was configured with, and a reference it does not recognise is not rendered as a disabled button or an error state — the control is not given a button trait at all. A fabricated actionRef reaches the device and produces nothing.
That last layer matters more than it sounds. It means the client does not trust the server, so a bug in the gate is not automatically a bug on screen.
How you would check it. Take the vendor's demo, fabricate a plausible action reference, and inject it into the stream. If a button appears, the model is authoring your navigation.
4. It cannot reach a write endpoint by default
Tool access is derived from HTTP verbs before anyone configures anything. GET and HEAD resolve to read; every other verb resolves to disabled. The rule is enforced twice, at two different layers: the agent is only ever handed tools whose safety is read, and the executor independently refuses to call anything marked disabled, returning an operation_disabled error rather than a request.
Two enforcement points for one rule is not redundancy for its own sake. It means a configuration mistake that widens what the agent sees still hits a second refusal before anything leaves your network.
The honest version of this claim has a second half. Safety is overridable per operation: mutating verbs are disabled by default, and an operation reaches the model only if a human both allowlists it and classifies it as read. That is deliberately a two-step, auditable decision rather than a single toggle, and the shipped Vaulted integration exercises it — a quote endpoint is a POST that a human promoted to read. The mechanism is a reviewable diff in a config file, not a property of the universe.
The safety story here is not that mutations are unreachable. It is that reaching one requires a named person to write it down twice, in a file your team reviews.
How you would check it. Ask to see the allowlist for a live deployment and count the operations classified read whose HTTP verb is not GET or HEAD. That number is the actual size of the write surface, and someone should be able to name every entry.
5. It cannot see a credential
Session state has three fields: a session identifier, an optional tool-call history, and the screens produced so far. There is no credentials field, no token field, no header bag. The model cannot leak an access token from session context because the type that carries session context has nowhere to hold one.
This is the difference between structural absence and redaction. A redaction step is a filter — it has to recognise the secret in order to remove it, and it fails silently on a format it does not recognise. A type with no field for secrets fails at compile time, in your CI, on the day someone tries to add one.
Authentication belongs entirely to the host. The iOS SDK and your app own credentials, deep links, callbacks and every mutating flow; the generated screen ends at a handoff into code you already wrote and already tested.
How you would check it. Ask for the type definition of whatever the vendor calls session state, and read it. If secrets are kept out by a redaction pass rather than by the shape of the type, ask what happens the first time a new field is added upstream.
6. It cannot have invalid output patched into shape
This is the constraint the rest depend on, because a contract you repair around is a contract in name only. Every line the model emits passes six checks in order before a single byte of it is forwarded:
1. JSON Schema the line validates against the record schema for its type
2. Screen identity screenId equals the runtime screenId the server assigned
3. Navigation screen_start uses the navigation mode the server chose
4. Grammar state this record type is legal in the stream's current state
5. Grounding every subjectRef and URL traces to real API data
6. Cardinality the record does not exceed the limits above
any failure -> the line is rejected, not corrected
first record -> one bounded re-ask, only if nothing has been forwarded
mid-stream -> screen_abort; the client restores the pre-turn screenNothing in that path coerces a type, drops an unknown key, or completes a truncated record. A line that fails is a line that does not travel. And what does travel is the original bytes — the gate forwards the exact string it validated rather than a re-serialised version of its own parse, so what renders on the device is byte-identical to what passed the check.
The one exception is worth stating precisely, because it is the kind of detail a careful reader will go looking for. The server never repairs model output. One bounded re-ask is permitted only when the first record fails and nothing has been forwarded — that is, before the user has seen anything at all. Once a stream has started, there is no second chance: it aborts, and the screen the user was already looking at comes back. Draft content is not interactive until the commit record arrives, so a stream that dies halfway never leaves a tappable half-screen behind.
A rejected screen is an outage you can see. A repaired screen is an outage that renders.
Compare that with the category's default. Guardrails AI, the framework most of the published guidance is written around, offers on-fail actions including fix, which auto-corrects failing output, and reask, which re-prompts the model with the error (checked 10 August 2026). For a summary, correcting is the right call. For an interface, fix produces a screen that no model authored and no designer approved, and nobody can say afterwards which of the two decided what the customer saw.
How you would check it. Ask what happens to a record that fails validation. If any answer contains the words "we clean it up," you are being offered a repaired interface.
What is still your problem
Six impossibilities are a bounded claim, and the bound is narrower than it first reads.
This is not a security model for your application. It constrains one output channel. Your authentication, your authorisation, your rate limits and your data-access rules are exactly as important as they were before, and nothing here substitutes for them. The relevant industry framing is OWASP's LLM05:2025 Improper Output Handling, checked 10 August 2026 — insufficient validation of model output before it reaches downstream components. A wire contract addresses that one entry, not the other nine.
Prompt injection is bounded, not solved. OWASP lists LLM01:2025 Prompt Injection first for good reason. Constraining the output format shrinks what a successful injection can accomplish — it cannot produce a button, a link, or a write call that was not already declared. It can still influence the words in a narrative record. Poisoned data in an API response can still be described accurately and misleadingly.
Grounding is not truth. Every value shown traces back to your API. If your API returns a wrong balance, the generated screen shows a wrong balance, in your typography, with your brand on it.
Read-only fights some products. These constraints are natural for account and service questions — a bill, a delayed flight, a denied claim, a missing order — where the honest answer is an explanation. They are a poor fit where the answer is a purchase. You cannot rent a dress read-only, and if your hardest customer questions end in a transaction, the handoff is doing all the work.
iOS is not mobile, and your spec is load-bearing. Uzori's implementation is iOS only. And because the integration is your OpenAPI spec plus an allowlist, the spec has to match what your service actually returns; drift a human reader would forgive becomes a hard tool error.
How to check any vendor's answer
Six questions, in the order that gets to a real answer fastest. Ask for artifacts, not descriptions.
- Show me the wire schema. If there is no versioned, checked-in schema file, the contract is whatever the prompt happened to produce this week.
- Where is the style field? Grep it yourself for colour, font and spacing. Absence is the answer you want; an enum of approved values is not the same thing.
- What is the nesting rule? A
childrenarray means arbitrary layout. A state machine with explicit group open and close means the shapes were decided in advance. - What can a button be? If a button carries a label or a URL from the model, your navigation is being authored at request time.
- What happens to an invalid record? Listen for "reject" versus "fix". Then ask what reaches the device when the failure happens mid-stream.
- Does the client validate independently? A server-side gate is one bug away from being the only gate. Ask whether the renderer re-checks, and which limit wins when the two disagree.
Any vendor who can answer six with artifacts has thought about this. Any vendor whose answer is a confidence level has not. The rest of this cluster takes these one at a time.
Frequently asked questions
What does fail-closed mean in LLM validation?
Fail-closed means an output that cannot be proven valid is discarded rather than passed through in a degraded form. The opposite, fail-open, ships whatever survived a best-effort clean-up. For a generated interface, fail-closed is the only defensible default: a rejected screen is a visible, debuggable outage, while a repaired screen is a silent one that looks fine in production.
Can structured outputs replace a server-side validation gate?
No. Constrained decoding makes malformed JSON far less likely, which removes one failure mode out of six. It does not check that a record targets the right screen, that it is legal at this point in the stream, that its references trace to real API data, or that it respects cardinality limits. Structured outputs improve the odds at generation time; the gate is what makes the guarantee.
Do these constraints stop prompt injection?
They reduce the blast radius rather than eliminating the attack. An injected instruction cannot make the model emit a colour, a layout, an undeclared button or a write call, because the format has no room for any of them. It can still influence the prose inside a narrative record, and it can still exploit poisoned data that your own API returned as fact.
Who is responsible when a generated screen shows the wrong thing?
You are, which is the reason the constraints are worth having. Every value on the screen traces to a response from your own API, and every action leads into a flow your team wrote. That makes wrong output diagnosable: either the data was wrong or the copy describing it was, and the two are distinguishable from the record stream alone.
Where to start
Take the six questions above to whichever generated-UI system you are currently evaluating, including one you are considering building yourself. The answers separate a contract from a prompt with good intentions in about twenty minutes, and they are the same questions your security reviewer will ask later, when it is more expensive to answer them.
If you want to see what these constraints look like in a running iOS app, Uzori is built entirely around them, and the schema and grammar above are the ones it ships.