n4nAI

Building an iOS chat app with n4n and SwiftUI

Step-by-step tutorial for building an iOS chat app with n4n and SwiftUI: SwiftUI views, async streaming via OpenAI-compatible API, and SSE parsing.

n4n Team2 min read509 words

Audio narration

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

Building an ios chat app n4n swiftui style means wiring a native SwiftUI interface to a language model gateway without dragging in heavy SDKs. This tutorial ships a minimal but production-shaped chat client that streams tokens from an OpenAI-compatible endpoint, so you can swap models without touching client code. We’ll cover the data model, SSE parsing, and a clean MVVM structure you can extend.

Prerequisites

  • Xcode 15+ on macOS 14+
  • iOS 17 deployment target (URLSession.async bytes require iOS 15+, but we use iOS 17 APIs for simplicity)
  • Swift 5.9
  • An API key from the gateway (we’ll reference api.n4n.ai as the endpoint host)
  • Basic familiarity with SwiftUI and @Observable/@Published

The starter ios chat app n4n swiftui project uses the SwiftUI App lifecycle. No third-party packages are needed.

Project Scaffold

Create a new iOS App project named ChatN4N with SwiftUI interface. Delete the default ContentView body and replace it with a ChatView referenced from @main. Keep Assets.xcassets default.

We’ll organize files as:

  • Models.swift – message and request types
  • LLMClient.swift – networking and SSE stream
  • ChatViewModel.swift – state
  • ChatView.swift – UI

Data Model and Request Types

Define the local message and the wire format expected by an OpenAI-compatible /v1/chat/completions endpoint.

import Foundation

struct ChatMessage: Identifiable, Codable {
    let id = UUID()
    let role: String // "user" or "assistant"
    var content: String
    let createdAt: Date = Date()
}

struct MessagePayload: Codable {
    let role: String
    let content: String
}

struct ChatRequest: Codable {
    let model: String
    let messages: [MessagePayload]
    let stream: Bool = true
}

The JSON sent over the wire looks like this:

{
  "model": "openai/gpt-4o-mini",
  "messages": [{"role": "user", "content": "Hello"}],
  "stream": true
}

Streaming Client Implementation

We use URLSession.shared.bytes(for:) to get a byte stream and iterate lines. The gateway responds with text/event-stream where each data line is a JSON delta.

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

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

    func streamChat(messages: [MessagePayload], model: String = "openai/gpt-4o-mini") async throws -> AsyncThrowingStream<String, Error> {
        var req = URLRequest(url: endpoint)
        req.httpMethod = "POST"
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
        let body = ChatRequest(model: model, messages: messages)
        req.httpBody = try JSONEncoder().encode(body)

        let (bytes, response) = try await URLSession.shared.bytes(for: req)
        guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
            throw URLError(.badServerResponse)
        }

        return AsyncThrowingStream { continuation in
            Task {
                for try await line in bytes.lines {
                    if line.hasPrefix("data:") {
                        let json = line.dropFirst(5).trimmingCharacters(in: .whitespaces)
                        if json == "[DONE]" { continuation.finish(); break }
                        if let data = json.data(using: .utf8),
                           let chunk = try? JSONDecoder().decode(StreamChunk.self, from: data),
                           let token = chunk.choices.first?.delta.content {
                            continuation.yield(token)
                        }
                    }
                }
            }
        }
    }
}

struct StreamChunk: Decodable {
    struct Choice: Decodable {
        struct Delta: Decodable { let content: String? }
        let delta: Delta
    }
    let choices: [Choice]
}

Note: the gateway provides automatic fallback when a provider is rate-limited or degraded, so the single endpoint call above stays resilient without custom retry loops.

ViewModel and State

The view model accumulates streamed tokens on the main actor.

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

    private let client: LLMClient

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

    func send() {
        let userMsg = ChatMessage(role: "user", content: inputText)
        let assistantMsg = ChatMessage(role: "assistant", content: "")
        messages.append(contentsOf: [userMsg, assistantMsg])
        let snapshot = messages
        inputText = ""
        isLoading = true

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

SwiftUI Layout

A scrollable message list with auto-scroll and a bottom input bar.

struct ChatView: View {
    @StateObject private var vm: ChatViewModel

    init(apiKey: String) {
        _vm = StateObject(wrappedValue: ChatViewModel(client: LLMClient(apiKey: apiKey)))
    }

    var body: some View {
        VStack {
            ScrollViewReader { proxy in
                ScrollView {
                    LazyVStack(alignment: .leading, spacing: 8) {
                        ForEach(vm.messages) { msg in
                            MessageBubble(message: msg).id(msg.id)
                        }
                    }
                    .padding()
                }
                .onChange(of: vm.messages.count) { _ in
                    if let last = vm.messages.last {
                        withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
                    }
                }
            }
            Divider()
            HStack {
                TextField("Message", text: $vm.inputText)
                    .textFieldStyle(.roundedBorder)
                Button(vm.isLoading ? "..." : "Send") { vm.send() }
                    .disabled(vm.isLoading || vm.inputText.isEmpty)
            }
            .padding()
        }
    }
}

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

Secure Key Handling

Do not hardcode the key. Read it from Info.plist under N4N_API_KEY and pass to the view:

extension Bundle {
    var n4nApiKey: String {
        (infoDictionary?["N4N_API_KEY"] as? String) ?? ""
    }
}

// In @main App:
ChatView(apiKey: Bundle.main.n4nApiKey)

Set the value in your Xcode scheme’s environment or a configuration file before running.

Running and Expected Output

Build and run on iPhone simulator. Type Explain Swift actors in one line. and tap Send.

Checkpoint 1 — UI: The assistant bubble appears immediately with , then fills live:


Actors isolate mutable state so only one task can access it at a time, preventing data races.

Checkpoint 2 — Network console (via Proxyman or Safari inspector): The request body matches the JSON above. The response streams:

data: {"choices":[{"delta":{"content":"Actors"}}]}
data: {"choices":[{"delta":{"content":" isolate"}}]}
data: {"choices":[{"delta":{"content":" mutable"}}]}
...
data: [DONE]

If you kill the network mid-stream, the AsyncThrowingStream throws and the bubble shows Error: ... without crashing.

Model Routing and Cache Hints

The architecture for our ios chat app n4n swiftui relies on a single OpenAI-compatible endpoint, so swapping models is a one-line change in streamChat’s model parameter. You can address 240+ models by name (e.g., "anthropic/claude-3-haiku") with no client modifications.

Because the gateway honors client routing directives and forwards provider cache-control hints, you can attach provider-specific cache_control fields to MessagePayload when extended:

struct MessagePayload: Codable {
    let role: String
    let content: String
    var cache_control: [String: String]? // forwarded if provider supports it
}

This keeps prompt caching working without bespoke per-provider branches in your iOS code.

Wrap-Up Mechanics

You now have a runnable chat client with real token streaming, MVVM separation, and zero dependencies. The same LLMClient can be unit-tested with a mocked URLProtocol, and the view model can be driven from SwiftUI previews by injecting a fake stream. From here, add persistence with SwiftData, tool calls via parallel streams, or markdown rendering for assistant content.

Tagsiosn4nswiftuichat-app

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 →