{"slug": "have-you-built-an-agent-harness-yet", "title": "Have you built an agent harness yet?", "summary": "Developer Alejandro M. P. argues that every programmer using AI coding tools should build a simple agent harness—a program that sits between the LLM and the outside world—to demystify how agents work. He provides a step-by-step guide to creating a minimal harness in Swift, emphasizing that the core loop of model, context, tools, and conversation management is simpler than marketing suggests.", "body_md": "#\n[Have You Built an Agent Harness Yet?](https://alejandromp.com/development/blog/have-you-built-an-agent-harness-already)\n\n[Alejandro M. P.](https://alejandromp.com/about)\n\nFor years I have repeated a thing that I still believe. Every programmer should write a promise library once. I think agent harnesses are the 2026 version of that exercise.\n\nIf you use AI coding tools every day, and especially if you have opinions about agent workflows, subagents, MCPs, commands, skills, and [whatever new ritual the week invented](/development/blog/development-dogma-is-back-a-i-agents/), build a tiny one yourself. Once. Not to ship it. Not because you are going to beat the existing ones, although honestly, with so much vibed slop around it would not be that hard. The point is that after you build one, the whole space stops feeling mystical. You stop thinking in terms of what big corp marketing wants and start thinking in terms of simple reality.\n\nThere is a model. There is a loop. There are tools. There is context. That is the heart of it.\n\nOf course the real products do much more. But it’s still built around a core that is much simpler than people think.\n\n## What is an Agent Harness\n\nA harness is just the little program that sits between the model and the outside world. That is it. Yes, a normal program with normal code. I guess nowadays we call it “classic”.\n\nThe model, the AI, does not touch your filesystem. The model does not open files. The model does not run commands. It only autocompletes text.\n\nIt is the harness that decides what messages get sent to the model, what context gets included, how the replies are interpreted, what tools the model has to *see* the world, and what result gets sent back after something happens locally.\n\nThat also means the conversation is yours.\n\nThis is one of the big things I think people should internalize. The continuity of the chat, the remembered context, the tool results, all of that is managed by the harness. The model endpoint is not secretly keeping your whole little world alive for you, it doesn’t have all your project in memory. The harness keeps appending messages and resending what matters. Sometimes even removing old ones!\n\nNow, to be fair, modern APIs offer more stateful variants and convenience helpers on the server side. That changes the ergonomics, one would argue that for the worse, but not the core mental model. Somebody still owns the conversation contract, and when you build your own harness that somebody is you.\n\nSo the smallest possible mental model is this:\n\n- You send a message.\n- The harness, the program you are running and interacting with, adds a bunch of context that the user doesn’t see, what’s often called the “system prompt”, and sends that to the LLM.\n- The model replies with text, by autocompleting from the last message it has received, which includes the entire history.\n- Your program decides what that text means.\n- If needed, your program does something in the world.\n- The result goes back as more text and context.\n\nThat is the whole trick. No magic. No AGI.\n\n## Let’s build a simple Harness\n\nI can describe what a harness is, but to make sure we internalize it let’s build a simple one, from scratch.\n\nTo start, let’s keep the first step easy and simple. Let’s just make a CLI app that lets you send messages to the AI and shows you the LLM responses. Very simple, but useful to see how this works if you’ve never seen it before, and a necessary step before we get into the proper *agentic* features.\n\nLet’s start with a simple CLI Swift package. Nothing fancy. No framework for agents. No giant abstraction tower. Just `swift package init --type executable`\n\nand the bits we actually need. As usual for command line tools in Swift, I used `swift-argument-parser`\n\n. That gives me a proper executable entrypoint with typed arguments.\n\nAt this stage the executable needs only a few things:\n\n- The base URL of the LLM chat-completions endpoint.\n- The model name.\n- An API key, loaded from the environment.\n\nThat is already enough to talk to an LLM.\n\nFor this post we will assume we have access to some LLM API, which in my project is represented by `OpenAICompatibleClient`\n\n. It’s not the interesting part of the project, but it is useful glue. It takes messages, performs the HTTP call to an OpenAI-compatible endpoint, and gives us back the assistant text. Don’t think it does anything special, it’s literally just an HTTP request-response.\n\nThe `SwiftAgentHarness`\n\napp is just a `AsyncParsableCommand`\n\nthat reads the arguments, validates the endpoint, loads the API key from the environment, instantiates the client, creates an `Agent`\n\n, and calls `run()`\n\n:\n\n```\n@main\nstruct SwiftAgentHarness: AsyncParsableCommand {\n    @Option(help: \"LLM chat completions endpoint.\")\n    var baseURL: String\n\n    @Option(help: \"Model name to use.\")\n    var model = \"gpt-5.4\"\n\n    mutating func run() async throws {\n        let environment = ProcessInfo.processInfo.environment\n        guard let apiKey = environment[\"LLM_API_KEY\"], apiKey.isEmpty == false else {\n            throw HarnessError.missingAPIKey\n        }\n        guard let endpoint = URL(string: baseURL) else {\n            throw HarnessError.invalidArguments(\"Invalid base URL: \\(baseURL)\")\n        }\n\n        let client = OpenAICompatibleClient(apiKey: apiKey, baseURL: endpoint, model: model)\n        let agent = Agent(client: client)\n        try await agent.run()\n    }\n}\n```\n\nAt this point you can see our agent harness only needs the API client, nothing more. Let’s now make this `Agent`\n\ndo something very basic.\n\n``` js\nstruct Agent {\n    let client: OpenAICompatibleClient\n\n    func run() async throws {\n        print(\"Swift Agent Harness\")\n        print(\"Model: \\(client.model)\")\n        print(\"Ctrl+C to quit\")\n    }\n}\n```\n\nWith this in place we can now iterate on the `Agent`\n\nitself.\n\n## Having a conversation\n\nThe first step is to set up what’s needed to have a conversation with the AI. A simple chat, nothing else.\n\nThe key thing to add now is the conversation structure itself. Not just reading one line and making one HTTP request, but actually keeping the state of the exchange in memory.\n\nFirst, we need a system prompt. This is the hidden part of the context that the user never sees but that we, as the harness developers, can manipulate. This is quite an important piece for AI to behave as one desires, it’s part of the context, and as you know, context is all that matters (because remember, it is all there is, the only thing the AI sees).\n\n``` js\nstruct Agent {\n    private let systemPrompt = \"You are a helpful assistant.\"\n```\n\nThen we need to construct and keep around the conversation array. This is the state that we maintain to keep track of the whole conversation between the user and the AI (and later extra things our harness will do)\n\n```\n    \n    func run() async throws {\n        print(\"Swift Agent Harness\")\n        print(\"Model: \\(client.model)\")\n        print(\"Ctrl+C to quit\")\n\n        var conversation = [Message(role: \"system\", content: systemPrompt)]\n```\n\nThen we need the loop that reads user input from the terminal.\n\n```\n        while true {\n            print(\"\\u{001B}[94mYou\\u{001B}[0m: \", terminator: \"\")\n            guard let input = readLine(), input.isEmpty == false else {\n                continue\n            }\n```\n\nThat gives us the human side of the conversation. But that input is not useful yet until we actually append it to the state that we keep around.\n\n```\n            conversation.append(Message(role: \"user\", content: input))\n```\n\nNow comes the important bit. We send the entire conversation, not just the last line typed by the user.\n\n``` js\n            let response = try await client.send(messages: conversation)\n```\n\nAnd once we get the response back, we print it and append it too.\n\n```\n            print(\"\\u{001B}[93mAssistant\\u{001B}[0m: \\(response)\")\n            conversation.append(Message(role: \"assistant\", content: response))\n        }\n    }\n}\n```\n\nAnd yes, this is the first place where the illusion starts to break. The only reason the AI feels like it remembers what you said two messages ago is because the harness, the app, keeps sending the history back.\n\nThat is already a harness. Not a tool-using one, not a coding agent yet, but a simple one that is just a chat. But definitely a harness.\n\nAnd we can give it a spin!\n\n```\nSwift Agent Harness\nModel: gpt-5.4\nCtrl+C to quit\nYou: Hello\nAssistant: Hello! How can I help you today?\nYou: What can you tell me about this project.\nAssistant: I’d be happy to help. Please share the project details—such as a description, code, repository link, files, screenshots, or goals—and I can explain:\n\n- what the project does\n- its architecture and components\n- the technologies used\n- how the code is organized\n- likely strengths, risks, and next steps\n\nIf you want, you can paste the README, folder structure, or source files here.\n```\n\nThis is why I wanted to start here. Before the model can read files or edit code or do anything that looks magical, it first has to live inside a very boring loop. Read input. Append message. Call model. Print output. Append response. Repeat.\n\nThis was where AI started, where the ChatGPT revolution stayed for quite some time. You can see how we asked a question about the project, and it had no clue about it. There is no magic, the AI doesn’t know about our project, it has no access to it, so it just replies asking for more context. For a long time, even today, people still use AI this way, copy-pasting information into the context. But that’s not the revolution we expected.\n\nSo then, we gave it tools.\n\n## AI interacting with our world\n\nSo far we only have a chat. Useful, yes. But not very exciting. The AI can only talk about whatever is already in the context. It still cannot *see* our project or interact with the world around it.\n\nThis is the first real step into agentic territory. We need to give the model a tool.\n\nThe important thing here is that there is still no hidden magic API involved. Current models are good enough that you can often teach them a new local convention just by adding it to the context. So for our little harness we do not need any special provider feature. We just extend the system prompt and tell the model that if it wants to use a tool it must reply in a very specific format.\n\nTo keep the first step small, let’s just add one. `read_file`\n\n.\n\n``` js\nprivate let systemPrompt = \"\"\"\nYou are a helpful assistant with access to one tool.\n\nTool:\nName: read_file\nDescription: Read a UTF-8 text file from disk.\nArguments: path\n\nWhen you want to use the tool, reply with exactly one line in this format and nothing else:\nTOOL_CALL {\"name\":\"read_file\",\"arguments\":{\"path\":\"some/file.txt\"}}\n\nAfter receiving a TOOL_RESULT message, continue the task.\nIf no tool is needed, answer normally.\n\"\"\"\n```\n\nThis is worth pausing on. The tool is not just a function in Swift. The tool is also part of the prompt. The model needs to know that it exists, what it is for, and what shape of arguments it expects. The better you describe the tool, the better the model can use it.\n\nThen, after the model replies, we check if the reply starts with `TOOL_CALL`\n\n.\n\n``` js\nlet response = try await client.send(messages: conversation)\n\nlet trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines)\nif trimmed.hasPrefix(\"TOOL_CALL \") {\n    print(\"tool-call: \\(trimmed)\")\n\n    let outcome: ToolOutcomeResult\n    do {\n        // Get the tool call text from the LLM\n        let payload = String(trimmed.dropFirst(10))\n        guard let data = payload.data(using: .utf8) else {\n            throw HarnessError.invalidToolInvocation\n        }\n      \n        // Parse the tool call text to the JSON contract we specified in the prompt\n        let invocation: ToolInvocation\n        do {\n            invocation = try JSONDecoder().decode(ToolInvocation.self, from: data)\n        } catch {\n            throw HarnessError.invalidToolPayload(payload)\n        }\n      \n        // Check for the only tool we have\n        guard invocation.name == \"read_file\" else {\n            throw HarnessError.invalidToolInvocation\n        }\n\n        // Run the \"tool\"\n        let path = invocation.arguments[\"path\"] ?? \"\"\n        let url = URL(fileURLWithPath: path)\n        let result = try String(contentsOf: url, encoding: .utf8)\n        print(\"tool-success: read_file -> \\(summarizeToolResult(result))\")\n        outcome = .success(result)\n    } catch {\n        let errorMessage = \"Tool invocation failed: \\(error.localizedDescription). Reply again, either with a valid TOOL_CALL line or a normal answer.\"\n        print(\"tool-error: \\(errorMessage)\")\n        outcome = .failure(errorMessage)\n    }\n\n    conversation.append(Message(role: \"assistant\", content: response))\n    conversation.append(Message(role: \"user\", content: formatToolResult(outcome)))\n\n    let followUp = try await client.send(messages: conversation)\n    print(\"Assistant: \\(followUp)\")\n    conversation.append(Message(role: \"assistant\", content: followUp))\n    continue\n}\n```\n\nAnd just like that now our harness gives more powers to the AI:\n\n```\nYou: Read the `README.md` file and summarize it\ntool-call: TOOL_CALL {\"name\":\"read_file\",\"arguments\":{\"path\":\"README.md\"}}\ntool-success: read_file -> 17 lines\nAssistant: `swiftagentharness` is a Swift command-line project built with Swift Package Manager and Apple’s ArgumentParser.\n\nKey points:\n- Requires Swift tools 6.2\n- Requires macOS 26 or later\n- Build with: `swift build`\n- Run with: `swift run`\n- Optionally run via: `./run.sh`\n```\n\nAgain, no magic. The model returns text. The harness interprets that text as a tool call. Then the harness decides to execute something locally. The `tool-call`\n\nand `tool-success`\n\nlines are just for the user, and for debugging in the terminal. What goes back to the AI is the `TOOL_RESULT`\n\nmessage, which includes the actual result together with a tiny `SUCCESS`\n\nor `ERROR`\n\nprotocol we invented, just to make it more clear to the AI. With that new context in place, the model can then reply with the summary of the file as we asked.\n\n## The `ToolOutcomeResult`\n\ndefinition\n\nThe only extra thing I did allow myself is one small enum to represent the outcome.\n\n```\nenum ToolOutcomeResult {\n    case success(String)\n    case failure(String)\n}\n```\n\nDoesn’t matter much, but I like having these things properly modeled.\n\nYes, that really is it. Now the AI has access to, a small part of, our world. The moment you do this, the conversation changes. Before, if you asked about a local file the AI had to ¯\\_(ツ)_/¯ and ask you to paste it. Now it can ask *you* to run the little contract you gave it.\n\nAnd notice what happened here. We did not give the AI direct access to the file system. We did not open a mystical portal into the machine. We only gave it a way to request something in text, and we wrote the normal boring Swift code that fulfills that request.\n\nAnd to make it even more clear, I have here a real run from this version of the harness that has so much to learn from:\n\n```\nSwift Agent Harness\nModel: gpt-5.4\nCtrl+C to quit\nYou: tell me about this project\nAssistant: Sure. If you want me to describe a specific project, I need to inspect its files first.\n\nPlease give me:\n- the project path, or\n- the main file to read\n\nYou: readme\nAssistant: Please provide the file path, e.g. `README.md` or `./README.md`, and I’ll read it and summarize the project.\n\nYou: README.md\ntool-call: TOOL_CALL {\"name\":\"read_file\",\"arguments\":{\"path\":\"README.md\"}}\ntool-success: read_file -> 17 lines\nAssistant: This project is `swiftagentharness`, a Swift command-line app. ...\n\nYou: now read the run file\ntool-call: TOOL_CALL {\"name\":\"read_file\",\"arguments\":{\"path\":\"run.sh\"}}\ntool-success: read_file -> 15 lines\nAssistant: `run.sh` is a convenience launcher for the CLI. ...\n```\n\nWhen I ask vaguely about the project, the AI still does not know anything about it. That part has not changed. It only knows what is in the conversation. But now knows it has a way to learn. It cannot inspect the whole project by magic, but it knows it can read files if I give it a path.\n\nBut it knows it only has access to reading single files, so note that when I say `readme`\n\n, it pushes back and asks for something more specific. It doesn’t have enough context to make up a proper tool call. That is nice. It means the model is trying to operate within the contract we gave it instead of hallucinating a path.\n\nThen I give it a proper file name `README.md`\n\n, it uses the read tool, and suddenly that file is now part of the conversation. Thanks to that, I can be a bit more ambiguous and say “the run file” and it understands I probably mean `run.sh`\n\n, because that is now in context too.\n\nThis is one of those moments where the illusion becomes very educational. The AI did not gain general awareness. It just accumulated a bit more text in the conversation, text that came from a tool call your harness fulfilled.\n\n## Models being nice\n\nOne funny thing here is that this first version did not stay stable for very long. As is typical with LLMs, things are not consistent and they reply with random responses, is part of their nature. In this case, it’s actually a good thing.\n\nSoon enough I hit errors like this:\n\n```\ntool-error: Tool invocation failed: Tool invocation payload could not be decoded: {\"name\":\"read_file\",\"arguments\":{\"path\":\"README.md\"}}\nI don’t have the contents of `README.md` yet. Please provide the file contents or ensure the tool result is available, and I’ll summarize it.. Reply again, either with a valid TOOL_CALL line or a normal answer.\n```\n\nAt first this looks confusing, because the JSON in there seems totally valid. The problem was that the model, in this case GPT, was trying to be nice. Instead of replying with only the exact `TOOL_CALL`\n\nline I asked for, it sometimes produced an extra little explanatory sentence. Good for the user, not so much for our parsing code.\n\nAnd honestly, that is a very model thing to do. Modern models are trained to be helpful, conversational, and a bit eager to explain themselves. They are not trying to break your harness. They are trying to be polite.\n\nSo this is a good moment to improve the code a bit. Not because the architecture changed, but because the real world showed us a new shape of output. This is a whole rabbit hole that makes making a production-ready harness a bit more complex. We would need to tailor the system prompt for every model, improve our parsing, and other engineering techniques that are not worth for this post.\n\nBut at least, let’s make it work for this use case so we can continue learning. Instead of assuming the whole response starts with `TOOL_CALL`\n\n, we now parse the assistant response line by line.\n\n``` php\nprivate func parseAssistantResponse(from response: String) throws -> ParsedAssistantResponse {\n    let lines = response\n        .split(whereSeparator: \\.isNewline)\n        .map { String($0).trimmingCharacters(in: .whitespaces) }\n        .filter { $0.isEmpty == false }\n\n    var userFacingLines = [String]()\n    var invocation: ToolInvocation?\n\n    for line in lines {\n        guard line.hasPrefix(\"TOOL_CALL \") else {\n            userFacingLines.append(line)\n            continue\n        }\n\n        let payload = String(line.dropFirst(10))\n        guard let data = payload.data(using: .utf8) else {\n            throw HarnessError.invalidToolInvocation\n        }\n        do {\n            invocation = try JSONDecoder().decode(ToolInvocation.self, from: data)\n        } catch {\n            throw HarnessError.invalidToolPayload(payload)\n        }\n    }\n\n    return ParsedAssistantResponse(\n        userFacingText: userFacingLines.joined(separator: \"\\n\"),\n        invocation: invocation\n    )\n}\n```\n\nThen the loop can show the assistant text to the user first, and still execute the tool if there is one.\n\n``` js\nlet response = try await client.send(messages: conversation)\nlet parsedResponse = try parseAssistantResponse(from: response)\n\nif parsedResponse.userFacingText.isEmpty == false {\n    print(\"Assistant: \\(parsedResponse.userFacingText)\")\n}\n\nif let invocation = parsedResponse.invocation {\n    // execute tool\n}\n```\n\nThis is a bit of the secret sauce of every harness. Not secret magic. Not genius algorithms. Just more cases of text interpretation, because at the end of the day that is still what the harness is doing. AI dumps text, and somebody needs to deal with it to create the illusion.\n\n## Let it explore\n\nAt this point the next obvious limitation appears. `read_file`\n\nis useful, but it still depends too much on the human already knowing what file should be read.\n\nThe harness can inspect.\n\nBut it still cannot explore.\n\nThat is why the next tool to add is `list_files`\n\n.\n\nAnd this is also the moment where the code starts earning a tiny bit of structure. Having one hardcoded tool inline was nice for the first learning step, but with a second tool it already makes sense to generalize a little.\n\nSo instead of special-casing everything directly in the loop, we will define a small tool type.\n\n``` js\nstruct ToolDefinition {\n    let name: String\n    let description: String\n    let arguments: [String]\n    let run: ([String: String]) throws -> String\n}\n```\n\nThen in the app entrypoint we can define the available tools explicitly.\n\n``` js\nlet tools: [ToolDefinition] = [\n    .readFile(),\n    .listFiles(),\n]\nlet agent = Agent(client: client, tools: tools)\n```\n\nAnd because the tools are now dynamic, the system prompt should be dynamic too. Instead of hardcoding the tool descriptions by hand, the `Agent`\n\nnow builds the tool section of the prompt from the actual `ToolDefinition`\n\nvalues it receives.\n\n```\ninit(client: OpenAICompatibleClient, tools: [ToolDefinition]) {\n    self.client = client\n    self.toolsByName = Dictionary(uniqueKeysWithValues: tools.map { ($0.name, $0) })\n\n    let toolsPrompt = tools\n        .sorted { $0.name < $1.name }\n        .map(\\.promptBlock)\n        .joined(separator: \"\\n\\n\")\n\n    self.systemPrompt = \"\"\"\n        You are a helpful assistant with access to tools.\n\n        Tools:\n        \\(toolsPrompt)\n\n        When you want to use the tool, reply with exactly one line in this format and nothing else:\n        TOOL_CALL {\"name\":\"tool_name\",\"arguments\":{\"path\":\"some/path\"}}\n\n        After receiving a TOOL_RESULT message, continue the task.\n        If no tool is needed, answer normally.\n        \"\"\"\n```\n\nThat is an important little detail. It means the prompt does not drift away from reality. If I add a tool in Swift but forget to tell the model about it or if I tell the model about a tool that is not really available, the harness will be lying and the model will freak out. Building the prompt from the tool definitions keeps both sides synchronized.\n\nWith this in place we can extend the `ToolDefinition`\n\nand use it as a way to host the different tools we have.\n\n``` php\nextension ToolDefinition {\n    static func readFile() -> Self { ... }\n```\n\nThe `list_files`\n\ntool itself is still very simple.\n\n``` php\nstatic func listFiles() -> Self {\n    let fileManager = FileManager.default\n\n    return ToolDefinition(\n        name: \"list_files\",\n        description: \"List files and directories in a path.\",\n        arguments: [\"path\"],\n        run: { arguments in\n            let path = arguments[\"path\"] ?? \".\"\n            let url = URL(fileURLWithPath: path)\n            let values = try fileManager.contentsOfDirectory(\n                at: url,\n                includingPropertiesForKeys: [.isDirectoryKey],\n                options: [.skipsHiddenFiles]\n            )\n            let lines = try values\n                .sorted { $0.lastPathComponent < $1.lastPathComponent }\n                .map { entry in\n                    let isDirectory = try entry.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false\n                    return isDirectory ? entry.lastPathComponent + \"/\" : entry.lastPathComponent\n                }\n            return lines.joined(separator: \"\\n\")\n        }\n    )\n}\n```\n\nNotice what happened here. The architecture did not change. We still have the same loop, the same text protocol, the same `TOOL_CALL`\n\n, the same `TOOL_RESULT`\n\n.\n\nWhat changed is just the model’s ability to gather context by itself.\n\nThis is a much bigger jump than it may look. `read_file`\n\ngave the AI a flashlight but `list_files`\n\ngives it a map.\n\n```\nYou: List the names of the files in this project.\ntool-call: TOOL_CALL {\"name\":\"list_files\",\"arguments\":{\"path\":\".\"}}\ntool-success: list_files -> 5 lines\nAssistant: Package.resolved\nPackage.swift\nREADME.md\nSources/\nrun.sh\nYou: summarize the readme\ntool-call: TOOL_CALL {\"name\":\"read_file\",\"arguments\":{\"path\":\"README.md\"}}\ntool-success: read_file -> 17 lines\nAssistant: The README says this is a Swift ...\n```\n\nNice! Now the user doesn’t have to specify the exact file names and paths because the model can explore directories itself.\n\n## AI needs longer turns\n\nThe example above was a bit forced to show the happy path. The reality is that we have a problem with our harness.\n\n```\nYou: explore this project and tell me what is about\nAssistant: I need to inspect the project files first. Please provide the tool results for the directory listing so I can explore it and summarize what the project is about.\ntool-call: TOOL_CALL {\"name\":\"list_files\",\"arguments\":{\"path\":\".\\/\"}}\ntool-success: list_files -> 5 lines\nAssistant: TOOL_CALL {\"name\":\"read_file\",\"arguments\":{\"path\":\"README.md\"}}\nYou:\n```\n\nNotice how we asked a more open ended question to the agent, and it correctly recognized that to answer needed to explore the project, which it did by using the `list_files`\n\ntool correctly. Then the harness gave the result of that tool call just like we’ve done before, but hold on. The agent didn’t reply with text, it replied with more tool calls!\n\nThis brings us to the next piece of our learning: **turns** and steps.\n\nIn this context a turn is what includes one user request and the agent response to that request. A step is every individual interaction the agent needs to answer the user’s request.\n\nWhen we started with the simple chat model, we had 1 step per 1 turn. User sends a message, the agent replies. When we added tool calling, we changed that a bit. User sends a message, the agent replies with a tool call, harness sends the result and agent replies with the final response. So there was an intermediate step in there, but that was it. And that’s not enough because agents often need multiple tool calls, multiple roundtrips to accomplish the goal the user asked for.\n\nIn this case it knew that it had to explore the project, but once it knew about the list of files, it then wanted to know about a specific file, but our harness is not yet ready for that, so it just stopped and waited for user input.\n\nThe problem is that right now our harness is just following a simple loop, we need to put the tool calling in an inner loop so the agent can have multiple steps in a single turn. We want the agent to be able to keep requesting tool calls until it is satisfied and gives as final answer.\n\nIn other words, a single turn can include multiple steps before the agent can give the final answer to the user.\n\nSo the code grows one inner loop.\n\n``` js\nwhile true {\n    turn += 1\n    print(\"<Turn \\(turn)>\")\n    ...\n    var step = 0\n\n    while true { // <= new inner loop\n        step += 1\n        print(\"<Step \\(step)>\")\n        let response = try await client.send(messages: conversation)\n        let parsedResponse = try parseAssistantResponse(from: response)\n\n        if parsedResponse.userFacingText.isEmpty == false {\n            ...\n        }\n\n        if let invocation = parsedResponse.invocation {\n            ...\n            conversation.append(Message(role: \"assistant\", content: response))\n            conversation.append(Message(role: \"user\", content: formatToolResult(outcome)))\n            continue\n        }\n\n        conversation.append(Message(role: \"assistant\", content: response))\n        break\n    }\n}\n```\n\nThat is a really important moment in understanding how agents work.\n\nLet’s see how this change impacts our previous example.\n\nNote that I’ve added turn and step logs so it’s clearer when there are roundtrips.\n\n```\n<Turn 1>\nYou: explore this project and tell me what is about\n<Step 1>\nAssistant: I need the project files to inspect it, but I don’t have the directory listing yet. Please provide the repository contents or let me know the project path, and I’ll explore it and summarize what it’s about.\ntool-call: TOOL_CALL {\"name\":\"list_files\",\"arguments\":{\"path\":\".\\/\"}}\ntool-success: list_files -> 5 lines\n<Step 2>\ntool-call: TOOL_CALL {\"name\":\"read_file\",\"arguments\":{\"path\":\"README.md\"}}\ntool-success: read_file -> 17 lines\n<Step 3>\ntool-call: TOOL_CALL {\"name\":\"read_file\",\"arguments\":{\"path\":\"Package.swift\"}}\ntool-success: read_file -> 22 lines\n<Step 4>\ntool-call: TOOL_CALL {\"name\":\"list_files\",\"arguments\":{\"path\":\"Sources\"}}\ntool-success: list_files -> swiftagentharness/\n<Step 5>\ntool-call: TOOL_CALL {\"name\":\"list_files\",\"arguments\":{\"path\":\"Sources\\/swiftagentharness\"}}\ntool-success: list_files -> 3 lines\n<Step 6>\ntool-call: TOOL_CALL {\"name\":\"read_file\",\"arguments\":{\"path\":\"Sources\\/swiftagentharness\\/App.swift\"}}\ntool-success: read_file -> 34 lines\n<Step 7>\ntool-call: TOOL_CALL {\"name\":\"read_file\",\"arguments\":{\"path\":\"Sources\\/swiftagentharness\\/Harness.swift\"}}\ntool-success: read_file -> 223 lines\n<Step 8>\ntool-call: TOOL_CALL {\"name\":\"read_file\",\"arguments\":{\"path\":\"Sources\\/swiftagentharness\\/OpenAICompatibleClient.swift\"}}\ntool-success: read_file -> 39 lines\n<Step 9>\nAssistant: This project is a small Swift command-line “agent harness” for talking to an OpenAI-compatible chat completions API and letting the model use a couple of local filesystem tools.\nWhat it does\n...\n<Turn 2>\nYou: Thanks!\n<Step 1>\nAssistant: You’re welcome!\n<Turn 3>\nYou:\n```\n\nThe model is not magically traversing your project in one giant sweep. It is taking little steps. List files. See the result. Decide what file matters. Read that file. See the result. Then decide if it can answer.\n\nThis is why I wanted to show the failing version first. Once you see the harness stop too early, the need for the inner loop becomes obvious.\n\n## Giving It Boundaries\n\nNow that the agent can list and read files on its own, we need to showcase the dangers of these tools.\n\n```\n<Turn 1>\nYou: Can you explore this project, and then find similar projects in this computer.\n<Step 1>\nAssistant: I can do that, but I need access to the project files first.\nPlease provide the project path, for example:\n- `.`\n- `./my-project`\n- `/home/user/project`\nOnce I have the path, I’ll:\n1. explore the project structure,\n2. identify what kind of project it is,\n3. search nearby/on this computer for similar projects.\ntool-call: TOOL_CALL {\"name\":\"list_files\",\"arguments\":{\"path\":\".\\/\"}}\ntool-success: list_files -> 5 lines\n<Step 2>\ntool-call: TOOL_CALL {\"name\":\"read_file\",\"arguments\":{\"path\":\"Package.swift\"}}\ntool-success: read_file -> 22 lines\n\n...\n\n<Step 9>\ntool-call: TOOL_CALL {\"name\":\"list_files\",\"arguments\":{\"path\":\"..\"}}\ntool-success: list_files -> 28 lines\n<Step 10>\n\n...\n\n<Step 23>\nAssistant: I explored the current project and found a few similar ones nearby.\nCurrent project: `swiftagentharness`\n- Type: Swift Package Manager executable\n...\nSimilar projects on this computer\n...\n<Turn 2>\nYou:\n```\n\nwow. I had to redact some things but you get the gist. The agent started listing files outside the directory we’re running it in, the magical `..`\n\npath. Sure, of course I asked for it, but you could imagine the LLM doing it on its own volition.\n\nNow this is a whole rabbit hole about security that is still an open question, only really solved with full isolation, aka running the agent in its own computer, a sandbox. I’m not gonna pretend to solve this in this exploratory blog post, but we can use this again as an excuse to learn more about the lack of magic.\n\nThe first thing we need is to introduce the concept of a workspace root into the CLI itself.\n\n``` js\n@Option(help: \"Workspace root for the agent.\")\nvar workspace = FileManager.default.currentDirectoryPath\n\nlet workspaceRoot = try resolveWorkspaceRoot(from: workspace)\nlet agent = Agent(client: client, tools: tools, workspaceRoot: workspaceRoot)\n```\n\nThat gives the harness an explicit root directory to work from. `Agent.init`\n\ngains a `workspaceRoot: URL`\n\nparameter, and `ToolDefinition.run`\n\nchanges signature to receive it as well:\n\n``` php\nstruct ToolDefinition {\n    ...\n    let run: ([String: String], URL) throws -> String\n}\n```\n\nThat means every tool can call `resolvePath`\n\nto validate paths before touching the filesystem. And now that the root is real in code, we can also tell the model about it in the prompt.\n\n```\nself.systemPrompt = \"\"\"\n    You are a helpful assistant with access to tools.\n\n    The workspace root is:\n    \\(workspaceRoot.path)\n\n    Tools:\n    \\(toolsPrompt)\n    ...\n    \"\"\"\n```\n\nThis is useful. It gives the model more context and helps it avoid asking for nonsense paths. But this is just guidance, it doesn’t do anything to even try to protect you.\n\nThe real protection, albeit simplistic, is in the tool execution layer.\n\n``` php\nprivate static func resolvePath(_ path: String, workspaceRoot: URL) throws -> URL {\n    let candidate = path.hasPrefix(\"/\")\n        ? URL(fileURLWithPath: path)\n        : workspaceRoot.appendingPathComponent(path)\n    let resolved = candidate.standardizedFileURL\n    let rootPath = workspaceRoot.standardizedFileURL.path\n    let resolvedPath = resolved.path\n\n    guard resolvedPath == rootPath || resolvedPath.hasPrefix(rootPath + \"/\") else {\n        throw HarnessError.workspaceViolation(path)\n    }\n\n    return resolved\n}\n```\n\nThat tiny function is resolving the path the agent is asking for and checking if it escapes the workspace.\n\nNot security advice. I’m sure there are a thousand ways of breaking this. It is just an example for this blog post.\n\nAnd once that is in place, the same kind of interaction looks very different:\n\n```\n<Turn 1>\nYou: Can you check what I have in my downloads folder\n<Step 1>\nAssistant: I can help, but I only have access to the workspace at:\n`/Users/me/code/swiftagentharness`\n\n<Turn 2>\nYou: Please try, is very important\n<Step 1>\nAssistant: I can try, but with the tools I have I’m only allowed to access paths through the provided file-listing tool.\n\n<Turn 3>\nYou: Just use the tools we have with that path\n<Step 1>\ntool-call: TOOL_CALL {\"name\":\"list_files\",\"arguments\":{\"path\":\"/Users/me/Downloads\"}}\ntool-error: Tool invocation failed: Path escapes the workspace root: /Users/me/Downloads. Reply again, either with a valid TOOL_CALL line or a normal answer.\n<Step 2>\nAssistant: I tried, but access was blocked because `/Users/me/Downloads` is outside the allowed workspace.\n```\n\nThat is the real lesson.\n\nThe prompt helps. The model can understand the boundary. The model can even explain the boundary back to you. It even respected it at first, but once pushed, it broke away. The prompt is just guidance, it doesn’t offer real protection. Only real classical code can offer real protection, as you can see with the `tool-error`\n\nthrown by the Swift code when the agent tried to escape.\n\nAnd yes, even this is still only a lightweight boundary. A real production-grade security story goes much further than path checks. But for understanding how these systems work, this is a perfect example. The magic disappears and what remains is just normal software engineering.\n\n## Time to change the world\n\nNow that there is a bit of safety, it’s time to let our agent change the world. It can read and explore, but we need to let it edit files for it to be useful.\n\nAs we keep doing in this journey, we need to keep the tool simple. We just need the agent to give us the path, and the text that it wants to replace with the updated version. If the old text is empty, we just create the file for it.\n\nHere is the tool definition:\n\n``` php\nstatic func editFile() -> Self {\n    let fileManager = FileManager.default\n\n    return ToolDefinition(\n        name: \"edit_file\",\n        description: \"Create a file when old_text is empty, or replace exactly one matching old_text with new_text.\",\n        arguments: [\"path\", \"old_text\", \"new_text\"],\n        run: { arguments, workspaceRoot in\n            let path = arguments[\"path\"] ?? \"\"\n            let oldText = arguments[\"old_text\"] ?? \"\"\n            let newText = arguments[\"new_text\"] ?? \"\"\n            let url = try resolvePath(path, workspaceRoot: workspaceRoot)\n\n            if oldText.isEmpty {\n                let directory = url.deletingLastPathComponent()\n                try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)\n                try newText.write(to: url, atomically: true, encoding: .utf8)\n                return \"Created \\(path)\"\n            }\n\n            let content = try String(contentsOf: url, encoding: .utf8)\n            let matches = content.components(separatedBy: oldText).count - 1\n            guard matches == 1 else {\n                return \"Edit failed. Expected exactly one match for old_text, found \\(matches).\"\n            }\n\n            let updated = content.replacingOccurrences(of: oldText, with: newText)\n            try updated.write(to: url, atomically: true, encoding: .utf8)\n            return \"Edited \\(path)\"\n        }\n    )\n}\n```\n\nNothing fancy.\n\nAnd let’s reiterate again the important part. The model is still not editing files directly. It is not dragging a cursor around. The LLM is not an editor. It is not doing anything mystical. It is just proposing a very boring text transformation contract and the harness applies it.\n\nAnd now, finally, the harness can do the thing that makes coding agents feel like coding agents. Not just read the world. Change it.\n\n```\n<Turn 1>\nYou: Can you create a new md file to summarize what this project is about.\n<Step 1>\ntool-call: TOOL_CALL {\"name\":\"read_file\",\"arguments\":{\"path\":\"README.md\"}}\ntool-success: read_file -> 17 lines\n<Step 2>\ntool-call: TOOL_CALL {\"name\":\"list_files\",\"arguments\":{\"path\":\".\"}}\ntool-success: list_files -> 5 lines\n<Step 3>\ntool-call: TOOL_CALL {\"name\":\"read_file\",\"arguments\":{\"path\":\"Package.swift\"}}\ntool-success: read_file -> 22 lines\n<Step 4>\n...\n<Step 9>\ntool-call: TOOL_CALL {\"name\":\"edit_file\",\"arguments\":{\"new_text\":\"# Project Summary\\n\\n...\",\"old_text\":\"\",\"path\":\"PROJECT_SUMMARY.md\"}}\ntool-success: edit_file -> Created PROJECT_SUMMARY.md\n<Step 10>\nAssistant: Created `PROJECT_SUMMARY.md` in the project root with a summary of the project, its purpose, architecture, tools, and usage.\n<Turn 2>\n```\n\nThis is the first moment where the harness stops being just an observer and starts becoming a real tool.\n\nWant the finished project?\n\nThe whole point of this post is that you build it yourself, and all the important ideas are already here. But if you want to support my writing, or you just want to save time, I packaged the project for you to download.\n\n## The world is whatever the harness says it is\n\nThere is one last exercise I really recommend if you build one of these.\n\nLie.\n\nI mean it. Go to one of your tools and make it return something false on purpose.\n\nMake `read_file`\n\nalways return a joke. Make `list_files`\n\ninvent files that do not exist. A fun one is to rewrite extensions and pretend every Swift file is actually a JavaScript file.\n\nThe model will believe you.\n\nAnd yes, of course it will. That is the whole point.\n\nBy now it should be obvious, but I think doing this with your own hands makes it click in a completely different way. The model does not have direct access to your machine. It does not have a secret backdoor into reality. It only has the world your harness describes to it through tool results.\n\nThat means the world, for the model, is whatever the harness says it is.\n\nNot because the model is stupid. Not because the model is broken. But because that is literally the way this [autocomplete machine](/personal/blog/accepting-pandora-s-box/) works.\n\n## And the Illusion is Broken\n\nIf you reached this point I hope you now see past the illusion. Now you know how agentic tools and AI harnesses work, you built one yourself!\n\nNo matter what new features you see from marketing, everything boils down to the simple concepts that you learned. The model emits text. The harness interprets that text as a tool call. Your code executes something locally, or pretends to, and then sends more text back.\n\nNo mystical illusion. Just tools and code.\n\nModels. Loops. Context. Tools. Turns. Engineering.\n\nNothing more.\n\nIn the next post in the series we will learn how the harness helps the AI pretend. Because [AI doesn’t remember your project, Markdown does](/development/blog/ai-doesn-t-remember-your-project-markdown-does/).", "url": "https://wpnews.pro/news/have-you-built-an-agent-harness-yet", "canonical_source": "https://alejandromp.com/development/blog/have-you-built-an-agent-harness-already/", "published_at": "2026-07-17 02:19:52+00:00", "updated_at": "2026-07-17 02:51:33.105728+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "large-language-models", "ai-tools"], "entities": ["Alejandro M. P.", "Swift", "LLM"], "alternates": {"html": "https://wpnews.pro/news/have-you-built-an-agent-harness-yet", "markdown": "https://wpnews.pro/news/have-you-built-an-agent-harness-yet.md", "text": "https://wpnews.pro/news/have-you-built-an-agent-harness-yet.txt", "jsonld": "https://wpnews.pro/news/have-you-built-an-agent-harness-yet.jsonld"}}