# 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:

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

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

If your agent stores its reasoning and tool calls on the reply, render them in `CustomAttachmentContent`, not in `MessageTextContent`. They are custom attachments, which the message list hands to `CustomAttachmentContent`, above the text. `MessageTextContent` only runs once a message has text, so a reply that is still reasoning, or a tool call waiting for approval, would show nothing. Pass the message's other attachments to the default:

```kotlin
class AiStepsComponentFactory(private val approver: AIToolApprover) : ChatComponentFactory {

    @Composable
    override fun CustomAttachmentContent(params: CustomAttachmentContentParams) {
        val message = params.state.message
        val (steps, others) = message.attachments.partition {
            it.type.orEmpty().startsWith(AIMessagePart.TYPE_PREFIX)
        }
        if (steps.isNotEmpty()) {
            AIMessageParts(parts = message.aiParts(), approver = approver, modifier = params.modifier)
        }
        if (others.isNotEmpty()) {
            super.CustomAttachmentContent(
                params.copy(state = params.state.copy(message = message.copy(attachments = others))),
            )
        }
    }
}
```

`aiParts()` and the `approver` are described in [Reasoning and Tool Calls](https://getstream.io/chat/docs/sdk/android/ai-components/reasoning-and-tool-calls/). Override both slots in the same factory to show the steps and the answer.

Because the agent streams its answer by updating one message, every update sets the message's `messageTextUpdatedAt`, and the default message footer shows **Edited** whenever it's set. To hide the label on AI replies, override `MessageFooterContent` in the same factory:

```kotlin
@Composable
override fun MessageFooterContent(params: MessageFooterContentParams) {
    val messageItem = params.messageItem
    if (messageItem.message.extraData["ai_generated"] == true) {
        // Without messageTextUpdatedAt, the default footer doesn't show "Edited".
        val message = messageItem.message.copy(messageTextUpdatedAt = null)
        super.MessageFooterContent(params.copy(messageItem = messageItem.copy(message = message)))
    } else {
        super.MessageFooterContent(params)
    }
}
```

The message list only calls `MessageTextContent` once a message has text, so the empty placeholder has nothing to animate until the first update arrives. Show what the agent is doing meanwhile with the AI indicator, as described next.

## Reacting to AI indicator events

The agent reports what it is doing with [`ai_indicator.*` events](https://getstream.io/chat/docs/sdk/android/ai-components/conversation-state/#say-what-the-agent-is-doing). Subscribe on the **channel**, since `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:

```kotlin
@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`:

```kotlin
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, with the id of the reply, as described in [Stop the reply](https://getstream.io/chat/docs/sdk/android/ai-components/conversation-state/#stop-the-reply):

```kotlin
ChatClient.instance().channel(cid)
    .sendEvent(
        eventType = EventType.AI_TYPING_INDICATOR_STOP,
        extraData = mapOf("message_id" to replyId),
    )
    .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. [`AIConversation`](https://getstream.io/chat/docs/sdk/android/ai-components/conversation-view/) can be the message list, with the reply's steps, attachments and actions built in:

```kotlin
@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, or AIConversation.

        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, as shown in [Show a message while it's sent](https://getstream.io/chat/docs/sdk/android/ai-components/conversation-view/#show-a-message-while-its-sent). The [sample app](https://github.com/GetStream/stream-chat-android/tree/main/stream-chat-android-ai-compose-sample) shows a whole app.

## What the backend has to send

The app only renders what the agent writes, and it cannot tell when a field is missing. [What your backend sends](https://getstream.io/chat/docs/sdk/android/ai-components/conversation-state/#what-your-backend-sends) covers each one:

- `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 [Stream with ephemeral updates](https://getstream.io/chat/docs/sdk/android/ai-components/conversation-state/#stream-with-ephemeral-updates).
- `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](https://github.com/GetStream/stream-chat-android/tree/main/stream-chat-android-ai-compose-sample).

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/android/ai-components/compose-integration/](https://getstream.io/chat/docs/sdk/android/ai-components/compose-integration/).