# Conversation State

> **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)](https://getstream.io/chat/docs/sdk/react-native/).

`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](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/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](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/#step-2-install-the-sdks) to install them.
>

## Messages

The components take each message as an `AIMessage`. Its fields are in [Show a conversation](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/conversation-view/#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](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/conversation-view/#show-a-message-while-its-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:

```tsx
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:

```tsx
createFromStreamMessage({
  isAssistant: (message) =>
    (message.user as { id?: string } | undefined)?.id === "my-agent",
  reasoningText: (messageId, stepId) => reasoning.get(messageId, stepId),
});
```

[What your backend sends](#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](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/reasoning-and-tool-calls/) shows the steps, and its [Step format](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/reasoning-and-tool-calls/#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](#the-transport) you write. Give it to the conversation and the composer as `conversation`:

```tsx
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](#the-transport).                                                           | -           |
| `approver`     | Who answers tool calls' questions and sign-ins on this device. See [Tool calls](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/conversation-view/#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`:

```ts
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](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/#step-4-connect-a-conversation-to-the-agent) 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:

```ts
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](#errors). 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](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/composer/#chat-options) 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`](#errors) 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:

```ts
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.

```tsx
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](#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`:

```tsx
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:

```tsx
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](#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](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/conversation-view/#suggestions-to-start-with) can be sent with the option picked in the composer, which `conversation.composer` holds:

```tsx
<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](#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`:

```tsx
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](#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](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/sdk-integration/#aitypingindicatorview) 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:

```tsx
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](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/streaming-messages/#show-what-the-agent-is-doing). To stop the reply, call `channel.stopAIResponse()`, as [Stop the reply](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/composer/#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](https://getstream.io/agents/docs/quickstart/?client=javascript), the [Stream Chat AI SDK](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/stream-chat-ai-sdk/) and the [LangChain SDK](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/stream-chat-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](https://getstream.io/chat/docs/node/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.

```js label="Node.js"
const { message } = await channel.sendMessage({
  text: "",
  user_id: botId,
  ai_generated: true,
});
```

```py label="Python"
placeholder = channel.send_message(
    MessageRequest(text="", user_id=bot_id, custom={"ai_generated": True}),
).data.message
```

The client reads them as `message.ai_generated` and `message.generating`. With TypeScript, declare them on `CustomMessageData`, as described in [TypeScript](https://getstream.io/chat/docs/sdk/react-native/v9/customization/typescript/):

```ts
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](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/sdk-integration/#streamingmessageview) shows the setup.

```tsx
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](#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](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/composer/#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](https://getstream.io/chat/docs/node/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.

```js label="Node.js"
// 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,
);
```

```py label="Python"
# 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](https://getstream.io/chat/docs/node/ai-message-streaming/#ephemeral-partial-update) 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.

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/conversation-state/](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/conversation-state/).