Build low-latency Vision AI applications using our new open-source Vision AI SDK. ⭐️ on GitHub ->

Build an AI Assistant with React Native

Ivan Sekovanikj
Ivan Sekovanikj
Published December 5, 2025

In this tutorial, we will demonstrate how easy it is to create an AI assistant for React Native using Stream Chat. As an example, we will leverage the StreamChat integration with Vercel's AI SDK; however, developers are free to use whichever LLM provider they like and still benefit from Stream's rich UI support for Markdown, tables, code samples, charts etc. To follow along with this tutorial, we recommend creating a free account and checking out our main React Native Chat SDK tutorial as a refresher.

Running the Backend

Before adding AI features to our iOS app, let's set up our node.js backend. The backend will expose two methods for starting and stopping an AI agent for a particular channel. If the agent is started, it listens to all new messages and sends them to the LLM provider of your choice. It provides the results by sending a message and updating its text.

The sample also supports sending different states of the typing indicator (for example, ThinkingChecking external sources, etc), client-side MCP tools, suggestions, summaries, memory with mem0 and much more.

You can find a working implementation of the backend here.

1. Install dependencies

bash
1
npm install @stream-io/chat-ai-sdk express cors dotenv

@stream-io/chat-ai-sdk brings the Agent, AgentManager, tool helpers, and the streaming logic. Express/cors/dotenv provide the basic HTTP server.

2. Configure Stream credentials

Create a .env file with:

STREAM_API_KEY=your_key
STREAM_API_SECRET=your_secret
OPENAI_API_KEY=your_open_api_key
ANTHROPIC_API_KEY=your_anthropic_api_key
XAI_API_KEY=your_xai_api_key
GOOGLE_API_KEY=your_google_api_key
MEM0_API_KEY=your_mem0_api_key

Apart from the Stream API key and secret, every other API key is optional. You would need to add at least one LLM service, for the AI to work.

Load it at the top of your entry file:

typescript
1
import 'dotenv/config';

3. Bootstrap Express and the AgentManager

Add the following to your index.ts (or similar):

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import express from 'express'; import cors from 'cors'; import { AgentManager, AgentPlatform, createDefaultTools, } from '@stream-io/chat-ai-sdk'; const app = express(); app.use(express.json()); app.use(cors({ origin: '*' })); const buildAgentUserId = (channelId: string): string => `ai-bot-${channelId.replace(/!/g, '')}`; const agentManager = new AgentManager({ serverToolsFactory: () => createDefaultTools(), agentIdResolver: buildAgentUserId, }); const normalizeChannelId = (raw: string): string => { const trimmed = raw?.trim() ?? ''; if (!trimmed) return trimmed; const parts = trimmed.split(':'); return parts.length > 1 ? parts[1] : trimmed; };

AgentManager owns the agent cache, pending state, and inactivity cleanup. Each channel uses an ID pattern such as ai-bot-{channelId}.

4. Starting an Agent

Next, let's add the endpoint that will start the AI agent. First, we need to validate the payload, normalize the channel, and then ask the AgentManager to start or reuse the agent:

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
app.post('/start-ai-agent', async (req, res) => { const { channel_id, channel_type = 'messaging', platform = AgentPlatform.ANTHROPIC, model, } = req.body; if (!channel_id) { res.status(400).json({ error: 'Missing channel_id' }); return; } const channelId = normalizeChannelId(channel_id); if (!channelId) { res.status(400).json({ error: 'Invalid channel_id' }); return; } try { await agentManager.startAgent({ userId: buildAgentUserId(channelId), channelId, channelType: channel_type, platform, model, }); res.json({ message: 'AI Agent started' }); } catch (error) { res.status(500).json({ error: 'Failed to start AI Agent', reason: (error as Error).message, }); } });

5. Stopping an Agent

To stop the agent and clean the cache, we can do the following:

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
app.post('/stop-ai-agent', async (req, res) => { const channelId = normalizeChannelId(req.body?.channel_id ?? ''); if (!channelId) { res.status(400).json({ error: 'Invalid channel_id' }); return; } try { await agentManager.stopAgent(buildAgentUserId(channelId)); res.json({ message: 'AI Agent stopped' }); } catch (error) { res.status(500).json({ error: 'Failed to stop AI Agent', reason: (error as Error).message, }); } });

6. Register Client Side Tools

Next, we can expose an endpoint to allow clients to register MCP tools that can be handled client-side:

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
app.post('/register-tools', (req, res) => { const { channel_id, tools } = req.body ?? {}; if (typeof channel_id !== 'string' || !channel_id.trim()) { res.status(400).json({ error: 'Missing or invalid channel_id' }); return; } if (!Array.isArray(tools)) { res.status(400).json({ error: 'Missing or invalid tools array' }); return; } const channelId = normalizeChannelId(channel_id); agentManager.registerClientTools(channelId, tools); res.json({ message: 'Client tools registered', count: tools.length }); });

7. Start the server

Add the log when the server is started.

typescript
1
2
3
4
const port = process.env.PORT || 3000; app.listen(port, () => { console.log(`Server is running on <http://localhost>:${port}`); });

You can start the server by running:

npm start

React Native Integration

Next, let’s setup things on on the JS side. You can find a working implementation of this project here.

1. Project Setup

In order to follow this tutorial, we must ensure a minimum version of 8.10.0 of the Stream Chat React Native SDK. It also expects that a minimum version of React Native 0.76.0 is used, with new architecture enabled.

As a final note, this tutorial will be done with React Native Community CLI - however, feel free to replicate it with Expo as well, and the results will be the same.

First, we will create and set up a new React Native project using our SDK. Let's initialize a new React Native project:

bash
1
2
npx @react-native-community/cli@latest init RNChatAISample cd RNChatAISample

Next, let’s install our Chat SDK:

bash
1
2
yarn add stream-chat-react-native yarn add stream-chat

As well as its required dependencies:

bash
1
yarn add @react-native-community/netinfo react-native-fs react-native-gesture-handler react-native-reanimated react-native-worklets react-native-svg

In order to use our new AI SDK, we’ll install it as well including its missing dependencies:

bash
1
yarn add @stream-io/chat-react-native-ai victory-native @shopify/react-native-skia react-native-image-picker @react-native-clipboard/clipboard @babel/plugin-proposal-export-namespace-from

We will additionally need to make sure the required babel plugins are included in the babel.config.js file, like so:

bash
1
2
3
4
5
6
7
module.exports = { presets: ['module:@react-native/babel-preset'], plugins: [ '@babel/plugin-proposal-export-namespace-from', 'react-native-worklets/plugin', ], };

Make sure that react-native-worklets/plugin is always listed last.

Finally, let’s also add the required Android and iOS permissions to make sure we can use the full extent of our new AI features.

First, let’s add them for iOS within ios/RNChatAISample/Info.plist:

bash
1
2
3
4
5
6
7
8
<key>NSPhotoLibraryUsageDescription</key> <string>$(PRODUCT_NAME) would like access to your photo gallery to share an image in a message.</string> <key>NSCameraUsageDescription</key> <string>$(PRODUCT_NAME) would like to use your camera to share an image in a message.</string> <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>

and then for Android within android/app/src/main/AndroidManifest.xml:

bash
1
2
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />

Finally, since we’ll need a navigation library you can install React Navigation as described in their documentation.

bash
1
2
3
yarn add @react-navigation/native yarn add react-native-screens react-native-safe-area-context yarn add @react-navigation/drawer

Finally, to install everything you may run:

bash
1
2
yarn install npx pod-install

at the root of your new project.

Get started! Activate your free Stream account today and start prototyping your chat app.

After all this, we can run the app by running yarn start —reset-cache to start Metro and running yarn run ios for iOS or yarn run android for Android and we should see the React Native welcome screen.

2. Backend Interaction APIs

Before we dive into implementing the actual chat screens, let’s create some utilities that will help us do HTTP requests towards our backend.

To do this, we’ll introduce two more files:

  • http/api.ts
typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export const post = async (url: string, data: unknown = {}) => { try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(data), }); if (!response.ok) { throw new Error(`An HTTP Error has occurred. Status: ${response.status}`); } return await response.json(); } catch (error) { console.error('Error:', error); } };
  • http/requests.ts
bash
1
2
3
4
5
6
7
8
import { post } from './api.ts'; export const startAI = async (channelId: string) => post('http://localhost:3000/start-ai-agent', { channel_id: channelId }); export const stopAI = async (channelId: string) => post('http://localhost:3000/stop-ai-agent', { channel_id: channelId }); export const summarize = async (text: string) => post('http://localhost:3000/summarize', { text });

3. Setting up the MessageList

Since we want to already begin talking to the LLM, let’s begin by building our main chat screen.

For this, we will use the Channel and MessageList components from stream-chat-react-native. We will also implement a DrawerNavigator to hold the navigation together.

To do this, let’s first create a couple of new files for clarity:

  • ./chatConfig.ts
bash
1
2
3
4
export const chatApiKey = 'zcgvnykxsfm8'; export const chatUserId = 'rn-chatgpt-demo-test-user'; export const chatUserToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicm4tY2hhdGdwdC1kZW1vLXRlc3QtdXNlciJ9.cE-FOtn8jz7k4ddRJ1kCA7g456tTEwxXEn4adka1p2s'; export const chatUserName = 'Bob';
  • contexts/AppContext.tsx
bash
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import React, { PropsWithChildren, useMemo, useState } from 'react'; import { Channel, StreamChat } from 'stream-chat'; import { chatUserId } from '../chatConfig.ts'; export type AppContextValue = { channel: Channel | undefined; setChannel: (channel: Channel) => void; }; export const AppContext = React.createContext<AppContextValue>({ setChannel: () => {}, channel: undefined, }); const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-'; export const nanoid = (size: number = 21): string => { let id = ''; for (let i = 0; i < size; i++) { const r = Math.floor(Math.random() * ALPHABET.length); id += ALPHABET[r]; } return id; }; export const createChannel = (client: StreamChat) => client.channel('messaging', nanoid(), { members: [chatUserId], }); export const AppProvider = ({ client, children, }: PropsWithChildren<{ client: StreamChat }>) => { const [channel, setChannel] = useState<Channel>(() => createChannel(client)); const contextValue = useMemo(() => ({ channel, setChannel }), [channel]); return ( <AppContext.Provider value={contextValue}>{children}</AppContext.Provider> ); }; export const useAppContext = () => React.useContext(AppContext);

This will allow us to easily consume whichever Channel is currently selected and initialize the chat with a brand new one every time we open the app.

Next, let’s tie everything together in App.tsx:

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import { NavigationContainer } from '@react-navigation/native'; import { createDrawerNavigator } from '@react-navigation/drawer'; import { StyleSheet, View } from 'react-native'; import { SafeAreaProvider, useSafeAreaInsets, } from 'react-native-safe-area-context'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import React from 'react'; import { Chat, OverlayProvider, useCreateChatClient, } from 'stream-chat-react-native'; import { AppProvider } from './contexts/AppContext.tsx'; import { chatApiKey, chatUserId, chatUserName, chatUserToken, } from './chatConfig.ts'; import { StreamTheme } from '@stream-io/chat-react-native-ai'; import { ChatContent } from './screens/ChatContent.tsx'; const Drawer = createDrawerNavigator(); const DrawerNavigator = () => ( <Drawer.Navigator screenOptions={{ drawerStyle: { width: 300, }, }} > <Drawer.Screen name="Chat" component={ChatContent} /> </Drawer.Navigator> ); const App = () => { const chatClient = useCreateChatClient({ apiKey: chatApiKey, tokenOrProvider: chatUserToken, userData: { id: chatUserId, name: chatUserName }, }); if (!chatClient) { return null; } return ( <SafeAreaProvider> <AppProvider client={chatClient}> <StreamTheme> <GestureHandlerRootView style={styles.container}> <OverlayProvider> <Chat client={chatClient}> <NavigationContainer> <DrawerNavigator /> </NavigationContainer> </Chat> </OverlayProvider> </GestureHandlerRootView> </StreamTheme> </AppProvider> </SafeAreaProvider> ); }; const styles = StyleSheet.create({ container: { flex: 1 }, }); export default App;

It is now time to implement our UI that resembles ChatGPT. But first, we want to let the SDK know how we resolve AI messages. Since the AI backend is currently set up to return the custom field ai_generated together with our message, we will check for that. However, you can check for any additional fields here to determine whether the message is AI-generated or not.

This can be done using the isMessageAIGenerated on the Chat component we created earlier, like so:

bash
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { LocalMessage } from 'stream-chat'; // ...other imports const isMessageAIGenerated = (message: LocalMessage) => !!message.ai_generated; // ... other code <Chat client={chatClient} isMessageAIGenerated={isMessageAIGenerated} > <NavigationContainer> <DrawerNavigator /> </NavigationContainer> </Chat>

and additionally add the new custom type, as explained here, as custom-types.d.ts at our root level:

bash
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { DefaultAttachmentData, DefaultChannelData, DefaultCommandData, DefaultEventData, DefaultMemberData, DefaultMessageData, DefaultPollData, DefaultPollOptionData, DefaultReactionData, DefaultThreadData, DefaultUserData, } from 'stream-chat-react-native'; declare module 'stream-chat' { interface CustomAttachmentData extends DefaultAttachmentData {} interface CustomChannelData extends DefaultChannelData {} interface CustomCommandData extends DefaultCommandData {} interface CustomEventData extends DefaultEventData {} interface CustomMemberData extends DefaultMemberData {} interface CustomUserData extends DefaultUserData {} interface CustomMessageData extends DefaultMessageData { ai_generated?: boolean; } interface CustomPollOptionData extends DefaultPollOptionData {} interface CustomPollData extends DefaultPollData {} interface CustomReactionData extends DefaultReactionData {} interface CustomThreadData extends DefaultThreadData {} }

Since this is done, our SDK is aware of an AI-generated message, and we can proceed further.

Let us now create a new file, screens/ChatContent.tsx which will contain our new MessageList and add a couple of code snippets to it:

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
const CustomMessage = (props: MessageProps) => { const { theme } = useTheme(); const { message } = props; const isFromBot = message.ai_generated; const hasPendingAttachments = useMemo( () => (message.attachments ?? []).some( attachment => (attachment.image_url && isLocalUrl(attachment.image_url)) || (attachment.asset_url && isLocalUrl(attachment.asset_url)), ), [message.attachments], ); const modifiedTheme = useMemo( () => mergeThemes({ theme, style: { messageSimple: isFromBot ? { content: { containerInner: { backgroundColor: 'transparent', borderRadius: 0, borderColor: 'transparent', }, }, } : { wrapper: { opacity: hasPendingAttachments ? 0.5 : 1, }, }, }, }), [theme, isFromBot, hasPendingAttachments], ); return ( <ThemeProvider mergedStyle={modifiedTheme}> <Message {...props} preventPress={true} /> </ThemeProvider> ); };

This component does 2 things:

  • It overrides the styles of messages specifically from the chatbot so that they’re essentially full width within the MessageList (similar to how they’re displayed in ChatGPT for instance)
  • It makes sure to reduce the opacity of the messages send by us in the event of having pending attachments to upload, so that we can signify that the message hasn’t been sent yet
typescript
1
2
3
4
5
6
7
8
const CustomStreamingMessageView = () => { const { message } = useMessageContext(); return ( <View style={styles.streamingMessageViewWrapper}> <StreamingMessageView text={message.text ?? ''} /> </View> ); };

This component overrides the default StreamingMessageView provided by stream-chat-react-native and displays the enriched StreamingMessageView from the AI SDK.

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
const CustomComposerView = () => { const messageComposer = useMessageComposer(); const { sendMessage } = useMessageInputContext(); const { channel } = useChannelContext(); const { aiState } = useAIState(channel); const stopGenerating = useCallback( () => channel?.stopAIResponse(), [channel], ); const isGenerating = [AIStates.Thinking, AIStates.Generating].includes( aiState, ); const safeAreaInsets = useSafeAreaInsets(); const insets = useMemo( () => ({ ...safeAreaInsets, bottom: safeAreaInsets.bottom + (Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) * 2 : 0), }), [safeAreaInsets], ); const serializeToMessage = useStableCallback( async ({ text, attachments }: { text: string; attachments?: any[] }) => { messageComposer.textComposer.setText(text); if (attachments && attachments.length > 0) { const localAttachments = await Promise.all( attachments.map(a => messageComposer.attachmentManager.fileToLocalUploadAttachment(a), ), ); messageComposer.attachmentManager.upsertAttachments(localAttachments); } await sendMessage(); }, ); return ( <ComposerView bottomSheetInsets={insets} onSendMessage={serializeToMessage} isGenerating={isGenerating} stopGenerating={stopGenerating} /> ); };

This component is a thin wrapper around the ComposerView from the AI SDK, making sure we update the SDK state before we send the message and we pass down the AIState of our channel downwards to it.

typescript
1
2
3
4
5
const EmptyStateIndicator = () => ( <View style={styles.emptyContainer}> <Text style={styles.emptyContainerText}>What can I help with ?</Text> </View> );

This customization allows us to display some default UI for a “headless” channel (an empty channel which we have not created yet, but want to use to start a new chat with the bot).

And finally, let’s tie it all together:

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
import { AIStates, Channel, isLocalUrl, mergeThemes, Message, MessageList, MessageProps, ThemeProvider, useAIState, useChannelContext, useMessageComposer, useMessageContext, useMessageInputContext, useStableCallback, useTheme, } from 'stream-chat-react-native'; import { useAppContext } from '../contexts/AppContext.tsx'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { startAI } from '../http/requests.ts'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { Platform, StatusBar, StyleSheet, Text, View } from 'react-native'; import { ComposerView, ComposerViewProps, StreamingMessageView, } from '@stream-io/chat-react-native-ai'; import { useCallback, useMemo } from 'react'; // ... our code snippets from above const RenderNull = () => null; const additionalFlatListProps = { maintainVisibleContentPosition: { minIndexForVisible: 0, autoscrollToTopThreshold: 0, }, ListHeaderComponent: null, }; export const ChatContent = () => { const { channel } = useAppContext(); const { bottom } = useSafeAreaInsets(); const preSendMessageRequest = useStableCallback(async ({ localMessage }) => { if (!channel) { return; } if (!channel.initialized) { await channel.watch({ created_by_id: localMessage.user_id, }); summarize(localMessage.text).then((response) => { const { summary } = response as { summary: string }; channel.update({ name: summary }) }); } if ( !Object.keys(channel.state.watchers).some(watcher => watcher.startsWith('ai-bot'), ) && channel.id ) { await startAI(channel.id); } }); if (!channel) { return null; } return ( <Animated.View key={channel.id} style={[styles.wrapper, { paddingBottom: bottom }]} entering={FadeIn.duration(200)} exiting={FadeOut.duration(200)} > <Channel channel={channel} keyboardVerticalOffset={Platform.OS === 'ios' ? 95 : -300} initializeOnMount={false} preSendMessageRequest={preSendMessageRequest} StreamingMessageView={CustomStreamingMessageView} Message={CustomMessage} enableSwipeToReply={false} EmptyStateIndicator={EmptyStateIndicator} allowSendBeforeAttachmentsUpload={true} NetworkDownIndicator={RenderNull} MessageAvatar={RenderNull} MessageFooter={RenderNull} > <MessageList additionalFlatListProps={additionalFlatListProps} /> <CustomComposerView /> </Channel> </Animated.View> ); }; const styles = StyleSheet.create({ wrapper: { flex: 1, backgroundColor: '#fcfcfc' }, emptyContainer: { flex: 1, width: '100%', backgroundColor: 'transparent', justifyContent: 'center', alignItems: 'center', }, emptyContainerText: { fontSize: 24, fontWeight: 'bold' }, streamingMessageViewWrapper: { maxWidth: '100%', paddingHorizontal: 16, }, aiTypingIndicatorWrapper: { paddingHorizontal: 24, paddingVertical: 12, }, });

This implementation covers a couple of things that help replicate the screen display style of a chatbot:

Additionally, we pass some props to our Channel component to give the app a chatbot look and feel:

  • initializeOnMount - This prop makes sure you don’t immediately create a new channel before sending a message
  • preSendMessageRequest - This callback prop is run before any other effects in the chat SDK state, but right after the first optimistic update and we use it to create the channel if it does not exist as well as start the agent server-side as well as update its name

With this, our app can now send messages to and handle responses from the AI agent, create channels on demand as well as make sure the UI has a chatbot style to it.

4. Create an AITypingIndicator

Since we can now communicate with the chatbot, it would also be useful for us to know its current generation state. For example, we’d want to know when the LLM is “thinking” about our question, when it’s generating an answer, whether it’s looking at external resource and so on.

For this, you can use the AITypingIndicatorView component from @stream-io/chat-ai-sdk for rendering purposes as it will provide a cool indicator with a shimmering animation. We can rely on the useAIState hook from stream-chat-react-native to listen for state changes around this and react accordingly.

To do that we can add the following code in screens/ChatContent.tsx:

bash
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { AITypingIndicatorView, ComposerView, StreamingMessageView, } from '@stream-io/chat-react-native-ai'; // ... rest of the code const CustomAITypingIndicatorView = () => { const { channel } = useChannelContext(); const { aiState } = useAIState(channel); const allowedStates = { [AIStates.Thinking]: 'Thinking about the question...', [AIStates.Generating]: 'Generating a response...', [AIStates.ExternalSources]: 'Checking external sources...', }; if (aiState === AIStates.Idle || aiState === AIStates.Error) { return null; } return ( <View style={styles.aiTypingIndicatorWrapper}> <AITypingIndicatorView text={allowedStates[aiState]} /> </View> ); }; // ... const styles = StyleSheet.create({ // ...other styles aiTypingIndicatorWrapper: { paddingHorizontal: 24, paddingVertical: 12, }, });

and then simply add it between our MessageList and CustomComposerView (that we just created), like so:

bash
1
2
3
4
5
6
7
// ... <MessageList additionalFlatListProps={additionalFlatListProps} /> <CustomAITypingIndicatorView /> <CustomComposerView /> // ...

Now, whenever we talk to an agent we will be able to see its state as it’s working on a response.

5. Setting up the ChannelList

As a next step, let’s present the Stream Chat channel list component. Similar to how chatbot applications look and feel, we will render it inside of a side-drawer. When a channel is tapped, want to display it together with the MessageList on the main screen. We will also customize the channel previews within the list to contain the name of the channel itself, for better readability.

Let us include a new file under screens/MenuDrawer.tsx and include the following code to it:

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import { chatUserId } from '../chatConfig.ts'; import { ChannelSort } from 'stream-chat'; import { ChannelList, ChannelPreviewMessengerProps, useChannelsContext, useStableCallback, Copy, useChatContext, } from 'stream-chat-react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { DrawerContentComponentProps } from '@react-navigation/drawer'; import { createChannel, useAppContext } from '../contexts/AppContext.tsx'; import { Channel as ChannelClass } from 'stream-chat'; import { SafeAreaView } from 'react-native-safe-area-context'; import React from 'react'; const filters = { members: { $in: [chatUserId], }, }; const sort: ChannelSort = { last_updated: -1 }; const ChannelPreview = (props: ChannelPreviewMessengerProps) => { const channel = props.channel; const { onSelect } = useChannelsContext(); const onPress = useStableCallback(() => { onSelect?.(channel); }); return ( <Pressable style={({ pressed }) => ({ paddingVertical: 8, paddingHorizontal: 12, opacity: pressed ? 0.6 : 1, })} onPress={onPress} > <Text style={styles.previewText} numberOfLines={1}> {channel.data?.name ?? channel.cid} </Text> </Pressable> ); }; export const MenuDrawer = ({ navigation }: DrawerContentComponentProps) => { const { client } = useChatContext(); const { setChannel } = useAppContext(); const onSelect = useStableCallback((channel: ChannelClass) => { setChannel(channel); navigation.closeDrawer(); }); const onCreateNewChat = useStableCallback(() => { setChannel(createChannel(client)); navigation.closeDrawer(); }); return ( <SafeAreaView style={styles.wrapper}> <View style={styles.container}> <Text style={styles.title}>Conversations</Text> <Pressable onPress={onCreateNewChat}> <Copy /> </Pressable> </View> <ChannelList filters={filters} sort={sort} onSelect={onSelect} Preview={ChannelPreview} /> </SafeAreaView> ); }; const styles = StyleSheet.create({ wrapper: { flex: 1 }, container: { flexDirection: 'row', justifyContent: 'space-between', marginHorizontal: 12, paddingVertical: 16, borderBottomWidth: 1, borderBottomColor: 'grey', }, title: { fontSize: 15, fontWeight: 'bold' }, previewText: { fontSize: 15, fontWeight: 'bold' }, });

and add the new component as drawerContent to our main navigator within App.tsx, like so:

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ... rest of the imports import { MenuDrawer } from './screens/MenuDrawer.tsx'; const Drawer = createDrawerNavigator(); const DrawerNavigator = () => ( <Drawer.Navigator drawerContent={MenuDrawer} screenOptions={{ drawerStyle: { width: 300, }, }} > <Drawer.Screen name="Chat" component={ChatContent} /> </Drawer.Navigator> ); // ... rest of the code

When we run the app at this point, we will be able to open a drawer on top of the main screen and see the channel list. When we tap on an item, we will set the channel within our AppContext to the one we just clicked, changing the main screen in the process. We can also create a brand new chat by clicking on the new chat button in the top-right corner.

Conclusion

In this tutorial, we have built an AI assistant bot that works mostly out of the box with the Stream Chat React Native SDK and the React Native Chat AI SDK.

  • We have shown how to use our AI components for message rendering of LLM responses, such as markdown, code, tables, charts etc.
  • We have shown how to create our server that will start and stop AI agents that will respond to user questions
  • You have learned how to customize our React Native SDK to integrate these new AI features

If you want to learn more about our AI capabilities, head to our AI landing page. Additionally, check our React Native Docs to learn how you can provide more customizations to your chat apps. Get started by signing up for a free Stream account today.

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->