# AI integrations overview

> **Info:** This is release candidate documentation for **Stream Chat React Native SDK v10**. For the latest stable version, see the [latest version (v9)](https://getstream.io/chat/docs/sdk/react-native/).

The AI UI components are built for AI-first React Native apps. Paired with the real-time [Chat API](https://getstream.io/chat/), they render replies from LLM providers like ChatGPT, Gemini, Anthropic, or a custom backend, with Markdown, syntax-highlighted code, tables, charts and thinking indicators.

The components ship with the SDK behind a dedicated entry point, `stream-chat-react-native/ai-components`, or `stream-chat-expo/ai-components` with Expo:

- `StreamingMessageView` renders Markdown, code and charts, and types out the text as it grows.
- `ComposerView` is a prompt composer with photo attachments, a bottom sheet of actions and speech input.
- `SpeechToTextButton` records the user's voice and transcribes it with the platform's speech recognizer.
- `AITypingIndicatorView` shows what the LLM is doing, such as "Thinking", with a shimmer over the text.
- `VoiceWave` and `VoiceGlow` show what a voice agent is doing as animated visualizations, and `useDictationAudioLevel` moves them with the microphone.

An app that never imports the entry point doesn't bundle any of it, and the entry point doesn't bundle the main SDK entry point. The [`ExpoChatGPTSample`](https://github.com/GetStream/stream-chat-react-native/tree/V10/examples/ExpoChatGPTSample) app in the SDK repository is a ChatGPT-style app built with these components.

[SDK integration](https://getstream.io/chat/docs/sdk/react-native/v10/features/ai/sdk-integration/) covers the AI support in the main entry point: `StreamingMessageView`, `AITypingIndicatorView` and the `StopMessageStreamingButton` the default composer shows. The main entry point has its own `StreamingMessageView` and `AITypingIndicatorView`, with other props than the components on this page.

## Usage

The entry point imports `victory-native` and `@shopify/react-native-skia`, which draw its charts and voice visualizations. Install both in any app that imports it:

**RN CLI**

```bash title="Terminal"
yarn add victory-native @shopify/react-native-skia
```

**Expo**

```bash title="Terminal"
npx expo install victory-native @shopify/react-native-skia
```

`react-native-reanimated`, `react-native-gesture-handler` and `react-native-svg` are already required by the SDK. The other JavaScript dependencies of the components are installed with the SDK.

The components depend on `zod`, which uses `export * as` syntax that the React Native Babel preset doesn't transform. Install the `export-namespace-from` plugin:

```bash title="Terminal"
yarn add -D @babel/plugin-transform-export-namespace-from
```

Then add it to `babel.config.js`, before the worklets plugin:

```js title="babel.config.js"
module.exports = {
  presets: ["module:@react-native/babel-preset"],
  plugins: [
    // your other plugins
    "@babel/plugin-transform-export-namespace-from",
    // react-native-worklets/plugin has to be the last one
    "react-native-worklets/plugin",
  ],
};
```

With Expo, keep the `babel-preset-expo` preset and add the same plugin. An app that doesn't import the AI components doesn't need the plugin.

Import the components from the entry point, and wrap the part of the app that uses them in `StreamTheme`. The components read their theme from it and throw outside of it:

```tsx title="AIRoot.tsx"
import type { PropsWithChildren } from "react";
import { StreamTheme } from "stream-chat-react-native/ai-components";

export const AIRoot = ({ children }: PropsWithChildren) => (
  <StreamTheme>{children}</StreamTheme>
);
```

`StreamTheme` is separate from the SDK's own theme, described in [Theming](#theming).

>
> **Note:** Coming from the standalone `@stream-io/chat-react-native-ai` package? See [Migrating from `@stream-io/chat-react-native-ai`](#migrating-from-stream-iochat-react-native-ai) below.
>

### Speech-to-text

`SpeechToTextButton`, and the microphone button of `ComposerView`, use the platform speech recognizers. Each platform needs its own setup.

#### iOS

App Store Connect requires a speech recognition usage description from every app whose binary references the Speech framework, so the SDK compiles its speech-to-text module only into apps that turn it on. To turn it on, add this line to your `Podfile`, before `use_native_modules!`, and run `pod install`:

```ruby title="ios/Podfile"
$StreamChatEnableDictation = true
```

Running `STREAM_CHAT_ENABLE_DICTATION=1 pod install` works as well. Then add the usage descriptions to `Info.plist`, with texts of your own:

```xml title="ios/YourApp/Info.plist"
<key>NSMicrophoneUsageDescription</key>
<string>$(PRODUCT_NAME) would like to access your microphone to capture your voice.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>$(PRODUCT_NAME) would like to access speech recognition to transcribe your voice.</string>
```

Without the module, the microphone button doesn't render, and a warning in development builds says how to turn it on. `isDictationAvailable()` returns whether the module is part of the build.

#### Android

The module is always part of an Android build. Add the permission to `android/app/src/main/AndroidManifest.xml`:

```xml title="android/app/src/main/AndroidManifest.xml"
<uses-permission android:name="android.permission.RECORD_AUDIO" />
```

The button asks for the permission at runtime the first time the user taps it.

#### Expo

Add the `stream-chat-expo` config plugin to `app.json` or `app.config.js`. It compiles the iOS module in, adds the two iOS usage descriptions and adds the Android permission:

```json title="app.json"
{
  "expo": {
    "plugins": [
      [
        "stream-chat-expo",
        {
          "dictationMicrophoneUsageDescription": "$(PRODUCT_NAME) would like to access your microphone to capture your voice.",
          "dictationSpeechRecognitionUsageDescription": "$(PRODUCT_NAME) would like to access speech recognition to transcribe your voice."
        }
      ]
    ]
  }
}
```

Both descriptions are optional. Without them, the plugin keeps the descriptions the app already has, and adds default texts where there are none. Pass `"enableDictation": false` to leave the iOS module out and add no descriptions or permission. The Android module is always part of the build, so on Android the microphone button still renders, and recording fails without the permission. The plugin applies on `npx expo prebuild`, so run it again after changing the options. If you maintain the native projects yourself, follow the iOS and Android steps above instead.

### Optional features

The bottom sheet of `ComposerView` has "Camera" and "Photos" buttons, which add photos to the prompt. They work with whichever of these packages is installed:

- `react-native-image-picker` for React Native CLI. For the camera, add `NSCameraUsageDescription` to `Info.plist` and the `android.permission.CAMERA` permission to `AndroidManifest.xml`.
- `expo-image-picker` for Expo. Its [config plugin](https://docs.expo.dev/versions/latest/sdk/imagepicker/#configuration-in-app-config) takes the `photosPermission` and `cameraPermission` texts.

Without either, the two buttons do nothing.

The copy button on code blocks works with `@react-native-clipboard/clipboard` for React Native CLI, or `expo-clipboard` for Expo. Without either, the button does nothing.

## Components

All components work with the [React Native Chat SDK](https://getstream.io/chat/docs/sdk/react-native/v10/getting-started/quick-start/), and with replies your app gets from elsewhere. The [developer guide](https://getstream.io/chat/solutions/ai-integration/) walks through AI integrations with Stream.

The components draw their own icons, and their labels, such as the composer's "Ask anything" placeholder and the "Camera" and "Photos" buttons, are fixed English text. They don't go through `Streami18n` or the SDK's icon overrides.

### `StreamingMessageView`

`StreamingMessageView` renders Markdown efficiently, with syntax highlighting for major languages. It supports common Markdown elements like tables, inline code, headings and lists.

When it mounts, it shows the text it has at once. After that, it types out whatever the text grows by, a few letters at a time on a timer. A stored reply renders in full, and a reply that is still streaming types out each new part. If the text gets shorter, for example when the backend replaces a half-written reply with an error message, it shows the new text at once.

| Name                     | Type                    | Required | Description                                                                                                         |
| ------------------------ | ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------- |
| `text`                   | `string`                | yes      | Markdown text to render.                                                                                            |
| `letterInterval`         | `number`                | no       | Milliseconds between typewriter ticks. Defaults to `0`, a tick as often as the JavaScript thread allows.            |
| `renderingLetterCount`   | `number`                | no       | Letters added per tick. Defaults to `2`.                                                                            |
| `paragraphNumberOfLines` | `number`                | no       | The `numberOfLines` of every paragraph, for a shortened preview of a reply.                                         |
| `onLink`                 | `(url: string) => void` | no       | Called when the user presses a link. Without it, the link opens with `Linking.openURL` when the device can open it. |
| `rules`                  | `MarkdownRules`         | no       | Markdown rules, merged into the default rules. See [Markdown, code and charts](#markdown-code-and-charts).          |

Before rendering, it turns plain URLs and email addresses in the text into links, and closes a code block that isn't closed yet, so a half-streamed code block renders as code.

#### Example

Pass the text as it grows. This component appends each chunk of a reply to its state, and `StreamingMessageView` types out the new letters:

```tsx title="StreamedReply.tsx"
import { useEffect, useState } from "react";
import { StreamingMessageView } from "stream-chat-react-native/ai-components";

// `chunks` yields the reply as it arrives, for example from your own streaming endpoint.
export const StreamedReply = ({
  chunks,
}: {
  chunks: AsyncIterable<string>;
}) => {
  const [text, setText] = useState("");

  useEffect(() => {
    let stopped = false;

    const read = async () => {
      for await (const chunk of chunks) {
        if (stopped) return;
        setText((previous) => previous + chunk);
      }
    };
    void read();

    return () => {
      stopped = true;
    };
  }, [chunks]);

  return (
    <StreamingMessageView
      letterInterval={30}
      renderingLetterCount={3}
      text={text}
    />
  );
};
```

With Stream Chat, the text is the AI reply's `message.text`, which grows with every `message.updated` event. [SDK integration](https://getstream.io/chat/docs/sdk/react-native/v10/features/ai/sdk-integration/#using-the-ai-components-streamingmessageview-instead) shows how to render AI replies in the message list with it.

### Markdown, code and charts

`StreamingMessageView` renders its text with the entry point's `Markdown` component. `Markdown` is exported too, for text that doesn't need the typewriter animation, and takes the same `rules`, `onLink` and `paragraphNumberOfLines`, with the Markdown as its children.

Images, underlines and reference-style links aren't parsed. A fenced code block renders with a header that shows its language and a copy button, and its code is highlighted with Prism. On Android, the highlighted code renders through a native text view, which keeps long code blocks cheap to render and scroll.

Some fenced code blocks render as a chart instead:

- a `json` block that holds a [Vega-Lite](https://vega.github.io/vega-lite/) spec with a `bar`, `line`, `area`, `point` or `arc` mark;
- a `mermaid` block with a `pie` diagram.

A block that can't be parsed as one of these renders as code, so a broken chart never breaks the message. Charts draw with `victory-native` and `@shopify/react-native-skia`.

The rules follow the [`simple-markdown` rule format](https://github.com/Khan/perseus/tree/main/packages/simple-markdown#adding-a-simple-extension), keyed by rule name, and are deep-merged into the defaults. To turn a rule off, give it a `match` that never matches. This view leaves `~~text~~` as it is instead of striking it through:

```tsx title="NoStrikethrough.tsx"
import {
  type MarkdownRules,
  StreamingMessageView,
} from "stream-chat-react-native/ai-components";

// Declared outside the component, so the view gets the same object on every render.
const rules: MarkdownRules = {
  del: { match: () => null },
};

export const ReplyWithoutStrikethrough = ({ text }: { text: string }) => (
  <StreamingMessageView rules={rules} text={text} />
);
```

`Markdown` re-renders only when its text changes, so a new `rules` object takes effect with the next text update.

### `AITypingIndicatorView`

`AITypingIndicatorView` shows a short text, for example "Thinking" or "Checking external sources", with a shimmer running over it. It shows whatever `text` you pass, so it's up to you which states it covers.

| Name   | Type     | Required | Description                          |
| ------ | -------- | -------- | ------------------------------------ |
| `text` | `string` | yes      | Text displayed inside the component. |

#### Example

```tsx title="ThinkingIndicator.tsx"
import { AIStates } from "stream-chat";
import { useAIState, useChannel } from "stream-chat-react-native";
import { AITypingIndicatorView } from "stream-chat-react-native/ai-components";

// Render inside Channel. Shows the indicator while the LLM thinks.
export const ThinkingIndicator = () => {
  const channel = useChannel();
  const { aiState } = useAIState(channel);

  return aiState === AIStates.Thinking ? (
    <AITypingIndicatorView text="Thinking of an answer..." />
  ) : null;
};
```

The main entry point's `AITypingIndicatorView` follows the channel's AI state by itself, while this one only draws the indicator. [Drive the AI indicator from events](#drive-the-ai-indicator-from-events) shows how to use this one for more states.

### `ComposerView`

`ComposerView` is a prompt composer: a text input, a `+` button that opens a bottom sheet, a microphone button, and a send button.

| Name                 | Type                                                                                                      | Required | Description                                                                                                                                                                                      |
| -------------------- | --------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `onSendMessage`      | `(data: { text: string; attachments?: MediaAsset[]; custom?: Record<string, unknown> }) => Promise<void>` | yes      | Called when the user presses send, with the text, the picked photos and the composer state's `custom` data. Each photo has a `uri`, and may have a `name`, `size`, `type`, `width` and `height`. |
| `isGenerating`       | `boolean`                                                                                                 | no       | Shows the stop button in place of the send and microphone buttons.                                                                                                                               |
| `stopGenerating`     | `() => Promise<void>`                                                                                     | no       | Called when the user presses the stop button.                                                                                                                                                    |
| `bottomSheetOptions` | `BottomSheetOption[]`                                                                                     | no       | Extra rows in the bottom sheet, below "Camera" and "Photos". Each has a `title`, an `action`, and an optional `subtitle` and `Icon`.                                                             |
| `bottomSheetInsets`  | `{ top: number; bottom: number; left: number; right: number }`                                            | no       | Insets for the bottom sheet. Only `bottom` is read, and it's added to the bottom padding of the sheet's content.                                                                                 |
| `mediaPickerService` | `AbstractMediaPickerService`                                                                              | no       | The service that picks photos and holds them. Defaults to a new `MediaPickerService`, which uses the installed image picker.                                                                     |
| `state`              | `StateStore<ComposerState>`                                                                               | no       | The store of the composer's text and `custom` data, for a store of your own. Create one with `createNewComposerStore()`.                                                                         |

The trailing button is, in this order:

- the stop button, while `isGenerating` is true;
- the send button, once there is text and the microphone isn't recording;
- the microphone button otherwise, a `SpeechToTextButton`.

While the microphone records, the transcript replaces the text in the input. Pressing send clears the input and the photos, then calls `onSendMessage`. A photo without text can't be sent, because the send button only shows with text.

#### Example

```tsx title="PromptComposer.tsx"
import { Alert } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
  type BottomSheetOption,
  ComposerView,
  type ComposerViewProps,
} from "stream-chat-react-native/ai-components";

const bottomSheetOptions: BottomSheetOption[] = [
  {
    action: () => Alert.alert("Create image"),
    subtitle: "Visualize anything",
    title: "Create image",
  },
  {
    action: () => Alert.alert("Deep research"),
    subtitle: "Get a detailed report",
    title: "Deep research",
  },
];

export const PromptComposer = ({
  onSendMessage,
}: Pick<ComposerViewProps, "onSendMessage">) => {
  const insets = useSafeAreaInsets();

  return (
    <ComposerView
      bottomSheetInsets={insets}
      bottomSheetOptions={bottomSheetOptions}
      onSendMessage={onSendMessage}
    />
  );
};
```

To send the prompt as a message in a Stream channel, see [With `ComposerView`](https://getstream.io/chat/docs/sdk/react-native/v10/features/ai/sdk-integration/#with-composerview).

The composer's state is available inside `ComposerView` through the `useMessageComposerContext`, `useComposerText` and `useComposerHasText` hooks, all exported from the entry point. `MediaPickerService` picks photos only, up to three at a time.

### `SpeechToTextButton`

`SpeechToTextButton` turns voice input into text with the native speech recognizers of iOS and Android. A tap starts recording, and the recording stops on the next tap or after a pause in speech.

| Name      | Type                                                                         | Required | Description                               |
| --------- | ---------------------------------------------------------------------------- | -------- | ----------------------------------------- |
| `options` | `{ language?: string; interimResults?: boolean; silenceTimeoutMs?: number }` | no       | Speech recognition options, listed below. |

| Option             | Type      | Description                                                               |
| ------------------ | --------- | ------------------------------------------------------------------------- |
| `language`         | `string`  | The language to transcribe, as a BCP-47 tag such as `de-DE`.              |
| `interimResults`   | `boolean` | Whether the transcript updates while the user speaks, or only at the end. |
| `silenceTimeoutMs` | `number`  | Milliseconds of silence after which the recording stops.                  |

Without `options`, the button records `en-US` with interim results and a 2500 ms silence timeout. When you pass `options`, it replaces those defaults: an option you leave out falls back to the native default, which is the device's language, interim results on, and 2500 ms.

The button writes the transcript to `dictationStore`, a store shared by every button in the app. Inside `ComposerView`, the input copies it. Outside, read it from the store:

```tsx title="VoiceNote.tsx"
import { Text, View } from "react-native";
import { useStateStore } from "stream-chat-react-native";
import {
  dictationStore,
  type DictationStoreState,
  SpeechToTextButton,
} from "stream-chat-react-native/ai-components";

const transcriptSelector = (state: DictationStoreState) => ({
  transcript: state.transcript,
});

const options = {
  interimResults: false,
  language: "de-DE",
  silenceTimeoutMs: 3500,
};

// Render inside StreamTheme.
export const VoiceNote = () => {
  const { transcript } = useStateStore(dictationStore, transcriptSelector);

  return (
    <View>
      <SpeechToTextButton options={options} />
      <Text>{transcript}</Text>
    </View>
  );
};
```

The store also holds `isRecording`, the recognizer's `state` and the last `error`. The button renders nothing when speech-to-text isn't part of the build, for example on iOS without the [opt-in](#ios). For voice input without the button, use the `useDictation(options)` hook, which returns `start`, `stop`, `toggle` and `cancel`, and writes to the same store.

### `VoiceWave`

`VoiceWave` shows what a voice agent is doing as a single string carrying a wave packet, which erupts while the agent speaks. It's the React Native counterpart of `AIVoiceWave` in the Android and iOS AI components and `VoiceWave` on the web, with the same motion. Everything in this section also applies to [`VoiceGlow`](#voiceglow), which takes the same props. Both are available from version 10.0.0-rc.3.

| Name           | Type                                       | Required | Description                                                                                                                                                                                                                                                                                     |
| -------------- | ------------------------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `state`        | `VoiceAgentState`                          | yes      | What the agent is doing: `'idle'`, `'connecting'`, `'listening'`, `'thinking'` or `'speaking'`. The states differ only in motion, never in color, and a change eases from one motion into the next.                                                                                             |
| `colors`       | `string[]`                                 | no       | One to five colors that flow through the shape, in any format React Native understands. Colors past the fifth, and colors that can't be read, are ignored. When omitted or empty, the theme's `voiceVisualization.colors` are used, which default to the accent color.                          |
| `expressivity` | `number`                                   | no       | From `0` to `2`: how far and how fast it moves. Colors never change with it. Defaults to `1`.                                                                                                                                                                                                   |
| `audioLevel`   | `SharedValue<number \| null>`              | no       | The level of whoever is talking, from `0` to `1`, as a Reanimated shared value. It's read once a frame on the UI thread, so a level that changes many times a second re-renders nothing. Update it at 15 Hz or more to follow the words. `null`, or leaving it out, animates speech on its own. |
| `stateLabels`  | `Partial<Record<VoiceAgentState, string>>` | no       | What screen readers read as the value for each state, for example to translate them. Defaults to "Ready", "Connecting", "Listening", "Thinking" and "Speaking".                                                                                                                                 |
|                | `...ViewProps`                             | no       | Any other `View` prop (`style`, `testID`, `accessibilityLabel`...) is passed to the root view.                                                                                                                                                                                                  |

Each state has its own motion:

- `idle` - waiting to be spoken to: a calm, barely-there shimmer.
- `connecting` - not ready yet: a loader sweep with a pulsing breath.
- `listening` - hearing somebody: a gentle expand and contract.
- `thinking` - working on an answer: a slow turn or a travelling wave.
- `speaking` - talking: strong motion that ebbs and pauses like speech.

To move with the user's voice while they dictate, pass the shared value [`useDictationAudioLevel`](#usedictationaudiolevel) returns. For any other source, such as a remote participant's audio level from Stream Video, keep a shared value of your own and write the level into it.

#### Size

Neither component has a size of its own. They draw in the middle of the view you give them, so size them with `style`: `VoiceWave` is about 80% as wide as the view's shorter side, and `VoiceGlow` about half. Give the glow a square and the wave a square or a wide box.

#### Behavior

- **It runs on the UI thread.** The motion is advanced in a Reanimated frame callback and drawn by Skia, so it stays smooth while the JS thread is busy, for example while a long answer streams in.
- **Reduced motion.** With Reduce Motion on iOS, or animations turned off on Android, the motion slows to a crawl and taps are ignored.
- **Only the shape takes touches.** Over the string or, for `VoiceGlow`, the globe, a tap bounces the shape and sends out a ripple, and a hovering mouse or pen ripples it too. Touches are only observed, never claimed, so a `Pressable` around the visualization still gets its press. Beside the shape, touches reach whatever is behind it.
- **It follows the theme.** The default colors come from the theme's `voiceVisualization.colors`, and the light or dark look from `voiceVisualization.backdropColor`, or the system color scheme when it isn't set. See [Theming](#theming).
- **It fades in.** The first frame fades in over 0.3 seconds, so the component never flashes empty while it starts.
- **It keeps running while mounted.** Unlike on iOS and the web, it doesn't pause while scrolled off screen; it only pauses while the app is in the background. Unmount it when it isn't needed.

#### Accessibility

The root is an image named "Voice agent", with the state as its value: "Ready", "Connecting", "Listening", "Thinking" or "Speaking", as on iOS and Android. Like the other labels of the entry point, they're English text that doesn't go through `Streami18n`. To translate them, pass `stateLabels` for the state values and `accessibilityLabel` for the name. `accessibilityValue` replaces the value outright. With keys of your own in `Streami18n`, read them through `asDynamicKey`, as [Your own keys](https://getstream.io/chat/docs/sdk/react-native/v10/ui/customizing/localization/#your-own-keys-and-loosetranslationdictionary) describes:

```tsx title="TranslatedGlow.tsx"
import { asDynamicKey, useTranslationContext } from "stream-chat-react-native";
import {
  type VoiceAgentState,
  VoiceGlow,
} from "stream-chat-react-native/ai-components";

// Render inside StreamTheme.
export const TranslatedGlow = ({ state }: { state: VoiceAgentState }) => {
  const { t } = useTranslationContext();

  return (
    <VoiceGlow
      accessibilityLabel={t(
        asDynamicKey("myApp.voiceAgent.label"),
        "Voice agent",
      )}
      state={state}
      stateLabels={{
        connecting: t(
          asDynamicKey("myApp.voiceAgent.connecting"),
          "Connecting",
        ),
        idle: t(asDynamicKey("myApp.voiceAgent.idle"), "Ready"),
        listening: t(asDynamicKey("myApp.voiceAgent.listening"), "Listening"),
        speaking: t(asDynamicKey("myApp.voiceAgent.speaking"), "Speaking"),
        thinking: t(asDynamicKey("myApp.voiceAgent.thinking"), "Thinking"),
      }}
    />
  );
};
```

#### Example

```tsx title="ListeningWave.tsx"
import { VoiceWave } from "stream-chat-react-native/ai-components";

// Render inside StreamTheme.
export const ListeningWave = () => (
  <VoiceWave
    colors={["#005fff", "#7c3aed"]}
    expressivity={1.4}
    state="listening"
    style={{ height: 96, width: "100%" }}
  />
);
```

### `VoiceGlow`

`VoiceGlow` shows the same states as a soft globe whose color masses orbit inside it and swell with speech. It's the React Native counterpart of `AIVoiceGlow` in the Android and iOS AI components and `VoiceGlow` on the web, and it takes the [`VoiceWave` props](#voicewave).

The glow is drawn by a Skia shader that computes every pixel: noise-marbled color masses, concentric waves, light and a halo, with ripples from a tap or a hovering pointer. It's the same shader as Android's, and every glow shares one compiled copy. Skia runs it on every Android version, so unlike Android's `AIVoiceGlow` there is no Canvas fallback and no `rendering` option.

#### Example

```tsx title="ThinkingGlow.tsx"
import { VoiceGlow } from "stream-chat-react-native/ai-components";

// Render inside StreamTheme.
export const ThinkingGlow = () => (
  <VoiceGlow
    accessibilityLabel="Assistant"
    state="thinking"
    style={{ width: 240, height: 240 }}
  />
);
```

To drive the glow with the channel's AI state, see [VoiceGlow](https://getstream.io/chat/docs/sdk/react-native/v10/features/ai/sdk-integration/#voiceglow) in the SDK integration.

### `useDictationAudioLevel`

`useDictationAudioLevel` gives the microphone's live level while dictation is listening, for the `audioLevel` prop of `VoiceWave` and `VoiceGlow`. It works with the `SpeechToTextButton` and the `useDictation` hook, and needs [speech-to-text](#speech-to-text) to be set up.

```ts
const useDictationAudioLevel: () => SharedValue<number | null>;
```

It returns a Reanimated shared value holding the level, from `0` to `1`, so nothing re-renders as the level changes. The value is `null` while dictation isn't listening, and the visualization then animates speech on its own. The native side measures the level about 10 to 30 times a second: on Android from the speech recognizer, and on iOS from the microphone input.

#### Example

Show the user's voice while they dictate. `useDictationState` reads the same `dictationStore` as the `SpeechToTextButton`:

```tsx title="DictationWave.tsx"
import { Pressable } from "react-native";
import {
  useDictation,
  useDictationAudioLevel,
  useDictationState,
  VoiceWave,
} from "stream-chat-react-native/ai-components";

// Render inside StreamTheme.
export const DictationWave = () => {
  const { toggle } = useDictation();
  const { isRecording } = useDictationState();
  const level = useDictationAudioLevel();

  return (
    <Pressable onPress={toggle}>
      <VoiceWave
        audioLevel={level}
        state={isRecording ? "listening" : "idle"}
        style={{ height: 96, width: "100%" }}
      />
    </Pressable>
  );
};
```

### AI states

A channel's AI state says how far the reply has come. `stream-chat` exports the states as `AIStates`:

- `AIStates.Thinking`, `AI_STATE_THINKING`. The LLM is working out an answer.
- `AIStates.Generating`, `AI_STATE_GENERATING`. The answer is being written into the message.
- `AIStates.ExternalSources`, `AI_STATE_EXTERNAL_SOURCES`. The LLM is checking external sources, such as a tool call.
- `AIStates.Error`, `AI_STATE_ERROR`. The reply failed.
- `AIStates.Stop`, `AI_STATE_STOP`. The user asked to stop the reply.
- `AIStates.Idle`, `AI_STATE_IDLE`. Nothing is happening.

The client keeps the channel's state in `channel.state`, from the backend's `ai_indicator.*` events, and [`useAIState(channel)`](https://getstream.io/chat/docs/sdk/react-native/v10/reference/hooks/ai-hooks/) reads it. Pass it the channel, for example from `useChannel()`. [Drive the AI indicator from events](#drive-the-ai-indicator-from-events) shows which event sets which state. The `AIState` type accepts any string, so a backend can send states of its own. Read them the same way, and replace the default components.

The [React Native assistant tutorial](https://getstream.io/blog/react-native-assistant/) walks through a complete integration.

### Theming

The AI components take their theme from `StreamTheme`, not from the SDK's theme. They don't follow the `style` you pass to `Chat`, and they don't switch to dark colors by themselves.

`StreamTheme` takes a `style`, which is deep-merged into the default theme. The theme has six parts:

| Key                     | Styles                                                                                                                                                                  |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `colors`                | The palette every component uses: `accent_blue`, `accent_red`, `black`, `white`, `white_smoke`, the `grey` shades, `code_block`, `overlay`, `shimmer` and `transparent` |
| `markdown`              | Each Markdown element, such as `paragraph`, `heading1`, `codeBlock`, `inlineCode`, `table` and `listItem`                                                               |
| `composer`              | The parts of `ComposerView`, such as `inputPill`, `textInput`, `sendIcon` and `stopGeneratingIcon`, and `inputPillHeight`                                               |
| `sheet`                 | The parts of the bottom sheet                                                                                                                                           |
| `aiTypingIndicatorView` | The `text` of `AITypingIndicatorView`                                                                                                                                   |
| `voiceVisualization`    | The default `colors` of `VoiceWave` and `VoiceGlow`, and the `backdropColor` they sit on                                                                                |

The [default theme](https://github.com/GetStream/stream-chat-react-native/blob/V10/package/src/ai-components/theme.ts) lists every key. The palette is named after the light theme: `black` colors the text, and `white` and `white_smoke` the surfaces, so a dark theme swaps them:

```tsx title="AITheme.tsx"
import type { PropsWithChildren } from "react";
import { useColorScheme } from "react-native";
import {
  type DeepPartial,
  StreamTheme,
  type Theme,
} from "stream-chat-react-native/ai-components";

const darkStyle: DeepPartial<Theme> = {
  colors: {
    accent_blue: "#4C9DFF",
    accent_red: "#FF636E",
    black: "#FFFFFF",
    code_block: "#1E1E22",
    grey: "#A1A1AA",
    grey_dark: "#71717A",
    grey_gainsboro: "#3F3F46",
    grey_neutral: "#C5C5C8",
    grey_whisper: "#27272F",
    shimmer: "#FFFFFF",
    white: "#050509",
    white_smoke: "#121214",
  },
};

export const AITheme = ({ children }: PropsWithChildren) => {
  const colorScheme = useColorScheme();

  return (
    <StreamTheme style={colorScheme === "dark" ? darkStyle : undefined}>
      {children}
    </StreamTheme>
  );
};
```

Keep the `style` object stable, as here, because `StreamTheme` merges the theme again whenever it gets a new one. Inside the provider, `useTheme()` from the entry point returns the merged theme.

The frame of the bottom sheet, with its drag handle, is white in every theme, because its colors aren't part of the theme. The `sheet` styles apply to the content inside it.

`VoiceWave` and `VoiceGlow` read the `voiceVisualization` keys: `colors`, one to five colors used when a visualization has no `colors` of its own, and `backdropColor`, the color they sit on, which tells them to draw for a light or a dark background. Without `backdropColor` they follow the system color scheme, so set it when the background they sit on doesn't follow it:

```tsx title="VoiceTheme.tsx"
import type { PropsWithChildren } from "react";
import {
  type DeepPartial,
  StreamTheme,
  type Theme,
} from "stream-chat-react-native/ai-components";

const voiceStyle: DeepPartial<Theme> = {
  voiceVisualization: {
    backdropColor: "#0b0b10",
    colors: ["#005fff", "#7b5cff", "#00c2ff"],
  },
};

export const VoiceTheme = ({ children }: PropsWithChildren) => (
  <StreamTheme style={voiceStyle}>{children}</StreamTheme>
);
```

## Migrating from `@stream-io/chat-react-native-ai`

The standalone `@stream-io/chat-react-native-ai` package is deprecated, and the entry point replaces it. The component APIs are the same. To move to it:

1. Uninstall `@stream-io/chat-react-native-ai` and change the imports:

   ```diff
   - import { ComposerView, StreamingMessageView } from "@stream-io/chat-react-native-ai";
   + import { ComposerView, StreamingMessageView } from "stream-chat-react-native/ai-components";
   ```

   With Expo, import from `stream-chat-expo/ai-components`.

2. Turn speech-to-text on for iOS, as described in [Speech-to-text](#speech-to-text). With Expo, replace the `@stream-io/chat-react-native-ai` config plugin with `stream-chat-expo`.
3. Keep the `export-namespace-from` Babel plugin. If you have `@babel/plugin-proposal-export-namespace-from` instead, it works too.
4. Remove uses of `ShimmeringView` and `toHexFromRNColor`, which the entry point doesn't have. `AITypingIndicatorView` draws its shimmer with the SDK's native shimmer view.
5. `SpeechToTextButton` and `isDictationAvailable` are exported, so you can use them on their own.

## Best practices

- Start with the default AI components before building custom UX.
- Keep AI state messaging clear and aligned with backend state names.
- Throttle streaming updates on the backend, so the message list doesn't re-render on every token.
- Ask for microphone and speech recognition permissions only when the user starts voice input.
- Validate model choices and attachments on the backend, not only in the composer.

## How the backend drives the UI

The sections below describe what the backend sends while it writes a reply, and how the app follows it.

### Mark assistant messages with `ai_generated`

The Chat SDK treats a reply from your AI bot like any other message. To render it with `StreamingMessageView` and its typewriter animation, the app needs a way to tell AI replies apart from the rest.

The convention used throughout these guides, the sample apps and the Stream backend SDKs is a custom field, `ai_generated: true`, set by the backend when it creates the assistant's placeholder message:

**Node.js**

```js
const { message } = await channel.sendMessage({
  text: "",
  user_id: botId,
  ai_generated: true,
});
```

**Python**

```py
placeholder = channel.send_message(
    MessageRequest(text="", user_id=bot_id, custom={"ai_generated": True}),
).data.message
```

`ai_generated` is custom message data, so the client puts it under `message.custom`, and the app reads it as `message.custom.ai_generated`. Declare it for TypeScript, together with the `generating` field described below, as optional fields, so messages without them still type-check. [Custom data types](https://getstream.io/chat/docs/sdk/react-native/v10/state/state-and-entities/data-conventions/#custom-data-types) explains the declaration file:

```ts title="stream-custom-data.d.ts"
import "stream-chat";

declare module "stream-chat" {
  interface CustomMessageData {
    ai_generated?: boolean;
    generating?: boolean;
  }
}
```

Use the field in three places:

- **Rendering.** Pass `Chat` an `isMessageAIGenerated` function that returns `!!message.custom?.ai_generated`. For every message it returns `true` for, the message bubble renders `StreamingMessageView` in place of the regular text, which gives the reply its typewriter animation. A message without the flag renders as regular text that jumps to the new text on every update. [SDK integration](https://getstream.io/chat/docs/sdk/react-native/v10/features/ai/sdk-integration/#streamingmessageview) shows the setup, and how to swap in the Markdown view from the AI components.
- **The "Edited" label.** Every update the backend makes to the text marks the message as edited, so `MessageFooter` hides the label for messages that `isMessageAIGenerated` marks. There is nothing to configure.
- **Your backend.** The bot usually listens for new messages in the channel. Checking `ai_generated` lets it skip its own replies instead of answering itself.

The second field the backend sets is `generating`, also under `message.custom` on the client. The SDK doesn't read it. Both `StreamingMessageView` components show the text they mount with at once and then type out whatever the text grows by, so stored replies don't replay the animation when the user scrolls back or reopens the channel. `generating` is still the only field that tells a client a reply is in flight, so set it to `true` on interim updates and `false` on the final one, and read it in your own components, for example to show a cursor while the reply streams.

### Drive the AI indicator from events

Before the first token arrives, the LLM can spend a while thinking or calling tools, and the message is still empty. The backend reports what it's doing with `ai_indicator.*` custom events on the channel. The client records the latest state on the channel, and the `useAIState` hook returns it as `aiState`:

| Event type            | Sent by | `aiState` becomes                                      | Use it to                                                                                                                        |
| --------------------- | ------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| `ai_indicator.update` | Backend | The event's `ai_state`, or `AI_STATE_IDLE` without one | Show the current state of the LLM. The event also carries the `cid`, the `message_id` of the reply and an optional `ai_message`. |
| `ai_indicator.clear`  | Backend | `AI_STATE_IDLE`                                        | Hide the indicator. Sent after the final text is stored.                                                                         |
| `ai_indicator.stop`   | App     | `AI_STATE_STOP`                                        | Ask the backend to stop the reply. See [Let users stop the response](#let-users-stop-the-response).                              |

Compare `aiState` with the `AIStates` constants. The [AI states](#ai-states) section above describes what each state means. The "SDK default" column is what the main entry point's `AITypingIndicatorView` (if you render it) and `MessageComposer` show:

| `ai_state`                  | `AIStates`                 | SDK default                                                  | Suggested UI                                                                          |
| --------------------------- | -------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
| `AI_STATE_THINKING`         | `AIStates.Thinking`        | "Thinking...", and the stop button replaces the send button. | A "Thinking" indicator and a stop button.                                             |
| `AI_STATE_EXTERNAL_SOURCES` | `AIStates.ExternalSources` | Nothing. The indicator hides and the send button comes back. | A "Checking external sources" indicator and a stop button.                            |
| `AI_STATE_GENERATING`       | `AIStates.Generating`      | "Generating...", and the stop button stays.                  | Hide the indicator, the text is now streaming into the message. Keep the stop button. |
| `AI_STATE_ERROR`            | `AIStates.Error`           | Nothing.                                                     | Hide the indicator. The backend writes the error into the message itself.             |

A typical reply sends `AI_STATE_THINKING` right after the placeholder, `AI_STATE_EXTERNAL_SOURCES` while a tool call runs, `AI_STATE_GENERATING` with the first token, and `ai_indicator.clear` once the final text is stored.

To show the suggested UI, combine `useAIState` with the `AITypingIndicatorView` from the AI components, and render it inside `Channel`, between the message list and the composer:

```tsx title="AssistantChannel.tsx"
import { StyleSheet, View } from "react-native";
import { AIStates, type Channel as StreamChannel } from "stream-chat";
import {
  Channel,
  MessageComposer,
  MessageList,
  useAIState,
  useChannel,
} from "stream-chat-react-native";
import { AITypingIndicatorView } from "stream-chat-react-native/ai-components";

const indicatorText: Record<string, string> = {
  [AIStates.Thinking]: "Thinking",
  [AIStates.ExternalSources]: "Checking external sources",
};

const AIIndicator = () => {
  const channel = useChannel();
  const { aiState } = useAIState(channel);
  const text = indicatorText[aiState];

  return text ? (
    <View style={styles.indicator}>
      <AITypingIndicatorView text={text} />
    </View>
  ) : null;
};

// Render inside StreamTheme.
export const AssistantChannel = ({ channel }: { channel: StreamChannel }) => (
  <Channel channel={channel}>
    <MessageList />
    <AIIndicator />
    <MessageComposer />
  </Channel>
);

const styles = StyleSheet.create({
  indicator: { paddingHorizontal: 16, paddingVertical: 8 },
});
```

The main entry point's `AITypingIndicatorView`, described in the [SDK integration](https://getstream.io/chat/docs/sdk/react-native/v10/features/ai/sdk-integration/#aitypingindicatorview), follows the channel's AI state itself, but only shows the thinking and generating states. The one from the AI components only draws the indicator. The [sample app](https://github.com/GetStream/stream-chat-react-native/blob/V10/examples/ExpoChatGPTSample/screens/ChatContent.tsx) uses the same pattern.

A few things to know about `useAIState`:

- **Pass it the channel.** It takes the channel as its argument and doesn't read one from context. Called without one, it returns `AI_STATE_IDLE`. Inside `Channel`, get it from `useChannel()`.
- **It reads the channel's own state.** The AI state lives on the channel, and the client keeps it up to date from the events. The hook reads whatever the client has collected for the channel you pass, so when you switch channels it shows the new channel's current state rather than starting from `AI_STATE_IDLE`.
- **It resets when the connection does.** The client sets every channel's AI state back to `AI_STATE_IDLE` when it closes the connection, which `Chat` does when the app goes to the background, and while the connection is unhealthy. The `ai_indicator.clear` that ends a reply could be missed then, and isn't replayed.
- **`AI_STATE_ERROR` stays.** The Stream backend SDKs don't send `ai_indicator.clear` after an error, so `aiState` stays `AI_STATE_ERROR` until the next reply starts or the connection resets it. Anything you show for it stays up until then.

Indicator events are only delivered live. They aren't stored or replayed, so a client that wasn't receiving the channel's events when they were sent sees no indicator and no stop button until the next event arrives. Use the events for the indicator only, and rely on the message's `text` and `generating` fields, which arrive with every message update, for the reply itself.

### Let users stop the response

A long reply can take a while, and the user may already have what they need or see the model heading the wrong way. The default `MessageComposer` shows `StopMessageStreamingButton` while `aiState` is `AI_STATE_THINKING` or `AI_STATE_GENERATING`. The button takes the place of whatever the trailing control would be, including the send button, the edit button, the cooldown timer and the record button. A tap calls `Channel.stopAIResponse()`, which sends an `ai_indicator.stop` event to the channel. To restyle or remove the button, override it as described in the [SDK integration](https://getstream.io/chat/docs/sdk/react-native/v10/features/ai/sdk-integration/#stopmessagestreamingbutton).

The default composer only checks for thinking and generating, so the stop button goes away during `AI_STATE_EXTERNAL_SOURCES`, while a tool call runs. With `ComposerView`, pass `isGenerating` and a `stopGenerating` callback that calls the same method, as shown in [With `ComposerView`](https://getstream.io/chat/docs/sdk/react-native/v10/features/ai/sdk-integration/#with-composerview), and include `AIStates.ExternalSources` in `isGenerating` if your agent uses tools.

Neither button hides itself on press. They go away once the `ai_indicator.stop` event, or the backend's `ai_indicator.clear`, reaches the channel and moves `aiState` out of the active states. `StreamingMessageView` doesn't listen for the event: it keeps typing out the text it has received.

The stop event carries only the channel's `cid`, not a message id, so on the backend treat it as "stop whatever reply is in flight in this channel". Abort the LLM stream, then finish the same way a normal reply finishes: store the text generated so far with a regular partial update and `generating: false`, then send `ai_indicator.clear`. Skipping that final update leaves the message stuck in its last stored state, as described in the next section. The [Stream Chat AI SDK](https://getstream.io/chat/docs/sdk/react-native/v10/features/ai/stream-chat-ai-sdk/) and the [Stream Chat LangChain SDK](https://getstream.io/chat/docs/sdk/react-native/v10/features/ai/stream-chat-langchain-sdk/) already listen for `ai_indicator.stop` and do this for you.

### Use ephemeral updates while the response is streaming

The components above render whatever your backend writes into the assistant's message. A typical backend posts an empty placeholder message as the AI bot, then keeps updating it as the LLM streams tokens back. Each update reaches the app as a `message.updated` event, and `StreamingMessageView` types out the new text.

How the backend sends those updates matters. A regular partial update (`partialUpdateMessage`) writes the message to the database on every call. A long reply flushed every few tokens turns into dozens or hundreds of writes, and with several conversations running at once you can quickly hit the [rate limits](https://getstream.io/chat/docs/node/rate-limits/) for message updates.

For the updates sent while the response is still streaming, use `ephemeralUpdateMessage` instead:

- It accepts the same payload as a partial update (`set`, `unset` and the acting user).
- It sends the same `message.updated` event to everyone watching the channel, so the live typing in the app looks exactly the same.
- It doesn't store anything in the database, it only sends the event. Skipping the database write is what keeps high-frequency streaming from getting rate limited.

**The last update must not be ephemeral.** When the LLM finishes, send the complete text with a regular partial update and set `generating` to `false`. Ephemeral updates are never stored, so if the final one is ephemeral too, anyone who opens the channel later or relaunches the app loads the last stored version of the message: the empty placeholder, still marked as generating. The same applies to whatever else ends the stream, such as an error message or the user stopping the generation.

`ephemeralUpdateMessage` is available only on the server side. Call it from your backend, not from the app. In Node.js it ships in the server-side `stream-chat` client from 9.27.0, and every server-side SDK exposes an equivalent:

**Node.js**

```js
// While the LLM is streaming: send the latest text without storing it.
await client.ephemeralUpdateMessage(
  messageId,
  { set: { text, generating: true } },
  botId,
);

// Once the LLM has finished: store the final text.
await client.partialUpdateMessage(
  messageId,
  { set: { text, generating: false } },
  botId,
);
```

**Python**

```python
# While the LLM is streaming: send the latest text without storing it.
client.chat.ephemeral_message_update(
    id=message_id,
    set={"text": text, "generating": True},
    user_id=bot_id,
)

# Once the LLM has finished: store the final text.
client.chat.update_message_partial(
    id=message_id,
    set={"text": text, "generating": False},
    user_id=bot_id,
)
```

**Ruby**

```ruby
# While the LLM is streaming: send the latest text without storing it.
client.chat.ephemeral_message_update(message_id, Models::UpdateMessagePartialRequest.new(
  set: { 'text' => text, 'generating' => true },
  user_id: bot_id,
))

# Once the LLM has finished: store the final text.
client.chat.update_message_partial(message_id, Models::UpdateMessagePartialRequest.new(
  set: { 'text' => text, 'generating' => false },
  user_id: bot_id,
))
```

**PHP**

```php
// While the LLM is streaming: send the latest text without storing it.
$client->ephemeralMessageUpdate($messageId, new Models\UpdateMessagePartialRequest(
    set: (object)["text" => $text, "generating" => true],
    userID: $botId,
));

// Once the LLM has finished: store the final text.
$client->updateMessagePartial($messageId, new Models\UpdateMessagePartialRequest(
    set: (object)["text" => $text, "generating" => false],
    userID: $botId,
));
```

**Go**

```go
// While the LLM is streaming: send the latest text without storing it.
_, err := client.Chat().EphemeralMessageUpdate(ctx, messageID, &getstream.EphemeralMessageUpdateRequest{
    Set: getstream.PtrTo(map[string]any{
        "text":       text,
        "generating": true,
    }),
    UserID: getstream.PtrTo(botID),
})

// Once the LLM has finished: store the final text.
_, err = client.Chat().UpdateMessagePartial(ctx, messageID, &getstream.UpdateMessagePartialRequest{
    Set: getstream.PtrTo(map[string]any{
        "text":       text,
        "generating": false,
    }),
    UserID: getstream.PtrTo(botID),
})
```

**C#**

```csharp
// While the LLM is streaming: send the latest text without storing it.
await chat.EphemeralMessageUpdateAsync(messageId, new UpdateMessagePartialRequest
{
    Set = new Dictionary<string, object>
    {
        { "text", text },
        { "generating", true },
    },
    UserID = botId,
});

// Once the LLM has finished: store the final text.
await chat.UpdateMessagePartialAsync(messageId, new UpdateMessagePartialRequest
{
    Set = new Dictionary<string, object>
    {
        { "text", text },
        { "generating", false },
    },
    UserID = botId,
});
```

**Java**

```java
// While the LLM is streaming: send the latest text without storing it.
chat.ephemeralMessageUpdate(messageId, EphemeralMessageUpdateRequest.builder()
    .set(Map.of("text", text, "generating", true))
    .userID(botId)
    .build()).execute();

// Once the LLM has finished: store the final text.
chat.updateMessagePartial(messageId, UpdateMessagePartialRequest.builder()
    .set(Map.of("text", text, "generating", false))
    .userID(botId)
    .build()).execute();
```

Even ephemeral updates are delivered to every device watching the channel, so don't send one per token. Flushing every 20 or so chunks, or at most every 50 to 100 ms, keeps the typing smooth without wasting bandwidth. The [AI message streaming](https://getstream.io/chat/docs/node/ai-message-streaming/) guide covers the full backend flow, including throttling and the AI indicator events.

If you use the [Stream Chat AI SDK](https://getstream.io/chat/docs/sdk/react-native/v10/features/ai/stream-chat-ai-sdk/) on your backend, this is already handled for you: it sends ephemeral updates while streaming and stores the final text with a regular partial update. The [Stream Chat LangChain SDK](https://getstream.io/chat/docs/sdk/react-native/v10/features/ai/stream-chat-langchain-sdk/) stores every update with a regular partial update, the interim ones included.

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react-native/v10/features/ai/ai-integrations-overview/](https://getstream.io/chat/docs/sdk/react-native/v10/features/ai/ai-integrations-overview/).