A trustworthy generative UI does not ask an LLM to emit arbitrary interface code. It lets the model select a bounded tool or presentation intent, validates the returned data against a schema, and maps that result to tested components such as a chart, table, metric, comparison, or action. Text remains the explanation; structured UI makes evidence inspectable.
How model output becomes trusted interface state
A typed allowlist renderer and action boundary
A tool result schema and tested components
- 01IntentAllowed tool or view
- 02SchemaValidated result
- 03ComponentTested rendering
- 04StatesLoading · empty · partial · error
Rendering contract
Separate model choice from component execution#
- 01
Interpret
The model identifies the user goal and selects an allowed tool or view intent.
- 02
Validate
A schema constrains fields, types, ranges, labels, and empty states.
- 03
Render
A trusted component receives validated data and owns accessibility and interaction.
- 04
Explain
Text states what the view shows, its source, and important uncertainty.
Copyable contract
Make every rendered view explain its state and provenance#
A small envelope keeps presentation intent separate from business data. The server validates it before the client selects a component; unknown component names, invalid fields, and unsupported actions fail closed.
The example is deliberately generic. In production, version the schema and authorize every action on the server rather than trusting values proposed by the model.
Let the model choose intent, not executable UI
This sanitized contract reflects a production conversational UI pattern: validate data, map only known variants, and authorize side effects outside the model response.
type ViewSpec =
| { schema_version: "1"; kind: "metric"; label: string; value: number }
| { schema_version: "1"; kind: "comparison"; rows: Array<{ label: string; value: number }> };
type RenderNode = { component: "MetricCard" | "ComparisonTable"; props: object };
function parseView(input: unknown): ViewSpec | null {
if (!input || typeof input !== "object") return null;
const value = input as Record<string, unknown>;
if (value.schema_version !== "1") return null;
if (value.kind === "metric" && typeof value.label === "string" && typeof value.value === "number") {
return value as ViewSpec;
}
if (value.kind === "comparison" && Array.isArray(value.rows)) {
const valid = value.rows.every(row => row && typeof row.label === "string" && typeof row.value === "number");
return valid ? value as ViewSpec : null;
}
return null;
}
const registry = {
metric: (view: Extract<ViewSpec, { kind: "metric" }>): RenderNode =>
({ component: "MetricCard", props: { label: view.label, value: view.value } }),
comparison: (view: Extract<ViewSpec, { kind: "comparison" }>): RenderNode =>
({ component: "ComparisonTable", props: { rows: view.rows } }),
};
export function renderToolResult(input: unknown): RenderNode | { component: "SafeFallback"; props: object } {
const view = parseView(input);
if (!view) return { component: "SafeFallback", props: { reason: "invalid_view_contract" } };
return view.kind === "metric" ? registry.metric(view) : registry.comparison(view);
}
export function authorizeAction(input: { action: string; requires_confirmation: boolean }, confirmed: boolean) {
if (input.requires_confirmation && !confirmed) return { status: "confirmation_required" };
return { status: "authorized_for_server_validation", action: input.action };
}
console.log(JSON.stringify([
renderToolResult({ schema_version: "1", kind: "metric", label: "Conversion", value: 12.4 }),
renderToolResult({ schema_version: "2", kind: "html", value: "<script>" }),
authorizeAction({ action: "publish_report", requires_confirmation: true }, false),
], null, 2));tsc trusted-renderer.ts && node trusted-renderer.jsExpected output
[
{
"component": "MetricCard",
"props": {
"label": "Conversion",
"value": 12.4
}
},
{
"component": "SafeFallback",
"props": {
"reason": "invalid_view_contract"
}
},
{
"status": "confirmation_required"
}
]Failure exercised. The invalid schema becomes SafeFallback; the sensitive action remains blocked until confirmation.
Production boundary. A framework adapter may render these nodes in React, Vue, or native UI. The trust boundary stays framework-independent.
View selection
Choose a representation by question, not novelty#
Use a metric for one important value, a table for precise lookup, a line for change over time, bars for category comparison, and text when the evidence does not justify a visualization.
Give the model a small catalog with descriptions and constraints. If no component fits, fall back to text or a table rather than generating an unsupported chart.
Sanitized product pattern
Conversation becomes a control surface for analysis#
In a conversational analytics product, the user does not only ask for an answer. They refine a segment, compare alternatives, inspect evidence and continue from the current state.
The UI therefore needs stable identifiers, provenance and reversible actions. A follow-up such as “compare only the top two” should transform validated data, not reinterpret a screenshot or prose paragraph.
Operational states
Loading, empty, partial, stale, and error are first-class#
Tool calls take time and can fail independently. Components need explicit states and accessible status messages, while the conversation needs a recovery path that preserves the user’s intent.
Do not render a confident empty chart when data is missing. Show the boundary: no data, filtered out, unavailable source, or tool failure are different states.
Action safety
A generated control still needs ordinary authorization#
Separate read-only exploration from state-changing actions. A button proposed by the model is not permission: the server must verify identity, scope, current state, and the exact operation before executing it.
Use stable action identifiers, idempotency for retries, confirmation for destructive or expensive changes, and a visible result. When authorization or evidence is missing, preserve the analysis and disable the action instead of guessing.
Accessibility
Every visualization needs a non-visual path#
- Provide a text summary of the main finding.
- Offer a table or equivalent data representation.
- Do not encode meaning only through color.
- Announce loading and result status programmatically.
- Keep keyboard focus predictable after updates.
- Test reflow and zoom on small viewports.
- Expose source, timestamp, filters, and uncertainty.
- Keep actions reversible or confirm destructive changes.
Boundary
What generative UI should not generate#
CAN
- Select from tested components
- Adapt presentation to validated data
- Expose evidence progressively
- Support follow-up analysis
CANNOT
- Execute arbitrary model-authored UI code
- Make unsupported data trustworthy
- Replace accessibility with a chart
- Hide uncertainty behind polish
Primary sources
Primary sources
- AI SDK: Generative User Interfaces
Tool results mapped to interface components.
- JSON Schema basics
Constraints for structured data.
- Web Content Accessibility Guidelines 2.2
Text alternatives, reflow, semantics, and status messages.
- OWASP Top 10 for LLM Applications 2025
Excessive agency, insecure output handling, and sensitive information disclosure.
Design beyond the chat bubble