Skip to content
Ivan Magda
Go back

Building a Coding Agent in Swift, Part 2: Tool Dispatch

Suggest Changes

Our agent can do a lot with just bash. It can read files with cat, write them with echo, search with grep, compile with swift build — a shell command is a universal interface to the operating system. So why would we need anything else?

The answer becomes clear when we watch the agent work. It reaches for cat to read a file, and the output silently truncates at some terminal buffer limit. It constructs a multi-line sed command to edit a source file, and one misplaced backslash corrupts the content. Every file operation goes through a shell command that the model has to construct from scratch, with no guardrails and no safety boundaries. Dedicated tools like read_file and write_file let us enforce constraints — path sandboxing, output limits, atomic writes — at the tool level rather than hoping the model’s bash commands happen to be correct.

In this guide, let’s build a tool dispatch system that scales to any number of tools without changing the agent loop. We’ll add three new tools — read_file, write_file, and edit_file — and replace the hardcoded bash handler with a dictionary-based dispatch map. The loop from the previous guide stays identical. Only the tool set changes.

The complete source code for this stage is available at the 02-tool-dispatch tag on GitHub. Code blocks below show key excerpts.


From one tool to many

In the previous guide, our executeTool method had exactly one job:

guard name == "bash" else {
    return .failure(.unknownTool(name))
}
return await executeBash(input)

This works perfectly for a single tool. But let’s say we add read_file. Now we need an if/else chain — or a switch. Add write_file and edit_file, and the switch grows to four cases. By the time we reach the end of this series with 14 tools, that switch statement would be unwieldy. Worse, adding a new tool means modifying the dispatch logic itself, mixing “which tools exist” with “how tools are routed.”

What we want is a separation: a data structure that maps tool names to handler functions, and a dispatch mechanism that just does a lookup. Adding a tool means adding one entry to the map — the routing code never changes.

The dispatch map

That’s where dictionary-based dispatch comes in. Instead of a switch or a chain of if statements, we build a [String: handler] dictionary. The agent loop looks up the tool name, calls the matching handler, and moves on. Here’s the core of executeTool:

// Sources/Core/Agent.swift
func executeTool(name: String, input: JSONValue) async -> Result<String, ToolError> {
    let handlers = [
        "bash": executeBash,
        "read_file": executeReadFile,
        "write_file": executeWriteFile,
        "edit_file": executeEditFile
    ]

    guard let handler = handlers[name] else {
        return .failure(.unknownTool(name))
    }

    return await handler(input)
}

One alternative we considered was a protocol-based registry — a Tool protocol with conforming structs, registered into some kind of container. For four tools, that’s more boilerplate than the tools themselves. The dictionary is the registry. If we ever reach a point where protocol dispatch makes sense, the refactor is straightforward — but at 14 tools by the end of this series, the dictionary still holds up fine.

Keeping tools inside the sandbox

Before we build the individual tool handlers, we need to solve a safety problem. When the model asks to read /etc/passwd or write to ../../../important_file, we want to reject that at the tool level — not hope the model behaves. Every file tool needs path sandboxing: resolve the path, check that it stays inside our working directory, and reject anything that escapes.

Here’s our resolveSafePath helper:

private func resolveSafePath(_ relativePath: String) -> Result<String, ToolError> {
    let workDirURL = URL(fileURLWithPath: workingDirectory, isDirectory: true)
    let resolvedWorkDir = workDirURL.standardized

    let fullURL =
        if relativePath.hasPrefix("/") {
            URL(fileURLWithPath: relativePath).standardized
        } else {
            workDirURL.appendingPathComponent(relativePath).standardized
        }

    guard
        fullURL.path.hasPrefix(resolvedWorkDir.path + "/")||
        fullURL.path == resolvedWorkDir.path
    else {
        return .failure(.executionFailed("Path escapes workspace: \(relativePath)"))
    }

    return .success(fullURL.path)
}

That hasPrefix("/") guards against a URL.appendingPathComponent quirk: it always appends, even to an absolute path, so /Users/foo/file.swift becomes /cwd/Users/foo/file.swift.

Building the file tools

With path sandboxing in place, let’s walk through each handler. First, read_file — it reads a file’s contents with an optional line limit and a 50,000-character cap. That cap matters because every tool result goes back into the conversation, and a single massive file read could eat a significant chunk of the context window:

private func executeReadFile(_ input: JSONValue) async -> Result<String, ToolError> {
    guard let path = input["path"]?.stringValue else {
        return .failure(.missingParameter("path"))
    }

    switch resolveSafePath(path) {
    case .failure(let error):
        return .failure(error)
    case .success(let resolvedPath):
        do {
            let text = try String(contentsOfFile: resolvedPath, encoding: .utf8)
            let lines = text.components(separatedBy: "\n")
            var output: String

            if let limit = input["limit"]?.intValue, limit < lines.count {
                output = lines.prefix(limit).joined(separator: "\n")
                    + "\n... (\(lines.count - limit) more lines)"
            } else {
                output = text
            }

            if output.count > 50_000 {
                output = String(output.prefix(50_000))
            }

            return .success(output)
        } catch {
            return .failure(.executionFailed("\(error)"))
        }
    }
}

Next, write_file — the model often asks to create files in directories that don’t exist yet, so the handler creates intermediate directories automatically:

private func executeWriteFile(_ input: JSONValue) async -> Result<String, ToolError> {
    guard let path = input["path"]?.stringValue else {
        return .failure(.missingParameter("path"))
    }
    guard let content = input["content"]?.stringValue else {
        return .failure(.missingParameter("content"))
    }

    switch resolveSafePath(path) {
    case .failure(let error):
        return .failure(error)
    case .success(let resolvedPath):
        do {
            let fileURL = URL(fileURLWithPath: resolvedPath)

            try FileManager.default.createDirectory(
                at: fileURL.deletingLastPathComponent(),
                withIntermediateDirectories: true
            )
            try content.write(toFile: resolvedPath, atomically: true, encoding: .utf8)

            return .success("Wrote \(content.utf8.count) bytes to \(path)")
        } catch {
            return .failure(.executionFailed("\(error)"))
        }
    }
}

Finally, edit_file — this one finds an exact text match and replaces it. One important design choice here: content.range(of:) returns the first occurrence only. This is deliberate — it matches how Claude Code’s real edit_file tool behaves. Single-occurrence replacement is safer because it forces the model to be precise about which match it means.

private func executeEditFile(_ input: JSONValue) async -> Result<String, ToolError> {
    guard let path = input["path"]?.stringValue else {
        return .failure(.missingParameter("path"))
    }
    guard let oldText = input["old_text"]?.stringValue else {
        return .failure(.missingParameter("old_text"))
    }
    guard let newText = input["new_text"]?.stringValue else {
        return .failure(.missingParameter("new_text"))
    }

    switch resolveSafePath(path) {
    case .failure(let error):
        return .failure(error)
    case .success(let resolvedPath):
        do {
            var content = try String(contentsOfFile: resolvedPath, encoding: .utf8)

            guard let range = content.range(of: oldText) else {
                return .failure(.executionFailed("Text not found in \(path)"))
            }

            content.replaceSubrange(range, with: newText)
            try content.write(toFile: resolvedPath, atomically: true, encoding: .utf8)

            return .success("Edited \(path)")
        } catch {
            return .failure(.executionFailed("\(error)"))
        }
    }
}

With all four handlers in place, our dispatch map is complete. The agent can now read, write, and edit files through dedicated tools — with path sandboxing on every operation — while still falling back to bash for everything else.

The loop didn’t change

Let’s take a step back and look at what didn’t change. The agent loop in run() is identical to the previous guide:

while true {
    let request = APIRequest(
        model: model,
        maxTokens: 4096,
        system: systemPrompt,
        messages: messages,
        tools: Self.toolDefinitions  // was [Self.bashToolDefinition]
    )

    let response = try await apiClient.createMessage(request: request)
    messages.append(Message(role: .assistant, content: response.content))

    guard response.stopReason == .toolUse else {
        return response.content.textContent
    }

    // ... execute tools, append results, continue
}

The only change is Self.toolDefinitions — four tool definitions instead of one. The loop still calls executeTool(name:input:), which now does a dictionary lookup instead of a hardcoded check. Everything else — the while true, the stopReason guard, the message accumulation — is untouched. This is the pattern that holds through the rest of the series: the loop is the invariant, tools are the variable.

Taking it for a spin

Let’s build and run:

swift build && swift run claude

Try asking the agent to read the file Package.swift — it should use read_file instead of shelling out to cat. Then try create a file called greeting.txt that says Hello, World! and watch it use write_file. For something more interesting, try create a file called math.swift with a function that adds two numbers, then edit it to add a docstring — this exercises write_file followed by edit_file in a multi-step chain, all within a single prompt. The system prompt now tells the model to prefer read_file/write_file/edit_file over bash for file operations, so it should reach for the dedicated tools naturally.

What we’ve built and where we’re going

We now have a dispatch system that scales to any number of tools by adding entries to a dictionary — no changes to the loop, no changes to the routing logic. Each tool handler enforces its own constraints (path sandboxing, output limits, single-occurrence edits), which is safer and more reliable than hoping bash commands are well-formed. The dispatch dictionary is small enough to read at a glance and large enough to handle the 14 tools we’ll have by the end of the series.

One dispatch dictionary works for now, but later we’ll need different tool sets for different contexts — subagents shouldn’t have access to every tool the main agent has. We’ll solve that when we build subagents and introduce LoopConfig to control which tools are available at each recursion level. In the next guide, we’ll give the agent a structured way to track its own work with a todo system, so it doesn’t lose its plan halfway through a long task. Thanks for reading!


Suggest Changes
Share this post on:

Previous Post
Building a Coding Agent in Swift, Part 1: The Agent Loop
Next Post
Building a Coding Agent in Swift, Part 3: Self-Managed Task Tracking