Compose Integration
The AI components work seamlessly with the Stream Chat Android SDK.
There are two ways to bring them in, depending on what you are building:
- An AI assistant inside your existing Stream chat UI — keep
ChannelScreen/MessageListand route AI replies through aChatComponentFactoryslot. Start here if your app already renders Stream channels. - A standalone AI chat screen — build the screen yourself on the low-level client and compose the AI components directly. Use this for a ChatGPT-style app with one assistant and no other members.
Rendering AI replies inside the prebuilt message list
The AI components do not depend on the Chat SDK, so the message list does not know about them. Override MessageTextContent on ChatComponentFactory to render AI replies with StreamingText, and delegate everything else to the default:
class AiComponentFactory : ChatComponentFactory {
@Composable
override fun MessageTextContent(params: MessageTextContentParams) {
val message = params.message
val isFromAi = message.extraData["ai_generated"] == true
if (isFromAi) {
StreamingText(
text = message.text,
// `generating` is true while the agent is streaming and false on the final
// update, so history renders instantly and live replies animate.
animate = message.extraData["generating"] == true,
)
} else {
super.MessageTextContent(params)
}
}
}Pass it to ChatTheme:
ChatTheme(componentFactory = AiComponentFactory()) {
ChannelScreen(
viewModelFactory = ChannelViewModelFactory(context = context, channelId = cid),
onBackPressed = { finish() },
)
}MessageTextContent is the narrowest slot that does the job. Overriding a composite slot such as MessageContainer or MessageContent replaces everything the default drew — avatars, grouping, reactions, replies and delivery status — unless you reproduce it yourself.
Because the agent streams its answer by updating one message, AI replies carry an Edited label. If you want to hide it for AI messages, override MessageFooterContent.
Reacting to AI indicator events
The agent reports what it is doing with ai_indicator.* events. Subscribe on the channel — ChannelClient.subscribeFor already filters by cid, so an indicator from another conversation cannot leak into this screen — and dispose the subscription when the screen goes away:
@Composable
fun rememberAssistantState(cid: String): State<String?> {
val assistantState = remember { mutableStateOf<String?>(null) }
DisposableEffect(cid) {
val channelClient = ChatClient.instance().channel(cid)
val disposable = channelClient.subscribeFor(
AIIndicatorUpdatedEvent::class.java,
AIIndicatorClearEvent::class.java,
) { event ->
assistantState.value = when (event) {
is AIIndicatorUpdatedEvent -> event.aiState
else -> null
}
}
onDispose { disposable.dispose() }
}
return assistantState
}Handle AIIndicatorClearEvent as well as the update — it is what ends the indicator. Subscribing only to the update leaves the indicator on screen forever.
Map the state to a label with AITypingIndicator:
val assistantState by rememberAssistantState(cid)
if (assistantState != null) {
AITypingIndicator(
label = {
Text(
when (assistantState) {
"AI_STATE_THINKING" -> "Thinking"
// Both spellings are in use: the reference agents send
// AI_STATE_EXTERNAL_SOURCES, other backends send AI_STATE_CHECKING_SOURCES.
"AI_STATE_EXTERNAL_SOURCES", "AI_STATE_CHECKING_SOURCES" -> "Checking sources"
"AI_STATE_GENERATING" -> "Generating response"
"AI_STATE_ERROR" -> "Something went wrong"
else -> ""
},
)
},
)
}ai_state arrives as a plain String with no SDK enum, so match the states you expect and fall through safely on anything else.
Stopping generation
There is no dedicated stop method — send the stop event on the channel:
ChatClient.instance().channel(cid)
.sendEvent(EventType.AI_TYPING_INDICATOR_STOP)
.enqueue()The agent is responsible for honouring it and keeping the partial answer.
Building a standalone AI screen
For a ChatGPT-style app, compose the pieces yourself on the low-level client:
@Composable
fun AiChatScreen(cid: String) {
val chatClient = ChatClient.instance()
val channelClient = remember(cid) { chatClient.channel(cid) }
val assistantState by rememberAssistantState(cid)
Column {
// Your own message list, rendering each AI reply with StreamingText.
if (assistantState != null) {
AITypingIndicator(label = { Text("Thinking") })
}
ChatComposer(
onSendClick = { messageData ->
channelClient.sendMessage(Message(text = messageData.text)).enqueue()
},
onStopClick = {
channelClient.sendEvent(EventType.AI_TYPING_INDICATOR_STOP).enqueue()
},
isGenerating = assistantState == "AI_STATE_GENERATING",
)
}
}MessageData.attachments holds the picked content URIs. Stream uploads attachments from a File, so convert each URI before sending — the sample app shows one way.
What the backend has to send
The app only renders what the agent writes, and it cannot tell when a field is missing:
ai_generated: trueon the reply when it is created, or it renders as an ordinary message with raw markdown.- One message, updated as tokens arrive — not one message per chunk. Use
ephemeralUpdateMessagefor the interim updates and a regular partial update for the final one, as described in Best practices. generating: truewhile streaming andfalseon the final update, on stop and on error.ai_indicator.updatewhile working andai_indicator.clearwhen done, or there is no indicator.
For a reference implementation showing how to integrate these components with Stream Chat's client API, please check our sample app.