The Swift client: first connection to the model
Article 3 · Series: A Local Coding Agent with apfel
In Article 2 we walked through apfel’s OpenAI protocol by hand with curl. Now we write the first code that docks onto that server. We set up a Swift package, build an async HTTP client on URLSession, send a chat-completion request to /v1/chat/completions and process the response as Server-Sent Events, token by token. The result is a small CLI: prompt in, streamed answer out. We choose Swift because the same language carries us from the first HTTP request to the Xcode integration at the end of the series. The state of this article is frozen as tag v0.3 in the demo repo: https://codeberg.org/rotecodefraktion/apfel-coding-agent/src/tag/v0.3
The package skeleton
We start with an executable Swift package that separates two targets: a library AgentCore and an executable apfel-agent. The library holds the logic; the executable holds only the CLI entry point.
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "apfel-coding-agent",
platforms: [.macOS(.v13)],
products: [
.library(name: "AgentCore", targets: ["AgentCore"]),
.executable(name: "apfel-agent", targets: ["apfel-agent"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"),
],
targets: [
.target(name: "AgentCore"),
.executableTarget(
name: "apfel-agent",
dependencies: [
"AgentCore",
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]
),
.testTarget(
name: "AgentCoreTests",
dependencies: ["AgentCore"],
resources: [.copy("Fixtures")]
),
]
)
The library/executable split costs one extra line now and pays off from the next article onward: tests attach to AgentCore, and everything we build later (tool registry, agent loop, MCP integration) grows in the library, not in the CLI entry point. The only external dependency is Apple’s swift-argument-parser for the CLI. During the build the resolver pinned version 1.8.2; we record this alongside the Swift toolchain (6.3.2) in docs/setup.md so the tagged state stays reproducible.
Request models as Codable
The protocol is JSON, so we model request and response as Codable types. A message carries a role and content; a request names the model, the messages, and optionally a temperature and a streaming flag.
public struct ChatMessage: Codable, Sendable, Equatable {
public let role: String
public let content: String
}
public struct ChatRequest: Codable, Sendable, Equatable {
public let model: String
public let messages: [ChatMessage]
public let temperature: Double?
public let stream: Bool?
public init(
model: String = "apple-foundationmodel",
messages: [ChatMessage],
temperature: Double? = nil,
stream: Bool? = nil
) {
self.model = model
self.messages = messages
self.temperature = temperature
self.stream = stream
}
}
Notable here is what is absent. In Article 2 we found that apfel rejects the parameters stop, n, logprobs, presence_penalty and frequency_penalty with HTTP 400. So we do not model them at all. JSONEncoder drops nil optionals automatically, meaning the generated request contains only fields the server accepts. A test records this invariant:
@Test("Omitted optionals do not appear in the JSON")
func omitsNilFields() throws {
let request = ChatRequest(messages: [ChatMessage(role: "user", content: "hi")])
let object = try encodedObject(request)
#expect(object["stream"] == nil)
#expect(object["temperature"] == nil)
#expect(object["stop"] == nil)
}
The first non-streaming round-trip
The client is a struct with a base URL, a model ID and a URLSession. The complete method builds the request, sends it, checks the status and decodes the response.
public func complete(
_ messages: [ChatMessage],
temperature: Double? = nil
) async throws -> ChatResponse {
let request = try makeRequest(
ChatRequest(model: model, messages: messages, temperature: temperature, stream: false)
)
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch {
throw ClientError.connectionFailed(error.localizedDescription)
}
try Self.check(response: response, body: data)
do {
return try JSONDecoder().decode(ChatResponse.self, from: data)
} catch {
throw ClientError.decoding("\(error)")
}
}
ChatResponse mirrors the shape from Article 2: an array choices, each with message and finish_reason, plus a usage object with the token count. An actual non-stream call against the running model:
$ swift run apfel-agent --no-stream "List three primary colors, comma separated."
Red, blue, yellow
Streaming with Server-Sent Events in Swift
For the streaming variant we open the connection with URLSession.bytes(for:). This returns an AsyncBytes sequence whose .lines property we iterate line by line and asynchronously. Each data: line passes through the parser; each decoded chunk is placed into an AsyncThrowingStream until [DONE] ends the stream.
public func stream(
_ messages: [ChatMessage],
temperature: Double? = nil
) -> AsyncThrowingStream<ChatChunk, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
let request = try makeRequest(
ChatRequest(model: model, messages: messages, temperature: temperature, stream: true)
)
let (bytes, response) = try await session.bytes(for: request)
try Self.check(streamResponse: response)
for try await line in bytes.lines {
switch try SSEParser.parse(line: line) {
case .chunk(let chunk): continuation.yield(chunk)
case .done: continuation.finish(); return
case .ignored: continue
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
The stream yields the full ChatChunk, not just the text. For this CLI we only read choices.first?.delta.content, but finish_reason and, from Article 4 onward, tool-call deltas do not travel via delta.content. We decode the chunk anyway; reducing it to a String would force a rewrite in the next article. So we keep the structure.
Parsing SSE correctly
The tempting shortcut is to split the entire response body on \n and take the pieces. That breaks at chunk boundaries, at the blank lines between events and at the [DONE] terminator. Instead we parse line by line. Each line maps to exactly one of three outcomes: a decoded chunk, the end signal, or something we ignore.
public enum SSELine: Sendable, Equatable {
case chunk(ChatChunk)
case done
case ignored
}
public enum SSEParser {
public static func parse(line rawLine: String) throws -> SSELine {
let line = rawLine.hasSuffix("\r") ? String(rawLine.dropLast()) : rawLine
guard line.hasPrefix("data:") else { return .ignored }
let payload = line.dropFirst("data:".count).trimmingCharacters(in: .whitespaces)
if payload == "[DONE]" { return .done }
if payload.isEmpty { return .ignored }
do {
let chunk = try JSONDecoder().decode(ChatChunk.self, from: Data(payload.utf8))
return .chunk(chunk)
} catch {
throw ClientError.decoding("SSE chunk: \(error)")
}
}
}
We verify this against a real recording. A longer model response arrives as multiple content chunks, separated by blank lines, closed by a chunk with finish_reason: stop and then [DONE]. The test drives the recorded sequence through the parser and checks that the reassembled text is correct, that the completion signal arrived and that [DONE] was recognised:
@Test("Recorded stream reconstructs the full assistant content")
func reconstructContent() throws {
let sse = try fixture("chat-stream", "sse")
var content = ""
var sawDone = false
for line in sse.components(separatedBy: "\n") {
switch try SSEParser.parse(line: line) {
case .chunk(let chunk):
if let delta = chunk.choices.first?.delta.content { content += delta }
case .done:
sawDone = true
case .ignored:
continue
}
}
#expect(content == "Recursion is a method of solving problems by breaking them down into smaller, more manageable sub-problems of the same type. It involves a function calling itself with a modified argument until it reaches a base case, which stops the recursion.")
#expect(sawDone)
}
The fixture is not a fabricated sample response; it is a real capture from apfel 1.5.1. The tests therefore run offline against recorded data, without a server running, but still exercise exactly the wire format the server actually produces.
The protocol as an architectural decision
The client speaks HTTP against apfel --serve, not against the FoundationModels framework directly. We laid out the reasoning in Article 2 and recorded it in the demo repo as docs/adr/001-serve-modus-als-backend.md: the HTTP protocol is a stable, documented seam. Responses can be recorded as fixtures and tested against offline; the model backend stays replaceable; and Apple’s platform-specific API sits behind apfel, not in our code. The price, a local HTTP hop and a running apfel process, is one we are happy to pay for a local developer tool.
Errors as values, not crashes
A client that aborts on every connection problem is useless as a building block for an agent. We model the failure cases as typed values:
public enum ClientError: Error, Sendable, Equatable, CustomStringConvertible {
case connectionFailed(String)
case httpStatus(code: Int, body: String)
case serverError(type: String, message: String)
case decoding(String)
case invalidResponse
}
The serverError case covers the 400 path from Article 2: when apfel rejects a parameter it returns a structured invalid_request_error response, which we decode and surface as an error message rather than showing a bare status code. For a non-stream response the status check reads the body and maps it to this case:
private static func check(response: URLResponse, body: Data) throws {
guard let http = response as? HTTPURLResponse else { throw ClientError.invalidResponse }
guard (200..<300).contains(http.statusCode) else {
if let envelope = try? JSONDecoder().decode(ServerErrorEnvelope.self, from: body) {
throw ClientError.serverError(
type: envelope.error.type ?? "error",
message: envelope.error.message ?? ""
)
}
throw ClientError.httpStatus(code: http.statusCode, body: String(decoding: body, as: UTF8.self))
}
}
When no server is running, complete catches the URLSession error and returns connectionFailed. The CLI turns this into a message on stderr and a non-zero exit code, without a stack trace.
Assembling the CLI
The entry point is an AsyncParsableCommand. The prompt is a positional argument; --base-url and a --stream/--no-stream flag with streaming as the default complete the interface.
@main
struct AgentCommand: AsyncParsableCommand {
@Argument(help: "The prompt to send to the model.")
var prompt: String
@Option(name: .long, help: "Base URL of the apfel serve endpoint.")
var baseURL: String = "http://127.0.0.1:11434"
@Flag(inversion: .prefixedNo, help: "Stream tokens as they arrive, or wait for the full reply.")
var stream: Bool = true
mutating func run() async throws {
guard let url = URL(string: baseURL) else {
throw ValidationError("Invalid base URL: \(baseURL)")
}
let client = APFELClient(baseURL: url)
let messages = [ChatMessage(role: "user", content: prompt)]
do {
if stream {
for try await chunk in client.stream(messages) {
if let text = chunk.choices.first?.delta.content {
FileHandle.standardOutput.write(Data(text.utf8))
}
}
FileHandle.standardOutput.write(Data("\n".utf8))
} else {
let response = try await client.complete(messages)
print(response.choices.first?.message.content ?? "")
}
} catch let error as ClientError {
FileHandle.standardError.write(Data((error.description + "\n").utf8))
throw ExitCode.failure
}
}
}
A streamed run against the live model:
$ swift run apfel-agent "Explain recursion to a beginner in two sentences."
Recursion is a method of solving problems by breaking them down into smaller,
more manageable sub-problems that resemble the original problem. It involves a
function calling itself with a modified argument until a base condition is met,
which stops the recursion.
And the error path when no server is running:
$ swift run apfel-agent --base-url http://127.0.0.1:11499 "hi"
Could not reach apfel --serve: Could not connect to the server.
$ echo $?
1
Demo repo: apfel-coding-agent v0.3
The state of this article is frozen as tag v0.3: https://codeberg.org/rotecodefraktion/apfel-coding-agent/src/tag/v0.3
Set up demo repo apfel-coding-agent v0.3
Clone (if not done already) and check out the tag:
git clone https://codeberg.org/rotecodefraktion/apfel-coding-agent.git
cd apfel-coding-agent
git checkout v0.3
New in v0.3 compared to v0.2:
Package.swift— Swift package with libraryAgentCoreand executableapfel-agentSources/AgentCore/Client/—ChatModels,APFELClient,SSEParser,ClientErrorSources/apfel-agent/AgentCommand.swift— the CLI entry pointTests/AgentCoreTests/— unit tests with real apfel captures as fixturesdocs/adr/001-serve-modus-als-backend.md— the backend decisionscripts/smoke-client.sh— end-to-end test against a running apfel serve
Build, test, run:
swift build
swift test # offline, no apfel needed
swift run apfel-agent "Explain higher-order functions in one sentence."
The unit tests run without apfel and without Apple Intelligence. The end-to-end test requires both:
./scripts/smoke-client.sh
When the last output shows SMOKE OK, everything is working. Prerequisites (apfel installation, Apple Intelligence) are listed in docs/setup.md.
Pitfalls from the build
AsyncParsableCommand, not ParsableCommand. swift-argument-parser has two command protocols. Attaching an async run() method to the synchronous ParsableCommand risks the method never executing. For async code the root command must be AsyncParsableCommand and run() must be marked async. This is noted in Apple’s documentation but easy to overlook when starting from a synchronous CLI.
Real chunks carry more fields than the schema. The recorded SSE chunks from apfel contain fields we did not model, such as logprobs and created. This is harmless: JSONDecoder ignores unknown keys by default. The decoder does not stumble over extra fields, and we only need to model the fields we actually read.
Short answers arrive as a single chunk. For a short response, apfel streams the entire content in one content chunk rather than token by token. Only with longer responses do we see multiple chunks. This is precisely why the parser must work line by line and handle one content chunk the same way it handles many.
usage is absent from the stream. The non-stream response carries a usage object with the token count; the streamed response does not. Anyone who wants to count tokens during streaming cannot rely on the stream and must calculate separately. The usage field in our ChatChunk is therefore optional and consistently empty in every recorded stream.
What comes next
With the client in place the connection to the model is established. Article 4 covers tool calling: what a tool definition looks like in the OpenAI schema, how apfel passes it through to the Foundation Model and how the round-trip works when the model invokes a tool and we feed the result back. That is where the decision to stream the full ChatChunk rather than just the text pays off: the tool-call deltas that come into play next travel in exactly the chunk fields we have already retained.
Previous article: The serve mode and the OpenAI protocol. Next article: Understanding tool calling: from schema to round-trip. Repo tag: v0.3.