---
title: "Build Your Own Intelligent UI: Stream Components in LLM Answers"
description: "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."
canonical_url: "https://comark.dev/use-cases/intelligent-ui"
---
# 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](https://openai.com/index/gpt-6-for-everyone/). 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 UI                                            | Comark                                                                              |
| --------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| A library of native components                            | Your Vue, React, Svelte, or Angular components, registered in the `components` prop |
| Streamable components                                     | `streaming` and `autoClose`: a component renders before its closing `::` arrives    |
| A compiler that processes the interface during generation | The parser turns `::component{props}` into data, and runs no code                   |
| The model picks the layout, or plain text                 | A 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:

```mdc
## 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](https://comark.dev/syntax/components) 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:

::code-group
```vue [QuantityCalculator.vue]
<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>
```

```tsx [QuantityCalculator.tsx]
'use client'
import { useState } from 'react'

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

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

export function QuantityCalculator({ guests, items = [] }: { guests?: number, items?: Item[] }) {
  // After the user edits the count, new `guests` props from the stream don't override it.
  const [edited, setEdited] = useState<number>()
  const count = edited ?? positive(guests) ?? 4

  return (
    <div className="quantity-calculator">
      <label>
        Guests
        <input
          type="number"
          min={1}
          value={count}
          onChange={event => setEdited(positive(Number(event.target.value)) ?? edited)}
        />
      </label>
      <ul>
        {items.filter(item => item.name && positive(item.perGuest)).map(item => (
          <li key={item.name}>
            {item.name}: {Math.ceil(item.perGuest! * count)} {item.unit}
          </li>
        ))}
      </ul>
    </div>
  )
}
```
::

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:

::code-group
```vue [Message.vue]
<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>
```

```tsx [Message.tsx]
'use client'
import { MarkdownClient } from '@comark/react'
import { QuantityCalculator } from './QuantityCalculator'
import { Timeline } from './Timeline'

const components = { 'quantity-calculator': QuantityCalculator, timeline: Timeline }

export function Message({ text, isStreaming }: { text: string, isStreaming: boolean }) {
  return <MarkdownClient value={text} streaming={isStreaming} components={components} caret />
}
```
::

See [AI chat streaming](https://comark.dev/use-cases/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:

```typescript [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`](https://github.com/comarkdown/comark/blob/main/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](https://comark.dev/plugins/built-in/security) and list your components in `allowedTags`, next to the Markdown tags that you render:

```typescript
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](https://comark.dev/use-cases/generative-ui#validate-the-output).

## FAQ

::accordion
  :::accordion-item{label="Do I need GPT-6 to build this?"}
  No. Any model that follows instructions can write component syntax. Test your catalog with the model that you use, and add examples where it makes mistakes.
  :::

  :::accordion-item{label="Can a component update while the model writes it?"}
  Yes. The component mounts on the first frame and receives new props on each frame. Keep its local state, such as the guest count in an input, separate from the props.
  :::

  :::accordion-item{label="What if the model writes a component I didn't register?"}
  Without the security plugin, it renders as a plain element with the same tag name. With `allowedTags`, the plugin removes it with its content. To keep the text instead, set the plugin's `tagFallback` option.
  :::

  :::accordion-item{label="How is this different from generative UI?"}
  It's the same technique. [Generative UI](https://comark.dev/use-cases/generative-ui) explains how component syntax becomes your components. This page adds a component library, streamable components, and a catalog that helps the model choose.
  :::
::

## Next steps

- [Let an LLM render UI components inside Markdown](https://comark.dev/use-cases/generative-ui)
- [Render streaming Markdown from an LLM](https://comark.dev/use-cases/ai-chat-streaming)
- [Component syntax](https://comark.dev/syntax/components)
- [Security plugin options](https://comark.dev/plugins/built-in/security#options)

---

- [Generative UI](https://comark.dev/use-cases/generative-ui)
- [Component syntax](https://comark.dev/syntax/components)


## Sitemap

See the full [sitemap](https://comark.dev/sitemap.md) for all pages.
