# Reasoning and Tool Calls

> **Info:** This is documentation for **Stream Chat React Native SDK v9**, which is no longer actively maintained. For up-to-date documentation, see the [latest version (v10)](https://getstream.io/chat/docs/sdk/react-native/).

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.

>
> **Info:** The components and helpers on this page are available from version 9.13.0 of `stream-chat-react-native` and `stream-chat-expo`, in the `stream-chat-react-native/ai-components` and `stream-chat-expo/ai-components` entry points. See [Install the SDKs](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/#step-2-install-the-sdks).
>

## 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:

```tsx
import { View } from "react-native";
import type { LocalMessage } from "stream-chat";
import {
  AIMessageParts,
  parseMessageParts,
  StreamingMessageView,
} from "stream-chat-react-native/ai-components";

export const AssistantReply = ({ message }: { message: LocalMessage }) => (
  <View>
    <AIMessageParts
      messageId={message.id}
      parts={parseMessageParts(message.attachments)}
    />
    <StreamingMessageView text={message.text ?? ""} />
  </View>
);
```

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](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/conversation-view/) renders the steps for you, and `AIAssistantMessage` does for a reply of your own.

Step ids are only unique within a message, so pass the message's id as `messageId`. A list that reuses the view for another reply then starts each step afresh.

### 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:

```tsx
<AIMessageParts messageId={message.id} parts={parts}>
  {(part) =>
    part.kind === "ai_citation" ? (
      <CitationCard payload={part.payload} />
    ) : (
      <AIMessagePart part={part} />
    )
  }
</AIMessageParts>
```

Steps you render this way are keyed by their `id`, so each keeps its state 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. Tapping the header opens or folds 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:

```tsx
<AIMessageParts
  messageId={message.id}
  parts={parts}
  reasoningText={(stepId) => fullReasoning[stepId]}
/>
```

With Stream Agents, `LiveReasoning` from `@stream-io/stream-agents` reassembles it from the channel's `message.updated` events. Read every event into one, and give `createFromStreamMessage` a `reasoningText` that looks the text up, in the transport's `connect`:

```ts
import { LiveReasoning } from "@stream-io/stream-agents";

const reasoning = new LiveReasoning();

const subscription = channel.on("message.updated", (event) => {
  if (event.message) {
    reasoning.read(event.message as { id: string; reasoning?: unknown });
  }
});

const fromStreamMessage = createFromStreamMessage({
  reasoningText: (messageId, stepId) => reasoning.get(messageId, stepId),
});
```

Unsubscribe in the function `connect` returns, along with the rest.

You can also use `AIReasoning` on its own, with reasoning you get some other way:

```tsx
<AIReasoning
  text={reasoning}
  isThinking={answer === ""}
  duration={thinkingSeconds}
/>
```

Blank lines separate paragraphs, and Markdown is rendered.

| Prop                 | Description                                                                               | Default                     |
| -------------------- | ----------------------------------------------------------------------------------------- | --------------------------- |
| `text`               | The reasoning so far.                                                                     | The step's preview          |
| `part`               | A reasoning step, whose preview, summary, status and duration it shows.                   | -                           |
| `reasoningText`      | `(stepId) => string`: the whole of the step's thinking, when your app has it.             | -                           |
| `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 points, before it scrolls.                               | `260`                       |
| `stepKey`            | Which step this is, across a conversation. When it changes, the view starts afresh.       | `part.id`                   |

Once the reader opens or folds the reasoning themselves, the view keeps their choice.

## Tool calls

`AIToolCall` shows a single tool call (`AIToolCallPart`): what it's doing (its `displayTitle`, 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.

```tsx
{
  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`:

```json
{
  "type": "ai_tool_call",
  "id": "toolu_01A",
  "name": "get_location",
  "status": "awaiting_approval",
  "executor": "client",
  "target_user_id": "u_123",
  "target_client_id": "ios-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 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 here and sends their answer to your backend:

```tsx
import {
  AIClientIdentity,
  type AIToolApprover,
} from "stream-chat-react-native/ai-components";

const approver: AIToolApprover = {
  userId: "u_123",
  clientId: AIClientIdentity.installId(),
  decide: (call, allowed) =>
    backend.answerApproval({ toolCallId: call.id, allowed }),
};
```

```tsx
<AIToolCall part={call} />
<AIToolApproval part={call} approver={approver} />
```

`AIClientIdentity.installId()` is this install's identifier for the app, created on first use and kept until the app's data is cleared: on both platforms in a file that isn't backed up, so a device restored from a backup gets its own. In an app built without the SDK's native code, it lasts as long as the app runs. Send it with the user's messages as `client_id` in their custom data, so the agent can address client tool calls to this device. Declare `client_id` on the custom message data first, in a declaration file of your app such as `stream-chat.d.ts`, so TypeScript accepts it:

```ts
import "stream-chat";

declare module "stream-chat" {
  interface CustomMessageData {
    client_id?: string;
  }
}
```

```tsx
await channel.sendMessage({ text, client_id: AIClientIdentity.installId() });
```

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/stream-agents`.

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.decision` becomes `allowed` and the call goes on. A client tool's call moves to `awaiting_client`.
- **Declined:** the call becomes `cancelled`, with `approval.decision` set to `declined`, and never runs. `isDeclined(call)` tells it apart from other cancelled calls, and `AIToolCall` shows it as "Declined".

The question is visible to every channel member, so keep private data out of it.

### What the call sends

Under the question, the card shows what the call will send, so the user knows what they allow: the call's `arguments`, as indented JSON, under "What it sends". Arguments longer than 280 characters or 6 lines start folded, and the user taps to open them. A call without arguments shows nothing there.

To show them your own way, such as an email as it will be sent rather than its JSON, give the approver an `argumentsPreview`. It returns the view to show, `null` to show nothing, or `undefined` for the default:

```tsx
const approver: AIToolApprover = {
  userId: "u_123",
  clientId: AIClientIdentity.installId(),
  decide: (call, allowed) =>
    backend.answerApproval({ toolCallId: call.id, allowed }),
  argumentsPreview: (call) =>
    call.name === "send_email" ? (
      <EmailPreview email={call.arguments} />
    ) : undefined,
};
```

`AIToolArguments` is the default view, for a card of your own, and `AIToolApprovalCard` takes the preview as `argumentsPreview`.

### Your own design

To ask in your own design, pass a render function. It gets the question, where the answer is (`AIToolApprovalState`, with `isSending` and `failed`), a function that answers, and what the call sends, as the approver previews it, or `null`:

```tsx
<AIToolApproval part={call} approver={approver}>
  {(approval, state, decide, argumentsPreview) => (
    <MyApprovalCard
      title={approval.title}
      details={argumentsPreview}
      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`:

```json
{
  "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": "The assistant 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`. `createAuthSessionAuthorizer` makes one that runs the sign-in in the platform's web authentication session:

```tsx
import { createAuthSessionAuthorizer } from "stream-chat-react-native/ai-components";

const approver: AIToolApprover = {
  userId: "u_123",
  clientId: AIClientIdentity.installId(),
  decide: (call, allowed) =>
    backend.answerApproval({ toolCallId: call.id, allowed }),
  authorizer: createAuthSessionAuthorizer({
    callbackScheme: "com.example.app",
    decline: (call) => backend.declineSignIn({ toolCallId: call.id }),
  }),
};
```

`AIMessagePart`, `AIMessageParts` and the [conversation view](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/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 on the device.

The sign-in comes back to a return URL on your app's own scheme, such as `com.example.app://sign-in`. Give it to the agent's backend as the session's return URL, since the sign-in comes back only to the URL it named, and pass its scheme as `callbackScheme`. The Stream Agents router takes only `https` URLs or a scheme in reverse domain form, as above. On Android, register the scheme on your app's main activity as you would for deep links, which Expo's `scheme` in `app.json` does for you.

When the user taps **Connect**:

1. The sign-in page, the authorization's `url`, opens in `ASWebAuthenticationSession` on iOS, and on Android in the browser's Auth Tab, or a Custom Tab where the browser has none.
2. The page reads the `handoff_token`, which ties the sign-in to this device, and leaves for the provider.
3. The provider sends the user back to your return URL, and the session closes. The card shows that it's connecting until the step changes. If the step hasn't changed after 30 seconds, it offers **Connect** again.
4. Your backend stores the grant and updates the step: the call goes back to `running`, with `authorization.status` set to `connected`. 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.

Closing the sign-in without finishing it declines the call as well, since the router's sign-in page opens only once. Pass `declineOnClose: false` if your backend can give the call a new sign-in. If the sign-in can't be opened, for example in an app built without the SDK's native code, the card says so and the user can try again.

With Stream Agents, `decline` is `session.declineLogin(call.id)`, and you pass the return URL as `returnUrl` when you create the session. With Expo Router, keep the app on the screen the sign-in was opened from, as the [Personal assistant](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/use-cases/personal-assistant/#open-a-conversation) use case shows.

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:

```tsx
<AIToolAuthorization part={call} approver={approver}>
  {(authorization, state, { connect, decline }) => (
    <MySignInCard
      title={authorization.title}
      busy={state.isConnecting || state.isDeclining}
      failed={state.connectFailed || state.declineFailed}
      onConnect={connect}
      onDecline={decline}
    />
  )}
</AIToolAuthorization>
```

`AIToolAuthorizationCard` is the default design. To run the sign-in some other way, write an `AIToolAuthorizer` of your own: a `connect(call)` that resolves `true` once the account is connected, `false` when it ended without connecting, or `'closed'` when the user closed it and it can't be opened again, and a `decline(call)`.

## Client tools

A tool call with `executor: client` runs on the user's device, such as reading the phone's clock or location. The agent writes it on the reply with status `awaiting_client`, addressed to one user (`target_user_id`) and one install (`target_client_id`), which your backend copies from the `client_id` of that user's message. A call whose tool asks first reaches the device only once the user allowed it.

Describe each tool as an `AIClientTool`: a `definition`, which reads the same JSON as a Model Context Protocol tool (`name`, `description`, `inputSchema`, and `annotations.readOnlyHint` for a tool that only reads), and a `run` that returns its result:

```tsx
import {
  AIClientIdentity,
  type AIClientTool,
  AIClientToolRunner,
  completedToolResult,
} from "stream-chat-react-native/ai-components";

const localContext: AIClientTool = {
  definition: {
    name: "get_local_context",
    description: "The user's current date, local time, timezone and language.",
    inputSchema: { type: "object", properties: {} },
    annotations: { readOnlyHint: true },
  },
  run: () => {
    const { locale, timeZone } = Intl.DateTimeFormat().resolvedOptions();
    return completedToolResult(
      { language: locale, local: new Date().toString(), timeZone },
      "Shared the local time",
    );
  },
};

const runner = new AIClientToolRunner({
  userId: "u_123",
  clientId: AIClientIdentity.installId(),
  tools: [localContext],
});
```

A result has the `output` for the model, which must serialize to JSON, and a short `summary` every channel member sees on the step. `failedToolResult(reason)` reports a call the device couldn't run, such as "Location not shared". A call's `arguments` hold what the agent passed. Keep private data out of summaries: "Shared approximate location", not the coordinates.

Send `runner.registrations` to your backend, so the agent can offer the tools to its model. Then hand the runner each AI reply's steps as they change:

```tsx
useEffect(() => {
  runner.run(parseMessageParts(reply.attachments), (call, result) =>
    backend.sendToolResult({ toolCallId: call.id, result }),
  );
}, [reply]);
```

Keep one runner while the user is signed in. It runs a call only when it awaits this user and this install, and only once. It leaves alone a call whose step has no `id` of its own: such a step is told apart only by its place in the reply, which another reply repeats. If `send` rejects, the result is offered again on a later update, without running the tool again, up to `maxAttempts` (at least 1, 3 by default). A tool that throws reports that it failed, without what it threw. Your backend should still accept a result only from the targeted user and install, only while the call waits, and only once.

The same tools can run from the UI an agent generates. See [Generative UI](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/generative-ui/#run-your-apps-tools).

## 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 AI components' theme, which `StreamTheme` provides. Its `messageParts` key styles them, such as `approvalCard`, `authorizationCard` and `unsupported`, and its `icons` replace the icons they draw, such as `HandRaised`, `Key` or `Device`. Their texts, such as "Thinking...", "Waiting for approval", "Allow", "Connect" or "Not now", go through the Chat SDK's translations inside `Chat`, so you can change or translate them with `Streami18n`.

## Going further

- [Conversation view](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/conversation-view/): Lay an agent conversation out by turn
- [ExpoChatGPTSample](https://github.com/GetStream/stream-chat-react-native/tree/v9-release/examples/ExpoChatGPTSample): An assistant that asks before its tools run, connects accounts in the chat and runs a tool on the phone

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/reasoning-and-tool-calls/](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/reasoning-and-tool-calls/).