Quickstart

The AI components give your Android app the screens of an AI assistant: a conversation that streams the agent's replies, a prompt composer with dictation and photos, the agent's reasoning and tool calls, and a face for a voice agent. The agent behind them runs on Stream Agents: you describe it once, with its instructions, knowledge, tools and voice, and your app talks to it with the agents SDK.

This page shows what you can build, how the pieces fit together, and takes you from an empty project to a working assistant.

What you can build

You want to build Start with
An AI-first app, like ChatGPT or Claude: one user, one assistant, many conversations This page, then the Personal assistant use case
A support agent that answers from your help center, looks up orders and hands over to your team The Customer support agent use case
A voice agent the user talks to, with a visual presence and a written transcript The Voice agent use case
An agent that shows its work and asks before it acts Reasoning and Tool Calls
An agent that uses the device: the calendar, the location, a screen of your app The Personal assistant use case
Replies that are UI, such as a trip plan with a map and a form Generative UI

Voice runs through all of them. Every composer has a microphone button that dictates into it, and the same agent can hold the conversation out loud: VoiceSession puts it on a call with the user, AIVoiceGlow or AIVoiceWave shows what it's doing, and AIConversation writes out what was said.

How it fits together

Your app never talks to the model, and it doesn't run the agent. It opens a session with your agent and asks it things. The agent runs on Stream's router, which calls the model, runs the agent's tools and writes the conversation into the session's Stream Chat channel as it goes, and the components show that channel:

sequenceDiagram
    participant App as Android app
    participant Router as Stream Agents
    participant LLM as Model
    App->>Router: agent("my-agent").chat() with the agents SDK
    App->>Router: responses.create("How do I...?")
    Router->>LLM: The conversation so far
    LLM-->>Router: Reasoning, tool calls, tokens
    Router-->>App: The reply in the session's channel, with its steps
    Note over App: AIConversation shows it
  • The agents SDK (stream-agents-android-core) signs the user in, opens and lists sessions, asks, stops a reply, answers the questions of tools that ask first, and runs the tools your app gives the agent on the phone.
  • The AI components (stream-chat-android-ai-compose) show it all: AIConversation and ChatComposer, the reasoning and tool calls of each reply, and the voice visualizations.
  • Your agent lives in a folder of plain files that you sync with the Stream CLI: its instructions, knowledge, skills, tools, connectors and models.

For voice, VoiceSession starts the agent on a call and joins it from the phone. The agent hears the user and answers out loud, and what is said is written into the session's channel.

Before you start

  • A Stream app, from the Stream Dashboard.
  • The beta Stream CLI, which has the getstream agents commands.
  • An app with compileSdk 37, minSdk 24 or later, and Kotlin 2.3 or later.

Step 1: Create the agent

Install the beta CLI, pick your Stream app, and make an agent from the text template:

curl -fsSL https://getstream.io/cli/beta/install.sh | bash
getstream init                                     # pick or create a Stream app
getstream agents init my-agent --template text     # or --template voice

That creates agents/my-agent/, with the agent's settings in agent.yaml and its system prompt in instructions.md. Write what the agent is for in instructions.md, then sync it to Stream and try it in the terminal:

getstream agents sync my-agent
getstream agents test my-agent

The Stream Agents quickstart covers the agent's folder: knowledge, skills, tools, connectors and guardrails.

Step 2: Install the SDKs

Add two libraries to your app: stream-agents-android-core, the client for your agent, and stream-chat-android-ai-compose, the views. Both come from Stream's Maven repository:

// settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven("https://stream-io-repo.com")
    }
}

// build.gradle.kts: needs compileSdk 37 and Kotlin 2.3 or newer
dependencies {
    implementation("io.getstream:stream-agents-android-core:0.2.0")
    implementation("io.getstream:stream-chat-android-ai-compose:7.18.0")
    implementation("androidx.lifecycle:lifecycle-runtime-compose:<version>")
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:<version>")
}

stream-agents-android-core brings Stream Chat and Stream Video with it, which keep the conversation and carry the voice.

Is your agent on a backend of your own? Add only stream-chat-android-ai-compose, and give the views the messages of your own channel, as described in Conversation State. The AI components don't depend on the other Chat UI libraries, so they work next to stream-chat-android-compose or a UI of your own. If your agent answers with generative UI, also add stream-chat-android-ai-a2ui or stream-chat-android-ai-openui.

The AI components ship with Stream Chat Android at the same version as the rest of the SDK, from 7.15.0, and need minSdk 23 on their own. Earlier versions were released separately, from stream-chat-android-ai, as 0.x. The Compose sample builds a ChatGPT-like app on them with a backend of your own.

The composer can attach photos and dictate. Its photo picker is the system's, which needs no permission. Dictation asks for the microphone the first time the user taps it, and RECORD_AUDIO is already in your merged manifest, from Stream Video.

Step 3: Sign the user in

Create one StreamAgents client for your app with your API key, and set the user once they sign in. The user's Stream token comes from your backend, which mints it with your API secret, as described in authentication. The client asks for it again when it expires:

val agents: StreamAgents = StreamAgents(apiKey = "your_api_key")

fun signIn(userId: String, name: String) {
    agents.setUser(User(id = userId, name = name)) { yourBackend.streamToken() }
}

To try things out before you have a backend, mint a token for a test user with the CLI, getstream token jane --ttl 2h, and pass it as agents.setUser(User(id = "jane"), token = "...").

Step 4: Connect a conversation to the agent

The router writes the conversation into the session's Stream Chat channel: each question, and each reply with its reasoning and tool calls. The views show that channel, so connecting them to the agent is two small types. Add them to your project. AgentMessage is one message, as the views show it:

/** A message of the session's channel, as the AI components show it. */
data class AgentMessage(val message: Message) : AIMessage {
    override val id: String = message.id

    // The agent's replies carry `ai_generated: true`.
    override val role: AIMessageRole =
        if (message.extraData["ai_generated"] == true) AIMessageRole.Assistant else AIMessageRole.User
    override val text: String = message.text
    override val isGenerating: Boolean = message.extraData["generating"] == true

    // The reasoning and tool calls the agent writes on its reply, as attachments. Stream Chat
    // lifts `name` out of an attachment's fields, and a tool call's step needs it back.
    override val parts: List<AIMessagePart> = AIMessagePart.parts(
        message.attachments.map { it.type.orEmpty() to it.extraData + ("name" to it.name) },
    )
}

AgentChat watches the channel, asks the agent, stops it, and says whether a reply is on its way. Its approver answers the tools that ask before they run:

/** A conversation with the agent: its channel's messages, and asking and stopping it. */
@OptIn(ExperimentalCoroutinesApi::class)
class AgentChat private constructor(
    val session: AgentSession,
    val approver: AIToolApprover,
    channel: StateFlow<ChannelState?>,
    private val scope: CoroutineScope,
) {
    /** The channel's messages, oldest first, as Stream Chat keeps them up to date. */
    val messages: StateFlow<List<AgentMessage>> = channel
        .flatMapLatest { it?.messages ?: flowOf(emptyList()) }
        .map { messages -> messages.filter { it.deletedAt == null }.map(::AgentMessage) }
        .stateIn(scope, SharingStarted.Eagerly, emptyList())

    /** Whether a reply is on its way, from the first thought to the last word. */
    val isAnswering: StateFlow<Boolean> = combine(session.conversation, messages) { conversation, messages ->
        when (conversation.state) {
            Conversation.State.Responding, is Conversation.State.Working -> true
            else -> messages.lastOrNull()?.isGenerating == true
        }
    }.stateIn(scope, SharingStarted.Eagerly, false)

    /** Asks the agent. The router writes the question into the channel, then the reply. */
    fun send(text: String) {
        scope.launch {
            try {
                session.responses.create(text)
            } catch (_: AgentsException) {
                // Not sent: the user can send it again.
            }
        }
    }

    /** Stops the reply in flight. What was written so far is kept. */
    fun stop() {
        scope.launch {
            try {
                session.interrupt()
            } catch (_: AgentsException) {
                // The reply finished first.
            }
        }
    }

    companion object {
        /** Opens the session's channel. Pass a [scope] on the main thread, such as `viewModelScope`. */
        suspend fun open(context: Context, session: AgentSession, scope: CoroutineScope): AgentChat {
            // Answers the tools that ask before they run, as the signed-in user.
            val userId = agents.backend.user?.id.orEmpty()
            val approver = AIToolApprover(userId, AIClientIdentity.installId(context), scope) { call, allowed ->
                session.decide(call.id, allowed)
            }
            // Connects to Stream Chat as the signed-in user and watches the session's channel.
            return AgentChat(session, approver, session.channelState(context, scope), scope)
        }
    }
}

The agent doesn't answer a message sent to the channel, so send asks the session, and the router writes the question into the channel itself. channelState connects Stream Chat as the user you set in step 3, and keeps the messages up to date, after a reconnect too.

Step 5: Show the conversation

Open a session with your agent in a ViewModel, so the conversation and the user's answers to tools outlive a configuration change, and show it with AIConversation and the composer:

class AssistantViewModel(application: Application) : AndroidViewModel(application) {
    private val _chat = MutableStateFlow<AgentChat?>(null)
    val chat: StateFlow<AgentChat?> = _chat.asStateFlow()

    init {
        viewModelScope.launch {
            // Opens a session with the agent you synced in step 1.
            val session = agents.agent("my-agent").chat()
            _chat.value = AgentChat.open(application, session, viewModelScope)
        }
    }
}

@Composable
fun AssistantScreen(viewModel: AssistantViewModel = viewModel()) {
    val chat by viewModel.chat.collectAsStateWithLifecycle()
    val current = chat
    if (current != null) {
        AgentConversation(current, Modifier.fillMaxSize())
    } else {
        Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
    }
}

@Composable
fun AgentConversation(chat: AgentChat, modifier: Modifier = Modifier) {
    val messages by chat.messages.collectAsStateWithLifecycle()
    val answering by chat.isAnswering.collectAsStateWithLifecycle()

    Column(modifier) {
        AIConversation(
            messages = messages,
            modifier = Modifier.weight(1f),
            // The agent is on it, and its reply isn't in the channel yet.
            isThinking = answering && messages.lastOrNull()?.role != AIMessageRole.Assistant,
            approver = chat.approver,
        )
        ChatComposer(
            onSendClick = { chat.send(it.text) },
            onStopClick = chat::stop,
            // While the agent answers, the send button is a stop button.
            isGenerating = answering,
        )
    }
}

chat() throws an AgentsException when the session can't be opened, for example when the user isn't set or the agent's name is wrong. Catch it to show an error in place of the progress indicator.

Run the app and ask something. The agent thinks, then its reply writes itself out, with Markdown, code and charts, and with the reasoning and tool calls that led to it. While it writes, the send button is a stop button, and under each finished reply the user can copy and share it.

The Conversation View guide covers what else the screen can show, such as suggestions while the conversation is empty, ratings and older messages. send here passes only the text: to send the photos picked in the composer too, see the Personal assistant use case.

Step 6: Add voice

The composer already takes voice: the microphone button dictates into it with Android's SpeechRecognizer.

To let the user talk to the agent instead, start a voice session. The agent joins a call right away, and the phone joins it with the microphone on. Ask for RECORD_AUDIO first:

suspend fun startVoice(context: Context): VoiceSession {
    val voice = VoiceSession.start(context, agents, SessionOptions(agent = "my-agent"))
    voice.join()
    return voice
}

join() doesn't throw. If joining fails, the error is in voice.failure.

Give the agent a face with AIVoiceGlow, which moves differently while the agent is connecting, listening, thinking and speaking. The Voice agent use case builds the whole screen: the agent's state, the visualizations, mute and hang up, and what was said written out as a conversation. An agent made with --template voice also lets you pick its speech-to-text and text-to-speech models, its voice and its greeting.

Theming

The components take their colors and text styles from the MaterialTheme around them, so your app's theme already styles them:

MaterialTheme(colorScheme = lightColorScheme(primary = Color(0xFF6E3FF3))) {
    AssistantScreen()
}

To change one component, pass it colors from its Defaults object, such as AIConversationDefaults.colors(...) or AIUserMessageDefaults.colors(...), and icons from icons(...) where it has them. Each component's guide lists its own under Appearance.

A component that AIConversation or ChatComposer draws for you, such as the user's message bubble or the composer's buttons, is a slot of ChatAiComponentFactory. Override the slots you want with CompoundChatAiComponentFactory, and keep the rest:

CompoundChatAiComponentFactory(
    factory = { current ->
        object : ChatAiComponentFactory by current {
            @Composable
            override fun AIConversationUserMessage(params: AIConversationUserMessageParams) {
                AIUserMessage(
                    text = params.text,
                    attachments = params.attachments,
                    modifier = params.modifier,
                    colors = AIUserMessageDefaults.colors(background = Color(0xFFEDE7FF)),
                )
            }
        }
    },
) {
    AssistantScreen()
}

Localization

Every text the components show, such as the composer's "Ask Assistant" placeholder or "Thought for 12s", is a string resource whose name starts with stream_ai_compose_. Override the ones you want in your app's strings.xml, and add the same names under values-<language> to translate them:

<string name="stream_ai_compose_composer_input_placeholder">Message the assistant</string>

The library ships English only. The names are listed in its strings.xml.

The components

Component What it does Guide
AIConversation Lays out a conversation the way AI assistants do Conversation View
AIMessage One message, as the views show it, made from your channel's messages Conversation State
ChatComposer, SpeechToTextButton Takes what the user writes, with photos and dictation Composer
StreamingText, AITypingIndicator Types out Markdown, code and charts as they stream Streaming Messages
AIMessageParts, StreamingReasoning, AIToolApprovalPrompt Shows a reply's reasoning and tool calls, and asks before a tool runs Reasoning and Tool Calls
AIClientToolRunner Runs the tools the agent asks the device to run Reasoning and Tool Calls
AIUIHost Shows UI the agent writes in place of a reply's text Generative UI
AIVoiceGlow, AIVoiceWave Shows what a voice agent is doing Voice Visualizations
AISuggestions Offers conversation starters Conversation View
AIOnDeviceModel Answers with Gemini Nano on the device when your agent can't Conversation State
ChatAiComponentFactory, *Defaults Sets the colors, icons and slots of all of them Theming

Next steps

  • Customer support agent: answers from your help center, order lookups, a refund the user confirms, and a handover to your team.
  • Personal assistant: a list of conversations, the calendar as a tool on the phone, and a voice mode.
  • Voice agent: a voice conversation with the agent as a glowing globe, and what was said written out.
  • Stream Agents: the agent itself, its tools, knowledge, connectors and phone numbers.