Conversation state

AIConversation shows a conversation, and AIMessageComposer takes what the user writes. Neither keeps the conversation itself: they show the data you give them, and hand you what the user does. With Stream Chat, the channel holds that data: its messages, with the agent's replies and their steps, and its AI state, which says what the agent is doing. This page covers what the components take, how to read it from a channel, and what your backend writes into the channel for them to show.

import { AIStates } from "stream-chat";
import type { Channel, MessagePaginatorState } from "stream-chat";
import { useAIState, useStateStore } from "stream-chat-react";
import {
  AIConversation,
  AITypingIndicator,
  fromStreamMessage,
} from "stream-chat-react/ai-components";

const itemsSelector = (state: MessagePaginatorState) => ({
  items: state.items,
});

export const AssistantConversation = ({ channel }: { channel: Channel }) => {
  const { items } = useStateStore(
    channel.messagePaginator.state,
    itemsSelector,
  );
  const { aiState } = useAIState(channel);

  return (
    <AIConversation
      messages={(items ?? []).map((message) => fromStreamMessage(message))}
      footer={
        aiState === AIStates.ExternalSources ? (
          <AITypingIndicator text="Checking external sources" />
        ) : undefined
      }
    />
  );
};

The channel's message paginator holds its loaded messages oldest first, which is the order the conversation expects, and updates as the backend writes the reply. Load the channel with channel.watch() before you show it. Conversation view covers the rest of what AIConversation takes.

Messages

The components take each message as an AIMessage:

Field Description Default
id The message's stable identity. -
role Who wrote it: "user", "assistant", or a role of your own, which shows as the agent's. Each of the user's messages starts a turn. -
text What it says. A reply's text is Markdown. -
isGenerating Whether the agent is still writing it. false
parts The steps the agent took for it, in order. []
attachments What it carries besides its text, such as images and files, as AIMessageAttachmentData. []
reasoningText (stepId) => string: the whole of a reasoning step's thinking, when your app has it. Otherwise the step's excerpt shows. -
uiDocument The UI the agent wrote for a reply, which shows in place of its text. See Generative UI. -

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. Show a message while it's sent covers it.

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 custom.ai_generated is true, "user" otherwise.
isGenerating custom.generating.
parts Its ai_reasoning and ai_tool_call attachments.
attachments Its image and file attachments. Attachments of other types are left out.
uiDocument custom.ai_ui, on a reply only.

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, {
  isAssistant: (message) => message.user?.id === "juno",
  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.

Follow what the agent is doing

useAIState from stream-chat-react returns what the agent is doing in a channel, as aiState:

import { AIStates } from "stream-chat";
import { useAIState, useChannel } from "stream-chat-react";

export const useIsAnswering = () => {
  const channel = useChannel();
  const { aiState } = useAIState(channel);

  return (
    aiState === AIStates.Thinking ||
    aiState === AIStates.ExternalSources ||
    aiState === AIStates.Generating
  );
};

aiState is one of the AIStates from stream-chat, 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 doesn't read the channel from context, so pass it, for example from useChannel(). Without one, it returns AI_STATE_IDLE.
  • It reads the channel's own state. The client keeps the state on the channel, in channel.state, from its ai_indicator.* events. So when you show another channel, the hook shows that channel's current state rather than starting from AI_STATE_IDLE. Channel keeps its children mounted when the channel changes, so state of your own components carries over to the next channel: reset it yourself if it belongs to one channel.
  • The connection resets it. When the connection drops, the client sets the state back to AI_STATE_IDLE, since the events that end a reply aren't replayed.

useAIState in the hooks reference has the details. 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 keeps custom fields under message.custom, so read them as message.custom.ai_generated and message.custom.generating. Declare them for TypeScript as optional fields, so messages without them still type-check. Custom data types explains the declaration file:

stream-custom-data.d.ts
import "stream-chat";

declare module "stream-chat" {
  interface CustomMessageData {
    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. Pass isMessageAIGenerated={(message) => !!message.custom?.ai_generated} to Chat. MessageUI then renders those messages with StreamedMessageText, which types them out, and hides their "Edited" label, which every update of the text would show. The channel list shows their preview as plain text instead of rendering its Markdown. SDK integration shows the setup.

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 set the channel's AI state:

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 AI_STATE_STOP 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. 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, or the connection drops. 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 state 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. The default composer's stop button does it, and Stop the reply shows a button of your own.

The event carries only the channel's cid, not a message id, 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 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 browser. 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.