Stream Foundation Models Responses Into SwiftUI

Waiting for an on-device model to finish a whole response before updating the screen makes a good feature feel slower than it is. The user taps a button, the app freezes into a spinner, and then a fully formed answer appears all at once.

Foundation Models has a better fit for SwiftUI. Its streaming API returns an AsyncSequence of response snapshots. For plain text, that can look like familiar incremental text. For structured output, the snapshot’s content is the PartiallyGenerated associated type for your @Generable result, which means your view can render fields as they arrive without parsing half-finished JSON.

The examples below use the Xcode 26.5 SDK shape: streamResponse returns a LanguageModelSession.ResponseStream, and each element is a ResponseStream.Snapshot with content and rawContent.

Describe the response

Start by describing a small response that maps naturally to the UI. Apple calls out property order as part of streaming behavior, so put the first thing you want to show first and the fields that depend on earlier context later.

import FoundationModels

@Generable
struct DraftReply {
    @Guide(description: "A short title for the reply")
    var title: String

    @Guide(description: "Three short bullets that preview the answer", .count(3))
    var bullets: [String]

    @Guide(description: "A concise final paragraph")
    var summary: String
}

This type gives you two useful shapes:

DraftReply
DraftReply.PartiallyGenerated

DraftReply is the complete response. DraftReply.PartiallyGenerated is the streaming-friendly shape that the framework updates while generation is still in progress. For a simple @Generable struct like this one, the synthesized partial type mirrors the stored properties as optionals, so the view renders with optional checks:

if let title = reply.title {
    Text(title)
}

That is the whole reason streaming structured output feels nice in SwiftUI. You render the state you have right now, and SwiftUI fills in the rest as the state changes.

Build a streaming view model

Here is a production-shaped model that checks availability, keeps only one request active, supports cancellation, and collects the final typed response after the stream finishes.

import Foundation
import FoundationModels
import Observation

@MainActor
@Observable
final class ReplyComposerModel {
    var prompt = ""
    var partialReply: DraftReply.PartiallyGenerated?
    var completedReply: DraftReply?
    var isGenerating = false
    var errorMessage: String?

    private let languageModel = SystemLanguageModel.default
    private var generationTask: Task<Void, Never>?
    private var generationID = UUID()

    var canGenerate: Bool {
        !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
        !isGenerating &&
        isModelAvailable
    }

    var isModelAvailable: Bool {
        if case .available = languageModel.availability {
            return true
        }

        return false
    }

    func generate() {
        let input = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !input.isEmpty else { return }

        generationTask?.cancel()

        let id = UUID()
        generationID = id
        partialReply = nil
        completedReply = nil
        errorMessage = nil

        switch languageModel.availability {
        case .available:
            break
        case .unavailable(let reason):
            errorMessage = unavailableMessage(for: reason)
            return
        }

        isGenerating = true

        generationTask = Task { @MainActor in
            defer {
                if generationID == id {
                    isGenerating = false
                    generationTask = nil
                }
            }

            let session = LanguageModelSession(
                instructions: """
                Draft helpful replies for an iOS developer. Keep the wording practical,
                specific, and calm. Do not invent APIs or version numbers.
                """
            )

            do {
                let stream = session.streamResponse(
                    to: """
                    Draft a reply to this question:

                    \(input)
                    """,
                    generating: DraftReply.self,
                    options: GenerationOptions(
                        temperature: 0.4,
                        maximumResponseTokens: 350
                    )
                )

                for try await snapshot in stream {
                    try Task.checkCancellation()
                    guard generationID == id else { return }
                    partialReply = snapshot.content
                }

                let response = try await stream.collect()
                guard generationID == id else { return }
                completedReply = response.content
            } catch is CancellationError {
                errorMessage = nil
            } catch LanguageModelSession.GenerationError.concurrentRequests {
                errorMessage = "The model is already responding. Try again in a moment."
            } catch LanguageModelSession.GenerationError.unsupportedLanguageOrLocale {
                errorMessage = "The model cannot respond in this language or locale."
            } catch LanguageModelSession.GenerationError.exceededContextWindowSize {
                errorMessage = "This prompt is too long for the current session."
            } catch {
                errorMessage = error.localizedDescription
            }
        }
    }

    func cancel() {
        generationTask?.cancel()
        generationTask = nil
        isGenerating = false
    }

    private func unavailableMessage(
        for reason: SystemLanguageModel.Availability.UnavailableReason
    ) -> String {
        switch reason {
        case .deviceNotEligible:
            "This device does not support Apple Intelligence."
        case .appleIntelligenceNotEnabled:
            "Apple Intelligence is not enabled."
        case .modelNotReady:
            "The language model is not ready yet."
        @unknown default:
            "The language model is not available right now."
        }
    }
}

The generationID is a small guard against stale work. If the user cancels and immediately starts another request, an older task should not clear the state for the newer task when it unwinds.

The other important line is stream.collect(). The stream itself is for updating the UI. collect() gives you the final LanguageModelSession.Response<DraftReply> after streaming completes. That final response carries the typed content, the underlying rawContent, and the transcript entries produced by the interaction.

Render the partial result

This view is an extract. The interesting part is how little special streaming code the view needs:

import SwiftUI

struct StreamingReplyView: View {
    @State private var model = ReplyComposerModel()

    var body: some View {
        Form {
            Section("Prompt") {
                TextEditor(text: $model.prompt)
                    .frame(minHeight: 120)

                HStack {
                    Button("Generate") {
                        model.generate()
                    }
                    .disabled(!model.canGenerate)

                    if model.isGenerating {
                        Button("Cancel", role: .cancel) {
                            model.cancel()
                        }
                    }
                }
            }

            if let errorMessage = model.errorMessage {
                Text(errorMessage)
                    .foregroundStyle(.secondary)
            }

            if let reply = model.partialReply {
                Section("Draft") {
                    DraftReplyPreview(reply: reply)
                }
            }
        }
        .navigationTitle("Reply")
    }
}

struct DraftReplyPreview: View {
    let reply: DraftReply.PartiallyGenerated

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            if let title = reply.title {
                Text(title)
                    .font(.headline)
            }

            if let bullets = reply.bullets {
                ForEach(Array(bullets.enumerated()), id: \.offset) { _, bullet in
                    Label(bullet, systemImage: "checkmark.circle")
                }
            }

            if let summary = reply.summary {
                Text(summary)
            }
        }
    }
}

For arrays, think about view identity early. A streaming array can grow while the model works. If the generated elements have their own stable identifiers, use those. For a short list of strings like this, the offset is usually good enough for a preview, but I would use real ids for editable or persisted rows.

A few practical edges

Availability should be part of the feature, not a last-minute guard. SystemLanguageModel.Availability tells you whether the model is available and, when it is not, whether the device is ineligible, Apple Intelligence is disabled, or the model is not ready yet.

Cancellation is worth adding even when the first version feels small. Streaming makes the feature feel responsive, but the user still needs a way to stop a bad prompt, a too-long answer, or an accidental tap.

Do not send overlapping requests through the same session. LanguageModelSession exposes isResponding, and the SDK also has a concurrentRequests generation error. In a SwiftUI view model, I still like keeping an explicit isGenerating flag because it also controls buttons, progress UI, and cancellation.

Finally, keep the streamed shape small. The on-device model is good at app-sized writing, extraction, tagging, and transformation tasks. If the response needs a giant nested object, break the workflow into smaller prompts or generate the high-level outline first and let deterministic app code handle the rest.

Further reading: