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:AIConversationandChatComposer, 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 agentscommands. - An app with
compileSdk37,minSdk24 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 voiceThat 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-agentThe 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.