AI integrations overview
The AI UI components are built for AI-first React Native apps. Paired with the real-time Chat API, 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:
StreamingMessageViewrenders Markdown, code and charts, and types out the text as it grows.ComposerViewis a prompt composer with photo attachments, a bottom sheet of actions and speech input.SpeechToTextButtonrecords the user's voice and transcribes it with the platform's speech recognizer.AITypingIndicatorViewshows what the LLM is doing, such as "Thinking", with a shimmer over the text.VoiceWaveandVoiceGlowshow what a voice agent is doing as animated visualizations, anduseDictationAudioLevelmoves 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 app in the SDK repository is a ChatGPT-style app built with these components.
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:
yarn add victory-native @shopify/react-native-skianpx expo install victory-native @shopify/react-native-skiareact-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:
yarn add -D @babel/plugin-transform-export-namespace-fromThen add it to babel.config.js, before the worklets plugin:
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:
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.
Coming from the standalone @stream-io/chat-react-native-ai package? See Migrating from @stream-io/chat-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:
$StreamChatEnableDictation = trueRunning STREAM_CHAT_ENABLE_DICTATION=1 pod install works as well. Then add the usage descriptions to Info.plist, with texts of your own:
<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:
<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:
{
"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-pickerfor React Native CLI. For the camera, addNSCameraUsageDescriptiontoInfo.plistand theandroid.permission.CAMERApermission toAndroidManifest.xml.expo-image-pickerfor Expo. Its config plugin takes thephotosPermissionandcameraPermissiontexts.
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, and with replies your app gets from elsewhere. The developer guide 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. |
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:
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 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
jsonblock that holds a Vega-Lite spec with abar,line,area,pointorarcmark; - a
mermaidblock with apiediagram.
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, 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:
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
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 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
isGeneratingis 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
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.
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:
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. 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, 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 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 aPressablearound 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 fromvoiceVisualization.backdropColor, or the system color scheme when it isn't set. See 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 describes:
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
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.
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
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 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 to be set up.
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:
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) reads it. Pass it the channel, for example from useChannel(). 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 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 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:
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:
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:
-
Uninstall
@stream-io/chat-react-native-aiand change the imports:- 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. -
Turn speech-to-text on for iOS, as described in Speech-to-text. With Expo, replace the
@stream-io/chat-react-native-aiconfig plugin withstream-chat-expo. -
Keep the
export-namespace-fromBabel plugin. If you have@babel/plugin-proposal-export-namespace-frominstead, it works too. -
Remove uses of
ShimmeringViewandtoHexFromRNColor, which the entry point doesn't have.AITypingIndicatorViewdraws its shimmer with the SDK's native shimmer view. -
SpeechToTextButtonandisDictationAvailableare 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:
const { message } = await channel.sendMessage({
text: "",
user_id: botId,
ai_generated: true,
});placeholder = channel.send_message(
MessageRequest(text="", user_id=bot_id, custom={"ai_generated": True}),
).data.messageai_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 explains the declaration file:
import "stream-chat";
declare module "stream-chat" {
interface CustomMessageData {
ai_generated?: boolean;
generating?: boolean;
}
}Use the field in three places:
- Rendering. Pass
ChatanisMessageAIGeneratedfunction that returns!!message.custom?.ai_generated. For every message it returnstruefor, the message bubble rendersStreamingMessageViewin 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 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
MessageFooterhides the label for messages thatisMessageAIGeneratedmarks. There is nothing to configure. - Your backend. The bot usually listens for new messages in the channel. Checking
ai_generatedlets 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. |
Compare aiState with the AIStates constants. The 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:
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, 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 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. InsideChannel, get it fromuseChannel(). - 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_IDLEwhen it closes the connection, whichChatdoes when the app goes to the background, and while the connection is unhealthy. Theai_indicator.clearthat ends a reply could be missed then, and isn't replayed. AI_STATE_ERRORstays. The Stream backend SDKs don't sendai_indicator.clearafter an error, soaiStatestaysAI_STATE_ERRORuntil 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.
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, 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 and the 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 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,unsetand the acting user). - It sends the same
message.updatedevent 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:
// 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,
);# 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,
)# 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,
))// 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,
));// 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),
})// 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,
});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 guide covers the full backend flow, including throttling and the AI indicator events.
If you use the 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 stores every update with a regular partial update, the interim ones included.