Skip to main content
Urbicon UI
Back to Recipes

Agent-generated UI (A2UI)

A chat where the agent answers with live UI instead of prose: A2uiStreamSplitter turns the token stream into a2ui parts, A2UIView renders them against a trusted catalog, and A2uiSurfaceRouter delivers a later turn's envelopes to the form the agent sent earlier. A multi-step flow (pick a date, load free rooms, choose one) patches one surface instead of rebuilding it.

Built with A2UIView Chat ChatMessage ChatMessageList PromptInput

Live preview

src/routes/chat/+page.svelte

Press Show free rooms: the patch an agent sends after its tool call reveals a room chooser, and the date you picked survives it. The demo feeds A2UIView these envelopes from local fixtures; the code reads the same wire format off the chat stream.
No action dispatched yet
<script lang="ts">
  import {
    A2UIView,
    A2uiStreamSplitter,
    A2uiSurfaceRouter,
    Chat,
    ChatMessage,
    ChatMessageList,
    PromptInput,
    revokeMessage,
    routeMessageParts,
    urbiconA2uiCatalog,
    type A2uiActionEvent,
    type A2uiValidationIssue,
    type ChatMessageData,
    type ChatMessagePart
  } from '@urbicon-ui/blocks';
  import { SvelteSet } from 'svelte/reactivity';

  let messages = $state<ChatMessageData[]>([]);
  let busy = $state(false);
  let pendingIssues = $state<A2uiValidationIssue[]>([]);

  // One router per conversation: it remembers which message owns which surface.
  const router = new A2uiSurfaceRouter();
  // Messages currently receiving a patch render with streaming grace, so a
  // half-arrived patch shows placeholders instead of dangling-reference chips.
  const patchTargets = new SvelteSet<string>();

  let idSeq = 0;
  const nextId = () => `msg-${++idSeq}`;

  function patchMessage(id: string, next: Partial<ChatMessageData>) {
    messages = messages.map((m) => (m.id === id ? { ...m, ...next } : m));
  }

  function routeParts(messageId: string, parts: ChatMessagePart[]): ChatMessagePart[] {
    const result = routeMessageParts(router, messages, messageId, parts);
    messages = result.messages;
    for (const target of result.targets) patchTargets.add(target);
    // A re-created surfaceId is a protocol slip — report it even as a warning.
    if (result.issues.length > 0) pendingIssues = [...pendingIssues, ...result.issues];
    return result.parts;
  }

  async function send(text: string) {
    if (busy) return;
    // Queued validation issues ride along so the agent can repair its surface.
    const wire = pendingIssues.length
      ? `[ui-error] ${JSON.stringify(pendingIssues)}\n${text}`
      : text;
    pendingIssues = [];

    messages = [
      ...messages,
      { id: nextId(), role: 'user', parts: [{ type: 'text', text }], status: 'complete' }
    ];
    const history = messages.map((m) => ({
      role: m.role,
      content: (m.metadata?.raw as string) ?? text
    }));

    const assistantId = nextId();
    messages = [
      ...messages,
      { id: assistantId, role: 'assistant', parts: [], status: 'streaming' }
    ];

    // One splitter per model round: it turns the token stream into ordered
    // text / a2ui parts and buffers partial lines, so fences may straddle chunks.
    const splitter = new A2uiStreamSplitter();
    busy = true;
    try {
      const response = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ messages: [...history.slice(0, -1), { role: 'user', content: wire }] })
      });
      const reader = response.body!.getReader();
      const decoder = new TextDecoder();
      for (;;) {
        const { done, value } = await reader.read();
        if (done) break;
        splitter.push(decoder.decode(value, { stream: true }));
        // Route BEFORE storing: foreign envelopes are delivered into earlier
        // payloads, and these parts are written on top of that updated list.
        patchMessage(assistantId, {
          parts: routeParts(assistantId, splitter.snapshot() as ChatMessagePart[]),
          metadata: { raw: splitter.raw }
        });
      }
      splitter.end();
      patchMessage(assistantId, {
        parts: routeParts(assistantId, splitter.snapshot() as ChatMessagePart[]),
        metadata: { raw: splitter.raw },
        status: 'complete'
      });
    } finally {
      busy = false;
      // Patch fully arrived — hand the targets back to strict validation.
      patchTargets.clear();
    }
  }

  // A control on a rendered surface: send it back as a fresh user turn. The
  // agent usually answers by PATCHING that surface rather than sending a new one.
  function handleAction(event: A2uiActionEvent) {
    send(`[ui-action] ${JSON.stringify(event)}`);
  }

  function regenerate(message: ChatMessageData) {
    // Take back what the dropped turn patched into earlier messages first.
    messages = revokeMessage(router, messages, message.id);
    messages = messages.filter((m) => m.id !== message.id);
  }
</script>

<Chat>
  <ChatMessageList {messages}>
    {#snippet message({ message: m })}
      {#snippet a2uiPart(part: Extract<ChatMessagePart, { type: 'a2ui' }>)}
        <A2UIView
          payload={part.payload}
          streaming={m.status === 'streaming' || patchTargets.has(m.id)}
          catalogs={[urbiconA2uiCatalog]}
          dataSchema={BOOKING_SCHEMA}
          onAction={handleAction}
          onValidationError={(issues) => {
            pendingIssues = [...pendingIssues, ...issues.filter((i) => i.severity === 'error')];
          }}
        />
      {/snippet}
      <ChatMessage message={m} partRenderers={{ a2ui: a2uiPart }} />
    {/snippet}
  </ChatMessageList>

  {#snippet composer()}
    <PromptInput {busy} onSubmit={({ text }) => send(text)} />
  {/snippet}
</Chat>

The system prompt

src/routes/api/chat/+server.ts

Three shipped sections plus your domain rules. The transport section is the prompt half of A2uiStreamSplitter: the format the agent is told to write is the format the client parses.
import {
  a2uiDataSchemaSection,
  a2uiFencedTransportSection,
  a2uiSystemPrompt,
  urbiconA2uiCatalogSpec
} from '@urbicon-ui/blocks';

// The catalog contract, the data-model contract and the wire format all come
// from the library, so the prompt can never describe UI the renderer rejects.
// Only the domain rules are yours to write.
const system = [
  a2uiSystemPrompt({ catalog: urbiconA2uiCatalogSpec }),
  a2uiDataSchemaSection(BOOKING_SCHEMA),
  a2uiFencedTransportSection(),
  'Call get_hotel_info before you offer any room or rate. Never invent one.'
].join('\n\n');

Two decisions

Streaming grace follows the patch

A patch lands in a message that completed an earlier turn, so its status is already complete and cannot flag that new envelopes are arriving for it. While they are, references to components still on the wire would fail strict validation and render fault chips. Only the router sees the delivery: routeMessageParts returns the ids it wrote into as result.targets, the page treats those messages as streaming, and the finally block returns them to strict validation.

Picking a room is not an action

The chooser binds its selection to the data model (value: { path: '/room' }): picking a room is instant, and nothing round-trips. Actions are reserved for the steps that need the agent or its tools, fetching the free rooms and committing the booking; because the surface was created with sendDataModel: true, the action that does fire carries the whole form state, read at click time. Give a control an action only when the agent has to react, and let everything else bind.

Why an untrusted payload is safe to render, what the catalog rejects, and how the generated prompt stays in step with the validator: the A2UIView page.