Conversation State
AIConversation shows a conversation, and ComposerView takes what the user writes. Neither has to keep the conversation itself: they show the data you give them, and hand you what the user does. You can keep that data yourself, or have an AIConversationController keep it for you. With Stream Chat, the channel holds it: its messages, with the agent's replies and their steps, and its ai_indicator.* events, which say what the agent is doing. This page covers what the components take, how to read it from a channel, how the controller keeps it, and what your backend writes into the channel for them to show.
Load the channel with channel.watch() before you show it. Conversation view covers the rest of what AIConversation takes.
AIConversationController, fromStreamMessage and the message parts are available from version 9.14.0 of stream-chat-react-native and stream-chat-expo, in the stream-chat-react-native/ai-components and stream-chat-expo/ai-components entry points. AIConversation is in ai-components/conversation. See the Quickstart to install them.
Messages
The components take each message as an AIMessage. Its fields are in Show a conversation. A message the user sent that isn't in the channel yet, such as while its photos upload, is an AIPendingMessage, with an id, a text and attachments. A controller keeps it for you. Show a message while it's sent covers it without one.
Messages from Stream Chat
fromStreamMessage turns a Stream Chat message into an AIMessage:
AIMessage |
From the Stream Chat message |
|---|---|
id, text |
Its id and text. |
role |
"assistant" when ai_generated is true, "user" otherwise. |
isGenerating |
generating. |
parts |
Its ai_reasoning and ai_tool_call attachments. |
attachments |
Its image and file attachments. Attachments of other types are left out. |
uiDocument |
ai_ui, on a reply only. |
createFromStreamMessage makes a fromStreamMessage that gives back the same AIMessage for the same Stream message. A channel's state replaces only the messages that change, so mapping its messages through one on every update leaves the others as they were, and the conversation renders only the changed ones again. Make one per conversation:
const [fromStreamMessage] = useState(() => createFromStreamMessage());
const messages = useMemo(
() => channelMessages.map(fromStreamMessage),
[channelMessages, fromStreamMessage],
);Pass isAssistant to tell the agent's messages apart some other way, such as by the user who sent them, and reasoningText when your app has the whole of the reasoning. fromStreamMessage(message, options) takes the same options, for a single message:
createFromStreamMessage({
isAssistant: (message) =>
(message.user as { id?: string } | undefined)?.id === "my-agent",
reasoningText: (messageId, stepId) => reasoning.get(messageId, stepId),
});What your backend sends covers how the backend sets these fields.
Steps of a reply
An agent writes each step it takes while replying, a round of reasoning or a tool call, as a custom attachment on its reply. parseMessageParts(message.attachments) reads them, skipping attachments that aren't steps, and fromStreamMessage does it for you. Each step is an AIMessagePartData:
| Field | Description |
|---|---|
id |
The step's stable identity. |
kind |
The attachment type: ai_reasoning, ai_tool_call, or another ai_ type. |
reasoning |
A round of reasoning, read: its status, and its summary, preview and durationMs. |
toolCall |
A tool call, read: its name, status and executor, its arguments, and the approval or sign-in it waits for. |
payload |
The attachment's fields, for a kind of your own or fields the SDK doesn't read. |
version |
The step's format version (v). A step in a newer version keeps its payload but has no reasoning or toolCall. |
The kinds and statuses are open sets, so new ones don't break your code. Reasoning and Tool Calls shows the steps, and its Step format lists what the backend writes.
A conversation controller
An AIConversationController keeps a conversation's state for you: its messages, the message on its way, whether the agent is answering, the ratings, and why the latest request failed. It reaches your agent through a transport you write. Give it to the conversation and the composer as conversation:
import { useEffect, useState } from "react";
import { View } from "react-native";
import type { Channel } from "stream-chat";
import { AIConversation } from "stream-chat-react-native/ai-components/conversation";
import {
AIConversationController,
ComposerView,
} from "stream-chat-react-native/ai-components";
export const Assistant = ({ channel }: { channel: Channel }) => {
const [conversation] = useState(
() =>
new AIConversationController({
transport: createChannelTransport(channel),
}),
);
useEffect(() => () => conversation.dispose(), [conversation]);
return (
<View style={{ flex: 1 }}>
<AIConversation conversation={conversation} />
<ComposerView conversation={conversation} />
</View>
);
};Make one controller per conversation and dispose of it once it's gone. To switch to another conversation, render the screen again with a key, such as <Assistant key={channel.cid} channel={channel} />, so it makes a new one.
With a conversation, AIConversation takes its messages, pendingMessage, isThinking, loadOlderMessages and replyActions from the controller, and its approver too unless you pass one. ComposerView sends to the controller and shows the stop button while the agent answers, in place of onSendMessage, isGenerating, stopGenerating and state. Their other props work as before.
The controller then handles:
- Sending. The user's message shows straight away, and the agent thinks until its reply starts.
- Stopping. What the agent wrote so far stays, and the reply counts as finished. If the reply hadn't started, the user's message stays, and they can try again.
- Reply actions. The latest reply can be written again, and every finished reply rated.
- Editing. The user's latest message gets an edit button while the agent isn't answering. Editing puts its text in the composer, which shows an "Editing message" chip with a button that cancels it. Sending the edit answers the message again, in place of the replies after it.
While a reply is written again, or an edited message answered again, the messages it replaces are hidden. They come back if the request fails or is stopped.
| Option | Description | Default |
|---|---|---|
transport |
How the controller reaches your agent and learns what the conversation holds. See The transport. | - |
approver |
Who answers tool calls' questions and sign-ins on this device. See Tool calls. | - |
composer |
The composer's state store, for an app that fills it itself. | A new store |
actions |
{ edit, rate, regenerate }: which of these the conversation offers. |
All true |
replyTimeout |
How many milliseconds to wait, once the agent has a request, for its reply to start or for the agent to say what it's doing. null or Infinity waits as long as it takes. |
5 minutes |
The transport
The transport is how the controller reaches your agent. You write it as an AIConversationTransport:
type AIConversationTransport = {
connect: (feed: AIConversationFeed) => void | (() => void);
send: (request: AIConversationRequest) => Promise<void>;
stop: () => Promise<void>;
loadOlderMessages?: () => Promise<void>;
rate?: (reply: AIMessage, rating: AIMessageRating | null) => Promise<void>;
};The controller calls connect once, as it's made, and the function it returns once it's disposed. Through the feed, tell the controller what the conversation holds: its messages, oldest first, every time they change, with updateMessages, whether there are older ones to load, with updateHasOlderMessages, and what the agent says it's doing, with updateActivity. feed.isConnected turns false once the controller is disposed.
With Stream Agents, the transport asks through the agent's session, which writes the question and the reply into the channel. Step 4 of the Quickstart writes that one.
With a backend of your own, the conversation is a channel. This transport follows its messages and its AI indicator events, sends the user's messages to it, and leaves regenerating and editing to your backend:
import type { Channel } from "stream-chat";
import {
type AIConversationTransport,
createFromStreamMessage,
} from "stream-chat-react-native/ai-components";
export const createChannelTransport = (
channel: Channel,
): AIConversationTransport => ({
connect: (feed) => {
const fromStreamMessage = createFromStreamMessage();
const update = () =>
feed.updateMessages(channel.state.messages.map(fromStreamMessage));
update();
const subscriptions = [
channel.on(update),
channel.on("ai_indicator.update", (event) => {
if (event.ai_state) {
feed.updateActivity({
state: event.ai_state,
text: event.ai_message,
});
}
}),
channel.on("ai_indicator.clear", () => feed.updateActivity(undefined)),
];
return () => subscriptions.forEach((s) => s.unsubscribe());
},
send: async (request) => {
if (request.kind === "message") {
await channel.sendMessage({
id: request.messageId,
text: request.text,
attachments: await upload(request.attachments),
});
} else {
await agent.answerAgain(channel.id, request);
}
},
stop: () => channel.stopAIResponse(),
});send resolves once the agent has the request, and the reply arrives through the feed. If it rejects, the request fails and the controller shows its error. A rejection with an AbortError ends the request without one. An AIConversationRequest has these fields:
| Field | Description |
|---|---|
kind |
'message', 'regenerate' or 'edit'. |
messageId |
An id for the user's message. Create the message with it, as above, so the conversation finds its copy by it. A retry keeps the id, so it won't add the message twice. |
text |
What the user wrote. For a regenerate, the text of the message the reply answers. |
attachments |
The files the user picked, on the device, to upload. |
chatOption |
The chat option picked in the composer. |
custom |
The composer state's custom data. |
message |
For a regenerate, the reply to write again. For an edit, the user's message they changed. |
The controller hides the messages a regenerate or an edit replaces only until the new reply starts. From then on it shows the messages as the feed has them, so change the conversation before the new reply arrives:
- For a regenerate, remove
messageand the messages after it, or write the new reply in place ofmessage, under its id. A regenerate withoutmessageasks for a reply to the latest message, such as after the agent couldn't answer it. - For an edit, change the user's
messageto say the newtext, under its id, and remove the messages after it.
stop stops the reply the agent is writing, and what it wrote stays. loadOlderMessages loads the messages before the first one, which arrive through the feed, and the conversation offers it while you've said there are older messages. rate tells your backend how the user rated a reply, or that they took the rating back with null. Without it, the ratings stay on the device. A reply's rating field shows the one your backend has, until the user rates the reply again.
What the agent is doing
updateActivity takes an AIAgentActivity: a state and an optional text, such as "Searching the web". AIAgentActivityStates has the AI indicator's states: thinking, checkingExternalSources, generating and error. Pass undefined once the agent stops saying.
While the agent says it's working, the controller waits for its reply as long as that takes, and the conversation shows it thinking until the reply starts. An error state ends the request with an AIAgentError that carries its text.
Reconnecting
Inside the Chat SDK's Chat, the connection closes while the app is in the background and opens again when it comes back, which sends connection.changed but no connection.recovered. Watch the channel again then, so the conversation catches up on what it missed:
const reconnected = channel.getClient().on("connection.changed", (event) => {
if (event.online) channel.watch().catch(() => {});
});Unsubscribe in the function connect returns, along with the rest.
Follow its state
useAIConversationState follows what a selector picks of the controller's state, and renders the component again when it changes. Keep the selector outside the component, so it stays the same between renders. Use this hook rather than the Chat SDK's useStateStore, which takes a different kind of store.
import {
type AIConversationControllerState,
useAIConversationState,
} from "stream-chat-react-native/ai-components";
const statusSelector = ({ status }: AIConversationControllerState) => ({
status,
});
const { status } = useAIConversationState(conversation, statusSelector);| Field | Description |
|---|---|
messages |
The conversation as it shows, oldest first. |
pendingMessage |
The message the user sent last that isn't in the conversation yet. |
status |
'ready', 'submitted' (the request is on its way, or its reply shows nothing yet), 'streaming' (the agent is writing its reply), or 'error'. |
error |
Why the latest request failed. See Errors. |
activity |
What the agent says it's doing, or undefined. |
hasOlderMessages |
Whether there are messages before the first one to load. |
ratings |
How the user rated replies, by the reply's id. |
isAIConversationAnswering(status) is true while a request is submitted or its reply streams. The conversation shows the agent thinking by itself, but not the rest of what it's doing. To show its text, pass it to the footer:
import {
AIAgentActivityStates,
AITypingIndicatorView,
} from "stream-chat-react-native/ai-components";
const activitySelector = ({ activity }: AIConversationControllerState) => ({
activity,
});
const { activity } = useAIConversationState(conversation, activitySelector);
<AIConversation
conversation={conversation}
footer={
activity?.text && activity.state !== AIAgentActivityStates.error ? (
<AITypingIndicatorView text={activity.text} />
) : undefined
}
/>;Errors
When a request fails, status is 'error' and error says why. It's an AIAgentError when the agent said it couldn't answer, with what it said as agentMessage, or when its reply didn't start within replyTimeout. When the agent said nothing of its own, its message is a translation key, AI_AGENT_ERROR_MESSAGE, so show it with t(error.message). The error stays until the next request, or until a reply starts after all.
The conversation doesn't show the error. Show it yourself, for example in the composer's header, with a way to try again:
const errorSelector = ({ error }: AIConversationControllerState) => ({ error });
const Failure = ({
conversation,
}: {
conversation: AIConversationController;
}) => {
const { error } = useAIConversationState(conversation, errorSelector);
const { t } = useAITranslation();
if (!error) return null;
return (
<Pressable onPress={() => conversation.retry()}>
<Text>{t(error.message)}</Text>
<Text>Try again</Text>
</Pressable>
);
};
<ComposerView
conversation={conversation}
header={<Failure conversation={conversation} />}
/>;retry() sends the request that failed again, with the same messageId, or asks the agent again for the reply it couldn't write. clearError() forgets the error and the request.
Act on it
The views call the controller's methods, and you can too:
| Method | What it does |
|---|---|
send({ text, attachments, chatOption, custom }) |
Sends a message, or the edit of the message being edited. |
stop() |
Stops the reply the agent is writing. |
regenerate(reply) |
Asks for a new reply in place of reply and the messages after it. |
edit(message) |
Puts one of the user's messages in the composer to edit. editingMessage is the message being edited. |
cancelEditing() |
Stops editing, and empties the composer. |
retry(), clearError() |
See Errors. |
rate(reply, rating) |
Rates a reply, or takes the rating back with null. Ratings reach the transport in order, and one it can't take goes back to the rating it has. |
loadOlderMessages() |
Loads the messages before the first one, while there are any. Rejects when they couldn't load, so AIConversation waits before it asks again. |
dispose() |
Stops following the transport. |
For example, a suggestion can be sent with the option picked in the composer, which conversation.composer holds:
<AISuggestions
suggestions={suggestions}
onSelect={(text) => {
const { chatOption } = conversation.composer.getLatestValue();
conversation.send({ chatOption: chatOption ?? undefined, text });
}}
/>For views of your own, replyActions(reply, isLatestReply) gives what the conversation offers for a finished reply, as the props of AIReplyActions, and editAction(message) gives the edit for a user's message, or undefined.
Follow what the agent is doing
With a controller, status and activity say it, as Follow its state shows. Without one, and with the Chat SDK's Channel, useAIState from stream-chat-react-native returns what the agent is doing in a channel, as aiState:
import {
AIStates,
useAIState,
useChannelContext,
} from "stream-chat-react-native";
export const useIsAnswering = () => {
const { channel } = useChannelContext();
const { aiState } = useAIState(channel);
return (
aiState === AIStates.Thinking ||
aiState === AIStates.ExternalSources ||
aiState === AIStates.Generating
);
};aiState is one of the AIStates from stream-chat-react-native, or a state of your own that your backend sends. Say what the agent is doing lists them, and the events that set them.
A few things to know about it:
- Pass it the channel. It takes the channel as its argument, and has no default. Inside
Channel, get the channel fromuseChannelContext(). - It follows the events. The hook listens for the channel's
ai_indicator.*events, and starts fromAI_STATE_IDLEwhen it mounts. So a user who opens the channel halfway through a reply seesAI_STATE_IDLEuntil the next event arrives. - Remount it for each channel. It returns to idle on
ai_indicator.clearand when the connection drops, but not when you pass it a different channel: it keeps the old state until the new channel sends an event. Render the screen withkey={channel.id}if one screen shows several channels in turn. - An error isn't followed by a clear. A backend can send
AI_STATE_ERRORand then write the error into the message without sendingai_indicator.clear, soaiStatestaysAI_STATE_ERRORuntil the next reply starts. Treat it as a final state. - Compare with
AIStates.ExternalSources. TheAIStatetype instream-chatlistsAI_STATE_CHECKING_SOURCES, but the backends sendAI_STATE_EXTERNAL_SOURCES, which is the value ofAIStates.ExternalSources. The type accepts any string, so a comparison with the wrong literal compiles and never matches. - Tool calls hide the default UI. The SDK's own
AITypingIndicatorViewand stop button only react toAI_STATE_THINKINGandAI_STATE_GENERATING. If your agent uses tools, show the AI components'AITypingIndicatorViewforAIStates.ExternalSourcestoo, as SDK integration shows.
useAIState returns only the state. When you also need the id of the reply, for example to show a cursor or a stop control on that message, listen with channel.on yourself. A channel's listeners only receive that channel's events, so no cid check is needed:
import { useEffect, useState } from "react";
import type { AIState, Channel } from "stream-chat";
import { AIStates } from "stream-chat-react-native";
export const useAIIndicator = (channel?: Channel) => {
const [aiState, setAIState] = useState<AIState>(AIStates.Idle);
const [messageId, setMessageId] = useState<string>();
useEffect(() => {
// Start from idle whenever the channel changes.
setAIState(AIStates.Idle);
setMessageId(undefined);
if (!channel) return;
const update = channel.on("ai_indicator.update", (event) => {
setAIState(event.ai_state ?? AIStates.Idle);
setMessageId(event.message_id);
});
const clear = channel.on("ai_indicator.clear", () => {
setAIState(AIStates.Idle);
setMessageId(undefined);
});
return () => {
update.unsubscribe();
clear.unsubscribe();
};
}, [channel]);
return { aiState, messageId };
};To show the state, see Show what the agent is doing. To stop the reply, call channel.stopAIResponse(), as Stop the reply in the composer shows.
What your backend sends
With Stream Chat, your backend writes the agent's reply into the channel, and the components show what it writes. A typical backend posts an empty placeholder message as the agent, updates it as the model streams, and stores the final text. Stream Agents, the Stream Chat AI SDK and the LangChain SDK do all of this for you. With a backend of your own, follow the conventions below, which the components and Stream's backend SDKs share. The AI message streaming guide covers the whole backend flow.
Mark the agent's replies
Set two custom fields on the agent's reply:
ai_generated: true, when the backend creates the placeholder. Your app reads it to tell the agent's replies apart, and your backend checks it to skip the agent's own replies instead of answering them.generating,trueon every update while the reply streams, andfalseon the final one. It's the only field that tells a client a reply is in flight.
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.messageThe client reads them as message.ai_generated and message.generating. With TypeScript, declare them on CustomMessageData, as described in TypeScript:
import type { DefaultMessageData } from "stream-chat-react-native";
declare module "stream-chat" {
interface CustomMessageData extends DefaultMessageData {
ai_generated?: boolean;
generating?: boolean;
}
}The SDK uses them in these places:
fromStreamMessagereadsai_generatedas the message'srole, andgeneratingasisGenerating.- The message list. Read
ai_generatedin a function, and pass it toChatasisMessageAIGenerated.MessageContentthen renders those messages withStreamingMessageView, which types them out, andMessageFooterhides their "Edited" label, which every update of the text would show. SDK integration shows the setup.
import type { LocalMessage } from "stream-chat";
import { Chat } from "stream-chat-react-native";
// Defined once, outside any component.
const isMessageAIGenerated = (message: LocalMessage) => !!message.ai_generated;
const App = () => (
<Chat client={client} isMessageAIGenerated={isMessageAIGenerated}>
{/* ... */}
</Chat>
);Two details make the flag silently stop working in the message list:
- Set it on
Chat, notChannel.Channelaccepts anisMessageAIGeneratedprop too, but always replaces it with the value from theChatcontext. Define the function once, outside your components, and base it only on the message, since the SDK isn't guaranteed to pick up a new function on a later render. - Keep
'ai_text'inmessageContentOrder. AI messages render their text through the'ai_text'entry, and the regular'text'entry skips them. If you pass your ownmessageContentOrdertoChannel, include'ai_text', or AI replies render without any text.
The message list doesn't read generating: a reply types out only the text it grows by after it shows, so a stored reply doesn't type itself out again. Read generating in UI of your own, for example to show a cursor while the reply streams.
Say what the agent is doing
Before the first word of the reply arrives, the model can spend a while thinking or calling tools, and the message is still empty. The backend says what it's doing with ai_indicator.* custom events on the channel, which reach the app as regular stream-chat channel events and which useAIState follows:
| Event type | Sent by | aiState becomes |
Means |
|---|---|---|---|
ai_indicator.update |
Backend | The event's ai_state |
The agent's state changed. Carries the ai_state, the cid, the reply's message_id, and an optional ai_message. |
ai_indicator.clear |
Backend | AI_STATE_IDLE |
The reply is done. Sent after the final text is stored. |
ai_indicator.stop |
App | Unchanged | The user stopped the reply. See Stop the reply. |
The ai_state of an update is one of these:
ai_state |
AIStates |
The agent is... |
|---|---|---|
AI_STATE_THINKING |
AIStates.Thinking |
Thinking. |
AI_STATE_EXTERNAL_SOURCES |
AIStates.ExternalSources |
Calling a tool, such as a search. |
AI_STATE_GENERATING |
AIStates.Generating |
Writing the reply, which streams into the message. |
AI_STATE_ERROR |
AIStates.Error |
Unable to answer. The backend writes the error into the message. |
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. Use these names, so the components' states match your backend's. The SDK's AI_STATE_IDLE is where useAIState starts, and the backend SDKs never send it. If your backend sends states of its own, read them from aiState the same way, and show them with components of your own.
The Stream backend SDKs don't send ai_indicator.clear after an error, so aiState stays AI_STATE_ERROR until the next reply starts. Anything you show for it stays up until then.
The indicator events reach only the clients connected when they're sent, and aren't stored, so a user who opens the channel halfway through a reply sees no indicator and no stop button until the next event arrives. Use them for the indicator only, and rely on the message's text and generating, which arrive with every update, for the reply itself.
Stop the reply
When the user stops the reply, the app calls channel.stopAIResponse(), which sends ai_indicator.stop with no other fields. The default composer's stop button does it, and Stop the reply shows how ComposerView does. The call changes no state in the app: useAIState has no setter, so the stop button stays until the backend sends ai_indicator.clear or a new ai_indicator.update.
The event names only the channel, not a message, so on the backend treat it as "stop whatever reply is in flight in this channel". Abort the model's stream, then finish the way a normal reply finishes: store the text written so far with a regular partial update and generating: false, then send ai_indicator.clear. Without that final update, the message stays as it was last stored.
Stream with ephemeral updates
A regular partial update writes the message to the database on every call. A long reply flushed every few tokens turns into hundreds of writes, and with several conversations at once, you can hit the rate limits for message updates. While the reply streams, update it with ephemeralUpdateMessage instead. It takes the same payload and sends the same message.updated event, so the reply types itself out the same way, but it stores nothing.
The last update must not be ephemeral. When the model finishes, store the complete text with a regular partial update, and generating: false. Otherwise anyone who opens the channel later, or relaunches the app, loads the last stored version of the message: the empty placeholder, still generating. The same goes for whatever else ends the reply, such as an error or the user stopping it.
// While the model streams: send the latest text without storing it.
await client.ephemeralUpdateMessage(
messageId,
{ set: { text, generating: true } },
botId,
);
// Once the model is done: store the final text.
await client.partialUpdateMessage(
messageId,
{ set: { text, generating: false } },
botId,
);# While the model streams: 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 model is done: store the final text.
client.chat.update_message_partial(
id=message_id,
set={"text": text, "generating": False},
user_id=bot_id,
)Ephemeral updates are server-side only: call them from your backend, not from the app. In Node.js they ship in stream-chat 9.27.0 and later, and every server-side SDK has them: the AI message streaming guide shows each one. Even ephemeral updates reach every device watching the channel, so don't send one per token: flush every 20 or so chunks, or at most every 50 to 100 ms, which keeps the typing smooth.