Skip to main content
Urbicon UI

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.

Copy

Sure — sign in here and I will pick up where you left off.

Loading UI
Copy
Scenario
<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

The agent's envelopes arrive one JSONL line at a time; the consumer only extends the payload array. While streaming, a reference to a not-yet-defined child renders a skeleton placeholder, and each component fills in as its envelope lands. Clicking the button dispatches an action event.
Loading UI
<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

The same engine against the opt-in Urbicon-native catalog (pass it via 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.
Loading UI
<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

Video is not in the basic subset. Rather than render a component the catalog does not define, A2UIView shows a visible fault chip in its place and reports the fault through onValidationError as a spec-compatible issue the consumer can relay to the agent.
<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 props
16 props 1 required
Prop
Type
Default
Description

06 Types

Local type definitions used by this component.

16 types
Name
Kind
Category
Used by
Description

07 Installation

Import

import { A2UIView, a2uiSystemPrompt } from '@urbicon-ui/blocks';
import type { A2uiActionEvent, A2uiValidationIssue } from '@urbicon-ui/blocks';