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.aias 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 typesLLMClient.swift– networking and SSE streamChatViewModel.swift– stateChatView.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.