n4nAI

Building a SwiftUI chat interface for an LLM API

Step-by-step tutorial for building a SwiftUI chat interface for an LLM API on iOS, with networking, streaming, and state code.

n4n Team2 min read493 words

Audio narration

Coming soon — every post will get a voice note here.

A swiftui chat interface llm api integration doesn’t require a heavyweight framework—just URLSession, async/await, and a disciplined message model. This tutorial builds a minimal but production-shaped iOS chat client that streams tokens from an OpenAI-compatible endpoint and renders them in real time.

Prerequisites

  • Xcode 15+ and an iOS 17 target (async/await and ObservableObject available).
  • A basic SwiftUI app project (@main App/WindowGroup).
  • An API key for an OpenAI-compatible LLM endpoint. For broad model access with automatic fallback, a gateway like n4n.ai exposes one endpoint that fronts 240+ models.
  • Comfort with URLSession and AsyncSequence.

Data model

Start with a strict message type. Avoid stringly-typed roles.

import Foundation

enum Role: String, Codable {
    case system, user, assistant
}

struct ChatMessage: Identifiable, Codable {
    let id = UUID()
    let role: Role
    var content: String
}

Keep content mutable on the assistant side so you can append streamed tokens.

API request shape

The endpoint expects a standard chat completion payload. Here’s the JSON you’ll send:

{
  "model": "gpt-4o-mini",
  "messages": [
    {"role": "system", "content": "You are a concise helper."},
    {"role": "user", "content": "Explain async/await in Swift."}
  ],
  "stream": true
}

Set stream: true to receive Server-Sent Events (SSE). Each event is a JSON patch with choices[0].delta.content.

Building the streaming client

Use URLSession.bytes(for:) to get an async byte stream. Parse lines prefixed with data: .

final class LLMClient {
    private let endpoint = URL(string: "https://api.openai.com/v1/chat/completions")!
    private let apiKey: String

    init(apiKey: String) { self.apiKey = apiKey }

    func streamChat(messages: [ChatMessage]) -> AsyncThrowingStream<String, Error> {
        AsyncThrowingStream { continuation in
            Task {
                var request = URLRequest(url: endpoint)
                request.httpMethod = "POST"
                request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
                request.setValue("application/json", forHTTPHeaderField: "Content-Type")
                let body = [
                    "model": "gpt-4o-mini",
                    "messages": messages.map { ["role": $0.role.rawValue, "content": $0.content] },
                    "stream": true
                ] as [String: Any]
                request.httpBody = try JSONSerialization.data(withJSONObject: body)

                let (bytes, response) = try await URLSession.shared.bytes(for: request)
                guard (response as? HTTPURLResponse)?.statusCode == 200 else {
                    continuation.finish(throwing: URLError(.badServerResponse))
                    return
                }

                for try await line in bytes.lines {
                    if line.hasPrefix("data: ") {
                        let payload = line.dropFirst(6)
                        if payload == "[DONE]" { continuation.finish(); break }
                        if let data = payload.data(using: .utf8),
                           let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
                           let choices = json["choices"] as? [[String: Any]],
                           let delta = choices.first?["delta"] as? [String: Any],
                           let token = delta["content"] as? String {
                            continuation.yield(token)
                        }
                    }
                }
            }
        }
    }
}

This client yields token strings. It does not manage conversation state—keep that in the view model.

View model

Use ObservableObject with @Published for simplicity. Store messages and a flag for in-flight requests.

import SwiftUI

final class ChatViewModel: ObservableObject {
    @Published var messages: [ChatMessage] = []
    @Published var inputText = ""
    @Published var isLoading = false

    private let client: LLMClient

    init(client: LLMClient) { self.client = client }

    func send() {
        let userMsg = ChatMessage(role: .user, content: inputText)
        messages.append(userMsg)
        inputText = ""
        isLoading = true

        var assistantMsg = ChatMessage(role: .assistant, content: "")
        messages.append(assistantMsg)

        Task {
            do {
                for try await token in client.streamChat(messages: Array(messages.dropLast())) {
                    assistantMsg.content += token
                    if let idx = messages.firstIndex(where: { $0.id == assistantMsg.id }) {
                        messages[idx] = assistantMsg
                    }
                }
            } catch {
                assistantMsg.content = "Error: \(error.localizedDescription)"
                if let idx = messages.firstIndex(where: { $0.id == assistantMsg.id }) {
                    messages[idx] = assistantMsg
                }
            }
            isLoading = false
        }
    }
}

We pass messages.dropLast() to avoid sending the empty assistant placeholder. In production, separate sendable history from UI state.

SwiftUI views

A List with ForEach plus a bottom input bar.

struct ChatView: View {
    @StateObject var vm: ChatViewModel

    var body: some View {
        VStack {
            List(vm.messages) { msg in
                MessageRow(message: msg)
            }
            HStack {
                TextField("Message", text: $vm.inputText)
                    .textFieldStyle(.roundedBorder)
                Button(vm.isLoading ? "..." : "Send") {
                    vm.send()
                }.disabled(vm.isLoading || vm.inputText.isEmpty)
            }.padding()
        }
    }
}

struct MessageRow: View {
    let message: ChatMessage
    var body: some View {
        HStack {
            if message.role == .user { Spacer() }
            Text(message.content.isEmpty ? "…" : message.content)
                .padding(8)
                .background(message.role == .user ? Color.blue.opacity(0.2) : Color.gray.opacity(0.2))
                .cornerRadius(8)
            if message.role == .assistant { Spacer() }
        }
    }
}

Wire it up in App

@main
struct LLMChatApp: App {
    var body: some Scene {
        WindowGroup {
            let client = LLMClient(apiKey: ProcessInfo.processInfo.environment["LLM_API_KEY"] ?? "")
            ChatView(vm: ChatViewModel(client: client))
        }
    }
}

Checkpoint: manual curl test

Before running the app, validate the endpoint with curl. Expected output is a stream of data: {...} lines.

curl https://api.openai.com/v1/chat/completions \
  -H "Authorization: Bearer $LLM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}],"stream":true}'

You should see lines like:

data: {"choices":[{"delta":{"content":"Hello"},"index":0}]}
data: {"choices":[{"delta":{"content":" there"},"index":0}]}
data: [DONE]

Checkpoint: simulator run

Launch the app in the iOS Simulator. Type “What is Swift concurrency?” and tap Send. The assistant row updates token-by-token:

You: What is Swift concurrency?
Assistant: Swift concurrency is built on async/await, actors, and structured tasks…

If you see the full text appear at once, confirm stream: true is set and the line parser strips data: correctly.

Handling errors and rate limits

Providers return 429 when rate-limited. In the client, map non-200 to a typed error. If you use a gateway that honors client routing directives and provides automatic fallback when a provider is degraded, your LLMClient code stays unchanged—only the endpoint URL differs.

Add a simple retry wrapper if needed:

func streamChatWithRetry(messages: [ChatMessage], retries: Int = 2) -> AsyncThrowingStream<String, Error> {
    // wrap streamChat; on URLError.timeout, recreate stream up to retries
}

Keep retries at the call site, not inside the byte loop, to avoid partial duplicates.

Production notes

  • Persist messages with Core Data or SwiftData if you need history across launches.
  • Sanitize user input before logging; never log the API key.
  • For long conversations, trim messages to the last N tokens to respect context windows.
  • Use URLSessionConfiguration.ephemeral for non-caching if privacy matters.

The swiftui chat interface llm api pattern above scales to multi-turn agents: swap the static system prompt for a dynamic one, and add tool-call deltas to the parser. The core—async byte streaming, immutable message IDs, and a single source of truth in the view model—doesn’t change.

Expected final structure

Your project should contain:

  • LLMClient.swift (networking)
  • ChatViewModel.swift (state)
  • ChatView.swift (UI)
  • LLMChatApp.swift (entry)

Build and run. You now have a runnable swiftui chat interface llm api client that streams tokens with roughly 150 lines of real code.

Tagsswiftswiftuichat-interfaceios

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All swift & ios llm integration posts →