A2UIViewexperimental
Renders an A2UI payload (a UI an agent describes as JSON) into live, interactive Urbicon components. Only components from a catalog you allow are rendered; anything outside it is reported, not shown.
Playground
I need to get into my account.
Sure — sign in here and I will pick up where you left off.
<script lang="ts">
import { A2UIView, ChatMessage } from '@urbicon-ui/blocks';
import type { A2uiActionEvent } from '@urbicon-ui/blocks';
// The settled payload. In a live chat it arrives envelope by envelope out of
// an ```a2ui fence — see `A2uiStreamSplitter` and `routeMessageParts`.
const thread = [
{
id: 'signin-ask',
role: 'user',
parts: [{ type: 'text', text: 'I need to get into my account.' }],
createdAt: new Date('2026-01-01T09:41:00.000Z'),
status: 'complete'
},
{
id: 'signin-reply',
role: 'assistant',
parts: [
{ type: 'text', text: 'Sure — sign in here and I will pick up where you left off.' },
{
type: 'a2ui',
payload: [
{
version: 'v0.9.1',
createSurface: { surfaceId: 'pg', catalogId: 'urbicon-ui/a2ui-basic-subset/v0.9.1' }
},
{
version: 'v0.9.1',
updateComponents: {
surfaceId: 'pg',
components: [
{ id: 'root', component: 'Card', child: 'col' },
{
id: 'col',
component: 'Column',
children: ['title', 'email', 'password', 'submit']
},
{ id: 'title', component: 'Text', text: 'Welcome back', variant: 'h4' }
]
}
},
{
version: 'v0.9.1',
updateComponents: {
surfaceId: 'pg',
components: [
{ id: 'email', component: 'TextField', label: 'Email', value: { path: '/email' } },
{
id: 'password',
component: 'TextField',
label: 'Password',
variant: 'obscured',
value: { path: '/password' }
},
{ id: 'submit-label', component: 'Text', text: 'Sign in' },
{
id: 'submit',
component: 'Button',
child: 'submit-label',
action: { event: { name: 'signin', context: { email: { path: '/email' } } } }
}
]
}
},
{
version: 'v0.9.1',
updateDataModel: { surfaceId: 'pg', value: { email: '', password: '' } }
}
]
}
],
createdAt: new Date('2026-01-01T09:41:04.000Z'),
status: 'complete'
}
];
function handleAction(event: A2uiActionEvent) {
// The only return path: send this back to the agent as the next turn.
// Typing in the surface does not report — the data model rides along here.
}
</script>
{#snippet a2ui(part)}
<!-- `streaming` while the answer is still arriving: a reference to a
not-yet-defined component then renders a placeholder instead of a fault
chip. Flip it off once the stream settles, or a genuinely dangling
reference stays a placeholder for good. -->
<A2UIView payload={part.payload} onAction={handleAction} streaming />
{/snippet}
{#each thread as message (message.id)}
<ChatMessage {message} partRenderers={{ a2ui }} />
{/each}01 How it works
A2UI (Agent-to-UI) lets an agent describe an interface as data, not
executable code. The agent emits JSONL envelopes that reference a trusted catalog your app already ships, rather than markup or scripts of its
own. A2UIView renders the Urbicon subset of A2UI v0.9.1 basic: it maps the catalog components onto real Urbicon primitives and renders
them live and interactive.
Why an untrusted payload is safe
The payload only references a catalog you control, so nothing in it executes. A
component name the registry does not know renders a fault chip; a prop the registry does
not declare is dropped before it reaches a Svelte component; a { call } function binding does nothing. The payload never reaches {@html}, a dynamic import, or a restProps spread.
Incremental & two-way
The payload is the accumulated envelope array: stream by extending it immutably ([...prev, envelope]). A2UIView applies only the newly appended envelopes, so local input edits survive a
mid-stream update. Inputs write straight into the view's data model, bound text updates
live, and the model syncs to the agent only on an action.
Policy-gated media
Image sources and Text markdown links pass the same
strict-by-default urlPolicy as StreamingMarkdown. Every external image is
blocked unless its prefix is allowlisted; a blocked image shows a labelled placeholder (blockedImageLabel) instead.
02 Examples
Golden-file replay — progressive rendering
<script lang="ts">
import { onDestroy } from 'svelte';
import { A2UIView, A2UI_CATALOG_ID, Button, type A2uiActionEvent } from '@urbicon-ui/blocks';
// A golden-file replay: the agent's JSONL envelopes arrive one line at a time.
// The consumer's only job is to extend the payload array immutably — A2UIView
// processes each new envelope incrementally and keeps local input edits. While
// the stream is in flight `streaming` is true, so a child reference to a
// not-yet-defined component renders a skeleton placeholder instead of a fault
// chip; components fill in as their envelopes land.
const SEQUENCE: unknown[] = [
{ version: 'v0.9.1', createSurface: { surfaceId: 'demo', catalogId: A2UI_CATALOG_ID } },
{
version: 'v0.9.1',
updateComponents: {
surfaceId: 'demo',
components: [
{ id: 'root', component: 'Card', child: 'col' },
{ id: 'col', component: 'Column', children: ['title', 'name', 'email', 'submit'] }
]
}
},
{
version: 'v0.9.1',
updateComponents: {
surfaceId: 'demo',
components: [
{ id: 'title', component: 'Text', text: 'Book a demo', variant: 'h4' },
{ id: 'name', component: 'TextField', label: 'Name', value: { path: '/name' } }
]
}
},
{
version: 'v0.9.1',
updateComponents: {
surfaceId: 'demo',
components: [
{ id: 'email', component: 'TextField', label: 'Work email', value: { path: '/email' } },
{ id: 'submit-label', component: 'Text', text: 'Request access' },
{
id: 'submit',
component: 'Button',
child: 'submit-label',
action: {
event: {
name: 'book_demo',
context: { name: { path: '/name' }, email: { path: '/email' } }
}
}
}
]
}
},
{
version: 'v0.9.1',
updateDataModel: { surfaceId: 'demo', value: { name: '', email: '' } }
}
];
let payload = $state<unknown[]>([]);
let streaming = $state(false);
let lastAction = $state<A2uiActionEvent | null>(null);
let timer: ReturnType<typeof setTimeout> | undefined;
function replay() {
clearTimeout(timer);
payload = [];
lastAction = null;
streaming = true;
let i = 0;
const tick = () => {
payload = [...payload, SEQUENCE[i]];
i += 1;
if (i < SEQUENCE.length) {
timer = setTimeout(tick, 550);
} else {
streaming = false;
}
};
timer = setTimeout(tick, 300);
}
replay();
onDestroy(() => clearTimeout(timer));
</script>
<div class="space-y-3">
<div class="mx-auto max-w-sm">
<A2UIView {payload} {streaming} onAction={(event) => (lastAction = event)} />
</div>
{#if lastAction}
<pre
class="bg-surface-base border-border-subtle text-text-secondary overflow-x-auto rounded-lg border p-3 text-xs">[ui-action] {JSON.stringify(
lastAction,
null,
2
)}</pre>
{/if}
<Button size="sm" variant="outlined" onclick={replay}>Replay stream</Button>
</div>
Urbicon catalog
catalogs): intents and variants, a Section structure layer, RichText (markdown) alongside plain Text, a Select / RadioGroup / DatePicker form, an Accordion, and a data schema that type-checks every model write. Basic stays the default; the Urbicon catalog is tree-shaken out unless you import it.<script lang="ts">
import { onDestroy } from 'svelte';
import {
A2UIView,
Button,
URBICON_A2UI_CATALOG_ID,
urbiconA2uiCatalog,
type A2uiActionEvent,
type A2uiDataSchema
} from '@urbicon-ui/blocks';
// The SAME golden-replay pattern as the Basic specimen, but against the
// Urbicon-native catalog: real intents, a Section structure layer, RichText
// (markdown) vs plain Text, a Select/RadioGroup/DatePicker form, an Accordion,
// and a data schema that type-checks the model writes. Pass the Urbicon catalog
// via `catalogs` (opt-in) and the schema via `dataSchema`.
const CID = URBICON_A2UI_CATALOG_ID;
const SCHEMA: A2uiDataSchema = {
'/name': { type: 'string', description: 'The guest name' },
// Select writes a string ARRAY (single-select = a one-element array).
'/room': { type: 'array', description: 'Chosen room type(s)' },
'/date': { type: 'string', format: 'date' },
'/time': { type: 'string' }
};
const SEQUENCE: unknown[] = [
{ version: 'v0.9.1', createSurface: { surfaceId: 'u', catalogId: CID } },
{
version: 'v0.9.1',
updateDataModel: {
surfaceId: 'u',
value: { name: '', room: [], date: '', time: 'afternoon' }
}
},
{
version: 'v0.9.1',
updateComponents: {
surfaceId: 'u',
components: [
{
id: 'root',
component: 'Section',
title: 'Plan a stay',
description: 'Pick a room and the day you arrive.',
child: 'card'
},
{ id: 'card', component: 'Card', variant: 'elevated', child: 'form' },
{
id: 'form',
component: 'Column',
children: ['intro', 'name', 'room', 'date', 'time', 'actions', 'faq']
}
]
}
},
{
version: 'v0.9.1',
updateComponents: {
surfaceId: 'u',
components: [
{
id: 'intro',
component: 'RichText',
content: 'Choose a **room** below — changes save as you go.'
},
{
id: 'name',
component: 'Input',
label: 'Your name',
value: { path: '/name' },
placeholder: 'Ada Lovelace'
},
{
id: 'room',
component: 'Select',
label: 'Room',
value: { path: '/room' },
options: [
{ label: 'Garden Room', value: 'garden' },
{ label: 'Corner Room', value: 'corner' },
{ label: 'Suite', value: 'suite' }
]
}
]
}
},
{
version: 'v0.9.1',
updateComponents: {
surfaceId: 'u',
components: [
{ id: 'date', component: 'DatePicker', label: 'Check-in', value: { path: '/date' } },
{
id: 'time',
component: 'RadioGroup',
label: 'Arrival',
value: { path: '/time' },
orientation: 'horizontal',
options: [
{ label: 'Morning', value: 'morning' },
{ label: 'Afternoon', value: 'afternoon' },
{ label: 'Evening', value: 'evening' }
]
}
]
}
},
{
version: 'v0.9.1',
updateComponents: {
surfaceId: 'u',
components: [
{
id: 'actions',
component: 'Row',
justify: 'spaceBetween',
align: 'center',
children: ['dur', 'book']
},
{ id: 'dur', component: 'Badge', text: 'From €300', intent: 'neutral', variant: 'soft' },
{ id: 'book-label', component: 'Text', text: 'Request the stay' },
{
id: 'book',
component: 'Button',
intent: 'primary',
child: 'book-label',
action: {
event: {
name: 'book',
context: { name: { path: '/name' }, room: { path: '/room' } }
}
}
}
]
}
},
{
version: 'v0.9.1',
updateComponents: {
surfaceId: 'u',
components: [
{
id: 'faq',
component: 'Accordion',
items: [
{ label: 'Can I change my dates?', child: 'faq1' },
{ label: 'Cancellation policy', child: 'faq2' }
]
},
{
id: 'faq1',
component: 'Text',
text: 'Yes — move your stay up to a week before arrival.'
},
{
id: 'faq2',
component: 'Text',
text: 'Cancellations within seven days are charged in full.'
}
]
}
}
];
let payload = $state<unknown[]>([]);
let streaming = $state(false);
let lastAction = $state<A2uiActionEvent | null>(null);
let timer: ReturnType<typeof setTimeout> | undefined;
function replay() {
clearTimeout(timer);
payload = [];
lastAction = null;
streaming = true;
let i = 0;
const tick = () => {
payload = [...payload, SEQUENCE[i]];
i += 1;
if (i < SEQUENCE.length) {
timer = setTimeout(tick, 550);
} else {
streaming = false;
}
};
timer = setTimeout(tick, 300);
}
replay();
onDestroy(() => clearTimeout(timer));
</script>
<div class="space-y-3">
<div class="mx-auto max-w-md">
<A2UIView
{payload}
{streaming}
catalogs={[urbiconA2uiCatalog]}
dataSchema={SCHEMA}
onAction={(event) => (lastAction = event)}
/>
</div>
{#if lastAction}
<pre
class="bg-surface-base border-border-subtle text-text-secondary overflow-x-auto rounded-lg border p-3 text-xs">[ui-action] {JSON.stringify(
lastAction,
null,
2
)}</pre>
{/if}
<Button size="sm" variant="outlined" onclick={replay}>Replay stream</Button>
</div>
A broken payload becomes a fault chip
<script lang="ts">
import { A2UIView, A2UI_CATALOG_ID, type A2uiValidationIssue } from '@urbicon-ui/blocks';
// Whitelist-only and fail-loud: a component outside the mapped subset never
// reaches the DOM. `Video` is not in the basic subset, so it renders as a
// visible fault chip in place of the node — and the same fault surfaces
// through `onValidationError` as a spec-compatible issue a consumer can relay
// back to the agent as an A2UI `error` message.
const payload: unknown[] = [
{ version: 'v0.9.1', createSurface: { surfaceId: 'broken', catalogId: A2UI_CATALOG_ID } },
{
version: 'v0.9.1',
updateComponents: {
surfaceId: 'broken',
components: [
{ id: 'root', component: 'Card', child: 'col' },
{ id: 'col', component: 'Column', children: ['heading', 'clip'] },
{ id: 'heading', component: 'Text', text: 'Product tour', variant: 'h4' },
{ id: 'clip', component: 'Video', url: 'https://example.com/tour.mp4' }
]
}
}
];
let issues = $state<A2uiValidationIssue[]>([]);
</script>
<div class="space-y-3">
<div class="mx-auto max-w-sm">
<A2UIView {payload} onValidationError={(next) => (issues = next)} />
</div>
{#if issues.length}
<ul class="text-text-secondary space-y-1 text-xs">
{#each issues as issue (`${issue.code}-${issue.path ?? ''}-${issue.message}`)}
<li>
<span
class={[
'font-mono uppercase',
issue.severity === 'error' ? 'text-danger' : 'text-warning'
]}>{issue.severity}</span
>
<span class="text-text-tertiary font-mono">{issue.code}</span> — {issue.message}
</li>
{/each}
</ul>
{/if}
</div>
03 Integration
Wire it in via partRenderers.a2ui
A2UIView is not a default ChatMessage part renderer, so it stays out of the
base conversation bundle until you opt in. Register it as the a2ui renderer; ChatMessageList forwards partRenderers to every ChatMessage. Couple the part's streaming flag to the owning message's status, so dangling references show as placeholders
while the reply is in flight and become faults once it settles.
ChatMessage wiring
<script lang="ts">
import {
ChatMessageList,
A2UIView,
type ChatMessageData,
type MarkdownUrlPolicy
} from '@urbicon-ui/blocks';
let messages: ChatMessageData[] = $state([]);
// Strict by default: external images are blocked unless a prefix is
// allowlisted; links keep the safe default protocols. Keep the object stable.
const urlPolicy: MarkdownUrlPolicy = { allowedImagePrefixes: ['https://cdn.example.com/'] };
function sendUserTurn(text: string) {
messages = [...messages, { id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text }] }];
}
</script>
<!-- A2UIView is NOT a default ChatMessage renderer — opt in per surface so it
stays out of the base bundle. ChatMessageList forwards partRenderers to
each ChatMessage. -->
<ChatMessageList {messages} partRenderers={{ a2ui: a2uiPart }} />
{#snippet a2uiPart(part)}
<A2UIView
payload={part.payload}
streaming={/* couple to the message status */ true}
{urlPolicy}
onAction={(event) => sendUserTurn(`[ui-action] ${JSON.stringify(event)}`)}
onValidationError={(issues) => {
// Relay error-severity issues back to the agent as an A2UI `error` message.
for (const issue of issues) if (issue.severity === 'error') reportToAgent(issue);
}}
/>
{/snippet}Generate the agent prompt
Never hand-roll the catalog description. a2uiSystemPrompt() renders the
envelope rules, the component subset (props, required flags, enums), the binding forms ({ path } only; function calls are forbidden), the root rule, child-vs-children, the template form and
the action rules, straight from the registry that validates the payload. It omits the
transport: how envelopes reach the client is app-specific, so append that yourself.
System prompt
// Server / agent side — no DOM needed. The prompt is rendered from the SAME
// registry that validates the payload, so the two can never drift.
import { a2uiSystemPrompt } from '@urbicon-ui/blocks';
const system = [
a2uiSystemPrompt(),
// Append your app-specific TRANSPORT section (how envelopes reach the client),
// e.g. a fenced ```a2ui JSONL block. a2uiSystemPrompt() deliberately omits it.
TRANSPORT_INSTRUCTIONS
].join('\n\n');04 Accessibility
Controls come from real primitives
Each component in the basic catalog maps onto a Urbicon primitive: TextField to
Input/Textarea, CheckBox to Checkbox, ChoicePicker to RadioGroup, Slider to Slider, DateTimeInput to DatePicker/TimeInput. So labels, roles and keyboard
behaviour come from the library rather than ad-hoc markup, and a component's accessibility.label becomes an aria-label.
Streaming placeholders
While streaming, a not-yet-defined reference renders a Skeleton with an sr-only label (pendingLabel), so
assistive tech announces a loading state rather than an empty gap.
Faults are text
A rejected component renders a fault chip with a readable label (unsupportedLabel) next to its danger icon, so the reason is conveyed as text and not by colour alone.
Envelope-level faults render in a danger Alert with its errorTitle.
05 API Reference
16 propsProp | Type | Default | Description | |
|---|---|---|---|---|
payload required | unknown | — | The A2UI payload. Accepts an array of envelopes (the accumulated JSONL
sequence — stream by extending it immutably: [...prev, envelope]), a
single envelope object, or the golden-file { messages: [...] } wrapper. | |
blockedImageLabel | string | 'Image blocked' | Chip label shown in place of a policy-blocked image. | |
catalogs | readonly A2uiCatalog[] | — | Additional A2UI catalogs this view can render, beyond the always-present
Basic catalog (which is prepended automatically as the default/fallback). A
surface renders through the catalog whose id its createSurface.catalogId
names. Pass the shipped urbiconA2uiCatalog to enable the Urbicon-native
catalog. Resolved once at init (icon setup reads context) — keep it
referentially stable. | |
class | string | — | Extra classes merged onto the root element. | |
dataSchema | A2uiDataSchema | — | Optional surface data schema. When set, every updateDataModel write is
validated against it (type mismatch on a declared pointer → error;
undeclared top-level branch → warning), reported via onValidationError.
Document the same schema to the agent with a2uiDataSchemaSection. Keep it
referentially stable. | |
errorTitle | string | 'Invalid UI payload' | Title of the top-level error Alert for envelope-level faults. | |
onAction | (event: A2uiActionEvent) => void | — | Fired when a Button is activated, with the spec-exact resolved action event. | |
onValidationError | (issues: A2uiValidationIssue[]) => void | — | Fired whenever the validation-issue list changes (errors AND warnings, each
with a severity). Relay error-severity issues to the agent as an A2UI
error message. | |
pendingLabel | string | 'Loading UI' | Screen-reader label of the streaming placeholder. | |
preset | string | — | Apply a named preset registered via <BlocksProvider presets={{ A2UIView: {...} }}>.
Prefer this over class overrides for reusable custom looks. | |
slotClasses | Partial<Record<A2UIViewSlots, string>> | — | Per-slot class overrides. Slots: root, surface, errorList,
errorChip, errorIcon, pending, column, row, list, listItem,
heading, caption, inlineText, image, blockedChip, icon,
svgIcon, choiceGroup, choiceLabel. | |
streaming | boolean | false | While true, a reference to a not-yet-defined component renders a placeholder instead of a fault chip (mid-stream tolerance). Flip to false when the stream settles so dangling references become errors. | |
unstyled | boolean | — | Strip the component's default tv() classes, and those of every catalog component the surface renders (Button, Input, Card …). | |
unsupportedLabel | string | 'Unsupported component' | Fault-chip label for an unknown/unsupported/incomplete component. | |
urlPolicy | MarkdownUrlPolicy | — | URL policy for Image sources (and Text markdown links). Strict by default:
every external image is blocked unless its prefix is allowlisted. Keep the
object referentially stable. | |
...HTMLAttributes<HTMLDivElement> inherited | HTMLAttributes | — | HTML attributes (excluding: 'class') |
06 Types
Local type definitions used by this component.
Name | Kind | Category | Used by | Description | |
|---|---|---|---|---|---|
A2UIViewProps | interface | props | 0 | — | |
MarkdownUrlPolicy | interface | helper | 1 | — | |
A2uiActionEvent | interface | helper | 0 | A resolved user action, spec-exact for the A2UI client→server action
message. All five fields are required; context bindings are resolved
against the data model in the source component's scope before dispatch. | |
A2uiValidationIssue | interface | helper | 0 | A validation finding. code is one of A2UI_ISSUE_CODES (a superset of
the spec's VALIDATION_FAILED); path is a JSON Pointer into the *payload*
(e.g. /messages/3/updateComponents/components/0/text) where determinable. | |
A2uiCatalog | interface | helper | 0 | A full, renderable catalog — the spec plus its Svelte wiring. Node is the
recursive dispatcher A2UIView renders per surface; createIcons builds the
icon map (must run during component init — resolveIcon reads the
IconProvider context). The Component/IconComponent references are
TYPE-ONLY, so this module stays runtime-Svelte-free. | |
A2uiDataSchema | type | helper | 1 | Surface data schema: absolute JSON Pointer → field declaration. | |
A2UIViewSlots | type | variant | 0 | Slot names derived from the tv() config — single source of truth for slotClasses. | |
A2uiIssueSeverity | type | helper | 0 | Severity of a validation issue. error renders a fault chip; warning degrades. | |
A2uiNodeProps | interface | props | 0 | Props of a catalog node dispatcher. Every dispatcher (Basic/Urbicon/…) takes
the same triple: the node, its render context, and the self-referencing
renderChild snippet A2UIView threads down for bounded recursion. | |
IconComponent | type | helper | 0 | — | |
A2uiSchemaField | interface | helper | 0 | One declared field: its type plus optional enum/format/description for the prompt. | |
SlotNames | type | helper | 0 | Extracts the slot-name union from a slotted tv() config function — the
companion to VariantProps. The slot-mode overload returns
(props?) => { [K in keyof S]: SlotFn }, so keyof ReturnType<T> is exactly
the set of slot names a component declares in tv({ slots: … }).
Use it to type a component's slotClasses prop from the single source of
truth (its *.variants.ts) instead of hand-maintaining a parallel union
that silently drifts when a slot is added or renamed: | |
A2uiRenderNode | interface | helper | 0 | One node of the assembled render tree. instance === null marks a dangling
reference (a child id that was never defined) — rendered as a streaming
placeholder or, once settled, a fault chip. children are pre-expanded and
ordered; key is stable across incremental rebuilds so keyed {#each} keeps
component identity (and input focus) through a keystroke-triggered rebuild. | |
A2uiRenderContext | interface | helper | 0 | The single context object each A2UINode receives. Carries resolved slot
classes, the icon map, the data-binding resolver, the two-way write-back
callbacks, the action sink and rendering flags. Rebuilt per version bump so a
live data-model edit propagates fresh resolved values without remounting. | |
IconProps | interface | props | 0 | — | |
A2uiSchemaType | type | helper | 0 | The JSON primitive/shape a declared field holds. |
07 Installation
Import
import { A2UIView, a2uiSystemPrompt } from '@urbicon-ui/blocks';
import type { A2uiActionEvent, A2uiValidationIssue } from '@urbicon-ui/blocks';