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 / MessageList and route AI replies through a ChatComponentFactory slot. 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: true on 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 ephemeralUpdateMessage for the interim updates and a regular partial update for the final one, as described in Best practices.
  • generating: true while streaming and false on the final update, on stop and on error.
  • ai_indicator.update while working and ai_indicator.clear when 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.