SDK integration
The main stream-chat-react-native entry point has three parts for AI replies: StreamingMessageView types out a reply as it streams, AITypingIndicatorView shows what the LLM is doing, and the default composer's StopMessageStreamingButton lets the user stop it. This page shows how to turn each on, and how to use the components from stream-chat-react-native/ai-components in their place. The AI integrations overview covers those components, and how the backend drives all of this. With Expo, import from stream-chat-expo and stream-chat-expo/ai-components instead.
For example, the user asks the support bot a question:
- The bot posts an empty reply, marked
ai_generated, and sendsai_indicator.updatewithAI_STATE_THINKING. - The client sets the channel's AI state to thinking.
AITypingIndicatorViewshows "Thinking...", and the composer's send button becomes the stop button. - The bot writes the answer into the reply, a few words at a time.
StreamingMessageViewtypes each new part out. - The bot stores the final text and sends
ai_indicator.clear. The indicator goes away, and the send button comes back.
Best practices
- Mark AI replies on the backend, and tell
Chathow to recognize them withisMessageAIGenerated, so every message list renders them the same way. - Show an indicator only while the LLM is busy: thinking, checking sources or generating.
- Keep a way to stop a long reply. The default composer has one, and a composer of your own needs its own button.
- Keep the default message layout around streamed text unless you need a message component of your own.
- Validate attachments and model choices on the backend, not only in the composer.
StreamingMessageView
The message bubble renders StreamingMessageView in place of the regular message text for every message that isMessageAIGenerated returns true for. It shows the text the message has when it mounts, then types out whatever the text grows by. A reply already stored doesn't replay the animation when the user scrolls back to it. It renders the text with the same Markdown rules as any other message.
Pass isMessageAIGenerated to Chat. AI replies carry the custom field ai_generated, which the client puts under message.custom:
import type { PropsWithChildren } from "react";
import type { LocalMessage, StreamChat } from "stream-chat";
import { Chat, OverlayProvider } from "stream-chat-react-native";
// Declared outside the component, so Chat gets the same function on every render.
const isMessageAIGenerated = (message: LocalMessage) =>
!!message.custom?.ai_generated;
export const App = ({
children,
client,
}: PropsWithChildren<{ client: StreamChat }>) => (
<OverlayProvider>
<Chat client={client} isMessageAIGenerated={isMessageAIGenerated}>
{children}
</Chat>
</OverlayProvider>
);Chat puts the function in its context, and every message reads it from there. A new function on every render would re-render every message on screen. For TypeScript, declare the field, optional, in CustomMessageData:
import "stream-chat";
declare module "stream-chat" {
interface CustomMessageData {
ai_generated?: boolean;
generating?: boolean;
}
}Mark assistant messages with ai_generated explains both fields and how the backend sets them. MessageFooter also hides the "Edited" label on these messages, since every streamed update edits them.
The bubble places the AI text through the 'ai_text' entry of messageContentOrder, and the 'text' entry skips AI messages. The default order has both. If you pass your own messageContentOrder to Channel or Thread, include 'ai_text', or AI replies render without text.
The default component takes letterInterval (milliseconds between ticks, 0 by default) and renderingLetterCount (letters per tick, 2 by default), but the bubble renders it without props. To change the speed, override it with a component that renders StreamingMessageView with your values, or that builds on the useStreamingMessage({ text, letterInterval, renderingLetterCount }) hook, which returns streamedMessageText.
Using the AI components' StreamingMessageView instead
Replace StreamingMessageView through WithComponents. This one renders the text with StreamingMessageView from stream-chat-react-native/ai-components, which adds code highlighting with a copy button, tables and charts:
import type { PropsWithChildren } from "react";
import { StyleSheet, View } from "react-native";
import { useMessageContext, WithComponents } from "stream-chat-react-native";
import {
StreamingMessageView,
StreamTheme,
} from "stream-chat-react-native/ai-components";
const MarkdownStreamingMessageView = () => {
const { message } = useMessageContext();
return (
<View style={styles.text}>
<StreamingMessageView text={message.text ?? ""} />
</View>
);
};
const overrides = { StreamingMessageView: MarkdownStreamingMessageView };
export const WithMarkdownReplies = ({ children }: PropsWithChildren) => (
<StreamTheme>
<WithComponents overrides={overrides}>{children}</WithComponents>
</StreamTheme>
);
const styles = StyleSheet.create({
text: { paddingHorizontal: 12, paddingVertical: 8 },
});The override receives no props: it reads the message from useMessageContext(). The components from the AI entry point throw outside StreamTheme, so keep one above them, as here, or higher in the app. Usage lists what the entry point needs installed.
A message component of your own
The override above still renders inside the default message, with its avatar, reactions and footer. To render AI replies differently, replace MessageItemView, and keep the default for every other message:
import type { PropsWithChildren } from "react";
import { StyleSheet, View } from "react-native";
import {
MessageItemView,
type MessageItemViewProps,
useChatContext,
useMessageContext,
WithComponents,
} from "stream-chat-react-native";
import {
StreamingMessageView,
StreamTheme,
} from "stream-chat-react-native/ai-components";
const AIAwareMessageItemView = (props: MessageItemViewProps) => {
const { isMessageAIGenerated } = useChatContext();
const { message } = useMessageContext();
if (!isMessageAIGenerated(message)) return <MessageItemView {...props} />;
// A full-width reply without a bubble.
return (
<View style={styles.aiReply}>
<StreamingMessageView text={message.text ?? ""} />
</View>
);
};
const overrides = { MessageItemView: AIAwareMessageItemView };
export const WithAIMessages = ({ children }: PropsWithChildren) => (
<StreamTheme>
<WithComponents overrides={overrides}>{children}</WithComponents>
</StreamTheme>
);
const styles = StyleSheet.create({
aiReply: { paddingHorizontal: 16, paddingVertical: 8, width: "100%" },
});The AI reply then renders only its text: it has no attachments, reactions or long-press menu. Overriding components explains the override keys, and Message list UI lists the parts of a message.
AITypingIndicatorView
AITypingIndicatorView shows "Thinking..." while the channel's AI state is AI_STATE_THINKING, and "Generating..." while it's AI_STATE_GENERATING. In every other state it renders nothing.
No SDK component renders it, so render it yourself, inside Channel. Between the message list and the composer, it sits just above where the user types:
import type { Channel as StreamChannel } from "stream-chat";
import {
AITypingIndicatorView,
Channel,
MessageComposer,
MessageList,
} from "stream-chat-react-native";
export const AssistantChannel = ({ channel }: { channel: StreamChannel }) => (
<Channel channel={channel}>
<MessageList />
<AITypingIndicatorView />
<MessageComposer />
</Channel>
);It reads the channel from Channel. Its channel prop shows another channel's state instead, but it still has to render inside a Channel or a Thread. To show other states, such as AI_STATE_EXTERNAL_SOURCES, read the state with useAIState(channel), as Drive the AI indicator from events shows.
The two texts are the translation keys aiTypingIndicator.thinking.label and aiTypingIndicator.generating.label, described in Localization. The aiTypingIndicatorView key of the theme styles its container and text, as described in Theming. With accessibility on, and announceTypingIndicator set in the accessibility config of OverlayProvider, screen readers announce the state when it changes. Accessibility describes the config.
StopMessageStreamingButton
While the channel's AI state is AI_STATE_THINKING or AI_STATE_GENERATING, the composer shows StopMessageStreamingButton in place of the send button. It also takes the place of the edit button, the cooldown timer and the record button, as The send button and its replacements lists. Pressing it calls Channel.stopAIResponse(), which sends ai_indicator.stop to the channel. The backend stops the reply when it gets the event.
Replace it through WithComponents. Your component receives the onPress that stops the reply:
import type { PropsWithChildren } from "react";
import { Pressable, StyleSheet, Text } from "react-native";
import {
type StopMessageStreamingButtonProps,
WithComponents,
} from "stream-chat-react-native";
const StopButton = ({ onPress }: StopMessageStreamingButtonProps) => (
<Pressable
accessibilityLabel="Stop the reply"
accessibilityRole="button"
onPress={onPress}
style={styles.button}
>
<Text>Stop</Text>
</Pressable>
);
const overrides = { StopMessageStreamingButton: StopButton };
export const WithStopButton = ({ children }: PropsWithChildren) => (
<WithComponents overrides={overrides}>{children}</WithComponents>
);
const styles = StyleSheet.create({
button: { padding: 8 },
});The default button draws the Stop icon, which you can replace through the icons key of the overrides instead. The stopMessageStreamingButton and stopMessageStreamingIcon keys under messageComposer in the theme style it.
To remove the button, set the key to undefined. The composer then shows its usual buttons while the reply is generated, so the user can send a message, and nothing in the composer stops the reply:
import type { PropsWithChildren } from "react";
import { WithComponents } from "stream-chat-react-native";
import type { ComponentOverrides } from "stream-chat-react-native";
// Declared once: WithComponents reads its overrides when it mounts.
const overrides: ComponentOverrides = { StopMessageStreamingButton: undefined };
export const WithoutStopButton = ({ children }: PropsWithChildren) => (
<WithComponents overrides={overrides}>{children}</WithComponents>
);The overrides are spread over the defaults, so a key set to undefined replaces the default rather than falling back to it. null isn't accepted: the key is typed as a component.
Let users stop the response lists when the default button doesn't show, and what the backend does with the event.
With ComposerView
To use ComposerView as the composer, render it inside Channel in place of MessageComposer, and send what it collects through the channel's message composer. Sending and uploads then work as with the default composer. ComposerView keeps the text in its own state, and the text reaches the message composer only on send, so drafts, mentions and commands don't apply to it. ComposerView takes the stop button as props, so this one passes the AI state:
import { AIStates, type AIState } from "stream-chat";
import {
useAIState,
useChannel,
useMessageComposer,
useMessageInputContext,
useStableCallback,
} from "stream-chat-react-native";
import {
ComposerView,
type ComposerViewProps,
} from "stream-chat-react-native/ai-components";
type SendData = Parameters<ComposerViewProps["onSendMessage"]>[0];
const activeStates: AIState[] = [
AIStates.Thinking,
AIStates.ExternalSources,
AIStates.Generating,
];
// Render inside StreamTheme, and inside Channel in place of MessageComposer.
export const AIComposer = () => {
const channel = useChannel();
const { aiState } = useAIState(channel);
const messageComposer = useMessageComposer();
const { sendMessage } = useMessageInputContext();
const onSendMessage = useStableCallback(
async ({ attachments, text }: SendData) => {
messageComposer.textComposer.setText(text);
if (attachments?.length) {
await messageComposer.attachmentManager.uploadFiles(
attachments.map((asset) => ({
...asset,
// The pickers can leave these out, and an upload needs them.
name: asset.name ?? asset.uri.split("/").pop() ?? "image.jpg",
size: asset.size ?? 0,
type: asset.type ?? "image/jpeg",
})),
);
}
await sendMessage();
},
);
const stopGenerating = useStableCallback(() => channel.stopAIResponse());
return (
<ComposerView
isGenerating={activeStates.includes(aiState)}
onSendMessage={onSendMessage}
stopGenerating={stopGenerating}
/>
);
};How it fits together:
- Text. On send, the component copies the text into the message composer with
TextComposer.setText(), then callssendMessage()fromuseMessageInputContext(). That is the same call the default composer makes: it sends withMessageComposer.send(), and stops with an alert when the text has a link and the channel doesn't allow links. - Attachments. The photos picked in
ComposerVieware uploaded through the message composer'sattachmentManager, and the message is sent once the uploads finish. To send the message at once and let the uploads finish after it, see Sending with pending uploads. - Stopping.
isGeneratingswaps the send button for the stop button. This one also includesAI_STATE_EXTERNAL_SOURCES, so the button stays while a tool call runs.
ComposerView clears its input before it calls onSendMessage. The sample app renders its channel screen this way. Usage lists what the entry point needs installed.
VoiceGlow
VoiceGlow, from the AI components, can show what the channel's agent is doing, for example in a voice mode or next to the composer. Map the channel's AI state, from useAIState, to the glow's state:
import { AIStates } from "stream-chat";
import { useAIState, useChannel } from "stream-chat-react-native";
import {
type VoiceAgentState,
VoiceGlow,
} from "stream-chat-react-native/ai-components";
const voiceStates: Record<string, VoiceAgentState> = {
[AIStates.Thinking]: "thinking",
[AIStates.ExternalSources]: "thinking",
[AIStates.Generating]: "speaking",
};
// Render inside StreamTheme, and inside Channel.
export const AgentGlow = () => {
const channel = useChannel();
const { aiState } = useAIState(channel);
return (
<VoiceGlow
state={voiceStates[aiState] ?? "idle"}
style={{ width: 160, height: 160 }}
/>
);
};While the agent's reply is read aloud, set the state to "speaking", and to "listening" while the user dictates, with useDictationAudioLevel as the audioLevel of a VoiceWave. The ExpoChatGPTSample has a complete hands-free voice mode built this way.