A Claude Agent in 235 Lines of Swift: One Actor, One Loop, Two Tools

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_file accepts 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

tomkausch

Leave a Reply

Your email address will not be published. Required fields are marked *