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

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_indicator.* events, which say 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 { useEffect, useState } from "react";
import type { Channel, LocalMessage } from "stream-chat";
import { AIStates, useAIState } from "stream-chat-react";
import {
  AIConversation,
  AITypingIndicator,
  fromStreamMessage,
} from "stream-chat-react/ai-components";

export const AssistantConversation = ({ channel }: { channel: Channel }) => {
  const [messages, setMessages] = useState<LocalMessage[]>([]);
  const { aiState } = useAIState(channel);

  useEffect(() => {
    const update = () => setMessages([...channel.state.messages]);
    update();
    const subscription = channel.on(update);
    return () => subscription.unsubscribe();
  }, [channel]);

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

A channel's state lists its loaded messages oldest first, which is the order the conversation expects, and the channel's events say when the backend writes the reply. Load the channel with channel.watch() before you show it. Conversation view covers the rest of what AIConversation takes.

Info:

AIConversation, fromStreamMessage and the message parts are available from version 14.15.0 of stream-chat-react, in the stream-chat-react/ai-components entry point. See the Quickstart to install them.

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

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,
  useAIState,
  useChannelStateContext,
} from "stream-chat-react";

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

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

aiState is one of the AIStates from stream-chat-react, 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. Without one, it never subscribes and always returns AI_STATE_IDLE. Inside Channel, get the channel from useChannelStateContext().
  • 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.
  • Call it inside Channel. Channel mounts its children again when the active channel changes, so the state starts from AI_STATE_IDLE for each channel. If you call the hook outside Channel and switch the channel yourself, the previous channel's state stays until the next event arrives.
  • Compare with AIStates.ExternalSources. The AIState type in stream-chat suggests AI_STATE_CHECKING_SOURCES, but the backends send AI_STATE_EXTERNAL_SOURCES, which is the value of AIStates.ExternalSources.

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. Declare them for TypeScript as optional fields, so messages without them still type-check. TypeScript and custom data types explains how the declaration file is picked up:

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