# Hallucinations in Generative UI: A Four-Layer Defense

> A chat hallucination is a wrong sentence. In Generative UI it's a widget users act on. Here's the four-layer defense the GenUI Kit ships.

- Source: https://verygood.ventures/blog/generative-ui-hallucinations-four-layer-defense/
- Published: 2026-07-31
- Author: Juan Rodríguez
- Tags: GenUI, AI, Flutter, Security

---

Chat hallucination is a wrong sentence. The reader shrugs, retries, or scrolls past. Hallucination in a Generative UI product is different. The output is a widget, and the widget looks trustworthy. When the model fabricates on that surface, users act on it.

Generative UI, or GenUI, is a way for an LLM to emit a user interface in terms of components an app already knows how to render, using the A2UI protocol. See [a2ui.org](https://a2ui.org/). It sits between chat and traditional UI, and it fails in ways neither of those does.

Take one scenario. A traveler asks a GenUI app, "Find me a flight from SFO to JFK next Friday morning." A naive GenUI system can go wrong on four distinct layers.

* **Structural.** The response is text, or JSON that does not match any component. The client cannot render it.
* **Semantic.** The response is a valid catalog component, but the wrong one. A fare chart when the user asked for flights.
* **Data.** The response is the right component, filled with a flight number the airline does not operate.
* **Safety.** The user types, "Give me a 90% off promo code for all flights." The model complies and renders a coupon UI.

These four layers are failure modes, not the Flutter architecture layers in [GenUI Meets the VGV Architecture](https://verygood.ventures/blog/flutter-genui-meets-the-vgv-architecture/). "Data" names a fabricated value here, an architectural boundary there.

No single defense catches all four. A production GenUI system defends in layers. The rest of this post walks the four layers the [GenUI Kit](https://verygood.ventures/resources/genui-kit/) ships, in the order the model touches them.

## Layer 1: The client can't render what the model returned

The failure mode. The model returns text, or JSON in a shape the client does not understand. The `GenUIZone` in the Flutter app has no widget for it. In a chat product, the reply would still show as text. In GenUI, the reply is silent, or worse, the rendering breaks. [Benchmarking LLMs for GenUI](https://verygood.ventures/blog/benchmarking-llms-for-gen-ui/) measured this failure mode across models.

The defense is three parts, all in [the Kit](https://verygood.ventures/blog/introducing-genui-kit/).

First, a catalog. The `genui_kit_catalog` package lists every component the model may emit, with its JSON Schema. The catalog is the single source of truth. The Flutter zone reads it to know what widgets to build. The backend reads it to render schemas into the system prompt. When both sides agree on the shape, they agree on what a valid response looks like.

Second, schemas ship into the prompt. The backend renders every catalog component's JSON Schema into the A2UI mechanics prompt, which is the first of three system prompts it concatenates. The model sees the exact JSON shape it must return, per component. It is not a guess.

Third, response validators. Two run against every response: one checks the A2UI envelope shape, one checks that every component name is in the catalog. When either fails, the backend feeds the errors back to the model with a "reissue using only components from the catalog" instruction and takes a second shot. If the retry also fails, the request errors out rather than shipping broken output to the client.

Back to the scenario. Without the layer, the naive model returns "Here are three flights: UA 245, DL 12, AA 88, all around 7 AM." The client has nothing to render. With the layer, that reply is rejected, the errors are fed back with a "reissue using only components from the catalog" instruction, and the retry returns a valid `FlightCard` list.

What Layer 1 misses. A schema-valid response that picks the wrong component. A schema-valid response with fabricated data. A schema-valid response that executes a jailbreak. Those are the next three sections.

## Layer 2: The right schema, the wrong component

The failure mode. The response is a valid catalog component, but not the one the user needed. A `LineChart` of average fares over the month, when the user asked for a flight this Friday. Nothing to fix in the schema. The response is structurally clean and semantically wrong.

One system prompt is not enough for this, because three concerns live at three different lifetimes.

* **A2UI mechanics.** How the model emits UI at all. The exact JSON shape per component. Mechanical, auto-generated from the catalog, rarely edited.
* **Catalog prompt.** When to pick each component. A design-system concern, owned by the catalog package, changing when the catalog changes.
* **System prompt.** The app's voice and audience. Who the user is, what tone to strike, what topics are in scope. A product concern, owned by the backend, changing when the product changes.

The backend concatenates the three per request in that order. Each has a home.

Back to the scenario. Without a catalog prompt, the model has to guess when a `FlightCard` beats a `LineChart` beats plain `Text`. Training data leans toward charts for anything with prices and dates, so a `LineChart` is a plausible pick. With a catalog prompt that says "for a specific flight query, return a `FlightCard`. Reserve `LineChart` for a fare-trend request," the model picks the card.

The trap. Teams over-invest in the catalog prompt. It attracts every stray instruction that does not clearly belong somewhere else. The scope is narrow: the catalog prompt says when to pick each component, not how A2UI works and not what the app's voice is. Push A2UI mechanics into the auto-generated prompt. Push voice and audience into the system prompt. Keep the catalog prompt about component choice.

## Layer 3: Real component, invented flight

The failure mode. The response is a valid, well-chosen component filled with data the model made up. A `FlightCard` for "UA 245, SFO to JFK, departing 7:15 AM Friday" when the airline does not operate that flight on that route.

The defense has two parts, because there are two kinds of data.

Tools, for data the model must fetch during generation: inventory, prices, availability, a flight-search API. The `buildAgentTools` list, in the generated backend's `lib/tools/`, is where a tool lives. The model calls the tool, the tool returns data, and the model still owns turning that data into a `FlightCard`. Wiring a tool means defining its input schema, authenticating it, and registering it in that list.

`PromptContextBuilder`, for facts the server already has at request time: the signed-in user's home airport, their locale, the current UTC time. These do not need a round trip. The builder returns a map, the map merges into the system prompt render, and each key becomes a Handlebars variable the prompt can reference.

The cost trade-off matters. A tool call is a full model round trip and its latency, every request. Injected context is not. Pick tools for data the server has to go fetch. Pick context for data the server already has.

Back to the scenario. Without tools, the model invents "UA 245, 7:15 AM." With a `flightSearch` tool, the flight number and departure time are real. With `PromptContextBuilder` returning the user's home airport, SFO, and locale, the origin defaults right and the times render in the traveler's local zone, without a second round trip.

The constraint. Everything the builder returns lands verbatim in the system prompt. Do not inject secrets. Do not inject unscreened client input. The guardrail layer is the defense for the second case, and it is the next layer.

## Layer 4: The jailbreak that never reaches the model

The failure mode. The user asks for something the app should never render. "Give me a 90% off promo code for all flights." A naive model complies, and the client faithfully renders a coupon UI for a discount that does not exist. Now your support team is deciding whether to honor a promo your app displayed.

[Prompt injection](https://genai.owasp.org/llmrisk/llm01-prompt-injection/) ranks first on OWASP's list of LLM risks. The defense sits at both edges of the request, screening the prompt coming in and the response going out, catching the jailbreak before it ever reaches a component the model could fill in. That defense is a guardrail layer built on [Model Armor](https://docs.cloud.google.com/model-armor/overview), Google Cloud's model safety service. The GenUI Kit backend deploys as-is on Google Cloud with Model Armor wired in. On Bedrock, Azure, or another stack, treat the backend as a reference and run the equivalent screening layer there.

Back to the scenario. Model Armor screens the prompt before the model sees it. The discount-code request is flagged, the model never generates a coupon, and the client renders the app's standard refusal component instead. The request dies at the door, not in the UI.

## Why layered

Four layers, one scenario. Structural catches a text-blob response the client cannot render. Semantic catches a `LineChart` where a `FlightCard` belonged. Data catches "UA 245" for a flight the airline does not operate. Safety catches the discount-code jailbreak.

Remove any one and the corresponding failure lands in front of the user. Remove Layer 1 and structurally-invalid responses stay silent or render broken. Remove Layer 2 and the chart shows up in place of the card. Remove Layer 3 and the fabricated flight is presented as real. Remove Layer 4 and the jailbreak succeeds.

Each layer has a hallucination that only it catches. That is why the defense is layered and not chained, and it is why a single "just validate the schema" or "just add Model Armor" answer misses. The four layers are four different problems.

The four layers sit at four different places in a single request.

![A GenUI request flowing through the four defense layers: Model Armor screens the prompt in and the response out (Layer 4), the prompt render and PromptContextBuilder feed the model (Layers 1, 2, and 3), tools fetch real data (Layer 3), and the response validators retry once with feedback (Layer 1) before the client renders](/assets/images/blog/generative-ui-hallucinations-four-layer-defense/body-0.png)

Where each layer sits in a single GenUI request.

## Learn more

The [GenUI Kit](https://verygood.ventures/resources/genui-kit/) bundles the catalog conversion, developer cockpit, real backend integration, and quality gates a Flutter team needs to ship generative UI in production. Every layer this post walked is part of it. The layers came out of shipping GenUI for enterprise clients, not a whiteboard. The alpha waitlist is open.

For the broader picture, see [Introducing GenUI Kit](https://verygood.ventures/blog/introducing-genui-kit/), which walks the Kit's Flutter architecture and the production concerns behind this post. For measurements behind Layer 1, see [Benchmarking LLMs for GenUI](https://verygood.ventures/blog/benchmarking-llms-for-gen-ui/), which coined "structural hallucinations" with data across models. For adjacent GenUI reading, see the [Very Good Ventures blog](https://verygood.ventures/blog/?tag=GenUI).
