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:
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, and ChannelTransport is written below. 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.
AIConversation is part of StreamChatAI, available from version 5.16.0 of the Stream Chat Swift SDK. See Install the SDKs.
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. | - |
composerViewModel |
What the user is writing, such as a view model with your chat options. | A new one |
approver |
Who answers the approvals and sign-ins that tool calls wait for. See Reasoning and Tool Calls. | nil |
actions |
What its views offer: .edit, .regenerate and .rate. See 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:
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 withinreplyTimeout. Itsmessageis what the agent said, if anything, and itslocalizedDescriptionfalls back to "The agent couldn't answer. Try again." (theconversation.error.agentkey).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 anAIAgentActivity, ornilonce it stops saying. Itsstateuses the raw values of Stream Chat's AI indicator, such as.thinkingor.checkingExternalSources, and itstextis 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
messageand the messages after it, or write the new reply in place ofmessage, under its id. Without amessage, 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
messageto say the request'stext, 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, sends what the user asks as a message in the channel, and stops the agent with an AIIndicatorStopEvent:
import Combine
import Foundation
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:
createNewMessagecompletes 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'serror, wait insend(_:)until the message'slocalStateisnil(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'srating, 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.
What your backend sends
With Stream Chat, your backend writes the agent's reply into the channel, and the transport shows what it writes. A typical backend posts an empty placeholder message as the agent, updates it as the model streams, and stores the final text. Stream Agents does all of this for you. With a backend of your own, follow the conventions below, which the components, the transport above and Stream's backend SDKs share. The AI message streaming guide covers the whole backend flow.
Mark the agent's replies
Set two custom fields on the agent's reply:
ai_generated: true, when the backend creates the placeholder. Your app reads it to tell the agent's replies apart, for example for your message'srole, and your backend checks it to skip the agent's own replies instead of answering them.generating,trueon every update while the reply streams, andfalseon the final one.ChatAIMessagereads it asisGenerating. A reply types itself out only while it'strue, so a stored reply, saved withfalse, doesn't type itself out again when the user scrolls back to it.
const { message } = await channel.sendMessage({
text: "",
user_id: botId,
ai_generated: true,
});placeholder = channel.send_message(
MessageRequest(text="", user_id=bot_id, custom={"ai_generated": True}),
).data.messageOn iOS, the fields are message.extraData["ai_generated"] and message.extraData["generating"].
Say what the agent is doing
Before the first word of the reply arrives, the model can spend a while thinking or calling tools, and the message is still empty. The backend says what it's doing with ai_indicator.* custom events on the channel, which the transport above turns into the conversation's activity:
| Event type | iOS event | Sent by | Means |
|---|---|---|---|
ai_indicator.update |
AIIndicatorUpdateEvent |
Backend | The agent's state changed. Carries the state, the cid, the reply's messageId, and an optional aiMessage. |
ai_indicator.clear |
AIIndicatorClearEvent |
Backend | The reply is done. Sent after the final text is stored. |
ai_indicator.stop |
AIIndicatorStopEvent |
App | The user stopped the reply. See Stop the reply. |
The state of an update is one of these:
ai_state |
AITypingState |
The agent is... |
|---|---|---|
AI_STATE_THINKING |
.thinking |
Thinking. The conversation view shows "Thinking... 4s". |
AI_STATE_EXTERNAL_SOURCES |
.checkingExternalSources |
Calling a tool, such as a search. |
AI_STATE_GENERATING |
.generating |
Writing the reply, which streams into the message. |
AI_STATE_ERROR |
.error |
Unable to answer. The conversation's error is an AIAgentError. |
A typical reply sends AI_STATE_THINKING right after the placeholder, AI_STATE_EXTERNAL_SOURCES while a tool call runs, AI_STATE_GENERATING with the first token, and ai_indicator.clear once the final text is stored.
The indicator events reach the client's eventsController(), not a channel's events controller, which only delivers events it can tie to its channel. They also reach only the clients connected when they're sent, and aren't stored, so a user who opens the channel halfway through a reply never gets the earlier ones. Use them for the indicator only, and rely on the message's text and generating, which arrive with every update, for the reply itself.
Stop the reply
When the user stops the reply, the transport sends ai_indicator.stop. It carries only the channel's cid, not a message id, so on the backend treat it as "stop whatever reply is in flight in this channel". Abort the model's stream, then finish the way a normal reply finishes: store the text written so far with a regular partial update and generating: false, then send ai_indicator.clear. Without that final update, the message stays as it was last stored.
Stream with ephemeral updates
A regular partial update writes the message to the database on every call. A long reply flushed every few tokens turns into hundreds of writes, and with several conversations at once, you can hit the rate limits for message updates. While the reply streams, update it with ephemeralUpdateMessage instead. It takes the same payload and sends the same message.updated event, so the reply types itself out the same way, but it stores nothing.
The last update must not be ephemeral. When the model finishes, store the complete text with a regular partial update, and generating: false. Otherwise anyone who opens the channel later loads the last stored version of the message: the empty placeholder, still generating. The same goes for whatever else ends the reply, such as an error or the user stopping it.
// While the model streams: send the latest text without storing it.
await client.ephemeralUpdateMessage(
messageId,
{ set: { text, generating: true } },
botId,
);
// Once the model is done: store the final text.
await client.partialUpdateMessage(
messageId,
{ set: { text, generating: false } },
botId,
);Ephemeral updates are server-side only, and every server-side SDK has them: the AI message streaming guide shows each one. Even ephemeral updates reach every device watching the channel, so don't send one per token: flush every 20 or so chunks, or at most every 50 to 100 ms.
Answer on the device
When your agent can't answer, because the user is offline or the agent reached its usage limit, a model on the device still can. AIOnDeviceModel is Apple's on-device model (Foundation Models), available on iOS 26 and later with Apple Intelligence turned on, and the conversation never leaves the device. It streams the answer, and each element is the whole answer so far:
let model = AIOnDeviceModel()
do {
try await backend.send(text)
} catch let error as URLError where error.code == .notConnectedToInternet {
guard model.isAvailable else { throw error }
let turns: [AIConversationTurn] = history + [.user(text)]
for try await answer in model.reply(instructions: "Answer briefly. You have no tools.", turns: turns) {
localAnswer = answer
}
}It's a small model, good for short answers and for working with what's in the conversation. Its context holds a few thousand tokens, so the oldest turns of a long conversation are left out. Tell it in the instructions what it can't do, since it has none of your agent's tools. Its answer isn't sent to the channel: keep it on the device, or send it yourself. To use another model, conform it to the AILocalModel protocol.
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.editrequest.
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:
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 guide lists the factory's other methods.
Generative UI
To show the UI your agent writes in place of a reply's text, pass the conversation's AIUIHost as ui:
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.