Reasoning and Tool Calls
Before an agent answers, it often reasons, and it may call tools: search the web, look up an order, or reach the user's Linear. 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. Stream Agents writes them for you.
The components and helpers on this page are available from version 14.15.0 of stream-chat-react, in the stream-chat-react/ai-components entry point. See Usage.
Show the steps of a reply
parseMessageParts 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:
import type { LocalMessage } from "stream-chat";
import {
AIMessageParts,
parseMessageParts,
StreamingMessage,
} from "stream-chat-react/ai-components";
export const AssistantReply = ({ message }: { message: LocalMessage }) => (
<div>
<AIMessageParts parts={parseMessageParts(message.attachments)} />
<StreamingMessage text={message.text ?? ""} />
</div>
);Each step shows through AIMessagePart: a round of reasoning as an AIReasoning, 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, and AIAssistantMessage does for a reply of your own.
Steps are an open set
New kinds of steps can appear without breaking your code. A part's kind is the attachment type (ai_reasoning, ai_tool_call, or any other ai_ type), and the kinds the SDK reads have typed views, part.reasoning and part.toolCall. Anything else keeps its payload, the attachment's fields. Statuses are open in the same way, so give a switch over them a default 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 (isSupportedPart(part) 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 a kind of step of your own, render each part yourself and fall back to AIMessagePart for the rest:
<AIMessageParts parts={parts}>
{(part) =>
part.kind === "ai_citation" ? (
<CitationCard payload={part.payload} />
) : (
<AIMessagePart part={part} />
)
}
</AIMessageParts>Steps are keyed by their 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. AIMessagePart shows it with AIReasoning: 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. Clicking the header opens or closes the reasoning.
When your app has the whole of the reasoning, such as from live updates, pass it as reasoningText, and the step shows it instead of its preview:
<AIMessageParts
parts={parts}
reasoningText={(stepId) => fullReasoning[stepId]}
/>With Stream Agents, LiveReasoning from @stream-io/calypso reassembles it from the channel's message.updated events.
You can also use AIReasoning on its own, with reasoning you get some other way:
<AIReasoning
text={reasoning}
isThinking={answer === ""}
duration={thinkingSeconds}
/>Blank lines separate paragraphs, and Markdown is rendered. Only the paragraph still being written is rendered again as the reasoning grows.
| Prop | Description | Default |
|---|---|---|
text |
The reasoning so far. | The step's preview |
part |
A reasoning step, whose preview, summary, status and duration it shows. | - |
isThinking |
Whether the model is still thinking. While it is, the header counts seconds. | Whether part is streaming |
duration |
How long the model thought, in seconds, shown as "Thought for 12s" once it's done. | The step's duration |
since |
When the thinking began (Date.now()), for a header that counts from before it appeared. |
- |
summary |
A one-line summary shown beside the header once the model is done. | The step's summary |
footnote |
A note under the open reasoning, such as how long it's kept. | - |
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, in pixels, before it scrolls. | 260 |
Once the reader opens or closes the reasoning themselves, the view keeps their choice.
Tool calls
AIToolCall shows a single tool call (AIToolCallPart): what it's doing (its display_title, such as "Checking your calendar", 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 key while it waits for a sign-in, a device while it waits for the user's device to run it, and a check mark, an exclamation mark or a minus once it completed, failed or was cancelled. A status the SDK doesn't know shows as still running.
{
part.toolCall && <AIToolCall part={part.toolCall} />;
}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".
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": "web-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"
}
}AIToolApproval asks the question under the call. It shows only to that user, on the install the call names (or in any of their browsers, 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 here and sends their answer to your backend:
import {
AIClientIdentity,
type AIToolApprover,
} from "stream-chat-react/ai-components";
const approver: AIToolApprover = {
userId: "u_123",
clientId: AIClientIdentity.installId(),
decide: (call, allowed) =>
backend.answerApproval({ toolCallId: call.id, allowed }),
};<AIToolCall part={call} />
<AIToolApproval part={call} approver={approver} />AIClientIdentity.installId() is this browser's identifier for the app, created on first use and kept in localStorage. Where storage is unavailable, it lasts as long as the page. Send it with the user's messages as client_id in their custom data, so the agent can address client tool calls to this browser.
Passing the approver to AIMessageParts, AIAssistantMessage or AIConversation does this for every tool call of the reply, and AIToolApproverProvider provides it to all the components below it. While the answer is on its way, and until the call's step changes, the buttons are disabled. If decide rejects, the card says the answer couldn't be sent, and the user can answer again.
With Stream Agents, decide is session.decide(call.id, { allowed }) from @stream-io/calypso.
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. - Declined: the call becomes
cancelled, withapproval.decisionset todeclined, and never runs.isDeclined(call)tells 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 a render function. It gets the question, where the answer is (AIToolApprovalState, with isSending and failed), and a function that answers:
<AIToolApproval part={call} approver={approver}>
{(approval, state, decide) => (
<MyApprovalCard
title={approval.title}
busy={state.isSending}
onAllow={() => decide(true)}
onDecline={() => decide(false)}
/>
)}
</AIToolApproval>AIToolApprovalCard is the default design, and it works on its own too, for example on a voice call where there's no reply to show the question under. 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": "toolu_01B",
"name": "linear_list_issues",
"status": "awaiting_authorization",
"target_user_id": "u_123",
"authorization": {
"type": "oauth",
"provider": "linear",
"provider_name": "Linear",
"title": "Connect Linear",
"message": "Juno needs your Linear account to read your issues.",
"url": "https://agents.example.com/connect/launch",
"handoff_token": "ht_9f2c",
"expires_at": "2026-11-03T12:10:00Z"
}
}AIToolAuthorization asks the user to sign in under the call, with Connect and Not now. It shows only to that user, only while the call waits, and only when the approver has an authorizer. createPopupAuthorizer makes one that runs the sign-in in a popup:
import { createPopupAuthorizer } from "stream-chat-react/ai-components";
const approver: AIToolApprover = {
userId: "u_123",
clientId: AIClientIdentity.installId(),
decide: (call, allowed) =>
backend.answerApproval({ toolCallId: call.id, allowed }),
authorizer: createPopupAuthorizer({
returnUrl: `${window.location.origin}/connected.html`,
decline: (call) => backend.declineSignIn({ toolCallId: call.id }),
}),
};AIMessagePart, AIMessageParts and the conversation view show it under every tool call, so an approver with an authorizer is all they need. Without an authorizer, no sign-in is offered in the browser.
The sign-in comes back to returnUrl, a page of your app that reports how it went and closes:
<script type="module">
import { completeConnectorSignIn } from "stream-chat-react/ai-components";
completeConnectorSignIn();
</script>The page goes through your bundler like any other entry, such as a second input of a Vite build. Give returnUrl to the agent's backend as the session's return URL, since the sign-in comes back only to the page it named.
When the user clicks Connect:
- The sign-in page, the authorization's
url, opens in a popup. It's opened in the click, so the browser doesn't block it. - The page asks your app for the
handoff_token, which ties the sign-in to this browser, and leaves for the provider. - The provider sends the popup back to your
returnUrl, which reports the outcome to your app and closes. The card shows that it's connecting until the step changes. - Your backend stores the grant and updates the step: the call goes back to
running, withauthorization.statusset toconnected. The sign-in hides, and the call carries on in the same reply.
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(call) is true for it, and AIToolCall shows it as "Declined". If decline rejects, the user can answer again. A sign-in can also end as expired or failed, which cancels the call too.
With Stream Agents, decline is session.declineLogin(call.id), and you pass returnUrl when you create the session.
If the browser blocks the popup, the card asks the user to allow pop-ups and try again.
To ask in your own design, pass a render function. It gets the authorization, where the user's answer is (AIToolAuthorizationState), and the connect and decline actions:
<AIToolAuthorization part={call} approver={approver}>
{(authorization, state, { connect, decline }) => (
<MySignInCard
title={authorization.title}
busy={state.isConnecting || state.isDeclining}
blocked={state.popupBlocked}
onConnect={connect}
onDecline={decline}
/>
)}
</AIToolAuthorization>AIToolAuthorizationCard is the default design.
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. |
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). |
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.
Styling and texts
The steps take their colors from the SDK's theme variables, in light and dark mode, and their class names start with str-chat__ai-, such as str-chat__ai-tool-call and str-chat__ai-tool-approval-card. Their texts, such as "Thinking...", "Waiting for approval", "Allow", "Connect" or "Not now", go through the SDK's translations, so you can change or translate them with Streami18n.