# 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. StreamChatAI shows 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.

>
> **Info:** The views and types on this page are part of StreamChatAI, available from version 5.13.0 of the Stream Chat Swift SDK. See [Install the SDKs](https://getstream.io/chat/docs/sdk/ios/ai-components/#step-2-install-the-sdks).
>

## Show the steps of a reply

`AIMessagePart` decodes the steps among a message's attachments, skipping attachments that aren't AI steps, such as images. `AIMessagePartsView` shows them in order. Render it before the reply's text:

```swift
let parts = AIMessagePart.parts(
    from: message.allAttachments.map { ($0.type.rawValue, $0.payload) }
)

VStack(alignment: .leading) {
    AIMessagePartsView(parts: parts)
    StreamingMessageView(content: message.text, isGenerating: isGenerating)
}
```

Each step shows through `AIMessagePartView`: a round of reasoning as a `StreamingReasoningView`, a tool call as an `AIToolCallView`, and a neutral placeholder ("A step this app version can't show") for a kind of step it doesn't know. [`AIConversationView`](https://getstream.io/chat/docs/sdk/ios/ai-components/conversation-view/) shows the steps of every reply for you. In the Chat SDK's message list, render them in the view of AI messages, as described in [Mark assistant messages with `ai_generated`](https://getstream.io/chat/docs/sdk/ios/ai-components/conversation-state/#mark-the-agents-replies).

### Steps are an open set

New kinds of steps can appear without breaking your code. A part's `kind` is a string-backed value (`.reasoning`, `.toolCall`, or any other `ai_` attachment type), and the kinds the SDK reads have typed views, `part.reasoning` and `part.toolCall`. Anything else keeps its JSON `payload`, which you can read with `part.decode(_:)`. Statuses and executors are open in the same way, so switch over them with a `default`.

Decoding is lenient: missing fields get defaults, a field of the wrong type reads as missing, and a step in a newer format version (`v`) keeps its payload but has no typed view. 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 `AIMessagePartView` for the rest:

```swift
AIMessagePartsView(parts: parts) { part in
    if let reasoning = part.reasoning {
        StreamingReasoningView(part: reasoning, text: fullReasoning[reasoning.id])
    } else if part.kind == "ai_citation", let citation = try? part.decode(Citation.self) {
        CitationView(citation: citation)
    } else {
        AIMessagePartView(part: part)
    }
}
```

## Reasoning

A round of reasoning 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`. `AIMessagePartView` shows it with `StreamingReasoningView`: 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 `StreamingReasoningView` on its own, with reasoning you get some other way, for example streamed from your backend:

```swift
StreamingReasoningView(
    text: reasoning,
    isThinking: answer.isEmpty,
    duration: thinkingDuration
)
```

Reasoning can run to tens of kilobytes and grow many times a second, so the view only lays out what changes: it renders one paragraph at a time, 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. | -                                |
| `duration`           | How long the model thought, shown as "Thought for 12s" once it's done.       | `nil`                            |
| `summary`            | A one-line summary shown beside the header once the model is done.           | `nil`                            |
| `footnote`           | A note under the open reasoning, such as how long it's kept.                 | `nil`                            |
| `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`                            |
| `font`               | The font of the reasoning. The header uses it in a medium weight.            | `AIAppearance.fonts.messagePart` |

Once the reader opens or closes the reasoning themselves, the view keeps their choice. `StreamingReasoningView.foldAnimation` is the animation the view folds and opens with: use it for your own changes that should move along with it. `StreamingReasoningView.foldDuration` 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

`AIToolCallView` shows a single tool call: 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 key badge while it waits for the user to sign in, a phone while it waits for the user's device, and a checkmark, an exclamation mark or a cross once it completed, failed or was cancelled.

```swift
if let call = part.toolCall {
    AIToolCallView(part: call)
}
```

When the agent asks the user's device to run a tool, the device runs it, as described next.

## 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](https://getstream.io/agents/docs/quickstart/?lang=ios), pass the session `AgentTool`s when you open it, and StreamAgents runs them, as the [Personal assistant](https://getstream.io/chat/docs/sdk/ios/ai-components/use-cases/personal-assistant/#give-the-agent-the-calendar-and-reminders) use case shows. With a backend of your own, StreamChatAI runs them:

1. Conform each tool to `AIClientTool`.
2. Give your tools to an `AIClientToolRunner`, and tell your backend about them, so the agent offers them to the model.
3. Hand the runner the steps of each reply. It runs the calls addressed to this device.

### Define a tool

A tool's `definition` describes it the way a Model Context Protocol (MCP) tool is described: a name, a description and a JSON schema of its input. When the agent calls the tool, `run(_:)` does the work on the main actor, so it can update your UI, and returns an `AIClientToolResult`:

```swift
@MainActor
final class OpenScreenTool: AIClientTool {
    struct Arguments: Decodable {
        let screen: String
    }

    let definition = AIClientToolDefinition(
        name: "openScreen",
        description: "Open a screen of the app",
        inputSchema: [
            "type": "object",
            "properties": [
                "screen": ["type": "string", "enum": ["settings", "profile"]]
            ],
            "required": ["screen"]
        ]
    )

    func run(_ call: AIToolCallPart) async -> AIClientToolResult {
        guard let arguments = try? call.decodeArguments(as: Arguments.self) else {
            return .failed("The screen to open is missing")
        }
        AppRouter.shared.open(arguments.screen)
        return .completed(["opened": arguments.screen], summary: "Opened \(arguments.screen)")
    }
}
```

- `.completed(_:summary:)` encodes any `Encodable` value as the output the model sees, with a short summary of the outcome. `.failed(_:)` says why the tool couldn't run.
- The input schema is a `RawJSON`, StreamCore's JSON value, which you can write with Swift literals, as above.
- `instructions` tell the model when to use the tool. Declare them as a `String?`: a `String` property doesn't fulfill the requirement, so the tool would send the default, `nil`. `showExternalSourcesIndicator` says whether the app shows that the tool reaches outside the conversation while it runs (`false` by default).
- A tool that only reads, and changes nothing, can say so with `annotations: .init(readOnlyHint: true)`, from version 5.16.0. A reply's [generated UI](https://getstream.io/chat/docs/sdk/ios/ai-components/generative-ui/#run-your-apps-tools) runs a tool by itself only when it's read-only.
- A tool you already define with the [Swift MCP SDK](https://github.com/modelcontextprotocol/swift-sdk) converts with `try AIClientToolDefinition(encoding: mcpTool)`.

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

```swift
let runner = AIClientToolRunner(
    userID: currentUserID,
    clientID: AIClientIdentity.installID,
    tools: [OpenScreenTool()]
)
```

`AIClientIdentity.installID` is a stable identifier of this install. When a conversation opens, send `runner.registrations` to your backend, so the agent can offer the tools to the model. Each registration carries the tool's `name`, `description`, `instructions`, `parameters` (its input schema) and `showExternalSourcesIndicator`.

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 `client_id` of the user's message, so send the install's identifier with every message:

```swift
channelController.createNewMessage(
    text: messageData.text,
    extraData: ["client_id": .string(AIClientIdentity.installID)]
)
```

Then hand the runner each reply's steps whenever they change, and send each result to your backend:

```swift
runner.run(parts) { call, result in
    try await backend.send(result, for: call, in: message)
}
```

The runner runs a call only when it awaits this user and this install, and only once. If your closure throws, the result is offered again on a later update (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.

## 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-7F3A",
  "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"
  }
}
```

`AIToolApprovalView` 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. Give it an `AIToolApprover`, which says who is signed in on this device and sends their answer to your backend:

```swift
let approver = AIToolApprover(
    userID: currentUserID,
    clientID: AIClientIdentity.installID
) { call, allowed in
    try await backend.answer(call, allowed: allowed)
}

AIToolCallView(part: call)
AIToolApprovalView(call: call, approver: approver)
```

Passing the approver to `AIMessagePartsView(parts: parts, approver: approver)` does this for every tool call of the reply. While the answer is on its way, the buttons are disabled. If `decide` throws, 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.decision` becomes `allowed` and the call goes on. A client tool's call moves to `awaiting_client`, so the device's `AIClientToolRunner` runs it.
- **Declined:** the call becomes `cancelled`, with `approval.decision` set to `declined`, and never runs. `isDeclined` tells it apart from other cancelled calls, and `AIToolCallView` shows 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, and a closure that answers:

```swift
AIToolApprovalView(call: call, approver: approver) { approval, state, decide in
    MyApprovalCard(
        title: approval.title,
        isBusy: state.isSending,
        onAllow: { decide(true) },
        onDecline: { decide(false) }
    )
}
```

`AIToolApprovalCard` is the default design.

## 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": "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"
  }
}
```

>
> **Info:** Tool sign-ins are available from version 5.15.0 of the Stream Chat Swift SDK.
>

`AIToolAuthorizationView` asks the user to sign in under the call. 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 authorizer names your app's URL scheme, which the sign-in page returns to, and sends the user's "Not now" to your backend:

```swift
let approver = AIToolApprover(
    userID: currentUserID,
    clientID: AIClientIdentity.installID,
    authorizer: AIToolAuthorizer(callbackScheme: "com.example.app") { call in
        try await backend.declineSignIn(call)
    }
) { call, allowed in
    try await backend.answer(call, allowed: allowed)
}

AIToolCallView(part: call)
AIToolAuthorizationView(call: call, approver: approver)
```

`AIMessagePartView`, `AIMessagePartsView(parts: parts, approver: approver)` and the [conversation view](https://getstream.io/chat/docs/sdk/ios/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.

When the user taps **Connect**:

1. The sign-in page opens in a web authentication session (`ASWebAuthenticationSession`). It's the authorization's `launchURL`: its `url`, with the handoff token in the fragment, which no server sees. Only an `http` or `https` URL with a host counts as a sign-in page.
2. 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.
3. Your backend stores the grant and updates the step, then the sign-in page returns to your `callbackScheme` with `status=connected` in its query. The card shows "Connecting..." until the step changes.
4. 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.

If the user closes the sheet, or the page returns without `status=connected`, they can connect again. If the page can't be opened, the card says so.

**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 `AIToolCallView` 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.

To ask in your own design, return your view from `makeToolAuthorizationView(options:)` of an `AIConversationViewFactory`. Its options carry the tool `call`, the `authorization`, where the user's answer is (`state`), and the `connect` and `decline` closures:

```swift
final class MyFactory: AIConversationViewFactory {
    func makeToolAuthorizationView(options: AIToolAuthorizationViewOptions) -> some View {
        MySignInCard(
            title: options.authorization.title,
            isBusy: options.state.isBusy,
            onConnect: options.connect,
            onDecline: options.decline
        )
    }
}

AIToolAuthorizationView(viewFactory: MyFactory(), call: call, approver: approver)
```

`AIMessagePartView` takes the factory as its `viewFactory` too, and the conversation view uses the one you pass it. `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.                |
| `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`). |

Every channel member can see the steps, their arguments and their summaries, so keep private data out of them.

## Appearance

The colors of the steps are part of `AIAppearance.colors`: `reasoningTitle`, `reasoningText`, `reasoningFootnote`, `reasoningShimmer` and `reasoningRule` for the reasoning, `toolCallTitle`, `toolCallDetail`, `toolCallAccent`, `toolCallSuccess` and `toolCallFailure` for tool calls, and `toolApprovalTitle`, `toolApprovalMessage`, `toolApprovalBackground`, `toolApprovalBorder`, `toolApprovalAccent` and `toolApprovalFailure` for the approval card. The sign-in card uses the approval card's colors.

Their fonts are part of `AIAppearance.fonts`: `messagePart` for the steps themselves, `reasoningFootnote`, `toolCallDetail`, and `toolApprovalMessage` and `toolApprovalFailure` for the approval and sign-in cards. Their icons are part of `AIAppearance.images`, such as `toolCallAwaitingAuthorization`, and their texts, such as "Waiting for approval", the default "Allow" and "Don't Allow" buttons, or the sign-in's "Connect" and "Not now", go through `AIAppearance.localizationProvider`. See [Theming](https://getstream.io/chat/docs/sdk/ios/ai-components/#theming).

---

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