An agent is a loop, a message history and a handful of tools. Here is what that looks like in Swift 6, with actors and strict concurrency.
Background
Thorsten Ball’s “How to Build an Agent” makes a point that is easy to forget amid all the framework announcements: a code-reading agent is not magic. It is an LLM, a loop, and a way to run tools. His reference implementation is in Go and uses the official anthropic-sdk-go. I wanted the same thing in Swift, and that is simple-swift-agent.
The first snag appeared immediately: Anthropic has no general-purpose Swift SDK for the Messages API. ClaudeForFoundationModels exists, but it is a provider for Apple’s Foundation Models framework and needs OS 27, not a raw API client. So the project builds on the community package SwiftAnthropic. The result is about 235 lines of code (not counting blank lines and comments). It is split into a reusable library, SimpleSwiftAgentSDK, and a tiny swift-agent executable.
How It Works
The entire agent is one actor. Agent owns the conversation history ([MessageParameter.Message]), the list of available tools and a terminal abstraction. The history is mutable state that run() touches on every turn, so actor isolation gives thread safety at no cost under the Swift 6 language mode. ToolDefinition is a Sendable struct whose closure is marked @Sendable. SwiftAnthropic’s types are not yet annotated for Sendable, so they come in via @preconcurrency import.
The loop alternates between two states, tracked by a single flag. Every model response goes into the history in full, including tool_use and thinking blocks. That part is essential. The API requires the assistant turn that contains a tool_use to be followed by a user turn that contains the matching tool_result. If you drop blocks from the history, the next request fails.
For each tool_use block, the agent looks the tool up by name, runs its closure and wraps the output as a tool_result keyed by the tool_use id. Failures do not crash the agent. They come back as results with isError: true, so Claude sees what went wrong and can try a different path. If a turn produced any tool results, they are sent straight back without prompting the user. Only a text-only response hands control back to the human.
Code
The core of Agent.run(), condensed:
while true {
if readUserInput {
guard let input = terminal.readUserInput() else { break } // EOF ends the session
messages.append(.init(role: .user, content: .text(input)))
}
let response = try await runInference()
// Keep the complete assistant turn, including tool_use and thinking blocks.
messages.append(.init(role: .assistant,
content: .list(response.content.compactMap(.asContentObject))))
var toolResults: [MessageParameter.Message.Content.ContentObject] = []
for block in response.content {
switch block {
case .text(let text, _): terminal.agent(text)
case .toolUse(let use): toolResults.append(executeTool(id: use.id, name: use.name, input: use.input))
default: break
}
}
// Tool results go straight back to Claude; only a text-only reply returns control to the user.
if toolResults.isEmpty {
readUserInput = true
} else {
messages.append(.init(role: .user, content: .list(toolResults)))
readUserInput = false
}
}
Adding a tool means writing a JSON Schema plus a closure:
let add = ToolDefinition(
name: "add",
description: "Returns the sum of two integers.",
inputSchema: JSONSchema(
type: .object,
properties: [
"a": .init(type: .integer, description: "First operand"),
"b": .init(type: .integer, description: "Second operand"),
],
required: ["a", "b"]
)
) { input in
guard case .integer(let a) = input["a"], case .integer(let b) = input["b"] else {
throw ToolError.invalidInput // becomes an is_error tool_result, not a crash
}
return "(a + b)"
}
let agent = Agent(service: service, tools: [.readFile, .listFiles, add])
Terminal I/O sits behind ChatTerminalProtocol, and the Anthropic client is any AnthropicService. Because of that, the tests use Swift Testing with a scripted terminal and canned model responses. They check the full tool round trip, meaning tool_use goes out and the matching tool_result comes back in the next request, without touching the network.
Trade-offs & Limitations
This version is deliberately minimal, and it has sharp edges:
- There is no iteration cap. A model that keeps calling tools will loop, and bill, indefinitely.
read_fileaccepts any path, including../and absolute paths, so there is no sandbox.- Tools are synchronous (
(ToolInput) throws -> String). That is fine for local file I/O, but a network-backed tool would block the actor. - There is no streaming, and the model ID is hard-coded.
- SwiftAnthropic is maintained by the community, mostly through occasional PRs, so new API features can lag behind.
My Take
Writing this confirmed what the Go version suggests: the interesting part of an agent is not the loop. The loop is 40 lines. What matters is what surrounds it: which tools you expose, how you constrain them, and how you observe what the model does with them. The next steps for this project follow from that. Tools become async, a turn budget and path sandboxing get added, and an MCP bridge lets the same agent call existing MCP servers. After that, I want to see how the design holds up on Apple’s Foundation Models, where LanguageModelSession runs the tool loop for you. There, everything in this post shrinks to configuration.
Tags: Swift, AI Agents, Claude, Swift Concurrency
