AI Integrations
The AI UI components are designed specifically for AI-first applications written in SwiftUI. When paired with our real-time Chat API, they make integrating with and rendering responses from LLM providers such as ChatGPT, Gemini, Anthropic or any custom backend easier, by providing out-of-the-box components able to render Markdown, code blocks, tables, charts, images, thinking indicators, reasoning and tool calls.
The components ship as the StreamChatAI library of the Stream Chat Swift SDK, and include:
StreamingMessageView- renders text, markdown, code and charts in real-time, using character-by-character animation, similar to ChatGPT.AIComposerView- a fully featured prompt composer with attachments, chat options and speech input.SpeechToTextButton- a reusable button that records voice input and streams the recognized transcript back into your UI.AITypingIndicatorView- displays different states of the LLM (thinking, checking external sources, etc).SuggestionsView- displays conversation starters for your users with your chat assistant.StreamingReasoningView- streams a model's reasoning into view while it thinks, then folds it into "Thought for 12s".AIMessagePartsView- shows the steps an agent took while replying (rounds of reasoning and tool calls), in order.AIToolApprovalView- asks the user whether a tool call may run, such as sharing their location.AIClientToolRunner- runs the tool calls an agent addresses to this device and sends back their results.SidebarView- a side menu that opens with a drag from the leading edge, for example for the list of conversations.AIOnDeviceModel- Apple's on-device model, for answering when your agent can't.
You can find a complete ChatGPT clone sample that uses these components here.
StreamChatAI is available from version 5.13.0 of the Stream Chat Swift SDK. Earlier, the components shipped in the standalone stream-chat-swift-ai package. If your app uses that package, see Migrating from stream-chat-swift-ai.
Installation
StreamChatAI is a library of the stream-chat-swift package, next to StreamChat and StreamChatUI, starting with version 5.13.0. Use the following steps to add it via the Swift Package Manager (SPM) in Xcode:
- Select "Add Package Dependencies..." in the File menu.
- Paste the URL https://github.com/GetStream/stream-chat-swift.git.
- In the option "Dependency Rule" choose "Up to Next Major Version", and enter "5.13.0".
- Add the StreamChatAI library to your app target.
If your app already uses the Chat SDK, the package is already in your project: add the StreamChatAI library to your app target's frameworks.
You can also add the library in your package file as a dependency:
dependencies: [
.package(url: "https://github.com/GetStream/stream-chat-swift.git", from: "5.13.0")
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "StreamChatAI", package: "stream-chat-swift")
]
)
]Then import the module wherever you use the components:
import StreamChatAIStreamChatAI depends on StreamCore, John Sundell's Splash and Guille Gonzalez's Swift Markdown UI, but not on the other Chat libraries, so you can pair it with StreamChatSwiftUI or with a UI of your own.
Requirements
- iOS 15 or later.
AIComposerViewrequires iOS 16, and charts render as code blocks on iOS 15. - Xcode 27 or later when you add StreamChatAI with SPM. The package supports iOS 13, while the Markdown renderer requires iOS 15. Xcode 27 raises the StreamChatAI target to iOS 15, and older versions of Xcode reject it.
Usage descriptions
The composer's attachment picker uses the camera and the photo library, and SpeechToTextButton uses the microphone and speech recognition. Add the matching keys to your Info.plist, each with a description of why your app needs it:
NSCameraUsageDescriptionandNSPhotoLibraryUsageDescription, for the attachment picker.NSMicrophoneUsageDescriptionandNSSpeechRecognitionUsageDescription, for dictation.
Migrating from stream-chat-swift-ai
Before version 5.13.0, the components shipped in the standalone stream-chat-swift-ai package (up to version 0.12.0), which StreamChatAI replaces. To move over:
- Remove the
stream-chat-swift-aipackage and add the StreamChatAI library as described above. The module name is the same, so yourimport StreamChatAIstatements stay. - Replace
ColorswithAIAppearance. The components no longer take acolors:parameter: they read their colors, fonts and icons from the appearance you inject, as described in Customizing the appearance. - Rename the composer types, which now start with
AI(see the table below), because StreamChatSwiftUI and StreamChatUI use the same names. - Set
isGeneratingon theAIComposerViewModelinstead of passing it to the composer view, as described in Let users stop the response. - Move your client tools from
ClientToolandClientToolRegistrytoAIClientToolandAIClientToolRunner, as described in Client Side Tools. StreamChatAI no longer depends on the Model Context Protocol (MCP) SDK: a tool is described with anAIClientToolDefinitioninstead of the MCP SDK'sTool, and one defined with the MCP SDK converts withtry AIClientToolDefinition(encoding: mcpTool).
| stream-chat-swift-ai | StreamChatAI |
|---|---|
ComposerView |
AIComposerView |
ComposerViewModel |
AIComposerViewModel |
ComposerViewFactory |
AIComposerViewFactory |
DefaultViewFactory |
DefaultAIComposerViewFactory |
ComposerInputView |
AIComposerInputView |
LeadingComposerViewOptions |
AIComposerLeadingViewOptions |
TrailingComposerViewOptions |
AIComposerTrailingViewOptions |
ComposerInputViewOptions |
AIComposerInputViewOptions |
ComposerInputTrailingViewOptions |
AIComposerInputTrailingViewOptions |
ComposerPickerViewOptions |
AIComposerPickerViewOptions |
ClientToolInvocation, ClientToolAction, ClientToolActionHandling and ToolRegistrationPayload were removed along with the registry: a tool does its work in run(_:), and the runner's registrations replace registrationPayloads(). A tool that already conformed to AIClientTool now declares a definition instead of a name.
AIComposerViewFactory and SpeechHandler are now @MainActor, so create and use them on the main actor. The stream-chat-swift-ai package required iOS 16. StreamChatAI supports iOS 15, apart from AIComposerView, which still requires iOS 16.
Components
Streaming Message View
The StreamingMessageView is a component that can render markdown content efficiently. It has code syntax highlighting, supporting all the major languages. It can render most of the standard markdown content, such as tables, images, charts, etc.
Under the hood, it implements letter by letter animation, with a character queue, similar to ChatGPT.
Here's an example how to use it.
StreamingMessageView(
content: content,
isGenerating: true
)Additionally, you can specify the speed of the animation, with the letterInterval parameter. The default value is 0.005 (5ms).
Code blocks render with syntax highlighting and a button that copies the code. A code block whose language is chart, json, chartjs, echarts, highcharts, vega-lite or vegalite, and whose content is a chart spec, renders as a native chart instead. The view reads Chart.js, ECharts, Highcharts, Plotly and (a subset of) Vega-Lite specs, as well as a simpler format of its own, which is handy to describe in your system prompt:
```chart
{
"chart_type": "bar",
"title": "Signups per month",
"x_label": "Month",
"y_label": "Users",
"series": [
{
"name": "2026",
"points": [{ "x": "Jan", "y": 120 }, { "x": "Feb", "y": 180 }, { "x": "Mar", "y": 240 }]
}
]
}
```The supported chart types are line, bar, area, scatter, bubble, pie, heatmap and histogram. Charts are drawn with Swift Charts, so they require iOS 16: on iOS 15 the spec renders as a code block, and on iOS 16 pie charts draw as bars.
AI Typing Indicator View
The AITypingIndicatorView is used to present different states of the LLM, such as "Thinking", "Checking External Sources", etc. You can specify any text you need. There's also a nice animation when the indicator is shown.
AITypingIndicatorView(text: "Thinking")Streaming Reasoning View
The StreamingReasoningView shows a model's reasoning (its "thinking") alongside its reply. While the model thinks, the reasoning streams into an open panel under a "Thinking... 7s" header. Once the model is done, the view folds into "Thought for 12s", and tapping the header opens the whole reasoning again.
StreamingReasoningView(
text: reasoning,
isThinking: answer.isEmpty,
duration: thinkingDuration
)The Reasoning and Tool Calls guide covers its options, and how to show the reasoning and tool calls your agent stores on its reply.
Composer View
The AIComposerView gives users a modern text-entry surface with attachment previews, chat options, and an integrated send button. It requires iOS 16. Pass a closure that receives a MessageData (the text, the attachment URLs and the selected chat option) every time the user taps send:
AIComposerView { message in
print(message.text, message.attachments)
}To control the composer from outside the view, for example to prefill the text, focus the field or offer chat options, pass your own AIComposerViewModel:
@StateObject private var composerViewModel = AIComposerViewModel()
AIComposerView(viewModel: composerViewModel) { message in
send(message)
}The composer clears its text and attachments once a message is sent. It keeps the temporary files it made for the attached photos, since the sent message may still be uploading them, and the system removes them later. While the view model's isGenerating is true, the composer swaps its send button for a stop button, as described in Let users stop the response.
The chatOptions of the view model, such as an "agent" or "deep research" mode, are listed in the attachment picker sheet, and tapping one calls its action. Set the option as the view model's selectedChatOption from there, to show it as a chip in the composer and send it with the message.
To focus the text field, or dismiss the keyboard, from anywhere that holds the view model, set isTextFieldFocused:
composerViewModel.isTextFieldFocused = trueCustomizing the composer
AIComposerView takes a viewFactory that conforms to AIComposerViewFactory. The protocol has five slots, and each one has a default implementation, so you override only the ones you want to change:
| Slot | Factory method | Default |
|---|---|---|
| Left of the text field | makeLeadingComposerView(options:) |
AddAttachmentsButton |
| The text field area | makeComposerInputView(options:) |
AIComposerInputView |
| Inside the text field, while it is empty | makeComposerInputTrailingView(options:) |
SpeechToTextButton |
| Right of the text field | makeTrailingComposerView(options:) |
EmptyView |
| Attachment picker sheet | makeComposerPickerView(options:) |
Built-in photo/camera picker |
For example, the following factory replaces the attachment button with a paperclip and leaves dictation out:
final class CustomComposerFactory: AIComposerViewFactory {
func makeLeadingComposerView(options: AIComposerLeadingViewOptions) -> some View {
Button {
options.onTap()
} label: {
Image(systemName: "paperclip")
.padding(10)
.background(.ultraThinMaterial, in: Circle())
}
}
func makeComposerInputTrailingView(options: AIComposerInputTrailingViewOptions) -> some View {
EmptyView()
}
}
AIComposerView(viewFactory: CustomComposerFactory()) { message in
send(message)
}The trailing slot is empty by default. Override makeTrailingComposerView(options:) to add a mode toggle, a slash-command trigger, or any other control next to the field.
The factory is @MainActor, like SwiftUI views. Its option types have public initializers, so a custom slot can build the default views itself, for example an AIComposerInputView with a trailing view of your own, and send messages through the onMessageSend closure it receives.
Speech to Text Button
SpeechToTextButton turns voice input into text using Apple's Speech framework. When tapped it asks for microphone access, records audio, and forwards the transcript recognized so far through its closure. Recording stops after silenceTimeout seconds of silence (3 by default), or when the button is tapped again.
SpeechToTextButton(locale: Locale(identifier: "en-US")) { transcript in
print("User said:", transcript)
}AIComposerView already shows one inside its text field, which dictates into the field. Use the button on its own when you build your own input.
These components are designed to work seamlessly with our existing SwiftUI Chat SDK. Our developer guide explains how to get started building AI integrations with Stream and SwiftUI.
Suggestions View
The suggestions view allows you to provide conversation starters with the chat assistant. It accepts a list of strings and a handler when a suggestion is tapped.
SuggestionsView(suggestions: suggestions) { messageData in
sendMessage(messageData)
}You can also pass the height of the row (100 by default) and the itemMaxWidth of a suggestion (160 by default).
Sidebar View
SidebarView puts a side menu, such as the list of the user's conversations, next to your content. The user opens it by dragging from the leading edge, and closes it by dragging back or by tapping the dimmed content. Bind isOpen to open and close it from code, for example from a toolbar button:
@State private var isSidebarOpen = false
SidebarView(isOpen: $isSidebarOpen, excludedBottomHeight: 80) {
ConversationListView()
} content: {
ConversationView()
}The menu takes splitWidthRatio of the width (0.82 by default), and a drag opens it only when it starts within edgeActivationWidth points of the leading edge (32 by default). Drags that start in the bottom excludedBottomHeight points of the content, such as on the composer, are left to the content.
On-Device Model
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.
Customizing the appearance
The components are styled with AIAppearance. It builds on DesignSystemTokens, the color, layout and typography tokens shared by Stream's SDKs, adds the colors and fonts only the AI components use (colors and fonts), and holds their icons (images). The components read the appearance through dependency injection, so set it once, before they are first shown, for example when your app starts:
let tokens = DesignSystemTokens()
tokens.colors.accentPrimary = .systemPurple
let appearance = AIAppearance(tokens: tokens)
appearance.colors.reasoningRule = .systemPurple
appearance.colors.suggestionBackground = .systemMint.withAlphaComponent(0.3)
appearance.fonts.suggestion = .footnote
appearance.images.composerSend = Image(systemName: "paperplane.circle.fill")
InjectedValues[\.aiAppearance] = appearanceStreamChatAI re-exports StreamCore and DesignSystemTokens, so this code needs no other import besides SwiftUI.
The AI colors and fonts are derived from the tokens, so change the tokens before you create the appearance. The colors are grouped by component: composer..., attachmentPicker..., attachmentBadge..., suggestion..., codeBlock... and code... (syntax highlighting, with light and dark variants), reasoning..., toolCall..., toolApproval... and sidebar.... The fonts are named the same way, such as suggestion, codeBlockLanguage, chartTitle and toolCallDetail, and messagePart is the font of the reasoning, tool call and approval views. Each of those views also takes a font: parameter, which overrides it.
Localization
Every text the components show, such as the composer's "Ask anything" placeholder or "Thought for 12s", goes through AIAppearance.localizationProvider, which reads the library's own Localizable strings table by default. Replace it to change or translate the texts. For example, to read your own strings first and fall back to the library's:
let libraryText = AIAppearance.localizationProvider
AIAppearance.localizationProvider = { key, table in
let text = Bundle.main.localizedString(forKey: key, value: nil, table: "StreamChatAI")
return text == key ? libraryText(key, table) : text
}With this provider, a StreamChatAI.strings table in your app overrides the keys it lists, such as "composer.placeholder.ask_anything" = "Message the assistant";, and every other text stays as it is. The keys are listed in the library's Localizable.strings.
Best practices
Mark assistant messages with ai_generated
The Chat SDK treats a reply from your AI bot like any other message. To render it with StreamingMessageView and its character-by-character animation, the app needs a way to tell AI replies apart from the rest.
The convention used by the AI components, the sample apps and the Stream backend SDKs is a custom field, ai_generated: true, set by the backend when it creates the assistant's placeholder message:
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 field is available as message.extraData["ai_generated"]. Use it in three places:
- Rendering. Return
truefromhasCustomAttachmentfor AI messages, so the SDK asks your view factory'smakeCustomAttachmentViewTypefor their content, and renderStreamingMessageViewthere. This is what gives the reply its character-by-character animation. A message without the flag renders as a regular text bubble that jumps to the new text on every update. - The "Edited" label. Every update the backend makes to the text counts as an edit, so by default the message list marks every AI reply as edited. Hide the label for AI messages with
skipEditedMessageLabel. - Your backend. The bot usually listens for new messages in the channel. Checking
ai_generatedlets it skip its own replies instead of answering itself.
class CustomMessageResolver: MessageTypeResolving {
func hasCustomAttachment(message: ChatMessage) -> Bool {
message.extraData["ai_generated"]?.boolValue == true
}
}
let messageListConfig = MessageListConfig(
skipEditedMessageLabel: { message in
message.extraData["ai_generated"]?.boolValue == true
}
)
let utils = Utils(
messageTypeResolver: CustomMessageResolver(),
messageListConfig: messageListConfig
)
let streamChat = StreamChat(chatClient: chatClient, utils: utils)The second field the app reads is generating. The SwiftUI integration passes it to StreamingMessageView as isGenerating. The view only animates, and only follows the text as it grows, while isGenerating is true, so the backend should set generating: true on every interim update. When it's false, the view shows the full text at once. That is also why stored replies (saved with generating: false) don't replay the animation when the user scrolls back to them.
Drive the AI indicator from events
Before the first token arrives, the LLM can spend a while thinking or calling tools, and the message is still empty. The backend reports what it's doing with ai_indicator.* custom events on the channel, and the iOS SDK decodes them into typed events:
| Event type | iOS event | Sent by | Use it to |
|---|---|---|---|
ai_indicator.update |
AIIndicatorUpdateEvent |
Backend | Show the current state of the LLM. Carries the state, the cid, the messageId of the reply and an optional aiMessage. |
ai_indicator.clear |
AIIndicatorClearEvent |
Backend | Hide the indicator. Sent after the final text is stored. |
ai_indicator.stop |
AIIndicatorStopEvent |
App | Ask the backend to stop the reply. See Let users stop the response. |
The state of an update is an AITypingState:
ai_state |
AITypingState |
Suggested UI |
|---|---|---|
AI_STATE_THINKING |
.thinking |
AITypingIndicatorView(text: "Thinking") |
AI_STATE_EXTERNAL_SOURCES |
.checkingExternalSources |
AITypingIndicatorView(text: "Checking external sources") |
AI_STATE_GENERATING |
.generating |
Hide the indicator, the text is now streaming into the message. Show a stop button. |
AI_STATE_ERROR |
.error |
Hide the indicator. The backend writes the error into the message itself. |
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.
Listen with chatClient.eventsController(), not a channel events controller. A ChannelEventsController (from channelController.eventsController() or chatClient.channelEventsController(for:)) only delivers events it can tie to its channel, and the AI indicator events aren't among them, so its delegate never receives them. Use the client-wide controller and compare each event's cid with the open channel yourself:
@MainActor
final class AIIndicatorHandler: ObservableObject, EventsControllerDelegate {
@Published var indicatorText: String?
@Published var generatingMessageId: MessageId?
private let cid: ChannelId
private let eventsController: EventsController
init(cid: ChannelId, chatClient: ChatClient) {
self.cid = cid
eventsController = chatClient.eventsController()
eventsController.delegate = self
}
func eventsController(_ controller: EventsController, didReceiveEvent event: Event) {
switch event {
case let event as AIIndicatorUpdateEvent where event.cid == cid:
switch event.state {
case .thinking:
indicatorText = "Thinking"
case .checkingExternalSources:
indicatorText = "Checking external sources"
default:
indicatorText = nil
}
generatingMessageId = event.state == .generating ? event.messageId : nil
case let event as AIIndicatorClearEvent where event.cid == cid:
indicatorText = nil
generatingMessageId = nil
default:
break
}
}
}Show AITypingIndicatorView(text:) while indicatorText is set, for example as an overlay at the bottom of the message list, as described in the SwiftUI integration. A more complete handler is in the sample app.
Indicator events only reach clients that are connected when they're sent. They aren't stored or replayed, so a user who opens the channel halfway through a reply never gets the earlier AI_STATE_THINKING or AI_STATE_GENERATING. Use them for the indicator only, and rely on the message's text and generating fields, which arrive with every message update, for the reply itself.
Let users stop the response
A long reply can take a while, and the user may already have what they need or see the model heading the wrong way. AIComposerView swaps its send button for a stop button while its view model's isGenerating is true, and calls onStopGenerating when it's tapped. Keep isGenerating in step with the indicator events, and send an AIIndicatorStopEvent to the channel when the stop button is tapped:
AIComposerView(
viewModel: composerViewModel,
onMessageSend: { messageData in
sendMessage(messageData)
},
onStopGenerating: {
channelController
.eventsController()
.sendEvent(AIIndicatorStopEvent(cid: channelController.cid))
indicatorHandler.generatingMessageId = nil
}
)
.onChange(of: indicatorHandler.generatingMessageId) { messageId in
composerViewModel.isGenerating = messageId != nil
}Sending through the channel's events controller is fine: only receiving the AI indicator events needs the client-wide one. Resetting generatingMessageId right away turns the stop button back into the send button without waiting for the backend to answer.
The stop event 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 LLM stream, then finish the same way a normal reply finishes: store the text generated so far with a regular partial update and generating: false, then send ai_indicator.clear. Skipping that final update leaves the message stuck in its last stored state, as described in the next section. The Stream Chat AI SDK and the Stream Chat LangChain SDK already listen for ai_indicator.stop and do this for you.
Use ephemeral updates while the response is streaming
The components above render whatever your backend writes into the assistant's message. A typical backend posts an empty placeholder message as the AI bot, then keeps updating it as the LLM streams tokens back. Each update reaches the app as a message.updated event, and StreamingMessageView animates the new text in.
How the backend sends those updates matters. A regular partial update (partialUpdateMessage) writes the message to the database on every call. A long reply flushed every few tokens turns into dozens or hundreds of writes, and with several conversations running at once you can quickly hit the rate limits for message updates.
For the updates sent while the response is still streaming, use ephemeralUpdateMessage instead:
- It accepts the same payload as a partial update (
set,unsetand the acting user). - It sends the same
message.updatedevent to everyone watching the channel, so the live typing in the app looks exactly the same. - It doesn't store anything in the database, it only sends the event. Skipping the database write is what keeps high-frequency streaming from getting rate limited.
The last update must not be ephemeral. When the LLM finishes, send the complete text with a regular partial update and set generating to false. Ephemeral updates are never stored, so if the final one is ephemeral too, anyone who opens the channel later or relaunches the app loads the last stored version of the message: the empty placeholder, still marked as generating. The same applies to whatever else ends the stream, such as an error message or the user stopping the generation.
The generating field is the one the SwiftUI integration reads to decide whether StreamingMessageView is still animating, so keep it true on interim updates and false on the final one.
ephemeralUpdateMessage is available only on the server side. Call it from your backend, not from the iOS app. In Node.js it ships in stream-chat 9.27.0 and later, and every server-side SDK exposes an equivalent:
// While the LLM is streaming: send the latest text without storing it.
await client.ephemeralUpdateMessage(
messageId,
{ set: { text, generating: true } },
botId,
);
// Once the LLM has finished: store the final text.
await client.partialUpdateMessage(
messageId,
{ set: { text, generating: false } },
botId,
);# While the LLM is streaming: send the latest text without storing it.
client.chat.ephemeral_message_update(
id=message_id,
set={"text": text, "generating": True},
user_id=bot_id,
)
# Once the LLM has finished: store the final text.
client.chat.update_message_partial(
id=message_id,
set={"text": text, "generating": False},
user_id=bot_id,
)# While the LLM is streaming: send the latest text without storing it.
client.chat.ephemeral_message_update(message_id, Models::UpdateMessagePartialRequest.new(
set: { 'text' => text, 'generating' => true },
user_id: bot_id,
))
# Once the LLM has finished: store the final text.
client.chat.update_message_partial(message_id, Models::UpdateMessagePartialRequest.new(
set: { 'text' => text, 'generating' => false },
user_id: bot_id,
))// While the LLM is streaming: send the latest text without storing it.
$client->ephemeralMessageUpdate($messageId, new Models\UpdateMessagePartialRequest(
set: (object)["text" => $text, "generating" => true],
userID: $botId,
));
// Once the LLM has finished: store the final text.
$client->updateMessagePartial($messageId, new Models\UpdateMessagePartialRequest(
set: (object)["text" => $text, "generating" => false],
userID: $botId,
));// While the LLM is streaming: send the latest text without storing it.
_, err := client.Chat().EphemeralMessageUpdate(ctx, messageID, &getstream.EphemeralMessageUpdateRequest{
Set: getstream.PtrTo(map[string]any{
"text": text,
"generating": true,
}),
UserID: getstream.PtrTo(botID),
})
// Once the LLM has finished: store the final text.
_, err = client.Chat().UpdateMessagePartial(ctx, messageID, &getstream.UpdateMessagePartialRequest{
Set: getstream.PtrTo(map[string]any{
"text": text,
"generating": false,
}),
UserID: getstream.PtrTo(botID),
})// While the LLM is streaming: send the latest text without storing it.
await chat.EphemeralMessageUpdateAsync(messageId, new UpdateMessagePartialRequest
{
Set = new Dictionary<string, object>
{
{ "text", text },
{ "generating", true },
},
UserID = botId,
});
// Once the LLM has finished: store the final text.
await chat.UpdateMessagePartialAsync(messageId, new UpdateMessagePartialRequest
{
Set = new Dictionary<string, object>
{
{ "text", text },
{ "generating", false },
},
UserID = botId,
});// While the LLM is streaming: send the latest text without storing it.
chat.ephemeralMessageUpdate(messageId, EphemeralMessageUpdateRequest.builder()
.set(Map.of("text", text, "generating", true))
.userID(botId)
.build()).execute();
// Once the LLM has finished: store the final text.
chat.updateMessagePartial(messageId, UpdateMessagePartialRequest.builder()
.set(Map.of("text", text, "generating", false))
.userID(botId)
.build()).execute();Even ephemeral updates are delivered to every device watching the channel, so don't send one per token. Flushing every 20 or so chunks, or at most every 50 to 100 ms, keeps the typing smooth without wasting bandwidth. The AI message streaming guide covers the full backend flow, including throttling and the AI indicator events that drive AITypingIndicatorView.
If you use the Stream Chat AI SDK or the Stream Chat LangChain SDK on your backend, this is already handled for you: both send ephemeral updates while streaming and store the final text with a regular partial update.