A2UI: Teaching an AI to Draw Forms, Not Just Fill Them
7/4/2026 · 7 min read · 245 views
Clinical software has a UI problem that most SaaS products don't: the number of distinct forms is huge, and each one is bound to something exacting. A "create patient" screen isn't just text inputs — it has ten optional sub-forms (name, identifiers, telecom, address, GP link, photo...). A "record a diagnosis" screen needs a real ICD-10/SNOMED terminology search, not a free-text box. An appointment flow has to walk through picking a practitioner, then a slot, then a confirmation, each step depending on data resolved from the last.
Building bespoke React screens for every one of these — and every FHIR resource type a clinical system touches — doesn't scale, and it actively fights against the thing users actually want: to describe what they're trying to do ("register a new patient", "log an allergy") and have the right screen show up.
That's the problem A2UI (Agent-to-UI) solves in one of my projects: a JSON protocol and rendering engine that lets a UI be described as data instead of written as code, so the same renderer can produce any of ~55 different clinical forms, dashboards, and data tables — and so an AI agent, not a developer, can decide which one to show.
The core idea
Instead of a component tree, A2UI works with a typed JSON tree. Every node looks like this:
{
"id": "code",
"type": "TerminologySelect",
"properties": {
"label": { "literalString": "Diagnosis (ICD-10 / SNOMED)" },
"placeholder": { "literalString": "Search diagnosis…" },
"valueType": { "literalString": "CodeableConcept" },
"serverSearch": { "minChars": 2, "debounceMs": 200 }
}
}
Two design decisions make this more than "JSON that maps to components":
- Values are never raw. Every property is wrapped as { literalString: "..." } or { path: "some.path" }. That indirection is what makes a schema data-bindable — the exact same node shape can hold a hardcoded label or a live pointer into a data model that gets resolved at render time.
- A type string is the only coupling between data and code. A discriminated union of ~45 possible node types (Row, Card, TextField, DataTable, Chart, TerminologySelect, RepeatableGroup...) is the entire contract. Adding a new component means adding one entry to a registry — the renderer itself never changes.
The rendering pipeline
Three layers, deliberately thin:
Renderer is a dispatch table lookup, nothing more:
export function Renderer({ processor, surfaceId, component, weight = "initial" }: RendererProps) { const config = DEFAULT_CATALOG[component.type]; if (!config) return null; const Component = config.component; return <Component processor={processor} surfaceId={surfaceId} component={component} weight={weight} />; }
Every container component (Card, Form, Row, Column, Tabs, Modal, RepeatableGroup) recursively calls <Renderer> on its own children — so an entire nested UI tree is walked through this one indirection point. It's the same "server-driven UI" pattern behind things like Airbnb's old React Native SDUI system or Shopify's Remote UI: the data shape is the API.
The catalog is a flat registry mapping every type string to a real, shadcn/Tailwind-based implementation. This is what makes the whole thing feel like a genuine design system rather than a toy: form fields, FHIR-aware pickers, full data tables, charts, media, layout primitives, feedback states — all schema-addressable.
The processor is a per-session data/event bus. It stores each "surface's" component tree and data model, resolves dot-paths (with automatic scoping so a component inside a repeated group only ever sees its own slice of data), and bridges user actions back out through a single dispatch() call. Catalog components don't know or care what happens after a user submits a form — they just dispatch an action and wait for a promise to resolve.
A few of the cleverer bits
Forms don't use controlled inputs. Form collects data at submit time by literally querying the rendered DOM — querySelectorAll("input, textarea, select"), reading .value/.checked/.valueAsNumber, with a special case for shadcn's button[role="checkbox"] pattern. It's a deliberately framework-light choice: any new catalog component just needs to render something with an id that looks like a form control, and it participates in submission automatically — no shared form-state library, no wiring per field type.
RepeatableGroup clones a JSON subtree, not a component. "Let the user add 1–N patient identifiers" is authored once as a template array in the schema. At runtime, adding an item clones that template and namespaces every child id so multiple copies don't collide in the DOM, then serializes all instances into a single hidden JSON input that the form's collector picks up.
DynamicSelect is one component doing the job of a dozen. Organization pickers, practitioner search, patient lookup — all the same combobox, just configured differently: three modes (client-filter / server-search / hybrid), infinite scroll, dot-path or template-string field resolution, and a small formatter mini-language. Declarative configuration doing real work, not just declarative layout.
Charts assume the AI got it slightly wrong. Chart wraps Vega-Lite, but it's not a thin pass-through — it post-processes whatever spec it's given: auto-fills a missing color encoding, adds hover-highlight params, and caps rendering at 25 categories with a "show top 25" fallback if the data has too many. It's built to gracefully absorb an imperfect spec rather than crash on one.
The twist: the AI doesn't generate fields, it picks procedures
The obvious design for "AI + dynamic UI" is: user types a message, LLM free-generates a JSON form on the spot, renderer draws it. There's actually a full prompt built for exactly that — a hand-written system prompt enumerating the whole component vocabulary, down to an embedded chart-generation spec. But that's not how the production path works, and the reason why is the most interesting engineering decision in the whole project.
In a regulated clinical context, letting an LLM freely improvise the shape of a diagnosis form or a medication order isn't just risky — it's genuinely hard to validate, audit, or trust. So the actual flow separates two concerns:
- The AI's job is intent routing, not UI generation. A message like "create a new patient" goes to an external agent service, which — using hints embedded in each workflow definition (intent examples, when-to-use / when-not-to-use) — picks a whole pre-authored, versioned workflow, not a form.
- Every step's UI comes from a hand-authored, hydrated JSON schema. Each workflow step declares server-side lookups that hydrate value sets and existing resource state before render, a reference into a static registry of ~55 UI schemas, and the write action itself (tool name, validation schema, retry/timeout policy). The client resolves placeholders in the schema against the fetched data, and only then does A2UI render it.
- Submission is deterministic and audited. Every tool call renders inline in the chat transcript as a collapsible trace card — form data in, raw result out — which doubles as an client and back, and defense in depth matters more than trusting a round trip.
So the honest one-line summary is: the AI decides what to do; a deterministic, versioned, auditable rendering layer decides how it looks and what it's allowed to write. That's a more defensible split than "the model generates the form" for anything touching real patient data.
Grounded in FHIR, not generic forms
The ~55 registered UI schemas read like a FHIR resource list, because they are one: Patient (plus nine sub-forms), Practitioner, Appointment, Condition, AllergyIntolerance, Observation/Vitals, Immunization, MedicationRequest, DeviceRequest, Procedure, DiagnosticReport, Encounter, Coverage, Invoice, Organization, and more. Terminology fields aren't decorative — the terminology picker genuinely searches ICD-10, SNOMED, LOINC, and RxNorm value sets pulled live from a FHIR terminology service, resolved server-side before the form even renders.
Where it's headed
The theming system is fully designed but not yet filled in — every component and sub-slot has a place to receive style overrides, but the default theme ships empty, so everything currently renders in one consistent look rather than being reskinned per tenant. That's deliberate scaffolding for later, not an oversight.
If you're building anything that has to render a lot of structurally different UI from a small, fixed set of primitives — especially where an AI or a config file, not a developer, decides which screen to show — the pattern here (typed JSON nodes, a dumb dispatch-table renderer, a schema-addressable component catalog, and a clear boundary between "what to do" and "what it's allowed to write") is a solid one to start from.