Reasoning and Tool Calls
Before an agent answers, it often reasons, and it may call tools: search the web, look up an order, or ask the user's device for their location. The AI components show these steps in the reply, in the order they happened, with the final answer after them.
An agent describes each step as a custom attachment on its reply message:
ai_reasoning- a round of the model's reasoning.ai_tool_call- a tool the agent called, run by the agent's backend or by a user's device.
The order of the attachments is the order of the steps, and the final answer stays in the message text. The agent's backend adds the steps to the reply's attachments and updates them as it works, with the same message updates that stream the text.
The components and types on this page are available from version 7.15.0 of the stream-chat-android-ai-compose artifact. See the Quickstart.
Show the steps of a reply
AIMessagePart.parts decodes the steps among a message's attachments, skipping attachments that aren't AI steps, such as images. AIMessageParts shows them in order. Render it before the reply's text:
// Stream Chat moves an attachment's `name` out of its extraData. A tool call's step needs it back.
fun Message.aiParts(): List<AIMessagePart> = AIMessagePart.parts(
attachments.map { it.type.orEmpty() to it.extraData + ("name" to it.name) },
)
@Composable
fun AssistantReply(message: Message) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
AIMessageParts(parts = message.aiParts())
StreamingText(
text = message.text,
animate = message.extraData["generating"] == true,
)
}
}The Stream Chat Android SDK lifts an attachment's name field into Attachment.name, so put it back into the payload, as above. Without it, every tool call loses its name. If you have each attachment's payload as JSON text instead, use AIMessagePart.partsFromJson.
Each step shows through AIMessagePartItem: a round of reasoning as a StreamingReasoning, a tool call as an AIToolCall, and a neutral placeholder ("A step this app version can't show") for a kind of step it doesn't know. The conversation view renders the steps for you. To show them in the Chat SDK's message list instead, see the Compose integration.
Steps are an open set
New kinds of steps can appear without breaking your code. A part's kind is an AIMessagePart.Kind holding the attachment type (Kind.Reasoning, Kind.ToolCall, or any other ai_ type), and the kinds the SDK reads have typed views, part.reasoning and part.toolCall. Anything else keeps its payload, a map of its fields. Statuses and executors are open in the same way, so give a when over them an else branch.
Decoding is lenient: missing fields get defaults, a field of the wrong type or an empty string reads as missing, and a step in a newer format version (v) keeps its payload but has no typed view (isSupported is false). Each step has a stable id (a tool call uses the model provider's tool-call ID), so the list updates smoothly while it streams.
To show some steps your own way, such as reasoning your backend streams in full, or a kind of step of your own, render each part yourself and fall back to AIMessagePartItem for the rest:
AIMessageParts(parts = parts) { part ->
val reasoning = part.reasoning
when {
reasoning != null -> StreamingReasoning(part = reasoning, text = fullReasoning[reasoning.id])
part.kind == AIMessagePart.Kind("ai_citation") -> CitationCard(payload = part.payload)
else -> AIMessagePartItem(part = part)
}
}Steps are keyed by their kind and id, so each keeps its state, such as whether its reasoning is open, as the reply updates.
Reasoning
A round of reasoning (AIReasoningPart) carries a capped preview of the model's thoughts (the latest ones while it streams, the opening once it's done) and a one-line summary. AIMessagePartItem shows it with StreamingReasoning: while the model thinks, the reasoning streams into an open panel under a "Thinking... 7s" header, and once it's done, the view folds into "Thought for 12s" and the summary. Tapping the header opens or closes the reasoning.
You can also use StreamingReasoning on its own, with reasoning you get some other way, for example streamed from your backend:
StreamingReasoning(
text = reasoning,
isThinking = answer.isEmpty(),
durationSeconds = thinkingSeconds,
)Reasoning can run to tens of kilobytes and grow many times a second, so the view only lays out what changes: it splits the text into paragraphs that render lazily, so only the paragraph still being written is laid out again. Blank lines separate paragraphs, and inline Markdown (bold, italics, code and links) is rendered.
| Parameter | Description | Default |
|---|---|---|
text |
The reasoning so far. | - |
isThinking |
Whether the model is still thinking. While it is, the header counts seconds. | - |
durationSeconds |
How long the model thought, shown as "Thought for 12s" once it's done. | null |
summary |
A one-line summary shown beside the header once the model is done. | null |
footnote |
A note under the open reasoning, such as how long it's kept. | null |
initiallyExpanded |
Whether the reasoning is open once the model is done. | false |
showsLiveReasoning |
Whether the reasoning is open while the model thinks. | true |
maxExpandedHeight |
How tall the reasoning grows before it scrolls. | 260.dp |
textStyle |
The style of the reasoning. The header uses it in a medium weight. | MaterialTheme.typography.bodyMedium |
colors |
The palette of the view. | StreamingReasoningDefaults.colors() |
The overload that takes a part: AIReasoningPart reads isThinking, durationSeconds and summary from the step, and shows its preview unless you pass the full reasoning as text.
Once the reader opens or closes the reasoning themselves, the view keeps their choice. StreamingReasoningDefaults.foldAnimationSpec() is the spring the view folds and opens with: use it for your own changes that should move along with it. StreamingReasoningDefaults.FoldDurationMillis (450) is how long the fold takes, so a reply that shows its answer only after it lets the reasoning fold first, rather than the two moving against each other.
Tool calls
AIToolCall shows a single tool call (AIToolCallPart): what it's doing (its display_title, such as "Checking your location", or else its name), its outcome (summary), and its duration once it's finished. Its icon follows the status: a spinner while it runs, a hand while it waits for approval, a device while it waits for the user's device to run it, and a check mark, an exclamation mark or a cross once it completed, failed or was cancelled. A status the SDK doesn't know shows as still running.
part.toolCall?.let { call ->
AIToolCall(part = call)
}Without a summary, a call that waits, failed or was cancelled shows its status in words instead, such as "Waiting for approval" or "Didn't complete".
When the agent asks a user's device to run a tool, AIClientToolRunner runs it there and sends the result back, as described in Tools on the device.
Tools on the device
An agent can ask the user's device to run a tool, such as reading their calendar or opening a screen of your app. Only the tool's answer leaves the device.
With Stream Agents, pass the session AgentTools in its SessionOptions when you open it, and the agents SDK runs them, as the Personal assistant use case shows. With a backend of your own, the AI components run them:
- Implement
AIClientToolfor each tool. - Give your tools to an
AIClientToolRunner, and tell your backend about them, so the agent offers them to the model. - Hand the runner the steps of each reply. It runs the calls addressed to this device.
The client tool APIs are available from version 7.15.0 of the stream-chat-android-ai-compose artifact, and a tool's description, inputSchema and isReadOnly from 7.18.0.
Define a tool
A tool has a name, the one the agent knows it by, and a run function. It can also describe itself to the agent: a description of what it does, the JSON Schema of its arguments as inputSchema, in plain Kotlin values, and whether it only reads and changes nothing, as isReadOnly. When the agent calls the tool, run does the work and returns an AIClientToolResult:
class OpenScreenTool : AIClientTool {
override val name: String = "openScreen"
override val description: String = "Open a screen of the app"
override val inputSchema: Map<String, Any?> = mapOf(
"type" to "object",
"properties" to mapOf(
"screen" to mapOf("type" to "string", "enum" to listOf("settings", "profile")),
),
"required" to listOf("screen"),
)
override suspend fun run(call: AIToolCallPart): AIClientToolResult {
// `arguments` is the JSON text of the object the agent sent.
val arguments = call.arguments?.let { runCatching { JSONObject(it) }.getOrNull() }
val screen = arguments?.optString("screen").orEmpty()
if (screen.isEmpty()) {
return AIClientToolResult.failed("The screen to open is missing")
}
AppRouter.open(screen)
return AIClientToolResult.completed(
outputJson = JSONObject().put("opened", screen).toString(),
summary = "Opened $screen",
)
}
}AIClientToolResult.completed(outputJson, summary)carries the output the model sees, a JSON object as text, with a short summary of the outcome.AIClientToolResult.failed(reason)says why the tool couldn't run. Ifrunthrows, the runner reports the call as failed ("The tool failed on this device."), so the agent always gets an answer.runruns in the runner'sscope. WithMainScope(), that's the main thread, so a tool can update your UI state directly, such as to show a dialog or open a screen.- A tool that only reads, such as one that loads the user's calendar, says so with
isReadOnly = true. A reply's generated UI runs a tool by itself, such as to load data while it shows, only when it's read-only. Any other tool runs from it only when the user taps something that runs it. - A tool's
definitioncollects its name, description, argument schema and read-only hint as anAIClientToolDefinition. It reads and writes the same JSON as a Model Context Protocol (MCP) tool:name,description,inputSchemaand thereadOnlyHintof itsannotations.toJson()andtoJsonString()write it, andAIClientToolDefinition.fromJson(map)andfromJsonString(text)read a tool described for MCP, or returnnullwhen it has no name.
Run the calls
Give your tools to one runner, and keep it while the user is signed in. It remembers which calls it already ran, so a new runner could run a call again. Create it when the user signs in, in an object that lives as long as they stay signed in:
// Created when the user signs in, and closed when they sign out.
class SignedInSession(userId: String, clientId: String) {
private val scope = MainScope()
val toolRunner = AIClientToolRunner(
userId = userId,
clientId = clientId,
tools = listOf(OpenScreenTool()),
scope = scope,
)
fun close() {
scope.cancel()
}
}clientId is AIClientIdentity.installId(context), a stable identifier of this install, created on first use and kept until the app's data is cleared. It isn't backed up, so a device restored from a backup gets its own. It reads and writes a small file, so call it off the main thread. The runner is confined to the main thread, so pass a scope that runs on it. When two tools share a name, the first is used.
When a conversation opens, tell your backend about the tools, in whatever format it registers tools with, so the agent can offer them to the model. runner.toolNames lists their names, and each tool's definition has its description and input schema.
A call with executor: client and status: awaiting_client asks a device to run a tool. It names the user (target_user_id) and the install (target_client_id), which your backend copies from the custom data (client_id) of the user's message, so send the install's identifier with every message:
val clientId = withContext(Dispatchers.IO) { AIClientIdentity.installId(context) }
ChatClient.instance().channel(cid)
.sendMessage(Message(text = text, extraData = mapOf("client_id" to clientId)))
.enqueue()Then hand the runner each reply's steps whenever they change, on the main thread, and send each result to your backend:
fun onReplyChanged(message: Message) {
runner.run(message.aiParts()) { call, result ->
backend.sendToolResult(messageId = message.id, toolCallId = call.id, result = result)
}
}aiParts() decodes the reply's steps, as shown in Show the steps of a reply. The runner runs a call only when the call has its own id, awaits this user and this install, and a tool has its name, and it runs each call only once. If your function throws, the result is offered again on a later update that still shows the call waiting (up to maxAttempts, 3 by default), without running the tool again. Your backend should accept a result only from the targeted user and install, only while the call waits, and only once. Every channel member can see a call's arguments and summary, so keep private data out of them: "Shared approximate location" rather than the coordinates.
A call whose tool asks the user first reaches the device only once they allowed it, as described in Tool approvals.
Tool approvals
Some tool calls should wait for the user: the agent wants their location, or to send an email on their behalf. The agent's backend writes such a call with status awaiting_approval, addresses it to that user (target_user_id, and target_client_id for a client tool), and adds the question as its approval:
{
"type": "ai_tool_call",
"id": "toolu_01A",
"name": "get_location",
"status": "awaiting_approval",
"executor": "client",
"target_user_id": "u_123",
"target_client_id": "android-7F3A9C2E-4B1D-4E8A-9F60-2C5D8B7A1E34",
"approval": {
"title": "Share your location?",
"message": "Only your city is shared.",
"reason": "to check the local weather",
"allow_title": "Share location",
"decline_title": "Don't share"
}
}AIToolApprovalPrompt asks the question under the call. It shows only to that user, on the install the call names (or on any of their devices, for a server tool), and only while the call waits, so it can sit under every tool call. Give it an AIToolApprover, which says who is signed in on this device and sends their answer to your backend:
class AssistantViewModel(
userId: String,
clientId: String,
private val backend: AgentBackend,
) : ViewModel() {
val approver = AIToolApprover(
userId = userId,
clientId = clientId,
scope = viewModelScope,
) { call, allowed ->
backend.answerApproval(toolCallId = call.id, allowed = allowed)
}
}AIToolCall(part = call)
AIToolApprovalPrompt(call = call, approver = approver)clientId is this install's identifier, from AIClientIdentity.installId(context), as described in Run the calls. The approver keeps where each answer is, so create it where it outlives the screen, such as a ViewModel, and pass a scope that runs on the main thread.
Passing the approver to AIMessageParts(parts = parts, approver = approver) does this for every tool call of the reply. While the answer is on its way, and until the call's step changes, the buttons are disabled. If decide throws, the card says the answer couldn't be sent, and the user can answer again.
Your backend holds the call until it gets the answer, and accepts one only from the targeted user (and install, for a client tool), only while the call waits, and only once. It then updates the step:
- Allowed:
approval.decisionbecomesallowedand the call goes on. A client tool's call moves toawaiting_client, so the device'sAIClientToolRunnerruns it. - Declined: the call becomes
cancelled, withapproval.decisionset todeclined, and never runs.isDeclinedtells it apart from other cancelled calls, andAIToolCallshows it as "Declined".
The question is visible to every channel member, so keep private data out of it.
To ask in your own design, pass the content. It gets the question, where the answer is (AIToolApprovalState, with isSending and failed), and a function that answers:
AIToolApprovalPrompt(call = call, approver = approver) { approval, state, decide ->
MyApprovalCard(
title = approval.title,
isBusy = state.isSending,
onAllow = { decide(true) },
onDecline = { decide(false) },
)
}AIToolApprovalCard is the default design. Without allow_title and decline_title, its buttons read "Allow" and "Don't allow".
Tool sign-ins
Some tools need the user's own account at a provider, such as "What are my most important Linear tickets?" before the user has connected Linear. The agent's backend then holds the call with status awaiting_authorization, addresses it to that user (target_user_id), and adds the sign-in as its authorization:
{
"type": "ai_tool_call",
"id": "call-1",
"name": "linear__list_issues",
"display_title": "Using Linear",
"status": "awaiting_authorization",
"target_user_id": "u_123",
"authorization": {
"type": "oauth",
"provider": "linear",
"provider_name": "Linear",
"title": "Connect Linear",
"message": "You sign in at Linear, and the agent never sees your password.",
"url": "https://agent.example.com/connectors/oauth/launch/linear",
"handoff_token": "h_7f3a9c",
"expires_at": "2026-10-21T21:42:19.834Z"
}
}Tool sign-ins are available from version 7.17.0 of the stream-chat-android-ai-compose artifact.
The call shows a key while it waits. AIToolAuthorizationPrompt asks the user to sign in under it, with Connect and Not now. It shows only to that user, only while the call waits, only for an oauth sign-in, and only when the approver has an AIToolAuthorizer. The SDK doesn't open the sign-in page itself: the authorizer hands it to your app, and sends the user's "Not now" to your backend. In the view model above, give the approver one:
val approver = AIToolApprover(
userId = userId,
clientId = clientId,
scope = viewModelScope,
authorizer = AIToolAuthorizer(
connect = { call, url -> signIns.open(url) },
decline = { call -> backend.declineSignIn(toolCallId = call.id) },
),
) { call, allowed ->
backend.answerApproval(toolCallId = call.id, allowed = allowed)
}AIToolCall(part = call)
AIToolAuthorizationPrompt(call = call, approver = approver)signIns is your app's code that opens the page, shown below.
AIMessageParts(parts = parts, approver = approver) and the conversation view show it under every tool call, and the call reads "Waiting for you to sign in" to that user, so an approver with an authorizer is all they need. Without an authorizer, no sign-in is offered on the device.
When the user taps Connect:
connectgets the authorization'slaunchUrl: itsurl, with the handoff token in the fragment, which no server sees. Your app opens it in a browser, such as a Custom Tab, and returns once the user is back.- The user signs in at the provider, which hands the grant to the agent's backend, never to the app. The step holds no credential.
- Your backend stores the grant, updates the step, and sends the page back to the return URL it gives its sign-ins, with
status=connectedin its query. Returntruefromconnectthen. The card shows "Connecting..." until the step changes. - The call goes back to
running, withauthorization.statusset toconnected. The sign-in hides, and the call carries on in the same reply.
Return false when the user comes back without connecting, such as after closing the tab, or as soon as they're back when your app can't tell. They can then connect again. If connect throws, the card says the sign-in couldn't be opened.
connect suspends while the page is open, so your app needs a way to resume it. With Custom Tabs, resume it from the activity that handles your return URL, and from onResume for a user who closed the tab:
class SignIns(private val context: Context) {
private var pending: CompletableDeferred<Boolean>? = null
suspend fun open(url: String): Boolean {
val result = CompletableDeferred<Boolean>()
pending?.complete(false)
pending = result
val tab = CustomTabsIntent.Builder().build()
tab.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
tab.launchUrl(context, Uri.parse(url))
return result.await()
}
// The return URL your backend sends the page back to, or null when the user came back without it.
fun onReturn(uri: Uri?) {
pending?.complete(uri?.getQueryParameter("status") == "connected")
pending = null
}
}override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
viewModel.signIns.onReturn(intent.data)
}
override fun onResume() {
super.onResume()
viewModel.signIns.onReturn(null)
}onNewIntent runs before onResume, so a return URL is read first. An Auth Tab, where the browser supports it, returns the URL to your app directly.
Not now calls the authorizer's decline. Your backend ends the call: it becomes cancelled, with authorization.status set to declined, and never runs. isDeclined is true for it, and AIToolCall shows it as "Declined". If decline throws, the user can answer again. Not now stays enabled while the sign-in is open, so a sign-in that never comes back still leaves the user a way out. A sign-in can also end as expired or failed, which cancels the call too. Once its expires_at passes, the card says so and stops offering Connect.
The handoff token travels in the page's address, so a sign-in is offered only for an https page, or an http page on the device itself (localhost, 127.x.x.x or [::1]), such as one a backend run on your machine serves. Any other url, or an authorization without a title, asks nothing.
To ask in your own design, pass the content. It gets the sign-in (AIToolAuthorization), where the answer is (AIToolAuthorizationState, with isConnecting, isDeclining, connectFailed, declineFailed, isExpired, and isBusy and canConnect), and functions that connect and decline:
AIToolAuthorizationPrompt(call = call, approver = approver) { authorization, state, connect, decline ->
MySignInCard(
title = authorization.title,
isBusy = state.isBusy,
onConnect = connect,
onNotNow = decline,
)
}AIToolAuthorizationCard is the default design. To replace it everywhere, including in AIMessageParts and the conversation view, override the AIToolAuthorizationContent slot of ChatAiComponentFactory, as in Customize the views.
Step format
The steps are custom attachments, with their fields at the top level of the attachment. Every step has:
| Field | Description |
|---|---|
type |
The kind of step: ai_reasoning, ai_tool_call, or a kind of your own starting with ai_. |
id |
The step's stable identity. Tool calls use the model provider's tool-call ID. Without one, the step's type and position are used. |
v |
The step's format version, 1 by default. It changes only when a kind changes incompatibly. |
A round of reasoning (ai_reasoning) also has:
| Field | Description |
|---|---|
status |
streaming while the model thinks, or completed (the default). |
summary |
A one-line summary of the reasoning, once it's done. |
preview |
A capped excerpt: the latest thoughts while streaming, the opening once done. |
duration_ms |
How long the model thought, in milliseconds. |
A tool call (ai_tool_call) also has:
| Field | Description |
|---|---|
name |
The tool's name. |
display_title |
What the call is doing, in words for people, such as "Checking your location". |
status |
running (the default), awaiting_approval, awaiting_authorization, awaiting_client, completed, failed or cancelled. |
executor |
Who runs the tool: server (the default) or client. |
target_user_id |
The user whose device must run a client tool, or who must approve the call or sign in. |
target_client_id |
The install that must run a client tool, copied from the client_id in the custom data of the user's message. |
arguments |
The call's arguments as a JSON object, present for client tools, which need them to run. AIToolCallPart.arguments holds them as JSON text. |
summary |
A short, shareable outcome, such as "Found your location". |
duration_ms |
How long the call took, in milliseconds. |
approval |
The question the call asks before it runs: title, message, reason, allow_title, decline_title, and the user's decision. |
authorization |
The sign-in the call waits for: type (oauth), provider, provider_name, title, message, url, handoff_token, expires_at, and how it ended (status: connected, declined, expired or failed). From 7.17.0. |
An approval without a title asks nothing. Every channel member can see the steps, their arguments and their summaries, so keep private data out of them.
Colors and texts
Each view takes its palette from the current MaterialTheme, and you can change one color at a time:
StreamingReasoningDefaults.colors(...):title,text,footnote,shimmerandrule.AIToolCallDefaults.colors(...):title,detail,accent,successandfailure.AIToolApprovalDefaults.colors(...):title,message,background,border,accentandfailure. The sign-in card uses them too.
AIMessageParts and AIMessagePartItem take all three as reasoningColors, toolCallColors and toolApprovalColors, plus a textStyle for the steps. Their texts, such as "Thinking...", "Waiting for approval" or "Allow", are string resources whose names start with stream_ai_compose_reasoning_, stream_ai_compose_tool_call_, stream_ai_compose_tool_approval_ and stream_ai_compose_tool_authorization_, which you can override in your app's strings.xml.