Info:
This is documentation for Stream Chat React Native SDK v9, which is no longer actively maintained. For up-to-date documentation, see the latest version (v10).

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.

Info:

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 message and the messages after it, or write the new reply in place of message, under its id. A regenerate without message asks for a reply to the latest message, such as after the agent couldn't answer it.
  • For an edit, change the user's message to say the new text, 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 from useChannelContext().
  • It follows the events. The hook listens for the channel's ai_indicator.* events, and starts from AI_STATE_IDLE when it mounts. So a user who opens the channel halfway through a reply sees AI_STATE_IDLE until the next event arrives.
  • Remount it for each channel. It returns to idle on ai_indicator.clear and 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 with key={channel.id} if one screen shows several channels in turn.
  • An error isn't followed by a clear. A backend can send AI_STATE_ERROR and then write the error into the message without sending ai_indicator.clear, so aiState stays AI_STATE_ERROR until the next reply starts. Treat it as a final state.
  • Compare with AIStates.ExternalSources. The AIState type in stream-chat lists AI_STATE_CHECKING_SOURCES, but the backends send AI_STATE_EXTERNAL_SOURCES, which is the value of AIStates.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 AITypingIndicatorView and stop button only react to AI_STATE_THINKING and AI_STATE_GENERATING. If your agent uses tools, show the AI components' AITypingIndicatorView for AIStates.ExternalSources too, 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, true on every update while the reply streams, and false on 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,
});

The 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:

  • fromStreamMessage reads ai_generated as the message's role, and generating as isGenerating.
  • The message list. Read ai_generated in a function, and pass it to Chat as isMessageAIGenerated. MessageContent then renders those messages with StreamingMessageView, which types them out, and MessageFooter hides 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, not Channel. Channel accepts an isMessageAIGenerated prop too, but always replaces it with the value from the Chat context. 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' in messageContentOrder. AI messages render their text through the 'ai_text' entry, and the regular 'text' entry skips them. If you pass your own messageContentOrder to Channel, 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,
);

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.