# Generative UI

> **Info:** This is documentation for **Stream Chat React Native SDK v9**, which is no longer actively maintained. For up-to-date documentation, see the [latest version (v10)](https://getstream.io/chat/docs/sdk/react-native/).

An agent can answer with UI rather than text: a weekend plan with a map of the places, a card for each day and a form to change the pace. `AIConversation` shows a reply's UI in place of its text, brings it in as the agent writes it, and sends what the user does in it back to the agent as their next message.

The AI components know no UI format of their own. You give a conversation a renderer for each format your agent writes, and the components of your app the agent can place, such as a map, a calendar or a card over your own data. The SDK ships a renderer of [A2UI](https://a2ui.org), and you can write one for any other format. OpenUI, which the React and iOS SDKs also read, has no renderer on React Native. A component works whichever format the agent writes.

>
> **Info:** Generative UI is available from version 9.13.0 of `stream-chat-react-native` and `stream-chat-expo`. The host and the components are in the `ai-components` entry point, `AIConversation` in `ai-components/conversation`, and the A2UI renderer in `stream-chat-react-native/a2ui`, or `stream-chat-expo/a2ui` with Expo.
>

## Installation

Generative UI needs nothing beyond the [AI components](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/#step-2-install-the-sdks). The A2UI renderer is built on the official `@a2ui/web_core` package, which is installed with the SDK. It has an entry point of its own, so an app that never imports it doesn't bundle the A2UI core.

```tsx
// React Native CLI
import { createA2UIRenderer } from "stream-chat-react-native/a2ui";
import { AIUIHost } from "stream-chat-react-native/ai-components";

// Expo
import { createA2UIRenderer } from "stream-chat-expo/a2ui";
import { AIUIHost } from "stream-chat-expo/ai-components";
```

An app without a renderer still shows every reply: a reply in a format it has no renderer for shows its text.

## How a reply carries its UI

The agent's backend writes a reply's UI in the message's `ai_ui` custom field. The reply's text stays readable on its own, for wherever the UI can't show, such as a push notification or an app without a renderer for the format:

```json
{
  "text": "Here's a weekend in Ohrid, with a map and a plan for each day.",
  "ai_ui": {
    "v": 1,
    "format": "a2ui",
    "version": "1.0",
    "status": "streaming",
    "content": "{\"version\":\"1.0\",\"createSurface\":{\"surfaceId\":\"trip\",\"catalogId\":\"getstream.io:app-catalog\"}}\n{\"version\":\"1.0\",\"updateComponents\":{\"surfaceId\":\"trip\",\"components\":[...]}}"
  }
}
```

| Field     | Description                                                                                     |
| --------- | ----------------------------------------------------------------------------------------------- |
| `v`       | The version of the field, `1`. An app that doesn't know a newer version shows the reply's text. |
| `format`  | The format the UI is written in, such as `a2ui`.                                                |
| `version` | The format's version, such as `1.0`, when the agent names one. Optional.                        |
| `status`  | `streaming` while the agent is still writing the UI, then `complete`.                           |
| `content` | The UI in its format: text, such as A2UI messages one per line, or JSON.                        |

A model usually writes the UI into its reply, for example in a fenced code block. Have your backend move it out of the text into `ai_ui` as the reply streams, so the text keeps only the words around it. Stream Agents does this for you. A message's custom data holds at most 50 KB, so leave the UI out when it doesn't fit beside the rest of it.

`fromStreamMessage` reads the field into the `uiDocument` of the `AIMessage`, for the agent's replies only. A field of a newer `v` than the SDK reads is left out, so the reply shows its text. To read the field yourself, use `parseAIUIDocument(message.ai_ui)`, and type the fields on your messages:

```ts
import type { DefaultMessageData } from "stream-chat-react-native";
import type {
  AIUIActionJSON,
  AIUIDocumentJSON,
} from "stream-chat-react-native/ai-components";

declare module "stream-chat" {
  interface CustomMessageData extends DefaultMessageData {
    ai_ui?: AIUIDocumentJSON;
    ai_ui_action?: AIUIActionJSON;
  }
}
```

## Show it in a conversation

Make an `AIUIHost` with the renderers of the formats your agent writes and your components, and pass it to `AIConversation` as `ui`. Keep one host for as long as its conversation shows, for example with `useState`, so each reply's UI keeps its state, such as what the user typed into a form, while it scrolls out of sight and back.

With Stream Agents, the host's `send` asks the agent with the user's words, from `@stream-io/stream-agents`, and carries the action as `uiAction`. The agents router keeps it on the user's message as `ai_ui_action`:

```tsx
import { useMemo, useState } from "react";
import { createA2UIRenderer } from "stream-chat-react-native/a2ui";
import { AIConversation } from "stream-chat-react-native/ai-components/conversation";
import {
  AIUIHost,
  aiUIActionJSON,
  createFromStreamMessage,
} from "stream-chat-react-native/ai-components";

export const Assistant = ({ session, messages }) => {
  const [ui] = useState(
    () =>
      new AIUIHost({
        components: [placeCard],
        renderers: [createA2UIRenderer()],
        send: (action) =>
          session.responses.create(action.label, {
            uiAction: aiUIActionJSON(action),
          }),
      }),
  );
  const [fromStreamMessage] = useState(() => createFromStreamMessage());
  const conversation = useMemo(
    () => messages.map(fromStreamMessage),
    [fromStreamMessage, messages],
  );

  return <AIConversation messages={conversation} ui={ui} />;
};
```

With a backend of your own, send the action as a message on the channel, with the action in its `ai_ui_action` field:

```tsx
import {
  AI_UI_ACTION_CUSTOM_DATA_KEY,
  aiUIActionJSON,
  type AIUIAction,
} from "stream-chat-react-native/ai-components";

const send = (action: AIUIAction) =>
  channel.sendMessage({
    text: action.label,
    [AI_UI_ACTION_CUSTOM_DATA_KEY]: aiUIActionJSON(action),
  });
```

With a [conversation controller](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/conversation-state/#a-conversation-controller), send the action through it, so it shows as the user's message straight away and reaches your transport like one. Its `custom` data arrives in the transport's `send` as `request.custom`:

```tsx
const send = (action: AIUIAction) =>
  conversation.send({
    text: action.label,
    custom: { uiAction: aiUIActionJSON(action) },
  });
```

A reply shows its UI in place of its text, after its steps, when one of the host's renderers reads its format and version, the first one listed asked first. Otherwise, it shows its text. The UI comes in as the agent writes it: the agent's thinking gives way to it as soon as it starts, and each part fades in, rising into place, after the parts before it. A reply written before it showed, such as one loaded from history, shows at once.

[Reply actions](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/conversation-view/#reply-actions) show under a reply once its UI is written, as they do under text.

A reply's UI never takes the conversation down with it: when it can't show, the reply shows its text. See [Handle errors](#handle-errors).

The list reuses its rows for the UI too, so one of your components that keeps state of its own should start afresh for a new `context.messageId`.

## The A2UI renderer

`createA2UIRenderer` reads [A2UI 1.0](https://a2ui.org) and draws all 18 components of its basic catalog natively, with React Native views.

```tsx
import { createA2UIRenderer } from "stream-chat-react-native/a2ui";

const ui = new AIUIHost({
  components: [placeCard, tripMap],
  renderers: [createA2UIRenderer()],
  send,
});
```

A reply's `content` is A2UI messages, one JSON object per line, and each one shows as it completes, while the agent writes it. Until the reply's first surface arrives, its UI shows a placeholder, and a finished reply that made no surface shows its text instead. The agent writes with the basic catalog, which your components join under their A2UI type (`place_card` is `PlaceCard`), in the `getstream.io:app-catalog` catalog. A parameter takes the value its schema describes, or a binding (`@path`) or function (`@call`), which your component gets worked out. Each reply's surfaces are its own, kept for as long as the host is, so what the user entered survives scrolling.

A button reaches the host as an `AIUIAction` named after its event and labeled with the button's text, so the user's message says what they tapped. Its payload is `{ surfaceId, sourceComponentId, context }`, with the action's `userMessage`, what the action asks of the agent, when the agent wrote one. What the user typed into an obscured `TextField`, such as a password, never leaves the device: it's cut out of what an action sends and of what a tool gets.

| Option         | Description                                                                                                                                                                             |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `components`   | Your own views of the basic catalog's components, by their A2UI type, such as `{ Card: MyCard }`. Each gets the node and its resolved props.                                            |
| `icons`        | Your own icons for the names A2UI's `Icon` takes, such as `{ shoppingCart: CartIcon }`, over the Material icons drawn by default.                                                       |
| `removing`     | The basic catalog's components or functions to leave out, such as `['Video']`. The agent's instructions leave them out too.                                                             |
| `locale`       | The locale numbers and dates are formatted in. The device's by default.                                                                                                                 |
| `mediaUrl`     | Decides what an `Image`, `Video` or `AudioPlayer` loads for a link the agent wrote: the address to load, or `null` not to.                                                              |
| `instructions` | What you tell the model before A2UI's own instructions in the host's `capabilities()`, such as which answers to write as UI.                                                            |
| `catalogId`    | The catalog your components join, `getstream.io:app-catalog` by default. With the default, surfaces that name `stream-app-catalog` (`A2UI_WEB_CATALOG_ID`), the web SDK's id, show too. |
| `onError`      | Gets the problems with a reply's A2UI, with the A2UI error code. See [Handle errors](#handle-errors).                                                                                   |

A view of your own for a basic component gets `node` and `props`, what the agent wrote with its bindings and functions worked out. A property the user can change, such as a `TextField`'s `value`, comes with a setter named after it (`setValue`). Draw a child with `A2UIChild`, and a list of them with `A2UIChildren`.

`Image`, `Video` and `AudioPlayer` load only public web links by default, never addresses on the user's network, such as `192.168.1.1` or `localhost`, since the agent writes the links. `Video` and `AudioPlayer` play in place with the player you set up for the Chat SDK's video and voice messages. Without one, they show a link that opens where the device plays it.

### Write a renderer

For any other format, write a renderer of your own. A renderer makes an engine for each conversation, which makes the view of each reply's UI:

```tsx
import { Text, View } from "react-native";
import {
  AIUIEntrance,
  type AIUIRenderer,
} from "stream-chat-react-native/ai-components";

const cardsRenderer: AIUIRenderer = {
  format: "cards",
  createEngine: () => ({
    view: (document) => {
      const cards =
        typeof document.content === "string"
          ? JSON.parse(document.content)
          : document.content;
      if (!Array.isArray(cards)) return null;
      return (
        <View>
          {cards.map((card, index) => (
            <AIUIEntrance key={index}>
              <Text>{card.title}</Text>
            </AIUIEntrance>
          ))}
        </View>
      );
    },
  }),
};
```

List it in the host like any other renderer.

- `view(document, messageId)` is asked again each time the conversation renders. Return `null` to show the reply's text instead. Keep what it makes for a reply and return it again.
- The host keeps its engines for as long as it's kept, so an engine can hold each reply's state by its `messageId`, or with `host.state(messageId)` and `host.setState(messageId, state)`.
- `receive(document, messageId)` gets each reply's document as it arrives, oldest first, including replies that aren't on screen. Use it for a format whose later replies change an earlier one's UI. `remove(messageId)` tells the engine a reply, or its UI, left the conversation.
- When what a reply shows changes outside a render, call `host.refresh()`, and the conversation shows its replies again.
- `supports(version)` says which versions of the format it reads. It reads every version by default.
- `capabilities(components, tools)` says what the agent's backend needs to write the format, such as the instructions for its model. It may return a promise. See [Tell the agent what the app shows](#tell-the-agent-what-the-app-shows).

To place the app's components, find one with `host.component(name)` and show it with `AIUIComponentView`, giving it the arguments the agent wrote and a context whose `emit` reports its events the way your format does.

## Components

A component is a view of your app the agent can place in a reply's UI. You describe it once, with the parameters the agent fills in and the events it reports, and say how it looks for the arguments the agent wrote:

```tsx
import { Pressable, Text } from "react-native";
import {
  AIUIParameterType,
  defineAIUIComponent,
  optionalAIUIParameter,
  requiredAIUIParameter,
} from "stream-chat-react-native/ai-components";

type PlaceCardArgs = { title: string; visitDate?: Date };

export const placeCard = defineAIUIComponent<PlaceCardArgs>(
  {
    name: "place_card",
    description: "A photo and a short summary of a place.",
    parameters: [
      requiredAIUIParameter(
        "title",
        AIUIParameterType.string,
        "The place's Wikipedia title.",
      ),
      optionalAIUIParameter("visit_date", AIUIParameterType.date),
    ],
    events: [
      {
        name: "ask_about",
        description: "The user asks about the place.",
        parameters: [],
      },
    ],
  },
  ({ args, context }) => (
    <Pressable
      onPress={() =>
        context.emit("ask_about", `Tell me more about ${args.title}`, {
          title: args.title,
        })
      }
    >
      <Text>{args.title}</Text>
    </Pressable>
  ),
);
```

`context.emit(event, label, payload?)` reports one of its events: the label is what the user did, in their words, which becomes their message. Every renderer places it by its name, so it works whichever format the agent writes. A component named like one of the format's own is left out and reported. The arguments arrive with camelCase keys, and dates as `Date`s, given as a day, such as `2026-11-14`, or a moment in RFC 3339. While the agent is still writing them and they don't fit the parameters yet, the component waits to show. `context.isStreaming` tells whether they may still change, and `context.messageId` which reply it's in.

The parameter types are `string`, `number`, `integer`, `boolean`, `date`, `dateTime`, `url`, `enumeration(values)`, `array(items)` and `object(fields)` on `AIUIParameterType`. A parameter's type can be any other JSON Schema.

A component can load what it shows itself, such as photos, which then never reaches the agent.

### One description for every platform

A component's definition is JSON, so your React Native, iOS, Android and web apps, and the agent's backend, can share one description of it. Its parameters are a JSON Schema object, as a tool's are:

```json
{
  "name": "place_card",
  "description": "A photo and a short summary of a place.",
  "parameters": {
    "type": "object",
    "properties": {
      "title": {
        "type": "string",
        "description": "The place's Wikipedia title."
      },
      "visit_date": { "type": "string", "format": "date" }
    },
    "required": ["title"],
    "x-order": ["title", "visit_date"]
  },
  "events": [
    { "name": "ask_about", "description": "The user asks about the place." }
  ]
}
```

Read it with `parseAIUIComponentDefinition` to use the same file in your app, and write one back with `aiUIComponentDefinitionJSON`:

```tsx
import definitions from "./UIComponents.json";

const placeCard = defineAIUIComponent<PlaceCardArgs>(
  parseAIUIComponentDefinition(definitions[0])!,
  PlaceCardView,
);
```

## Run your app's tools

A reply's UI can run your app's tools on the device, such as to load the user's saved places for the days of a plan. Pass them to the host as `tools`. They are the same `AIClientTool`s an [`AIClientToolRunner`](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/reasoning-and-tool-calls/#client-tools) takes, so a tool is defined once:

```tsx
const savedPlaces: AIClientTool = {
  definition: {
    name: "saved_places",
    description: "The user's saved places in a city.",
    inputSchema: {
      type: "object",
      properties: { city: { type: "string" } },
      required: ["city"],
    },
    annotations: { readOnlyHint: true },
  },
  run: async (call) => {
    const { city } = call.arguments as { city: string };
    return completedToolResult(await loadSavedPlaces(city));
  },
};

const ui = new AIUIHost({ renderers, send, tools: [savedPlaces] });
```

A tool runs by itself, such as to load data while a reply shows, only when its definition says it's read-only (`annotations: { readOnlyHint: true }`). Any other tool runs only from something the user did, such as a tap. Output that isn't JSON is refused.

An A2UI reply calls a tool as a function of the catalog, named after the tool, and gives up on one that hasn't answered within 30 seconds. The A2UI instructions list your tools, mark the read-only ones, and tell the model that the others run only from a button the user taps. A component of yours runs one through its context's host, and gets the tool's output:

```tsx
const places = await context.host.runTool(
  "saved_places",
  { city: "Ohrid" },
  { userInitiated: true },
);
```

Pass `userInitiated: true` only when the user asked for it, such as from a tap. `runTool` rejects with an `AIUIToolError` when the tool can't run, fails or gives output that isn't JSON.

The host's `open` option opens the links of a reply's UI. By default, web and email links open, and others don't, since the agent writes the links, and another kind could call someone or act in another app.

## What the user does

When the user does something in a reply's UI that's for the agent, such as tapping a place or submitting a form, the host calls your `send` with an `AIUIAction`. Send it as the user's next message. The host sends a reply's actions one after another, in the order they happened.

| Property    | Description                                                                                                                |
| ----------- | -------------------------------------------------------------------------------------------------------------------------- |
| `messageId` | The reply whose UI it came from.                                                                                           |
| `format`    | The format of that UI.                                                                                                     |
| `name`      | What happened, such as a component's event (`ask_about`) or an A2UI button's event.                                        |
| `label`     | What the user did, in their words, such as "Tell me more about St. Naum". Use it as the message's text.                    |
| `payload`   | The details, in the format's own shape, such as what was chosen and a form's values. A component's event adds `component`. |

`aiUIActionJSON(action)` is the action as the message's `ai_ui_action` field, or as Stream Agents' `uiAction`. Have your backend tell the model what the user did after their words, and which reply it was in, since the same UI can show in several replies.

An action the agents router would refuse, one without a reply, format or name, or over 8 KB as JSON (`AI_UI_ACTION_MAX_BYTES`), isn't sent. It goes to the host's `onError(error, action)`, as does one that fails to send, and neither holds up the actions after it. `aiUIActionProblem(action)` says why one would be refused. See [Handle errors](#handle-errors).

## Tell the agent what the app shows

The model has to know which formats and components your app shows. The host's `capabilities()` describes them: each format, with what its renderer adds, and your components' definitions and tools:

```tsx
const capabilities = await ui.capabilities();
// { v: 1, renderers: [{ format: "a2ui", fence, catalog_id, instructions }], components: [...], tools: [...] }
```

`tools` are your tools as Model Context Protocol tools: each one's `name`, `description`, `inputSchema` (`{ type: "object" }` when it takes nothing) and `annotations.readOnlyHint`. Without tools, `capabilities()` leaves `tools` out.

The A2UI renderer adds the `fence` the model writes its UI in (`ui:a2ui@1.0`), the `catalog_id` of the catalog with your components, and the `instructions` for the model, written from the basic catalog, your components and your tools.

Send the capabilities to your backend, for example when a conversation starts, and add the instructions to what the model is told. You can also write the instructions on your backend or at build time, from the same component definitions, with the functions of the `a2ui` entry point:

- `a2uiInstructions(config)` writes A2UI's instructions with your `components`, `examples`, `rules`, `notes`, `tools` and the components to leave out (`removing`). It rejects with an `A2UIInstructionsError` when an example wouldn't render, since a broken example teaches the model broken UI.
- `a2uiProblems(text, config)` lists what's wrong with A2UI messages for the catalog, for checking what a model writes.

```tsx
import { a2uiInstructions } from "stream-chat-react-native/a2ui";

const instructions = await a2uiInstructions({
  components: [placeCard, tripMap],
});
```

Both take your components or their JSON definitions, such as the entries of a `UIComponents.json`.

## Handle errors

A reply's UI never takes the conversation down with it. The host and the renderer each take a callback for what goes wrong, and log it by default:

```tsx
const ui = new AIUIHost({
  components: [placeCard],
  renderers: [
    createA2UIRenderer({
      onError: (error, messageId) => report("a2ui", error, messageId),
    }),
  ],
  send,
  onError: (error, action) => setUnsent(`"${action.label}" wasn't sent.`),
  onRenderError: (error, messageId) => report("render", error, messageId),
});
```

| Callback                               | Gets                                                                                                                                                                                                                                                                                                                                                                                |
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Renderer `onError(error, messageId?)`  | The problems with a reply's A2UI, such as a message that isn't JSON or a component the catalog doesn't have, as an `A2UIError` with its `code`, `message`, and the `path` and `surfaceId` where it went wrong. The rest of the reply still shows. A problem with the renderer rather than a reply, such as one of your components that can't join the catalog, comes without an id. |
| Host `onRenderError(error, messageId)` | A reply's UI that threw while it rendered, or one of your components that threw.                                                                                                                                                                                                                                                                                                    |
| Host `onError(error, action)`          | An action that wasn't sent: one the agents router would refuse, or one your `send` failed to send, by throwing or rejecting. The actions after it are still sent.                                                                                                                                                                                                                   |

A reply shows its text in place of its UI when:

- No renderer reads its format or version, or its `ai_ui` field is of a newer `v` than the SDK reads.
- A finished reply draws nothing, such as an A2UI reply that made no surface.
- Its UI throws while it renders, outside your components. A reply the agent is still writing tries again once it's written. `ui.hasFailed(messageId)` says whether a reply's UI failed.

One of your components that throws, such as when given what it didn't expect, leaves only itself out of the reply's UI, and is tried again when what it's given changes.

A chat has no channel for these problems, so forward them to your backend if the agent should hear about them. To tell the user that what they did wasn't sent, throw from `send`, as when the agent is still answering, and show the error from `onError` where your app shows a failed question.

## Theming

A reply's UI follows the AI components' theme, which `StreamTheme` provides. The theme's `generatedUI.container` styles what a reply's UI sits in, and its `a2ui` key styles A2UI's basic components: `colors`, over ones from the theme's own colors, and one style per part.

| Key (`a2ui.colors.`) | Used for                                                       |
| -------------------- | -------------------------------------------------------------- |
| `accent`             | Primary buttons, chosen chips and tabs, links and sliders      |
| `onAccent`           | Text on the accent                                             |
| `text`               | Text                                                           |
| `secondaryText`      | Secondary text                                                 |
| `surface`            | Inputs and chips                                               |
| `subtleSurface`      | Cards, default buttons and placeholders                        |
| `border`             | Borders                                                        |
| `error`              | What a check found, and the border of the input it found it in |

The styles of the parts are `surfaces`, `card`, `button`, `buttonText`, `input`, `label`, `chip`, `chipText`, `tab`, `tabText`, `divider`, `caption` and `image`:

```tsx
<StreamTheme
  style={{
    a2ui: {
      colors: { accent: "#6d28d9", onAccent: "#ffffff" },
      card: { borderRadius: 20 },
    },
  }}
>
  {children}
</StreamTheme>
```

## Show it in your own views

To show replies in views of your own rather than with `AIConversation`, show a reply's UI with `AIUIView`, and its text when the host shows nothing for it:

```tsx
import {
  AIUIView,
  StreamingMessageView,
  useAIUIHostRefresh,
} from "stream-chat-react-native/ai-components";

const Reply = ({ message, ui }) => {
  useAIUIHostRefresh(ui);
  const document = message.uiDocument;
  return document && ui.content(document, message.id) !== null ? (
    <AIUIView document={document} host={ui} messageId={message.id} />
  ) : (
    <StreamingMessageView text={message.text} />
  );
};
```

Each time your messages change, pass them to `ui.receive(messages)` before the replies render, so the renderers get the UI of every reply, also of those that aren't on screen. `useAIUIHostRefresh` renders again when the host's answer changes, such as once a finished reply turns out to draw nothing.

## Motion

`AIUIEntrance` brings a part of a reply's UI in when it shows: the part fades in, rising into place and, on Android 12 and later, coming into focus, after the parts that came in just before it. Parts that show together come in together, so a reply written before it showed shows at once. With reduced motion, parts only fade in.

The A2UI renderer gives it to each part it places, and every component has it. Use it on the parts of your own renderer's views, as the cards renderer does above.

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/generative-ui/](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/generative-ui/).