# Conversation State

`AIConversationView` shows a conversation, and `AIComposerView` takes what the user writes. Between them and your agent sits the state of the conversation: its messages, whether the agent is thinking or writing, the message on its way, and why the last request failed. `AIConversation` holds that state and acts on it. You tell it how to reach your agent with a transport, and give it to both views:

```swift
struct AssistantView: View {
    @StateObject private var conversation: AIConversation<ChatAIMessage>

    init(channel: ChatChannelController) {
        _conversation = StateObject(
            wrappedValue: AIConversation(transport: ChannelTransport(channel: channel))
        )
    }

    var body: some View {
        AIConversationView(conversation: conversation)
            .safeAreaInset(edge: .bottom) {
                AIComposerView(conversation: conversation)
            }
    }
}
```

`ChatAIMessage` is the message type from [Messages from Stream Chat](https://getstream.io/chat/docs/sdk/ios/guides/ai-integrations/conversation-view/#messages-from-stream-chat), and `ChannelTransport` is written [below](#a-transport-over-a-stream-chat-channel). With these two views, the conversation:

- Shows the user's message as soon as they send it, and the agent thinking until its reply starts.
- Turns the composer's send button into a stop button while the agent answers, and stops the agent when it's tapped.
- Loads older messages as the user scrolls up.
- Offers to copy, share and rate each finished reply, and to regenerate the latest one.
- Offers to edit the user's latest message, which is then answered again.
- Keeps the error of a request that failed, so the user can try it again.

If you know `useChat` from Vercel's AI SDK on the web, `AIConversation` plays the same part in a SwiftUI app.

>
> **Info:** `AIConversation` is part of StreamChatAI, available from version 5.16.0 of the Stream Chat Swift SDK. See [Installation](https://getstream.io/chat/docs/sdk/ios/guides/ai-integrations/#installation).
>

## Make a conversation

`AIConversation` is an `ObservableObject` on the main actor. Make one per conversation and keep it for as long as its screen shows, for example in a `@StateObject`. It connects to its transport straight away.

| Parameter           | Description                                                                                                                                                                                                                                         | Default   |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- |
| `transport`         | How it reaches the agent and learns what the conversation holds. See [Write a transport](#write-a-transport).                                                                                                                                       | -         |
| `composerViewModel` | What the user is writing, such as a view model with your [chat options](https://getstream.io/chat/docs/sdk/ios/guides/ai-integrations/#composer-view).                                                                                                                  | A new one |
| `approver`          | Who answers the approvals and sign-ins that tool calls wait for. See [Reasoning and Tool Calls](https://getstream.io/chat/docs/sdk/ios/guides/ai-integrations/reasoning-and-tool-calls/#tool-approvals).                                                                | `nil`     |
| `actions`           | What its views offer: `.edit`, `.regenerate` and `.rate`. See [Actions](#actions).                                                                                                                                                                  | `.all`    |
| `replyTimeout`      | How many seconds it waits, once the agent has a request, for the reply to start or the agent to say what it's doing. After that, it shows an `AIAgentError`. While the agent says it's working, it waits as long as that takes. `nil` always waits. | `300`     |

It publishes what the views show, so a view of your own that holds it updates with it:

| Property           | Description                                                                                                                        |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| `messages`         | The conversation as it shows, oldest message first.                                                                                |
| `pendingMessage`   | The message the user sent last that isn't in the conversation yet. It stays until its reply starts, or while it can be sent again. |
| `status`           | Where the latest exchange is: `.ready`, `.submitted`, `.streaming` or `.error`. `status.isAnswering` is `true` for the middle two. |
| `error`            | Why the latest request failed. It stays until the next request, or until a reply starts after all.                                 |
| `activity`         | What the agent says it's doing besides what its reply shows, such as checking external sources.                                    |
| `hasOlderMessages` | Whether there are messages before the first one to load.                                                                           |
| `ratings`          | How the user rated replies, by the reply's id.                                                                                     |
| `editingMessage`   | The user's message they're editing in the composer, if any.                                                                        |

`status` is `.submitted` from the moment the user sends a message until the reply shows something, which is when the conversation view shows the agent thinking. It's `.streaming` while the agent writes the reply, and `.ready` again once the reply is finished or stopped.

The views call its methods, and you can call them too, for example from a button of your own: `send(_:attachments:chatOption:)`, `stop()`, `regenerate(_:)`, `edit(_:)`, `cancelEditing()`, `retry()`, `clearError()`, `rate(_:_:)` and `loadOlderMessages()`.

## Show what the agent is doing and errors

The view's `footer` shows under the latest turn's messages, or under the empty content while there are none. With a conversation, the footer is made again each time the conversation changes, so it can read the conversation directly:

```swift
AIConversationView(conversation: conversation) {
    if conversation.status.isAnswering, let text = conversation.activity?.text {
        AITypingIndicatorView(text: text)
    }
    if let error = conversation.error {
        HStack {
            Text(error.localizedDescription)
            Button("Try again") {
                conversation.retry()
            }
        }
    }
} emptyContent: {
    Text("How can I help?")
}
```

The `error` is one of two kinds:

- **The request failed**, such as when the message couldn't be sent. It's the error your transport's `send(_:)` threw. `retry()` sends the same request again, with the same message id.
- **The agent couldn't answer.** It's an `AIAgentError`, when the agent reports the error state (`AI_STATE_ERROR`) or its reply doesn't start within `replyTimeout`. Its `message` is what the agent said, if anything, and its `localizedDescription` falls back to "The agent couldn't answer. Try again." (the `conversation.error.agent` key). `retry()` asks the agent for the reply again, without sending the user's message twice.

`clearError()` forgets the error and the request that failed.

When the user stops the agent, what it wrote so far stays, and the reply counts as finished even while the channel still marks it as generating. A reply that showed nothing yet is hidden, and the conversation ignores what the agent says it's doing until the agent clears it.

## Write a transport

The transport is the part that knows your agent. It follows what the conversation holds and tells the conversation through a feed, and it sends what the user asks:

| Requirement           | Description                                                                                                                               | Default        |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
| `connect(_:)`         | Starts telling the feed what the conversation holds. The conversation calls it once, when it's made.                                      | -              |
| `send(_:)`            | Sends what the user asks. Return once the agent has the request: the reply arrives through the feed. A thrown error shows as the `error`. | -              |
| `stop()`              | Stops the reply the agent is writing.                                                                                                     | -              |
| `loadOlderMessages()` | Loads the messages before the first one, which arrive through the feed.                                                                   | Does nothing   |
| `rate(_:_:)`          | Tells your backend how the user rated a reply, or `nil` when they took their rating back.                                                 | Kept on device |

The transport is `@MainActor`. Its `AIConversationFeed` has three updates, and holds the conversation weakly, so keep the feed for as long as the transport follows the conversation:

- `update(messages:)` - the conversation's messages, oldest first, each time they change.
- `update(hasOlderMessages:)` - whether there are messages before the first one.
- `update(activity:)` - what the agent says it's doing, as an `AIAgentActivity`, or `nil` once it stops saying. Its `state` uses the raw values of Stream Chat's AI indicator, such as `.thinking` or `.checkingExternalSources`, and its `text` is what the agent says about it.

`isConnected` tells whether the conversation is still there. Stop following what it holds once it isn't.

### Requests

`send(_:)` receives an `AIConversationRequest`:

| Property      | Description                                                                                                                      |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `kind`        | What the user asks for: `.message`, `.regenerate` or `.edit`. It's an open set, so treat a kind you don't know as a new message. |
| `text`        | What the user wrote. When a reply is written again, the text of the message it answers.                                          |
| `attachments` | The files the user picked, on the device, to upload with their message.                                                          |
| `chatOption`  | The option the user picked in the composer, such as thinking harder.                                                             |
| `message`     | The message it's about: the reply to write again, or the user's message they edited.                                             |
| `messageID`   | An id for the user's message. Create the message with it, so the conversation finds its copy by it.                              |

A retry sends the same request again, with the same `messageID`. If the first attempt reached your backend, or you keep a copy of the failed message, use the id so the message isn't added twice.

While a `.regenerate` or an `.edit` is on its way, the conversation leaves out the messages it replaces. Once the new reply starts, it shows the messages as the feed has them again, so update the conversation on your backend before the new reply arrives:

- For a regenerate, remove the request's `message` and the messages after it, or write the new reply in place of `message`, under its id. Without a `message`, it asks for a reply to the latest message, which has none, such as after the agent couldn't answer it.
- For an edit, change the user's `message` to say the request's `text`, under its id, and remove the messages after it.

### A transport over a Stream Chat channel

With Stream Chat, the conversation is a channel. This transport follows the channel's messages and its [AI indicator events](https://getstream.io/chat/docs/sdk/ios/guides/ai-integrations/#drive-the-ai-indicator-from-events), sends what the user asks as a message in the channel, and stops the agent with an `AIIndicatorStopEvent`:

```swift
import Combine
import StreamChat
import StreamChatAI

@MainActor
final class ChannelTransport: AIConversationTransport {
    private let channel: ChatChannelController
    // The AI indicator events reach the client's events controller, not a channel's.
    private let events: EventsController
    private var feed: AIConversationFeed<ChatAIMessage>?
    private var subscriptions: Set<AnyCancellable> = []

    init(channel: ChatChannelController) {
        self.channel = channel
        events = channel.client.eventsController()
    }

    func connect(_ feed: AIConversationFeed<ChatAIMessage>) {
        self.feed = feed
        let needsSynchronizing = channel.state == .initialized
        channel.messagesChangesPublisher.map { _ in () }
            .merge(with: channel.statePublisher.map { _ in () })
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in MainActor.assumeIsolated { self?.channelChanged() } }
            .store(in: &subscriptions)
        events.allEventsPublisher
            .receive(on: DispatchQueue.main)
            .sink { [weak self] event in MainActor.assumeIsolated { self?.received(event) } }
            .store(in: &subscriptions)
        if needsSynchronizing {
            channel.synchronize()
        }
        channelChanged()
    }

    func send(_ request: AIConversationRequest<ChatAIMessage>) async throws {
        let attachments = try request.attachments.map { url in
            try AnyAttachmentPayload(
                localFileURL: url,
                attachmentType: AIMessageAttachment(url: url).kind == .image ? .image : .file
            )
        }
        // A regenerate asks the question again, and an edit sends the new text as a new message.
        try await withCheckedThrowingContinuation { (done: CheckedContinuation<Void, Error>) in
            channel.createNewMessage(
                messageId: request.messageID,
                text: request.text,
                attachments: attachments
            ) { result in
                done.resume(with: result.map { _ in () })
            }
        }
    }

    func stop() async throws {
        try await withCheckedThrowingContinuation { (done: CheckedContinuation<Void, Error>) in
            channel.eventsController().sendEvent(AIIndicatorStopEvent(cid: channel.cid)) { error in
                if let error {
                    done.resume(throwing: error)
                } else {
                    done.resume()
                }
            }
        }
    }

    func loadOlderMessages() async throws {
        try await withCheckedThrowingContinuation { (done: CheckedContinuation<Void, Error>) in
            channel.loadPreviousMessages { error in
                if let error {
                    done.resume(throwing: error)
                } else {
                    done.resume()
                }
            }
        }
        channelChanged()
    }

    private func channelChanged() {
        // The controller lists its messages newest first.
        feed?.update(messages: channel.messages.reversed().map(ChatAIMessage.init))
        feed?.update(
            hasOlderMessages: channel.state == .remoteDataFetched && !channel.hasLoadedAllPreviousMessages
        )
    }

    private func received(_ event: Event) {
        switch event {
        case let event as AIIndicatorUpdateEvent where event.cid == channel.cid:
            feed?.update(activity: AIAgentActivity(
                state: AIAgentActivity.State(rawValue: event.state.rawValue),
                text: event.aiMessage
            ))
        case let event as AIIndicatorClearEvent where event.cid == channel.cid:
            feed?.update(activity: nil)
        default:
            break
        }
    }
}
```

It follows the channel through the controller's publishers, so the controller's delegate stays free for the rest of your app. A few things to keep in mind:

- `createNewMessage` completes once the message is saved on the device, and the SDK sends it to the channel after that. To show a message that couldn't be sent as the conversation's `error`, wait in `send(_:)` until the message's `localState` is `nil` (sent) or `.sendingFailed`, and throw for the latter.
- A channel has no way to write a reply again or to edit a message, so this transport asks the question again for a regenerate, and sends an edit as a new message. The messages they replace come back once the new reply starts. If your backend can't remove or replace them, leave those actions out: `AIConversation(transport: transport, actions: [.rate])`.
- Ratings stay on the device unless the transport implements `rate(_:_:)`. To show the ratings your backend keeps, return them from your message's `rating`, which the conversation shows until the user rates the reply on the device.

For an agent with an API of its own, follow its messages the same way, and send the requests to it, where `.regenerate` and `.edit` can replace the messages they're about.

## Actions

The conversation's views offer what its `actions` allow:

- **Under each finished reply**, an `AIReplyActionsView`: copy and share (iOS 16 and later) while the reply has text, rate (`.rate`), and, while the agent isn't answering, regenerate on the latest reply (`.regenerate`).
- **On the user's latest message**, while the agent isn't answering, pressing the bubble offers to edit it (`.edit`). The message goes back into the composer, which shows "Editing message" with a button that cancels it, and the next message the user sends is an `.edit` request.

The actions reach the factory methods of your own views through their options. `AIReplyActionsViewOptions.actions` holds the reply's `AIReplyActions`, with the user's rating and the closures that regenerate and rate it, and is `nil` only when the list doesn't show an `AIConversation`. `AIUserMessageViewOptions.onEdit` puts the message back in the composer, and is `nil` when the message can't be edited. For example, to add a button of your own next to the conversation's:

```swift
final class AssistantViewFactory: AIConversationViewFactory {
    func makeReplyActionsView(options: AIReplyActionsViewOptions) -> some View {
        HStack(spacing: 0) {
            if let actions = options.actions {
                AIReplyActionsView(text: options.text, actions: actions)
            }
            ReadAloudButton(text: options.text)
        }
    }
}

AIConversationView(conversation: conversation, viewFactory: AssistantViewFactory())
```

The [Conversation View](https://getstream.io/chat/docs/sdk/ios/guides/ai-integrations/conversation-view/#customize-the-messages) guide lists the factory's other methods.

## Generative UI

To show the [UI your agent writes](https://getstream.io/chat/docs/sdk/ios/guides/ai-integrations/generative-ui/) in place of a reply's text, pass the conversation's `AIUIHost` as `ui`:

```swift
AIConversationView(conversation: conversation, ui: ui)
    .safeAreaInset(edge: .bottom) {
        AIComposerView(conversation: conversation)
    }
```

Everything else stays as it is: the actions show under each finished reply, also one that answers only with UI, the user's latest message offers to edit it, and the `footer` and `emptyContent` work the same. To change the views too, pass a factory: `AIConversationView(conversation: conversation, ui: ui, viewFactory: AssistantViewFactory())`. A reply whose UI none of the host's renderers reads shows its text.

The thinking indicator gives way to a reply's UI as soon as it shows. The conversation's `status` can't tell a reply from its UI alone, so it stays `.submitted` until the reply has text, steps or attachments, or is finished. Keep that in mind in a footer that reads the status.

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/ios/guides/ai-integrations/conversation-state/](https://getstream.io/chat/docs/sdk/ios/guides/ai-integrations/conversation-state/).