I'm a mobile developer (mostly iOS) and I wanted to understand how coding agents like Claude Code actually work under the hood. Not the API, but the architecture.
So I built one from scratch in Swift across 9 stages: file operations, shell execution, subagents, context compaction, task DAGs, background tasks, skill loading. 14 tools total.
The biggest surprise: almost none of the hard bugs were AI-related. Linux handles SIGTERM differently inside child processes spawned via Process — macOS timeout strategy silently failed on CI. Swift 6.2's strict concurrency caught real data races at compile time. The nastiest bug: you must read stdout/stderr data before calling waitUntilExit(), or the pipe buffer fills and the process hangs forever.
Meanwhile, the actual agent loop is ~20 lines and never changed across all 9 stages.
This is Part 0 — the foundation. We're starting with the decisions that saved us from restructuring later.
ivan-magda
/
swift-claude-code
A Swift reimplementation of a Claude Code-style coding agent, built stage by stage to explore what makes coding agents work
swift-claude-code
Exploring the architecture of coding agents by rebuilding a Claude Code-style CLI from scratch in Swift.
Learning Series
A complete 9-part learning series is available on ivanmagda.dev.
Why This Exists
Claude Code feels unusually effective compared to other coding agents, and I suspect most of it comes from architectural restraint rather than architectural complexity. I studied the tool surface, traced the interaction loop, and tried to isolate which design choices actually matter.
My working theory: coding agents benefit more from a small set of excellent tools and tight loop design than from large orchestration layers.
Claude Code doesn't have many tools. The tools it does have are simple: a search tool, a file editing tool. But those tools are really good. And the system leans on the model far more than most agent implementations — less scaffolding, more trust in the LLM to do…
The complete source code for this stage is available at the 00-bootstrap tag on GitHub. Code blocks below show key excerpts.
Every great CLI tool starts the same way — with an empty directory and a handful of decisions that will shape everything built on top of it. For our Swift agent, those decisions matter more than usual. We're going to build a Claude Code-style coding assistant from scratch over the next eight guides, adding one mechanism per stage to a core that never changes. Getting the foundation right means we won't need to restructure anything later.
The thesis driving this project is simple: Claude Code's effectiveness comes from architectural restraint — a small set of excellent tools, thin orchestration, and heavy reliance on the model itself. We're going to prove that by building our own version in Swift, one layer at a time.
In this guide, let's set up the project structure, make sure everything compiles and runs, and lay the groundwork for the agent we'll start building in the next stage.
Starting with Swift Package Manager
Let's create our project and initialize it as a Swift package:
mkdir swift-claude-code
cd swift-claude-code
git init
swift package init --type executable --name swift-claude-code
This gives us a working starting point — SPM generates a Sources/ directory, a Package.swift, and a basic executable target. We could start writing code here and it would compile just fine.
However, the default layout puts everything into a single executable target, which means our agent logic and our command-line entry point live in the same place. That's a problem for two reasons: we can't write unit tests against an executable target (Swift Testing needs a library to import), and we can't reuse any of our agent logic outside the CLI. Let's fix that by splitting into two targets.
The two-target layout
The architecture we want is straightforward — a Core library that holds all the real logic, and a thin cli executable that just wires things together and starts the REPL:
swift-claude-code/
├── Package.swift
├── Sources/
│ ├── Core/ ← library (all agent logic)
│ └── cli/ ← executable (thin entry point)
└── Tests/
└── CoreTests/ ← tests import Core
Let's replace SPM's generated code with our two-target structure:
rm -rf Sources/*.swift
mkdir -p Sources/Core
mkdir -p Sources/cli
Now we need something for each target to compile. Let's start with the Core library — for now, just a placeholder that proves the target exists:
// Sources/Core/Agent.swift
public enum Agent {
public static let version = "0.1.0"
}
We're using a caseless enum as a pure namespace here — it'll evolve into a full class in the next guide once we need mutable state.
The cli target is our executable entry point. Here's where Swift's @main attribute comes in:
// Sources/cli/SwiftClaudeCode.swift
import Core
@main
enum SwiftClaudeCode {
static func main() async throws {
print("swift-claude-code v\(Agent.version)")
}
}
Notice async throws on main() — we don't need async yet, but every API call we'll make starting in the next guide will be asynchronous, so we're declaring the entry point as async from day one.
One thing to keep in mind: @main and main.swift can't coexist in the same target. If you see a main.swift in the target, delete it — @main replaces it and will let us adopt AsyncParsableCommand from swift-argument-parser later without any restructuring.
The package manifest
With our source files in place, let's replace SPM's generated Package.swift with a manifest that reflects our two-target architecture:
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "swift-claude-code",
platforms: [.macOS(.v10_15)],
products: [
.executable(name: "agent", targets: ["cli"]),
.library(name: "Core", targets: ["Core"]),
],
dependencies: [
.package(
url: "https://github.com/swift-server/async-http-client.git",
from: "1.32.0"
),
],
targets: [
.executableTarget(
name: "cli",
dependencies: ["Core"],
path: "Sources/cli"
),
.target(
name: "Core",
dependencies: [
.product(
name: "AsyncHTTPClient",
package: "async-http-client"
),
],
path: "Sources/Core"
),
.testTarget(
name: "CoreTests",
dependencies: ["Core"],
path: "Tests/CoreTests"
),
]
)
There's a deliberate dependency choice here worth discussing. We're pulling in AsyncHTTPClient from the swift-server project rather than using Foundation's built-in URLSession. The reason is cross-platform reliability — URLSession's async APIs weren't available on Linux until very recently and remain inconsistent between Apple's Foundation and the open-source swift-corelibs-foundation. AsyncHTTPClient is built on SwiftNIO, works identically on macOS and Linux, and handles async responses cleanly with Swift's concurrency model.
Also note swift-tools-version: 6.2. This gives us Swift's strict concurrency checking enabled by default — the compiler will catch data races at compile time rather than leaving them as runtime surprises. That strictness will pay for itself when we add background tasks and actors later in the series.
Adding tests from the start
Let's set up our test target before we forget:
mkdir -p Tests/CoreTests
And our first test file to go inside it:
// Tests/CoreTests/AgentTests.swift
import Testing
@testable import Core
@Test func versionExists() {
#expect(Agent.version == "0.1.0")
}
We're using Swift Testing (the @Test macro and #expect assertions) rather than XCTest. It's the modern testing framework, it works on both macOS and Linux, and it supports async test functions — which we'll need extensively once we start testing the agent loop.
One test might seem trivial, but it proves something important: Core is importable as a library, the test target can reach it, and our whole build graph is wired up correctly.
Environment configuration
Our agent will need an Anthropic API key to function. Let's set up the convention now with an .env.example that documents what's needed, and a .gitignore to keep the real .env, .build/, and other artifacts out of version control:
# .env.example
ANTHROPIC_API_KEY=your-api-key-here
MODEL_ID=claude-sonnet-4-6
We'll read the API key from the process environment using ProcessInfo.processInfo.environment["ANTHROPIC_API_KEY"] when we build the API client in the next guide.
Taking it for a spin
Let's verify everything works. The first build will take a minute or two as SPM resolves AsyncHTTPClient and its SwiftNIO dependencies:
swift build
swift run agent
# swift-claude-code v0.1.0
swift test
# Test Suite 'All tests' passed
# 1 test passed
If all three commands succeed, our foundation is solid. We have a two-target package where all logic lives in a testable library, an entry point ready for async work, and a dependency on the HTTP client we'll need for API calls. That's a lot of infrastructure for a few files, but none of it will need to change as we add capabilities over the next eight guides.
What we've built and where we're going
We now have a Swift package with a clean separation between library and executable, strict concurrency enabled, and a test harness ready to go. It doesn't do anything interesting yet — but that's the point. Every stage in this series adds exactly one mechanism, and this stage's mechanism is the project structure itself.
In the next guide, we'll bring this project to life by making our first API call to Claude and building the agent loop — the kernel that drives everything else. Thanks for reading!
The complete series on ivanmagda.dev:
- Part 0: Bootstrapping the project ← you are here
- Part 1: The agent loop
- Part 2: Tool dispatch
- Part 3: Self-Managed Task Tracking
- Part 4: Subagents
- Part 5: Skill loading
- Part 6: Context compaction
- Part 7: Task system
- Part 8: Background tasks
Stack: Swift 6.2, AsyncHTTPClient (not URLSession), raw HTTP to the Anthropic Messages API. No SDK.
Have you built agents outside Python? What surprised you most?

Top comments (0)