We Stopped Letting the Model Write JSX A developer replaced an AI support case composer that let a language model stream React/JSX snippets into a transpiler and new Function with a declarative A2UI v0.9 architecture, in which the model emits JSON UI descriptions against a host-owned component catalog instead of executable code. The v1 approach produced inconsistent styling, hallucinated components, malformed markdown tables and an unacceptable prompt-injection-to-code-execution trust boundary; the new design separates surface creation, component descriptions and data-model updates, with no onClick, style or dangerouslySetInnerHTML fields available to the model. How a support case composer went from "suggest a React snippet" to a declarative catalog with A2UI v0.9, and what broke along the way The scariest pull request I reviewed last quarter was four lines long. It took a string the model had streamed back, ran it through a transpiler, and handed the result to new Function. The author a good engineer, on a deadline had a comment above it: // sandboxed enough for internal tools. It wasn't. I left a review comment I'm still a little proud of, closed the laptop, and went for a walk. That PR was the end of v1 of our support case composer. This is the story of what we replaced it with. v1: let the model cook The feature sounded simple. A support agent opens a case, clicks "Compose", and an AI helper builds a working view for that case: customer summary, recent timeline, a draft reply, maybe a small table of related tickets. Our first version asked the model for markdown, plus the occasional fenced React snippet when it wanted something richer than a table. For about two weeks it felt like magic. Then staging started teaching us things. The model had no idea what our design system was. It would write a perfectly reasonable with Tailwind classes we don't use. It invented a that didn't exist. The same prompt on Monday and Thursday produced two different visual dialects. Markdown tables are not a UI. A row would arrive with six cells in a five-column table. A "status" column would be an emoji one time and a word the next. There was nothing to bind an action to, so every interactive thing the model wanted turned into the sentence "you could click here." And then there was the trust boundary. Customer messages are untrusted input. Customer messages flow into the model's context. The model's output flows into our renderer. Draw that line on a whiteboard and you'll feel your stomach drop. We never shipped the eval, but we were one sprint away from it, and that's the part that bothered me. Prompt injection plus code execution isn't a bug you patch. It's an architecture you refuse. The shift: the model emits data, the host owns the pixels We moved to A2UI v0.9. The idea, in one sentence: the agent doesn't send UI code, it sends a description of a UI as JSON, and our app decides how and whether to render each piece. The wire format is a stream of small messages. The three we use constantly: { "version": "v0.9", "createSurface": { "surfaceId": "case-4821", "catalogId": "support.internal:case-composer" } } { "version": "v0.9", "updateComponents": { "surfaceId": "case-4821", "components": { "id": "root", "component": "Column", "children": "summary", "reply" }, { "id": "summary", "component": "CaseSummary", "caseId": "4821" }, { "id": "reply", "component": "DraftReply", "body": { "path": "/draft/body" } } } } { "version": "v0.9", "updateDataModel": { "surfaceId": "case-4821", "path": "/draft", "value": { "body": "Hi Dana, thanks for your patience..." } } } A surface gets created, components get described, and data gets poured in separately. That separation turned out to matter more than I expected. The structure of the view and the content of the view are different messages, so the model can fix a typo in the draft without re-describing the whole layout. Notice what's missing: there's no onClick, no style, no dangerouslySetInnerHTML. There's nowhere to put them. The catalog is the contract The catalogId in createSurface points at something we own: a catalog of the components the agent is allowed to ask for. If it's not in the catalog, it doesn't exist as far as the model is concerned. We define each component as a name plus a Zod schema, then bind it to a real React renderer. The exact shape of the binding depends on your @a2ui/react version, so check the docs for the current signature. The gist looks like this: python import { z } from 'zod'; import { Catalog } from '@a2ui/web core/v0 9'; import type { ReactComponentImplementation } from '@a2ui/react/v0 9'; // The contract: what the agent may say about a CaseSummary export const CaseSummarySchema = z.object { caseId: z.string .regex /^\d+$/ , showTimeline: z.boolean .optional , } ; // The renderer: what we decide it looks like function CaseSummary { caseId, showTimeline }: z.infer