AI Integrations

The AI UI components are designed specifically for AI-first applications written in Jetpack Compose. When paired with our real-time Chat API, it makes integrating with and rendering responses from LLM providers such as ChatGPT, Gemini, Anthropic or any custom backend easier, by providing out-of-the-box components able to render Markdown, Code blocks, tables, thinking indicators, images, charts etc.

This library includes the following components which assist with this task:

  • StreamingText - a composable that progressively reveals text content word-by-word with smooth animation, perfect for displaying AI-generated responses in real-time, similar to ChatGPT. Includes built-in markdown rendering with support for code blocks, code fences, and Chart.js diagrams.
  • ChatComposer - a fully featured prompt composer with attachments and speech input.
  • SpeechToTextButton - a reusable button that records voice input and streams the recognized transcript back into your UI.
  • AITypingIndicator - a component that can display different states of the LLM (thinking, checking external sources, etc).

You can find a complete ChatGPT clone sample that uses these components here.

Installation

The AI components are available via Maven. Add the dependency to your build.gradle.kts:

dependencies {
    implementation("io.getstream:stream-chat-android-ai-compose:$version")
}

StreamingText

The StreamingText composable progressively reveals text content word-by-word with smooth animation, perfect for displaying AI-generated responses in real-time. It includes built-in markdown rendering with support for code blocks, tables, images, charts, etc.

Here's an example how to use it:

import io.getstream.chat.android.ai.compose.ui.component.StreamingText

@Composable
fun AssistantMessage(
    text: String,
    isGenerating: Boolean
) {
    StreamingText(
        text = text,
        animate = isGenerating
    )
}

Additionally, you can specify the speed of the animation with the chunkDelayMs parameter. The default value is 30ms.

AITypingIndicator

The AITypingIndicator is used to present different states of the LLM, such as "Thinking", "Checking External Sources", etc. You can specify any text you need. There's also a nice animation when the indicator is shown.

Basic Usage:

import io.getstream.chat.android.ai.compose.ui.component.AITypingIndicator

@Composable
fun ThinkingIndicator() {
    AITypingIndicator(
        label = { Text("Thinking") }
    )
}

Customization:

AITypingIndicator(
    modifier = Modifier.padding(16.dp),
    label = { Text("Processing...") },
    indicator = {
        // Custom indicator composable
        CircularProgressIndicator()
    }
)

ChatComposer

The ChatComposer is a complete chat input component that provides text input, attachment support, voice input, and send/stop buttons. It manages state internally and provides a polished UI with automatic keyboard handling.

Basic Usage:

import io.getstream.chat.android.ai.compose.ui.component.ChatComposer
import io.getstream.chat.android.ai.compose.ui.component.MessageData

@Composable
fun ChatScreen(isGenerating: Boolean) {
    ChatComposer(
        onSendClick = { messageData: MessageData ->
            // Handle message send
            sendMessage(messageData.text, messageData.attachments)
        },
        onStopClick = {
            // Handle stopping AI streaming
            stopStreaming()
        },
        isGenerating = isGenerating,
    )
}

The composer automatically shows different buttons based on state: stop button when generating, send button when text is entered, and voice button when text is empty. It also automatically resets attachments once a message is sent.

SpeechToTextButton

SpeechToTextButton turns voice input into text using Android's SpeechRecognizer. When tapped it asks for microphone access, records audio, and forwards the recognized transcript through its closure.

import io.getstream.chat.android.ai.compose.ui.component.SpeechToTextButton
import io.getstream.chat.android.ai.compose.ui.component.rememberSpeechToTextButtonState

@Composable
fun VoiceInput() {
    val state = rememberSpeechToTextButtonState(
        onFinalResult = { transcript ->
            // Handle recognized text
            appendToInput(transcript)
        }
    )

    SpeechToTextButton(
        state = state
    )
}

You can also use onPartialResult to receive real-time updates as the user speaks:

val state = rememberSpeechToTextButtonState(
    onPartialResult = { partialText ->
        // Called with partial results as user speaks
        text = partialText
    },
    onFinalResult = { finalText ->
        // Called with the final result when recording stops
        text = finalText
    }
)

These components are designed to work seamlessly with our existing Chat SDK. Our developer guide explains how to get started building AI integrations with Stream and Jetpack Compose.

Best practices

Mark assistant messages with ai_generated

The Chat SDK treats a reply from your AI bot like any other message. To render it with StreamingText and its word-by-word animation, the app needs a way to tell AI replies apart from the rest.

The convention used by the AI components, the sample apps and the Stream backend SDKs is a custom field, ai_generated: true, set by the backend when it creates the assistant's placeholder message:

const { message } = await channel.sendMessage({
  text: "",
  user_id: botId,
  ai_generated: true,
});

The Android SDK has no dedicated property for it. Like any custom field, it ends up in the message's extra data, so read it as message.extraData["ai_generated"] == true. Use it in three places:

  • Rendering. Override MessageTextContent in your ChatComponentFactory and render StreamingText there for AI messages. This is what gives the reply its word-by-word animation. A message without the flag renders as a regular text bubble that jumps to the new text on every update.
  • The "Edited" label. Every update the backend makes to the text sets the message's messageTextUpdatedAt, and the default message footer shows "Edited" whenever it's set, so by default the message list marks every AI reply as edited. Hide the label for AI messages by overriding MessageFooterContent.
  • Your backend. The bot usually listens for new messages in the channel. Checking ai_generated lets it skip its own replies instead of answering itself.
fun Message.isAiGenerated(): Boolean = extraData["ai_generated"] == true
fun Message.isGenerating(): Boolean = extraData["generating"] == true

class AiChatComponentFactory : ChatComponentFactory {

    @Composable
    override fun MessageTextContent(params: MessageTextContentParams) {
        val message = params.message
        if (message.isAiGenerated()) {
            Box(modifier = params.modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
                StreamingText(
                    text = message.text,
                    animate = message.isGenerating(),
                )
            }
        } else {
            super.MessageTextContent(params)
        }
    }

    @Composable
    override fun MessageFooterContent(params: MessageFooterContentParams) {
        val messageItem = params.messageItem
        if (messageItem.message.isAiGenerated()) {
            // 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)
        }
    }
}

Pass it to the ChatTheme that wraps your chat screen, with ChatTheme(componentFactory = AiChatComponentFactory()). The message list only calls MessageTextContent once a message has text, so the empty placeholder has nothing to animate until the first update arrives. The AI indicator covers that stage.

The second field the app reads is generating, which you pass to StreamingText as animate, as in the Compose integration. StreamingText only reveals new text progressively while animate is true, so the backend should set generating: true on every interim update. When animate is false, it shows the full text at once. That is also why stored replies (saved with generating: false) don't replay the animation when the user scrolls back to them. When animate turns false in the middle of an animation, as it does with the final update, StreamingText finishes revealing the rest of the text instead of jumping to the end.

Drive the AI indicator from events

Before the first token arrives, the LLM can spend a while thinking or calling tools, and the message is still empty. The backend reports what it's doing with ai_indicator.* custom events on the channel, and the Android SDK parses them into typed events:

Event type Android event Sent by Use it to
ai_indicator.update AIIndicatorUpdatedEvent Backend Show the current state of the LLM. Carries the aiState, the cid and the messageId of the reply.
ai_indicator.clear AIIndicatorClearEvent Backend Hide the indicator. Sent after the final text is stored.
ai_indicator.stop AIIndicatorStopEvent App Ask the backend to stop the reply. See Let users stop the response.

The type strings are also available as EventType.AI_TYPING_INDICATOR_UPDATED, EventType.AI_TYPING_INDICATOR_CLEAR and EventType.AI_TYPING_INDICATOR_STOP. aiState is a plain String holding the raw ai_state value, not an enum, so a misspelled state compiles fine and never matches. Compare it with these values:

ai_state Suggested UI
AI_STATE_THINKING AITypingIndicator(label = { Text("Thinking") })
AI_STATE_EXTERNAL_SOURCES AITypingIndicator(label = { Text("Checking external sources") })
AI_STATE_GENERATING Hide the indicator, the text is now streaming into the message. Keep the stop button.
AI_STATE_ERROR Hide the indicator and treat the reply as finished. The backend writes the error into the message itself, and the Stream backend SDKs don't send ai_indicator.clear after it.

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.

Subscribe on the channel, and dispose the subscription yourself. ChannelClient.subscribeFor only delivers events whose cid matches its channel, AI indicator events included, so you don't need to filter them. The subscription isn't tied to a coroutine or to the composition, though: it keeps running until you call dispose() on the Disposable it returns. Hold it in a ViewModel and dispose it in onCleared():

class AIIndicatorViewModel(cid: String) : ViewModel() {

    private val channel = ChatClient.instance().channel(cid)

    // The latest update for the reply in flight, or null while the assistant is idle.
    private val _indicator = MutableStateFlow<AIIndicatorUpdatedEvent?>(null)
    val indicator: StateFlow<AIIndicatorUpdatedEvent?> = _indicator.asStateFlow()

    // The listener runs on a background thread, which is fine for a StateFlow.
    private val subscription: Disposable = channel.subscribeFor(
        AIIndicatorUpdatedEvent::class.java,
        AIIndicatorClearEvent::class.java,
    ) { event ->
        _indicator.value = when (event) {
            // The Stream backend SDKs send no ai_indicator.clear after an error, so reset here.
            is AIIndicatorUpdatedEvent -> event.takeUnless { it.aiState == "AI_STATE_ERROR" }
            else -> null
        }
    }

    override fun onCleared() {
        subscription.dispose()
    }
}

Show AITypingIndicator while the reply is thinking or checking sources, for example between the message list and the composer, as in the Compose integration:

val aiIndicator = viewModel { AIIndicatorViewModel(cid) }
val indicator by aiIndicator.indicator.collectAsState()

val label = when (indicator?.aiState) {
    "AI_STATE_THINKING" -> "Thinking"
    "AI_STATE_EXTERNAL_SOURCES" -> "Checking external sources"
    else -> null
}
if (label != null) {
    AITypingIndicator(label = { Text(label) })
}

The subscribeFor overloads that take a LifecycleOwner dispose the subscription for you, but they also drop every event that arrives while the lifecycle is below STARTED. If ai_indicator.clear arrives while the screen is stopped, for example while the app is in the background, the event is lost and the indicator stays on.

Indicator events only reach clients that are connected and watching the channel when they're sent. They aren't stored or replayed, so a user who opens the channel halfway through a reply never gets the earlier AI_STATE_THINKING or AI_STATE_GENERATING. Use them for the indicator only, and rely on the message's text and generating fields, which arrive with every message update, for the reply itself.

If you send these events from your own backend, always include message_id in ai_indicator.update. The Android SDK requires it: it can't parse an update without it, drops the event and treats the failure as a connection error, so the client reports a disconnect and reconnects. The Stream backend SDKs always include it.

Let users stop the response

A long reply can take a while, and the user may already have what they need or see the model heading the wrong way. ChatComposer swaps its send button for a stop button while isGenerating is true, and calls onStopClick when it's tapped.

While isGenerating is true, ChatComposer also disables its text field and attachment button. Set it for the whole reply, from the first ai_indicator.update until ai_indicator.clear or AI_STATE_ERROR, and make sure it always resets: if it gets stuck at true, the user can't type anymore. With the view model above, that's indicator != null. Add a stop function to the view model and pass it to the composer:

// In AIIndicatorViewModel
fun stopGenerating() {
    val indicator = _indicator.value ?: return
    channel.sendEvent(
        eventType = EventType.AI_TYPING_INDICATOR_STOP,
        extraData = mapOf("message_id" to indicator.messageId),
    ).enqueue()
    _indicator.value = null
}
ChatComposer(
    onSendClick = { messageData -> sendMessage(messageData) },
    onStopClick = aiIndicator::stopGenerating,
    isGenerating = indicator != null,
)

sendEvent sends the entries of extraData as top-level fields of the event, so the stop event carries the message_id of the reply next to the channel's cid. Without it, the backend only knows which channel to stop. Resetting the indicator right away turns the stop button back into the send button, and re-enables the text field, without waiting for the backend to answer.

On the backend, stop the reply whose id matches message_id, or whatever reply is in flight in the channel if the event has none. Abort the LLM stream, then finish the same way a normal reply finishes: store the text generated so far with a regular partial update and generating: false, then send ai_indicator.clear. Skipping that final update leaves the message stuck in its last stored state, as described in the next section. The Stream Chat AI SDK and the Stream Chat LangChain SDK already listen for ai_indicator.stop and do this for you, including the message_id check.

Use ephemeral updates while the response is streaming

The components above render whatever your backend writes into the assistant's message. A typical backend posts an empty placeholder message as the AI bot, then keeps updating it as the LLM streams tokens back. Each update reaches the app as a message.updated event, and StreamingText animates the new text in.

How the backend sends those updates matters. A regular partial update (partialUpdateMessage) writes the message to the database on every call. A long reply flushed every few tokens turns into dozens or hundreds of writes, and with several conversations running at once you can quickly hit the rate limits for message updates.

For the updates sent while the response is still streaming, use ephemeralUpdateMessage instead:

  • It accepts the same payload as a partial update (set, unset and the acting user).
  • It sends the same message.updated event to everyone watching the channel, so the live typing in the app looks exactly the same.
  • It doesn't store anything in the database, it only sends the event. Skipping the database write is what keeps high-frequency streaming from getting rate limited.

The last update must not be ephemeral. When the LLM finishes, send the complete text with a regular partial update and set generating to false. Ephemeral updates are never stored, so if the final one is ephemeral too, anyone who opens the channel later or relaunches the app loads the last stored version of the message: the empty placeholder, still marked as generating. The same applies to whatever else ends the stream, such as an error message or the user stopping the generation.

The generating field is the one the Compose integration reads to decide whether StreamingText is still animating, so keep it true on interim updates and false on the final one.

ephemeralUpdateMessage is available only on the server side. Call it from your backend, not from the Android app. In Node.js it ships in stream-chat 9.27.0 and later, and every server-side SDK exposes an equivalent:

// While the LLM is streaming: send the latest text without storing it.
await client.ephemeralUpdateMessage(
  messageId,
  { set: { text, generating: true } },
  botId,
);

// Once the LLM has finished: store the final text.
await client.partialUpdateMessage(
  messageId,
  { set: { text, generating: false } },
  botId,
);

Even ephemeral updates are delivered to every device watching the channel, so don't send one per token. Flushing every 20 or so chunks, or at most every 50 to 100 ms, keeps the typing smooth without wasting bandwidth. The AI message streaming guide covers the full backend flow, including throttling and the AI indicator events that drive AITypingIndicator.

If you use the Stream Chat AI SDK or the Stream Chat LangChain SDK on your backend, this is already handled for you: both send ephemeral updates while streaming and store the final text with a regular partial update.