Build Your Own Intelligent UI: Stream Components in LLM Answers

Let a model answer with your own interactive components, rendered progressively as it streams, in Vue, React, Svelte, or Angular. The same pattern as ChatGPT Intelligent UI.

An intelligent UI answers with interactive components, such as a calculator, a timeline, or a chart, instead of text only. With Comark, the model writes component syntax in its Markdown answer, and your app renders your own components while the answer streams.

The pattern

On October 7, 2026, OpenAI introduced Intelligent UI in ChatGPT. ChatGPT can now answer with graphics, buttons, forms, charts, and small tools. OpenAI describes how it works:

We built a library of native, streamable components, along with a compiler that processes the interface as the model generates it.

You can build the same pattern in your own app. Each part has a Comark equivalent:

Intelligent UIComark
A library of native componentsYour Vue, React, Svelte, or Angular components, registered in the components prop
Streamable componentsstreaming and autoClose: a component renders before its closing :: arrives
A compiler that processes the interface during generationThe parser turns ::component{props} into data, and runs no code
The model picks the layout, or plain textA system prompt that lists your components and when to use them

Comark isn't affiliated with OpenAI, and ChatGPT doesn't use Comark. This page shows how to build a similar experience with your own model and components.

What the model writes

The model answers in Markdown, and places a component where it helps. Here, a dinner plan includes a calculator for quantities and a timeline:

## Pizza night for friends

Here is a plan that scales with your guest count.

::quantity-calculator{:guests="6"}
---
items:
  - name: Pizza dough balls
    perGuest: 1.5
    unit: balls
  - name: Mozzarella
    perGuest: 80
    unit: g
---
::

::timeline
- **4:00 pm** Take the dough out of the fridge.
- **6:30 pm** Heat the oven to its highest setting.
::

The : prefix gives a typed value: guests is the number 6. The YAML block passes items as an array of objects. The list inside ::timeline is the default slot. See component syntax for props, slots, and nesting.

Write streamable components

A component renders on the first frame that contains its opening line, and gets new props on each frame. Your component receives partial props while the model writes them:

  1. While the model writes {:guests=", the guests prop is undefined.
  2. While it writes the YAML block, items is missing.
  3. Then items grows one entry at a time, and the last entry can miss fields, for example { name: 'Mozzarel' }.

Make every prop optional, and render what you have. Check model values before you use them, and keep the user's edits separate from the props, so a later frame doesn't overwrite them:

<script setup lang="ts">
import { computed, ref } from 'vue'

interface Item { name?: string, perGuest?: number, unit?: string }

const props = defineProps<{ guests?: number, items?: Item[] }>()

// Model values are untrusted: keep only positive, finite numbers.
const positive = (value: unknown) =>
  typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined

// After the user edits the count, new `guests` props from the stream don't override it.
const edited = ref<number>()
const count = computed(() => edited.value ?? positive(props.guests) ?? 4)
const rows = computed(() => (props.items ?? []).filter(item => item.name && positive(item.perGuest)))

function onInput(event: Event) {
  edited.value = positive(Number((event.target as HTMLInputElement).value)) ?? edited.value
}
</script>

<template>
  <div class="quantity-calculator">
    <label>
      Guests
      <input :value="count" type="number" min="1" @input="onInput">
    </label>
    <ul>
      <li v-for="item in rows" :key="item.name">
        {{ item.name }}: {{ Math.ceil(item.perGuest! * count) }} {{ item.unit }}
      </li>
    </ul>
  </div>
</template>

Register the components, and render each text part with streaming set from the AI SDK part state. Svelte and Angular take the same components map:

<script setup lang="ts">
import { Markdown } from '@comark/vue'
import QuantityCalculator from './QuantityCalculator.vue'
import Timeline from './Timeline.vue'

defineProps<{ text: string, isStreaming: boolean }>()

const components = { 'quantity-calculator': QuantityCalculator, timeline: Timeline }
</script>

<template>
  <Suspense>
    <Markdown :value="text" :streaming="isStreaming" :components="components" caret />
  </Suspense>
</template>

See AI chat streaming for the chat loop in each framework.

Help the model pick the right component

The model can only use the components it knows about. A catalog in the system prompt tells it what each component does, which props it takes, and when it helps. Keep the catalog in code next to the components, so the prompt never drifts from what you registered:

server/catalog.ts
interface CatalogEntry {
  tag: string
  description: string
  useWhen: string
  example: string
}

export const catalog: CatalogEntry[] = [
  {
    tag: 'quantity-calculator',
    description: 'Scales ingredient or material quantities with an editable guest count.',
    useWhen: 'The answer lists quantities that depend on a number of people.',
    example: '::quantity-calculator{:guests="6"}\n---\nitems:\n  - name: Flour\n    perGuest: 100\n    unit: g\n---\n::',
  },
  {
    tag: 'timeline',
    description: 'Shows ordered steps with times. The default slot is a Markdown list.',
    useWhen: 'The answer is a schedule or a sequence of timed steps.',
    example: '::timeline\n- **9:00** Arrive.\n- **9:30** Start.\n::',
  },
]

export function systemPrompt(): string {
  const components = catalog
    .map(entry => `### ${entry.tag}\n${entry.description}\nUse when: ${entry.useWhen}\nExample:\n${entry.example}`)
    .join('\n\n')

  return `Answer in Markdown. You can place these Comark components in your answer:

${components}

Rules:
- Use a component only when it makes the answer easier to use than text.
- Answer in plain Markdown when text is enough.
- Don't use other components or raw HTML.`
}

Three practices help the model choose well:

  • Describe the intent, not only the props. The useWhen line gives the model a reason to pick the component.
  • Allow plain text. Without the rule, a model tends to place components in every answer.
  • Give one example per component. The model copies the syntax of the example, including the : prefix and the YAML block.

To test the catalog, run a fixed set of questions against it, and check which components each answer uses. This docs site builds its page generator prompt from a component registry the same way: see docs/server/utils/prompt.ts.

Keep model output safe

Model output is untrusted input. Component syntax runs no code, but the model can still write raw HTML or name a tag that you didn't register. Add the security plugin and list your components in allowedTags, next to the Markdown tags that you render:

import security from '@comark/vue/plugins/security'

const plugins = [
  security({
    allowedTags: [
      'quantity-calculator', 'timeline',
      'p', 'h2', 'h3', 'ul', 'ol', 'li', 'strong', 'em', 'a', 'code', 'pre', 'blockquote', 'table', 'thead', 'tbody', 'tr', 'th', 'td',
    ],
  }),
]

Pass plugins to the same <Markdown> component. Your components also receive values from the model, so check them before you use them, for example a negative perGuest. For more about props, unknown tags, and the data prop, see generative UI.

FAQ

Next steps

© 2026 Vercel, Inc.