{
  "authors" : [
    {
      "name" : "Alejandro M. P."
    }
  ],
  "description" : "Alejandro M.P. official site",
  "favicon" : "https://alejandromp.com/favicon-32x32.png",
  "feed_url" : "https://alejandromp.com/feed.json",
  "home_page_url" : "https://alejandromp.com",
  "icon" : "https://alejandromp.com/apple-touch-icon-152x152.png",
  "items" : [
    {
      "authors" : [
        {
          "name" : "Alejandro M. P."
        }
      ],
      "content_html" : "<p>A few weeks ago I read <a href=\"https://www.cocoanetics.com/2026/05/an-interpreter-for-swift/\">An Interpreter for Swift</a>, from Cocoanetics, and I had that nice feeling of somebody else having already articulated the thing that was vaguely floating in your head.</p><blockquote><p>Let me just say it outright: I get very sad every time somebody insists that TypeScript is the future of agentic coding. So I kept sending my wish to the universe that Swift should be also in the race for that. Now – finally – I was able to manifest the missing piece: an interpreter for the language I love. - Cocoanetics</p></blockquote><p>That resounded inside me as I’ve always thought that Swift was the ideal language for this. And if I am already building a tiny Swift AI harness to learn how these systems work, then this felt like the perfect place to test the idea. So I immediately knew I had to try using this Swift interpreter in the harness. Even if the project is still tiny and mostly for learning, I thought it could still be useful.</p><h2 id=\"the-old-bash\">The old Bash</h2><p>In the <a href=\"https://alejandromp.com/development/blog/do-we-even-need-multiple-tools/\">previous post</a> I had already replaced the little file toolbox with <code>bash</code>, mostly to see what happened if I stopped pretending those file tools were anything other than wrappers around shell commands.</p><p>So we were already in a good position to perform the next part of the experiment and answer, what if the generic tool was not <code>bash</code>, but <code>Swift</code>?</p><p>Instead of letting the model write shell, let it write whatever top-level Swift it needs to accomplish the task. That sounds much nicer to me already.</p><div class=\"callout-promo\"><p class=\"callout-promo-label\">Want the finished project?</p><div class=\"callout-promo-body\"><p>The 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.</p><p><a href=\"https://buymeacoffee.com/alejandromp/e/532022\">Get the harness by supporting me on Buy me a coffee</a></p></div></div><h2 id=\"the-first-fork-in-the-road\">The first fork in the road</h2><p>The most obvious version of the idea is very simple. Keep the harness the same. Just replace <code>bash</code> with a <code>swift</code> tool. Let the model write top-level Swift, and call out to the compiler to execute it.</p><p>That would definitely work, but running real Swift brings the whole toolchain with it. Module caches. Compiler behavior. We all know that running Swift means going through a compilation step, and that is not the fastest thing in the world, especially if we need to start a new compiler process every time. And given LLMs might roundtrip many times to accomplish a task, any gains we get here are worth it. It is the usual reality of “this is a language toolchain”, not “this is a tiny embedded runtime”.</p><p>It also means we have the security issues we had with Bash, we can still use Seatbelt of course, but maybe we can take this chance to do better.</p><p>And finally, I just wanted to use the Swift Interpreter. Despite not being an official solution, it looks very promising and useful, and like something that should have been taken more seriously over the years. I still think Swift is the best language even for scripting, but performance is not there. So in my head there have always been two options: increase the performance of the toolchain and be as fast as Jai, or accept the limits and make a faster interpreter. So what we have here now, despite not coming from the core team, lets us prove the second idea.</p><h2 id=\"embedding-swift\">Embedding Swift</h2><p>That is where <a href=\"https://github.com/Cocoanetics/SwiftScript\">SwiftScript</a> becomes interesting.</p><p>SwiftScript is a tree-walking Swift interpreter that you can embed as a library. No <code>swiftc</code>. No shelling out to <code>swift -e</code>. Just parse Swift and interpret it.</p><p>Now the <code>swift</code> tool is not another subprocess. It can just be part of the harness itself. So we just make a new <code>ToolDefinition</code> that looks very similar to the <code>bash</code> one.</p><pre><code class=\"language-swift\">static func swift() -&gt; Self {\n    ToolDefinition(\n        name: &quot;swift&quot;,\n        description: &quot;Run top-level Swift code directly inside the harness process, using SwiftScript as the execution engine and ShellKit for workspace confinement.&quot;,\n        arguments: [&quot;code&quot;],\n        run: { arguments, workspaceRoot in\n            let code = arguments[&quot;code&quot;] ?? &quot;&quot;\n            let result = await SwiftScriptToolRunner.executeInProcess(\n                code: code,\n                at: workspaceRoot\n            )\n\n            return if result.exitCode == &quot;ExitStatus(0)&quot;,\n                      let stdout = result.stdout.nonEmpty {\n                stdout\n            } else {\n                executionResultJSON(\n                    stdout: result.stdout,\n                    stderr: result.stderr,\n                    exitCode: result.exitCode\n                )\n            }\n        }\n    )\n}\n</code></pre><h2 id=\"where-does-the-interpreter-run?\">Where does the interpreter run?</h2><p>This was the next question I had to understand. I have experience writing interpreters and things are easy when you just need to exercise pure code. Interpreting a syntax tree that wants a mathematical computation is trivial, but things get trickier when you start talking about I/O and other features that need to interact with an “environment”.</p><p>So we have an interpreter that we can embed and it knows how to run Swift, but what is the environment around it? Where does <code>print</code> go? What is the current directory? How do file APIs know what to allow?</p><p>This is where <a href=\"https://github.com/Cocoanetics/ShellKit\">ShellKit</a>, another library from the same author, enters the picture.</p><p>SwiftScript’s interpreter defaults to using <code>Shell.current</code> for output and runtime context, and ShellKit lets an embedder install a custom <code>Shell</code> for the current async task with <code>withCurrent { ... }</code>.</p><p>So the actual execution path ends up looking like this:</p><pre><code class=\"language-swift\">let shell = Shell(\n    stdout: stdoutSink,\n    stderr: stderrSink,\n    environment: .synthetic(workingDirectory: workspaceRoot.path),\n    scriptName: &quot;&lt;swift-tool&gt;&quot;,\n    sandbox: try? makeSandbox(...)\n)\n\nlet interpreter = Interpreter()\n\nlet status = try await shell.withCurrent {\n    try await interpreter.evalScript(code, fileName: &quot;&lt;swift-tool&gt;&quot;)\n}\n</code></pre><p>That <code>withCurrent</code> call is the key. It binds our synthetic shell as <code>Shell.current</code> for the duration of the script.</p><p>So when interpreted Swift does things that route through the ShellKit bridge, it sees:</p><ul><li>our stdout and stderr sinks</li><li>our working directory</li><li>our sandbox policy</li></ul><p>That was one of those nice moments where the implementation becomes less magical after you read it. The interpreter is not doing anything mysterious. It is just running inside a runtime context we provide.</p><h2 id=\"more-runtime,-less-terminal\">More Runtime, Less Terminal</h2><p>This change is already feeling better than just running <code>bash</code>. Not because Swift is “safer” in some abstract sense, but because the execution model is narrower and more controlled.</p><p>With <code>bash</code>, the natural shape of the tool is “here is a shell, good luck, have fun”. With the embedded SwiftScript, the natural shape is “here is an interpreter we host, and here is the runtime context we chose to expose”. That is a very different starting point.</p><p>And of course this could be accomplished too by using some bash shell virtualizer, but that’s not why we are here today ^^.</p><p>If the one-tool experiment is supposed to give the model a general-purpose language, Swift feels like a great candidate.</p><h2 id=\"does-that-mean-we-no-longer-need-sandboxing?\">Does that mean we no longer need sandboxing?</h2><p>The good part is that the interpreter is not just floating there in the void. It runs inside the virtualized shell we control, and <a href=\"https://github.com/Cocoanetics/ShellKit\">ShellKit</a> gives that shell a sandbox too.</p><p>So when we build the shell for the <code>swift</code> tool, we can attach a policy like this:</p><pre><code class=\"language-swift\">return Sandbox(\n    documentsDirectory: workspaceRoot,\n    downloadsDirectory: workspaceRoot,\n    libraryDirectory: workspaceRoot.appendingPathComponent(&quot;.swift-script-library&quot;, isDirectory: true),\n    temporaryDirectory: temporaryDirectory,\n    homeDirectory: workspaceRoot,\n    authorize: { url in\n        guard url.isFileURL else {\n            guard let host = url.host, host.isEmpty == false else {\n                throw Sandbox.Denial(url: url, reason: &quot;non-file URL has no host to authorize&quot;)\n            }\n            return\n        }\n\n        let resolved = url.standardizedFileURL.resolvingSymlinksInPath()\n        let path = resolved.path\n\n        if path == gitDirectory.path || path.hasPrefix(gitDirectory.path + &quot;/&quot;) {\n            throw Sandbox.Denial(url: url, reason: &quot;the Swift tool cannot access .git&quot;)\n        }\n\n        if isAllowed(path: path, under: workspaceRoot.path)\n            || isAllowed(path: path, under: globalSkillsRoot.path)\n            || isAllowed(path: path, under: temporaryDirectory.path) {\n            return\n        }\n\n        throw Sandbox.Denial(url: url, reason: &quot;file URL is outside the Swift tool sandbox&quot;)\n    }\n)\n</code></pre><p>That is already a much nicer situation than <code>bash</code>.</p><p>The interpreter gets a view of the world that we shape. It can read the workspace, the user-level skills directory, a temp directory, and it can also make network requests through the bridged APIs. It cannot touch <code>.git</code>. It cannot just wander off into the rest of the machine through the bridged file APIs.</p><p>So due to this, my first instinct was that SwiftScript plus ShellKit might already be enough. Once the execution model moves from <code>bash</code> to an embedded interpreter, the whole thing already feels much more controlled. There is no arbitrary shell pipeline by default. No compiler subprocess. No “just spawn more tools and see what happens” as the natural path.</p><p>But that still does not make the interpreter a hard security boundary.</p><p>The important distinction is that <code>ShellKit.Sandbox</code> is an in-process policy layer. It is very useful, but is different from an OS-enforced sandbox. It is also still part of the same process and the same runtime we are controlling so it also exposes our harness and the quality of our code to security attacks.</p><p>Another approach I took, but discarded to keep things simple, was to use the same embedded interpreter and shell sandbox, but spawning it in a separate process just like we did with bash. If we do that, then we can wrap that separate process in the same Seatbelt system and get yet an extra, OS-enforced layer, of protection.</p><pre><code class=\"language-bash\">sandbox-exec -p &#39;&lt;seatbelt profile&gt;&#39; swiftagentharness swift-script-helper &#39;print(40 + 2)&#39;\n</code></pre><p>This reminds me of when browsers started going multi-process for isolation. Even though for this learning harness keeping it simple seems enough thanks to ShellKit and the interpreter, it’s still interesting to see how an out-of-process system would play in practice.</p><h2 id=\"so-what-does-this-look-like-in-practice?\">So what does this look like in practice?</h2><p>At that point, the only interesting question left was the practical one. If I now run the harness with this <code>swift</code> tool, does it actually behave the way I want?</p><p>The answer was yes, at least for the kind of things that matter in this series.</p><p>The LLM can call the <code>swift</code> tool and just execute Swift:</p><pre><code class=\"language-session\">&lt;Step 1&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;swift&quot;,&quot;arguments&quot;:{&quot;code&quot;:&quot;print(40 + 2)&quot;}}\ntool-success: swift -&gt; 42\n&lt;Step 2&gt;\nAssistant: The Swift tool worked. It printed 42 from top-level Swift.\n</code></pre><p>It can also use <code>Foundation</code>, inspect the workspace, and read files:</p><pre><code class=\"language-session\">&lt;Step 1&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;swift&quot;,&quot;arguments&quot;:{&quot;code&quot;:&quot;import Foundation ...&quot;}}\ntool-success: swift -&gt; cwd=/Users/me/code/swiftagentharness\ntop_level_count=11\ntop_level_sample=.agents,.build,.git,.gitignore,AGENTS.md,Package.resolved\npackage_lines=34\nagents_lines=6\n&lt;Step 2&gt;\nAssistant: The Swift tool worked and returned the requested result.\n</code></pre><p>So from the LLM point of view, this already behaves much more like a real general-purpose language tool than a terminal wrapper.</p><p>And the sandbox does kick in when the script tries to escape:</p><pre><code class=\"language-session\">&lt;Step 1&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;swift&quot;,&quot;arguments&quot;:{&quot;code&quot;:&quot;import Foundation ... try \\&quot;hello\\&quot;.write(toFile: \\&quot;../escape.txt\\&quot;, atomically: true, encoding: .utf8)&quot;}}\ntool-success: swift -&gt; {&quot;stdout&quot;:&quot;&quot;,&quot;stderr&quot;:&quot;file URL is outside the Swift tool sandbox\\n&quot;,&quot;exit_code&quot;:&quot;interpreter-error&quot;}\n</code></pre><p>Which is exactly the kind of behavior I wanted. The model can read and write files, but only inside the world we decided to expose.</p><p>Of course, because this is still an interpreter library built by a single author and not some giant official runtime, there are parts of the surface that are not fully complete yet. That is fine. It still proves the point very well. The important thing is not that it supports every possible Swift API today. The important thing is that the one-tool experiment already feels real with Swift.</p><h2 id=\"where-i-land-on-it\">Where I land on it</h2><p>Replacing <code>bash</code> with embedded Swift is not just a gimmick. It genuinely changes the shape of the harness, and makes the one-tool experiment feel much more deliberate.</p><p>And I also like that the final answer is not perfectly clean.</p><p>The experiment did not end with “Swift replaces everything and all concerns disappear.” It ended somewhere more honest. Swift is a much nicer one-tool language than bash, embedding the interpreter is the interesting version of the idea, ShellKit gives that interpreter a controlled runtime context, and the question of hard sandboxing becomes smaller, but not completely gone.</p><p>Huge kudos to <a href=\"https://x.com/cocoanetics\">@cocoanetics</a>, because this is already an impressive foundation for exploring ideas like this in practice. Like him, I wish the Swift project had an officially supported embeddable interpreter. It’s been many <a href=\"https://alejandromp.com/development/blog/wwdc21-first-impressions/\">WWDCs</a> now that I expected a “SwiftCoreKit” as an alternative to JavaScriptCore.</p><p>I’m very pleased with this exploration, not because I found the final perfect tool, but because the model around tool execution, shells and interpreters is clearer in my head now. I also had fun exploring Swift interpretation and out-of-process execution. For this series, that is the whole point.</p>",
      "date_published" : "2026-05-21T22:00:00Z",
      "id" : "https://alejandromp.com/development/blog/replacing-bash-with-swift-in-an-ai-harness",
      "summary" : "A few weeks ago I read <a href=\"https://www.cocoanetics.com/2026/05/an-interpreter-for-swift/\">An Interpreter for Swift</a>, from Cocoanetics, and I had that nice feeling of somebody else having already articulated the thing that was vaguely floating in your head.",
      "tags" : [
        "AI Development",
        "Swift",
        "Project"
      ],
      "title" : "Replacing Bash with Swift in an AI Harness",
      "url" : "https://alejandromp.com/development/blog/replacing-bash-with-swift-in-an-ai-harness"
    },
    {
      "authors" : [
        {
          "name" : "Alejandro M. P."
        }
      ],
      "content_html" : "<p>Skills are everywhere right now. People talk about them as if they were some magical incantation that will make AI more powerful while also keeping it under control.</p><p>But as with pretty much everything else in this space, once you look closely, it is much less mystical than it sounds. It is mostly markdown.</p><p>If you already understood how <a href=\"https://alejandromp.com/development/blog/ai-doesn-t-remember-your-project-markdown-does/\">AGENTS.md works</a>, then skills are not that different. They are just a more structured and reusable way of doing a very similar thing.</p><p>So the question I wanted to answer was simple. What does it actually take to teach skills to a tiny AI harness?</p><h2 id=\"so-what-is-a-skill\">So what is a skill</h2><p>In its most basic form a skill is just a markdown file containing a reusable prompt for the LLM. Just like it happened with the AGENTS.md, the goal is to fill the context with useful information so the back and forth with the model improves.</p><p>In practice, a skill has a bit more. It also contains a bit of metadata and can contain extra files. For this, there is a <a href=\"https://agentskills.io/specification\">standard for skills</a>, which is honestly great because it means we are not talking only about one product’s private feature, but something compatible across harnesses.</p><p>The spec describes a skill as a folder with a markdown file called <code>SKILL.md</code>. That file contains the actual instructions in markdown, plus a bit of metadata like a name and a description as front matter.</p><p>That extra metadata is one of the most important aspects of skills since it lets harnesses follow a progressive disclosure principle. Skills can be discovered and loaded immediately, but just their name and description. Then, only when the skill is needed, the entire instruction content is loaded. This means the LLM context is not filled with unnecessary tokens. That progressive disclosure part is one of my favorite ideas here, unsurprisingly as it’s one of the core tenets of Swift language design.</p><p>It is funny too, because progressive disclosure is one of those principles that make so much sense for humans. Don’t dump everything at once. Show the right thing at the right moment. And it is interesting to see the same instinct apply to LLMs too.</p><h2 id=\"discovery\">Discovery</h2><p>The first actual implementation step is to find the skills themselves. As the standard and the harnesses I looked at support two locations, I wanted to implement the same. This would allow me to already test the harness with the real skills I’m using everywhere else.</p><p>There are two main locations where skills can live:</p><ul><li>a repo-local <code>.agents/skills</code> folder, for workflows that belong to the project</li><li>a user-level <code>~/.agents/skills</code> folder, for global workflows available everywhere</li></ul><p>But discovery is not only finding the right files and folders. It is also policy. And although the security of loading skills is not something we will get into here, it’s a can of worms that is worth considering. A repo-local skill that I wrote for the project is not the same thing as a skill I dumped into my home folder. So the harness has to decide not only where to scan, but also what kind of instructions it is willing to trust and surface automatically.</p><p>For our purposes, the actual code ended up being a small dedicated type for this. We can make a catalog type so the harness has specific knowledge and control of the skills.</p><pre><code class=\"language-swift\">struct SkillCatalog {\n    let skills: [SkillDefinition]\n    let workspaceSkillCount: Int\n    let userSkillCount: Int\n\n    static func load(workspaceRoot: URL, homeDirectory: URL = URL(fileURLWithPath: NSHomeDirectory())) -&gt; Self {\n        let workspaceSkills = loadSkills(\n            in: workspaceRoot.appendingPathComponent(&quot;.agents/skills&quot;, isDirectory: true),\n            source: .workspace\n        )\n        let userSkills = loadSkills(\n            in: homeDirectory.appendingPathComponent(&quot;.agents/skills&quot;, isDirectory: true),\n            source: .user\n        )\n\n        var discoveredByName = [String: SkillDefinition]()\n\n        // Workspace skills load first, so they win name collisions.\n        for skill in workspaceSkills + userSkills {\n            let key = skill.name.lowercased()\n            guard discoveredByName[key] == nil else { continue }\n            discoveredByName[key] = skill\n        }\n\n        return SkillCatalog(\n            skills: discoveredByName.values.sorted { ... },\n            workspaceSkillCount: workspaceSkills.count,\n            userSkillCount: userSkills.count\n        )\n    }\n}\n</code></pre><p>The loader walks both directories recursively, looks for files named exactly <code>SKILL.md</code>, and builds an in-memory catalog.</p><p>That final step looks like this:</p><pre><code class=\"language-swift\">private static func loadSkill(at url: URL, source: SkillSource) -&gt; SkillDefinition? {\n    guard let content = try? String(contentsOf: url, encoding: .utf8) else {\n        return nil\n    }\n\n    let parsed = parseSkillFile(content)\n    let fallbackName = url.deletingLastPathComponent().lastPathComponent\n    let name = parsed.metadata[&quot;name&quot;]?.nonEmpty ?? fallbackName\n    let description = parsed.metadata[&quot;description&quot;]?.nonEmpty ?? &quot;No description provided.&quot;\n\n    return SkillDefinition(\n        name: trimQuotes(from: name),\n        description: trimQuotes(from: description),\n        path: url,\n        source: source\n    )\n}\n</code></pre><p>As you can see, the loop that loads the skills just skips entries whose name already exists. Since workspace skills are loaded first, that means they win collisions. If a repository wants to provide its own version of a skill, that should override my personal default for that session.</p><p>I also made the harness print what it discovered at startup, just like it already did for <code>AGENTS.md</code>. That way I can immediately verify that discovery is working before even asking the model to do anything:</p><pre><code class=\"language-session\">Loaded workspace skills (2)\nLoaded user skills (29)\nDiscovered skills: harness-super-test, harness-swift-helper, agent-browser, ..., find-skills, ...\nLoaded AGENTS.md (11 lines)\n</code></pre><h2 id=\"parsing\">Parsing</h2><p>Once the harness finds the skills, it has to parse them. The good part is that there is nothing special about it, if you’ve ever worked with a <a href=\"https://alejandromp.com/development/blog/back-to-the-basics-with-genesis/\">markdown-based static site generator</a>, you will be familiar with <a href=\"https://alejandromp.com/development/blog/github-action-to-create-new-posts/\">YAML front matter</a>. That’s what needs to be extracted, just the <code>name</code> and <code>description</code>. Maybe validate them a bit. But at this stage the harness does not keep the rest of the markdown in memory. It only keeps the catalog metadata and the path to the file.</p><p>Each discovered <code>SKILL.md</code> gets loaded as text. Then the harness looks for the front matter delimiters, parses the little metadata block, extracts <code>name</code> and <code>description</code>, and stores just enough information to build the catalog.</p><pre><code class=\"language-swift\">private static func parseSkillFile(_ content: String) -&gt; (metadata: [String: String], instructions: String) {\n    let lines = content.components(separatedBy: .newlines)\n\n    guard lines.first == &quot;---&quot; else {\n        return ([:], content.trimmingCharacters(in: .whitespacesAndNewlines))\n    }\n\n    var metadata = [String: String]()\n\n    for line in lines.dropFirst() {\n        if line == &quot;---&quot; { break }\n        guard let separator = line.firstIndex(of: &quot;:&quot;) else { continue }\n\n        let key = line[..&lt;separator].trimmingCharacters(in: .whitespaces)\n        let value = line[line.index(after: separator)...].trimmingCharacters(in: .whitespaces)\n\n        guard key.isEmpty == false, value.isEmpty == false else { continue }\n        metadata[key] = value\n    }\n\n    return (metadata, content)\n}\n</code></pre><p>We already have skills on disk. We already know what they are. We already know enough to tell the model they exist. But we have not paid the token cost of loading them all. Not yet.</p><h2 id=\"updating-the-system-prompt\">Updating the system prompt</h2><p>We now have a catalog with the metadata of the available skills. The harness knows them, but the LLM still doesn’t. So just like with <code>AGENTS.md</code>, we now need to inject the catalog into the system prompt so it is available in the model context.</p><pre><code class=\"language-swift\">let catalog = skillCatalog.skills\n    .map(\\.promptBlock)\n    .joined(separator: &quot;\\n&quot;)\n\nskillsPrompt = &quot;&quot;&quot;\nAvailable skills:\nThese skills provide specialized instructions for specific tasks. When a task matches a skill&#39;s description, read the `SKILL.md` at the listed location before proceeding.\n&lt;available_skills&gt;\n\\(catalog)\n&lt;/available_skills&gt;\n&quot;&quot;&quot;\n</code></pre><pre><code class=\"language-swift\">var promptBlock: String {\n    &quot;&quot;&quot;\n    &lt;skill&gt;\n    name: \\(name)\n    description: \\(description)\n    location: \\(path.path)\n    &lt;/skill&gt;\n    &quot;&quot;&quot;\n}\n</code></pre><p>That last field matters more than it may seem.</p><p>The <code>location</code> is what lets the model bridge the gap between “I know this skill exists” and “now I want to actually use it”. Without that field, the model would know the skill exists but would still need some other mechanism to get to the file.</p><p>And once that prompt change exists, we can finally test the interesting part.</p><h2 id=\"implicit-activation\">Implicit activation</h2><p>Once the skills are in context, implicit activation is almost the purest version of the idea. The model sees that a skill exists, sees what it is for, and decides to load it when the task matches. We will look at explicit activation later, but this is the best place to start.</p><p>For this harness, that also means something very concrete. I am not going to add a special <code>activate_skill</code> tool right now. If the point of the project is learning, then I would rather keep the mechanism visible. The model sees a skill in the catalog, notices the path, and reads the file with the normal file-reading tool.</p><div class=\"callout-tip\"><p>A dedicated activation tool is still a valid design, and maybe even the better one for a more serious agent. It gives tighter control, better permissions, and cleaner analytics. But for this harness it would hide the mechanism too early. I want to see the model load the markdown directly.</p></div><p>To make that work in practice, I had to do one small but important change. The harness already knew how to discover user-level skills in <code>~/.agents/skills</code>, but the original implementation of the <a href=\"https://alejandromp.com/development/blog/do-we-even-need-multiple-tools/\"><code>read_file</code> tool</a> and the <a href=\"https://alejandromp.com/development/blog/sandboxing-an-ai-harness-on-macos/\">sandbox</a> would limit access to that directory because it sits outside the workspace.</p><p>So I updated two things:</p><ul><li>the <code>read_file</code> tool can now read both the workspace and <code>~/.agents/skills</code></li><li>the sandbox profile for <code>bash</code> can now read <code>~/.agents/skills</code> too</li></ul><pre><code class=\"language-swift\">let globalSkillsRoot = URL(fileURLWithPath: NSHomeDirectory())\n    .appendingPathComponent(&quot;.agents/skills&quot;, isDirectory: true)\n    .standardizedFileURL\n\nlet isInsideWorkspace = resolvedPath == rootPath || resolvedPath.hasPrefix(rootPath + &quot;/&quot;)\nlet isInsideGlobalSkills = resolvedPath == globalSkillsPath || resolvedPath.hasPrefix(globalSkillsPath + &quot;/&quot;)\n\nguard isInsideWorkspace || isInsideGlobalSkills else {\n    throw HarnessError.workspaceViolation(path)\n}\n</code></pre><pre><code class=\"language-scheme\">;; Global skills\n(allow file-read*\n    (subpath &quot;/Users/me/.agents/skills&quot;))\n</code></pre><p>That keeps the mechanism simple and visible. The model sees a skill in the catalog, notices the path, and reads the markdown like any other file.</p><p>Here is the local skill case:</p><pre><code class=\"language-session\">&lt;Turn 1&gt;\nYou: Can you use the local harness-super-test skill?\n&lt;Step 1&gt;\nAssistant: I can use it, and I&#39;ve loaded the local `harness-super-test` skill instructions. What would you like me to do with it?\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;.agents/skills/harness-super-test/SKILL.md&quot;}}\ntool-success: read_file -&gt; 10 lines\n&lt;Step 2&gt;\nAssistant: You now have super powers, you are a super harness.\n  That phrasing came from the repo-local skill in `.agents/skills/harness-super-test`.\n</code></pre><p>And here is the global skill case:</p><pre><code class=\"language-session\">&lt;Turn 1&gt;\nYou: Use the find-skills skill to search for the caveman skill, and read the skill file before proceeding.\n&lt;Step 1&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;~/.agents/skills/find-skills/SKILL.md&quot;}}\ntool-success: read_file -&gt; 89 lines\n&lt;Step 2&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;bash&quot;,&quot;arguments&quot;:{&quot;command&quot;:&quot;npx skills find caveman&quot;}}\ntool-success: bash -&gt; command succeeded\n&lt;Step 3&gt;\nAssistant: I used the `find-skills` skill and searched for `caveman`. I read the local `find-skills` skill first, then followed its workflow. The best match was...\n</code></pre><p>That second example is important because it proves the whole chain in a very ordinary way. The catalog exposes a user-level skill, the model reads the file directly, and that file then guides the next action. There is no hidden magic layer in the middle. It is still just context plus tools.</p><h2 id=\"extra-resources\">Extra resources</h2><p>A nice thing about the skills spec is that it allows for a skill to package extra resources. You might have noticed the skill shape: it is not a markdown file named as the skill, no. It’s a folder named as the skill, with a markdown file generically named <code>SKILL.md</code> inside. This is because there can be more.</p><p>The skill folder can contain other subfolders and files inside, which the main skill file can point to. More resources, reference docs, and even scripts. And again, all without polluting the context on load, just when it is needed. This is just progressive disclosure, one level deeper.</p><p>The previous local skill was intentionally tiny, just enough to make activation obvious. That was useful, but still a bit toy-like. So I added a second repo-local skill that feels a bit more like a real workflow:</p><p>It has a <code>SKILL.md</code>, a small reference note, and a tiny Swift helper script. That gave me a much better test, because now the skill was not just shaping the answer. It was guiding the model toward a concrete project-specific action using plain files and plain tools.</p><p>Here is the transcript:</p><pre><code class=\"language-session\">&lt;Turn 1&gt;\nYou: Use the harness-swift-helper skill to summarize this package. Read the skill file first, follow its execution note, run the Swift helper, and then tell me the result.\n&lt;Step 1&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;.agents/skills/harness-swift-helper/SKILL.md&quot;}}\ntool-success: read_file -&gt; 15 lines\n&lt;Step 2&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;.agents/skills/harness-swift-helper/references/RUNNING.md&quot;}}\ntool-success: read_file -&gt; 16 lines\n&lt;Step 3&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;bash&quot;,&quot;arguments&quot;:{&quot;command&quot;:&quot;swift .agents/skills/harness-swift-helper/scripts/package_summary.swift&quot;}}\ntool-success: bash -&gt; command succeeded\n&lt;Step 4&gt;\nAssistant: I read the `harness-swift-helper` skill, followed its execution note, ran the Swift helper, and here is the actual output:\n  Package: swiftagentharness\n  Platform...\n</code></pre><p>That is exactly the kind of behavior I wanted to see. The helper script and the execution note were not sitting in the initial context from the start. The model loaded them only when the skill became relevant, and then used them to complete the task.</p><p>And this is yet another proof that skills are nothing magical, just reusable prompts that can easily package extra resources.</p><h2 id=\"explicit-activation\">Explicit activation</h2><p>Doing explicit activation, where the user directly invokes a skill, can quickly turn into terminal UI and UX work instead of learning about AI and harnesses. I did not want to go down that rabbit hole here. But it still felt useful to dip our toes into it, if only to prove that explicit activation is also not magical.</p><p>So I built the smallest version worth having:</p><pre><code class=\"language-text\">/skill-name [task]\n</code></pre><p>That is it. No autocomplete. No picker. No search. No fancy terminal behavior.</p><p>When the harness sees an input line that starts with <code>/</code>, it checks whether the first token matches a discovered skill name. If it does, the harness reads that <code>SKILL.md</code>, injects it into the conversation, and then the loop continues as normal.</p><pre><code class=\"language-swift\">conversation.append(Message(role: &quot;user&quot;, content: input))\nif let explicitSkillActivation = try resolveExplicitSkillActivation(from: input) {\n    ui.printLine(\n        &quot;skill-load: /\\(explicitSkillActivation.skill.name) -&gt; \\(explicitSkillActivation.skill.path.path)&quot;\n    )\n    conversation.append(Message(\n        role: &quot;system&quot;,\n        content: try explicitSkillSystemMessage(for: explicitSkillActivation)\n    ))\n}\n</code></pre><p>And because I wanted this to be debuggable, the harness also prints a visible confirmation line in the terminal:</p><pre><code class=\"language-text\">skill-load: /harness-swift-helper -&gt; /Users/.../SKILL.md\n</code></pre><p>That matters because we do not have to guess whether the harness explicitly loaded the skill, or whether the model later decided to read the file on its own. We can see it.</p><p>This is also the useful difference with implicit loading. There, we still depend on the model noticing that a skill is relevant. Here, the user decides first, and the harness makes sure those desires come true.</p><p>This is an important insight. It reinforces, again, that skills are nothing special, just text dumped into the context. Before, it was the LLM using the read tool to load that text. Now it is the harness doing it manually. After that, the loop just continues as usual. The fact that there are no other changes necessary clarifies a lot.</p><pre><code class=\"language-session\">&lt;Turn 1&gt;\nYou: /find-skills caveman\nskill-load: /find-skills -&gt; ~/.agents/skills/find-skills/SKILL.md\n&lt;Step 1&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;bash&quot;,&quot;arguments&quot;:{&quot;command&quot;:&quot;npx skills find caveman&quot;}}\ntool-success: bash -&gt; command succeeded\n&lt;Step 2&gt;\nAssistant: I found several `caveman`-related skills, including `juliusbrussee/caveman@caveman` and `mattpocock/skills@caveman`.\n</code></pre><p>We can see how there was no read tool call. It was not necessary because the skill was already in the context, so the LLM could directly go and use <code>bash</code> as told by the skill. That means explicit invocation can save a round-trip, tokens, and cost.</p><p>This also affects the cost and behavior of different harnesses. For example, what happens if in the next turn we ask for something related?</p><pre><code class=\"language-session\">&lt;Turn 2&gt;\nYou: and can you find the &quot;web-search&quot; one?\n&lt;Step 1&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;bash&quot;,&quot;arguments&quot;:{&quot;command&quot;:&quot;npx skills find web-search&quot;}}\ntool-success: bash -&gt; command succeeded\n&lt;Step 2&gt;\nAssistant: I found several web-search-related skills, including: ...\n</code></pre><p>You can see how the model decided to use the skill again to answer our request. This time it did not have to load it again, because the information was already in the context. In this harness, that happens because the skill stays in the conversation. That is just a decision I made during the implementation. Some harnesses may keep the skill only on the demanded turn, clean it after a few turns, or reload it every time. These small decisions are part of what makes each harness feel distinct.</p><div class=\"callout-tip\"><p>Better harnesses usually go much further here. They offer autocomplete, skill listing, filtering, search, and richer terminal interactions. I am intentionally stopping earlier. The point of this post is to demystify skills, not to deep dive into terminal UI. That can be its own journey later.</p></div><h2 id=\"back-to-reality\">Back to reality</h2><p>After walking through discovery, parsing, prompt disclosure, implicit loading, extra resources, and even a tiny explicit activation flow, I think the main lesson is simple.</p><p>Skills are not some new magical agent primitive. They are reusable prompts with a folder convention.</p><p>They can be great. They are also, at the end of the day, still mostly markdown. So think twice before jumping into the <a href=\"https://alejandromp.com/development/blog/development-dogma-is-back-a-i-agents/\">dogma hype train</a>.</p><p>That is not diminishing them. If anything, I think that makes them more useful, because now the value is obvious. The harness is not growing a mysterious new organ. We are just giving the model a structured way to discover the right instructions, the right note, the right script, and only load them when they become relevant.</p><p>And because of that, all the usual context problems still apply. Pollution. Distraction. Conflicting instructions. Stale workflows. Too much noise. Skills do not escape any of that. They are just a cleaner way of doing context engineering.</p><p>The security side matters too. Pulling a random skill from the internet is letting somebody else inject instructions into an agent that may already have access to your files, your shell, your browser, whatever tools you gave it. We should all talk about that more.</p><p>So my recommendation is simple. Write your own skills. And if you find a good one online, take the core of the idea, read it carefully, tailor it, make it yours. The nice part is that once you stop treating skills like magic, that becomes trivial. They are mostly markdown, and the LLM can help you write them.</p><p>That is the real win for me. Not that skills are fancy. Not that they feel powerful. But that once you understand what they really are, you can shape them on purpose.</p><div class=\"callout-promo\"><p class=\"callout-promo-label\">Want the finished project?</p><div class=\"callout-promo-body\"><p>The 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.</p><p><a href=\"https://buymeacoffee.com/alejandromp/e/532022\">Get the harness by supporting me on Buy me a coffee</a></p></div></div>",
      "date_published" : "2026-05-09T22:00:00Z",
      "id" : "https://alejandromp.com/development/blog/teaching-skills-to-an-ai-harness",
      "summary" : "Skills are everywhere right now. People talk about them as if they were some magical incantation that will make AI more powerful while also keeping it under control.",
      "tags" : [
        "AI Development",
        "Swift",
        "Project"
      ],
      "title" : "Teaching Skills to an AI Harness",
      "url" : "https://alejandromp.com/development/blog/teaching-skills-to-an-ai-harness"
    },
    {
      "authors" : [
        {
          "name" : "Alejandro M. P."
        }
      ],
      "content_html" : "<p>In the <a href=\"https://alejandromp.com/development/blog/do-we-even-need-multiple-tools/\">previous post</a> we gave superpowers to the harness in the form of a <code>bash</code> tool. Even doing it just for the learning experience, it was already very eye-opening. It made this little Swift harness feel much closer to the real thing.</p><p>And that is exactly why I don’t want to hand-wave the next part away.</p><p>We haven’t spent the time to make it production-ready, and we won’t because that’s not the purpose of this series. There are no carefully tuned prompts. No tailored rules for different models. No approval flows. No guardrails worth the name. It is just a tiny learning project.</p><p>But learning projects need to teach real lessons, and one of them is that giving an LLM shell access changes the nature of the problem immediately. It is no longer just “can the model inspect the project better?” It is also “what can this thing touch now, and what did I accidentally give it access to?”</p><p>So before happily moving on with the harness, I wanted to stop and look directly at that danger.</p><h2 id=\"the-dangers-of-free-execution\">The dangers of free execution</h2><p>It’s easy to see what power we have given to the LLM:</p><pre><code class=\"language-session\">&lt;Turn 1&gt;\nYou: Inspect this project and the surrounding projects so you can understand the environment better.\n&lt;Step 1&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;bash&quot;,&quot;arguments&quot;:{&quot;command&quot;:&quot;pwd &amp;&amp; echo &#39;--- ROOT ---&#39; &amp;&amp; ls -la &amp;&amp; echo &#39;--- PARENT ---&#39; &amp;&amp; ls -la .. &amp;&amp; echo &#39;--- GRANDPARENT ---&#39; &amp;&amp; ls -la ../../&quot;}}\ntool-success: bash -&gt; {&quot;stdout&quot;:&quot;\\/Users\\/me\\/code\\/swiftagentharness\\n--- ROOT --...\n&lt;Step 2&gt;\nAssistant: I inspected the current project and nearby directories.\nCurrent project:\n- `swiftagentharness`\n...\nSibling projects in `../`:\n...\nHigher-level directories in `../../`:\n...\n</code></pre><p>We can see how even with the workspace guidance in the prompt, the model can easily use <code>bash</code> to escape the workspace.</p><p>It’s not hard to imagine what would happen if the model decided to use <code>rm</code> or inspect files that should be totally private, like credentials.</p><h2 id=\"what-are-the-options-then\">What are the options then</h2><p><img src=\"https://alejandromp.com/development/blog/sandboxing-an-ai-harness-on-macos/giphy.gif\" alt=\"\"></p><p>A common typical answer for this is to bring the cannons and use Docker to create a fully virtual and controlled environment so the AI can’t harm anything outside of it. And although that is the prevailing mentality across many developer communities, it’s not the only solution, nor the best or most elegant one.</p><p>So just for the sake of exploration and learning, let’s try something else. Let’s use native operating system tooling to sandbox execution. After all, if the entire revolution of mobile apps could happen with system sandboxes, surely we can benefit from the same work.</p><h2 id=\"what-other-harnesses-do\">What other harnesses do</h2><p>Before going further, I wanted to see whether this was one of those cases where I was overthinking it and everybody else had already moved on to some obvious better approach.</p><p>The short answer is no. The pattern I kept seeing was much more pragmatic.</p><p>OpenAI’s own <a href=\"https://developers.openai.com/api/docs/guides/tools-local-shell\">local shell guide</a> explicitly warns that running arbitrary shell commands can be dangerous and says you should sandbox execution or add strict allow and deny lists. That already tells us something important. Even if your product is all about agentic shell use, the default assumption is still that free execution is too much.</p><p><a href=\"https://code.claude.com/docs/en/sandboxing\">Claude Code’s sandboxing docs</a> are probably the clearest public explanation of the approach. On macOS they use Seatbelt. On Linux they use <code>bubblewrap</code>. Filesystem access and network access are treated as two different problems, and all child processes inherit the same restrictions.</p><p>Anthropic also extracted that work into an open-source project, <a href=\"https://github.com/anthropic-experimental/sandbox-runtime\">sandbox-runtime</a>. Their README is very explicit about what it does: native OS sandboxing primitives plus proxy-based network filtering. That last bit is worth noticing. The serious versions of this don’t stop at filesystem restrictions.</p><p><a href=\"https://goose-docs.ai/docs/guides/sandbox/\">Goose Desktop’s macOS sandbox docs</a> show a very similar shape. Again, native sandboxing on macOS, plus extra machinery around network control.</p><p>And even when looking at OpenAI Codex in the wild, you can see the same reality leak through. OpenAI points to <a href=\"https://github.com/openai/codex\">Codex CLI</a> as a reference implementation from the shell guide, and their issue tracker has real macOS reports that mention the Seatbelt sandbox directly, like <a href=\"https://github.com/openai/codex/issues/10390\">this network-related issue</a>.</p><p>So the pattern seems pretty clear:</p><ul><li>If you want the strongest isolation, people reach for containers.</li><li>If you want native process sandboxing on macOS, people still seem to reach for Seatbelt through <code>sandbox-exec</code>.</li><li>If you care about network too, the more serious tools add a proxy on top.</li></ul><p>For me that is enough indication that is worth spending some time understanding Seatbelt and native sandboxing and seeing how it can improve our tiny harness.</p><h2 id=\"seatbelt\">Seatbelt</h2><p>Seatbelt is the low-level sandboxing facility in macOS. It is one of the pieces behind the strong sandboxing model Apple has used for years across macOS and iOS. So when we talk about app sandboxes and processes being limited by the operating system, this is the machinery that is under the hood.</p><p>That was already a good sign for this little experiment. If the whole mobile app world has been able to rely on OS sandboxing for such a long time, there is probably something useful here for us too, even if our use case is much smaller and much messier than a normal app. Apple’s own documentation around <a href=\"https://developer.apple.com/documentation/security/app_sandbox\">App Sandbox</a> and the macOS <a href=\"https://developer.apple.com/documentation/xcode/configuring-the-macos-app-sandbox\">App Sandbox configuration</a> is mostly focused on proper app development, entitlements, containers, helper tools, and that whole world. That is not exactly our situation, but it tells us the underlying model is very real.</p><p>The slightly confusing part is that there is a difference between the modern Apple recommendation and the practical tool we can use from a little CLI harness.</p><p>The modern recommendation is App Sandbox. That is the official, entitlement-based way to sandbox a macOS app. But our harness is not a signed app with entitlements and containers. It is just a command-line tool that spawns <code>/bin/bash</code>.</p><p>That is where <code>sandbox-exec</code> comes in.</p><p><a href=\"https://manp.gs/mac/1/sandbox-exec\"><code>sandbox-exec</code></a> is a command-line wrapper around that lower-level <a href=\"https://manp.gs/mac/7/sandbox\">sandbox facility</a>. You give it a policy, then tell it which command to run, and it launches that command under those restrictions. That is why it keeps showing up in agent harnesses and AI tools on macOS. It fits the shape of the problem much better than App Sandbox does.</p><p>The idea is very simple:</p><pre><code class=\"language-bash\">sandbox-exec -p &#39;(version 1) ...rules here...&#39; /bin/bash -lc &#39;pwd&#39;\n</code></pre><p>That <code>-p</code> argument is an inline sandbox profile. You can also load profiles from files, but for our tiny harness an inline profile is probably enough.</p><p>And this matters too: child processes inherit the sandbox. That means if we launch <code>bash</code> under Seatbelt restrictions, and then <code>bash</code> launches <code>git</code>, <code>swift</code>, <code>ls</code>, or anything else, those processes keep running inside the same boundary. That is exactly what we want. It would be quite useless to sandbox only the first command and then accidentally let the rest of the process tree escape 🤣.</p><p>There is one awkward detail though. Apple marks <code>sandbox-exec</code> as deprecated in the man page. So this is not me saying “this is the shiny modern blessed API.” It is more like “this is the old but still practical door into Seatbelt for command-line process sandboxing.” For our purposes, and also looking at what other harnesses are doing, that is still good enough to learn something real from it.</p><h2 id=\"let’s-wire-it-into-the-harness\">Let’s wire it into the harness</h2><p>We already had a <code>bash</code> tool that used <code>Subprocess.run</code>. So we don’t have to redesign the whole harness around sandboxing. We just need a small change that can prove the idea.</p><p>Let’s start with preparing the sandbox as a custom method on <code>Executable</code>:</p><pre><code class=\"language-swift\">extension Executable {\n    func runSandboxed&lt;\n        Input: InputProtocol,\n        Output: OutputProtocol,\n        Error: ErrorOutputProtocol\n    &gt;(\n        arguments: [String] = [],\n        environment: Environment = .inherit,\n        workingDirectory: FilePath? = nil,\n        input: Input = .none,\n        output: Output,\n        error: Error = .discarded,\n        sandboxProfile: String\n    ) async throws -&gt; ExecutionRecord&lt;Output, Error&gt; {\n        let resolvedExecutable = try self.resolveExecutablePath(in: environment)\n        let wrappedArguments = [&quot;-p&quot;, sandboxProfile, resolvedExecutable.string] + arguments\n\n        return try await Subprocess.run(\n            .path(&quot;/usr/bin/sandbox-exec&quot;),\n            arguments: Arguments(wrappedArguments),\n            environment: environment,\n            workingDirectory: workingDirectory,\n            input: input,\n            output: output,\n            error: error\n        )\n    }\n}\n</code></pre><p>This part is actually quite simple. We are still just spawning a process, but with the difference that instead of spawning <code>/bin/bash</code> directly, we spawn <code>sandbox-exec</code>, give it the Seatbelt profile, and then tell it to launch the real executable inside that sandbox.</p><p>Then the <code>bash</code> tool can just call this instead of the original <code>Subprocess.run</code>. I’ve added an argument to the harness to be able to use the sandbox only when desired, mostly to try the differences and for learning purposes.</p><pre><code class=\"language-swift\">let result = if sandboxEnabled {\n    try await executable.runSandboxed(\n        arguments: [&quot;-c&quot;, command],\n        workingDirectory: workingDirectory,\n        output: capturedIO,\n        error: capturedIO,\n        sandboxProfile: SeatbeltProfile.workspaceBoundary(for: workspaceRoot)\n    )\n} else {\n    try await Subprocess.run(\n        executable,\n        arguments: [&quot;-c&quot;, command],\n        workingDirectory: workingDirectory,\n        output: capturedIO,\n        error: capturedIO\n    )\n}\n</code></pre><p>That shape was exactly what I wanted. The harness is still the same little harness. The <code>bash</code> tool is still the same <code>bash</code> tool. We are not replacing it with a different concept. We are just giving it a stricter execution path when sandboxing is enabled.</p><blockquote><p>Just FYI, in my final implementation I also included a <code>sandbox</code> subcommand, that helped me run commands directly inside the sandbox without going through the LLM at all. That ended up being really handy for testing the profile itself without having to convince the LLM to do what I needed.</p></blockquote><p>But of course, the interesting part is the profile.</p><p>I’m not gonna lie. I’m not an expert on sandboxing so what I’ve got working probably has flaws, but after some trial and error it seems to work for a few use cases. And that’s all I need for the learning process :)</p><p>For example, the first version was too strict and basically useless. Even <code>pwd</code> and <code>ls</code> were crashing. So this turned into a very practical exercise in seeing what macOS tools actually need to function under Seatbelt.</p><pre><code class=\"language-session\">&lt;Turn 1&gt;\nYou: List the files in this project and summarize what it is. Use bash.\n&lt;Step 1&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;bash&quot;,&quot;arguments&quot;:{&quot;command&quot;:&quot;ls -la&quot;}}\ntool-success: bash -&gt; {&quot;stdout&quot;:&quot;&quot;,&quot;stderr&quot;:&quot;&quot;,&quot;exit_code&quot;:&quot;signaled(6)&quot;}\n</code></pre><p>The key unlock was importing Apple’s built-in <code>system.sb</code> base profile, and then layering our own file restrictions on top. After that, the profile started becoming actually useful instead.</p><pre><code class=\"language-lisp\">(version 1)\n(deny default)\n(import &quot;system.sb&quot;)\n\n;; Start from a deny-all profile, then re-allow only the minimum needed\n;; for &quot;run bash inside the workspace&quot; to work at all.\n(allow process-exec)\n(allow process-fork)\n(allow signal (target self))\n(allow sysctl-read)\n(allow mach-lookup)\n\n(allow file-read*\n    (subpath &quot;/Applications&quot;)\n    (subpath &quot;/bin&quot;)\n    (subpath &quot;/dev&quot;)\n    (subpath &quot;/System&quot;)\n    (subpath &quot;/usr&quot;)\n    (subpath &quot;/var/folders&quot;)\n    (subpath &quot;/private/var/folders&quot;)\n    (subpath &quot;/var/select&quot;)\n    (subpath &quot;/private/var/select&quot;)\n    (subpath &quot;&lt;workspace&gt;&quot;)\n    (subpath &quot;&lt;sandbox-temp-directory&gt;&quot;))\n\n(allow file-read-metadata\n    (literal &quot;&lt;workspace-parent&gt;&quot;))\n\n(allow file-write*\n    (subpath &quot;/var/folders&quot;)\n    (subpath &quot;/private/var/folders&quot;)\n    (subpath &quot;&lt;workspace&gt;&quot;)\n    (subpath &quot;&lt;sandbox-temp-directory&gt;&quot;))\n\n(deny file-write*\n    (subpath &quot;&lt;workspace&gt;/.git&quot;))\n</code></pre><p>It’s all quite self explanatory but here is my own description of what’s happening:</p><ol><li>We deny everything by default, and we will only enable what we want.</li><li>Import Apple’s base profile, this is what makes pretty much everything work :D</li><li>Allow a few process related rules so we can actually call tools.</li><li>Allow reads on the system locations that <code>bash</code>, <code>ls</code>, <code>git</code>, <code>swift</code>, and friends need in practice, plus the workspace and dedicated sandbox temp directory.</li><li>Allow metadata reads on the parent of the workspace because even something like <code>ls -la</code> peeks at <code>..</code> while listing the current directory.</li><li>Allow writes in the workspace and the dedicated sandbox temp directory, including its <code>var/folders</code> aliases that some commands use under the hood.</li><li>But deny writes to the git folder, this was just to try the ruleset.</li></ol><p>Note that because we’re making a custom subfolder in the temporary directory, we have to update the <code>bash</code> tool instructions to include that path. Otherwise the LLM won’t have any clue where it can write temporary files.</p><div class=\"callout-promo\"><p class=\"callout-promo-label\">Want the finished project?</p><div class=\"callout-promo-body\"><p>The 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.</p><p><a href=\"https://buymeacoffee.com/alejandromp/e/532022\">Get the harness by supporting me on Buy me a coffee</a></p></div></div><p>The nice part is that once this was in place we could actually test the boundary in a very concrete way.</p><pre><code class=\"language-session\">&lt;Turn 1&gt;\nYou: run ls and pwd\n&lt;Step 1&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;bash&quot;,&quot;arguments&quot;:{&quot;command&quot;:&quot;pwd &amp;&amp; ls&quot;}}\ntool-success: bash -&gt; {&quot;stdout&quot;:&quot;/Users/me/code/swiftagentharness\nAGENTS.md\nPackage.resolved\n...&quot;,&quot;stderr&quot;:&quot;&quot;,&quot;exit_code&quot;:&quot;exited(0)&quot;}\n&lt;Step 2&gt;\nAssistant: /Users/me/code/swiftagentharness\nAGENTS.md\n...\n</code></pre><pre><code class=\"language-session\">&lt;Turn 2&gt;\nYou: do it at the home folder\n&lt;Step 1&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;bash&quot;,&quot;arguments&quot;:{&quot;command&quot;:&quot;cd ~ &amp;&amp; pwd &amp;&amp; ls&quot;}}\ntool-success: bash -&gt; {&quot;stdout&quot;:&quot;/Users/me\n&quot;,&quot;stderr&quot;:&quot;cd: error retrieving current directory: getcwd: cannot access parent directories: Operation not permitted\nls: .: Operation not permitted\n&quot;,&quot;exit_code&quot;:&quot;exited(1)&quot;}\n&lt;Step 2&gt;\nAssistant: Tried in `~`, but permissions prevented listing it.\nOutput: `/Users/me`\nErrors:\n- `getcwd: cannot access parent directories: Operation not permitted`\n- `ls: .: Operation not permitted`\n</code></pre><pre><code class=\"language-session\">&lt;Turn 3&gt;\nYou: write it\n&lt;Step 1&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;bash&quot;,&quot;arguments&quot;:{&quot;command&quot;:&quot;printf &#39;workspace write test\\n&#39; &gt; scratch.txt &amp;&amp; ls -l scratch.txt &amp;&amp; cat scratch.txt&quot;}}\ntool-success: bash -&gt; {&quot;stdout&quot;:&quot;-rw-r--r--@ 1 me staff 21 ... scratch.txt\nworkspace write test\n&quot;,&quot;stderr&quot;:&quot;&quot;,&quot;exit_code&quot;:&quot;exited(0)&quot;}\n&lt;Step 2&gt;\nAssistant: Wrote `scratch.txt` in the workspace successfully.\n</code></pre><pre><code class=\"language-session\">&lt;Turn 4&gt;\nYou: prove that writing into .git failed\n&lt;Step 1&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;bash&quot;,&quot;arguments&quot;:{&quot;command&quot;:&quot;printf &#39;git dir write test\\n&#39; &gt; .git/scratch-test 2&gt;&amp;1; status=$?; if [ $status -eq 0 ]; then echo WRITE_OK; else echo WRITE_FAILED; fi; exit $status&quot;}}\ntool-success: bash -&gt; {&quot;stdout&quot;:&quot;WRITE_FAILED\n&quot;,&quot;stderr&quot;:&quot;/bin/bash: .git/scratch-test: Operation not permitted\n&quot;,&quot;exit_code&quot;:&quot;exited(1)&quot;}\n&lt;Step 2&gt;\nAssistant: Writing into `.git` failed with `Operation not permitted`.\n</code></pre><pre><code class=\"language-session\">&lt;Turn 5&gt;\nYou: prove that writing ../sandbox-escape.txt failed\n&lt;Step 1&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;bash&quot;,&quot;arguments&quot;:{&quot;command&quot;:&quot;printf &#39;escape test\\n&#39; &gt; ../sandbox-escape.txt 2&gt;&amp;1; status=$?; if [ $status -eq 0 ]; then echo WRITE_OK; else echo WRITE_FAILED; fi; exit $status&quot;}}\ntool-success: bash -&gt; {&quot;stdout&quot;:&quot;WRITE_FAILED\n&quot;,&quot;stderr&quot;:&quot;/bin/bash: ../sandbox-escape.txt: Operation not permitted\n&quot;,&quot;exit_code&quot;:&quot;exited(1)&quot;}\n&lt;Step 2&gt;\nAssistant: Writing `../sandbox-escape.txt` failed with `Operation not permitted`.\n</code></pre><pre><code class=\"language-session\">&lt;Turn 0&gt;\nYou: write a random file in a temporary file\n&lt;Step 1&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;bash&quot;,&quot;arguments&quot;:{&quot;command&quot;:&quot;tmpfile=$(mktemp); head -c 32 /dev/urandom | base64 &gt; \\&quot;$tmpfile\\&quot;; printf &#39;%s&#39; \\&quot;$tmpfile\\&quot;&quot;}}\ntool-success: bash -&gt; {&quot;stdout&quot;:&quot;/var/folders/.../swiftagentharness-sandbox/&lt;workspace-hash&gt;/tmp.sjpO8WrRyF&quot;,&quot;stderr&quot;:&quot;&quot;,&quot;exit_code&quot;:&quot;exited(0)&quot;}\n&lt;Step 2&gt;\nAssistant: Created a temporary file with random content at `/var/folders/.../swiftagentharness-sandbox/&lt;workspace-hash&gt;/tmp.sjpO8WrRyF`\n</code></pre><p>It’s actually pretty cool to see that with so little we already have a more secure harness. Now there are some restrictions and safety around what we let the AI do. It still has bash superpowers, but now with a bit more responsibility.</p><h2 id=\"what-this-does-not-solve\">What this does not solve</h2><p>Now, we need to be honest about what this is and what it is not. This does not make the harness “safe” in any absolute sense. It just makes it <em>safer</em>.</p><p>The profile still has to allow a bunch of system paths so normal commands can even run. That means this is not some perfect tiny prison where the process only knows about the repo. It is a constrained execution environment, sure, but it is not perfect isolation.</p><p>Also, this is not solving network access. Some of the more serious tools pair sandboxing with proxy-based network control. We are not doing that here. So this post is not a guide to production-grade AI isolation. It is a guide to learning how native sandboxing on macOS can help, and where it stops helping. Just be careful out there.</p><p>But sandboxing is not a substitute for judgment. A good harness still needs clear instructions, approvals where appropriate, sane defaults, and probably other layers around command execution. If you want the strongest isolation, containers and VMs still exist for a reason.</p><p>And let’s be honest, even with all of that, with enough tools and computing power, LLMs could probably cause trouble on any environment, so this is just about minimizing those risks at the obvious levels.</p><h2 id=\"good-enough-to-learn-something\">Good enough to learn something</h2><p>We’ve transformed some obscure features in popular harnesses and a trust me bro attitude, to an investigation into the implications of letting AI execute and what’s one of the possible mitigations. That’s very valuable.</p><p>Now I understand a bit better what macOS sandboxing actually looks like in practice. I understand the gap between App Sandbox and <code>sandbox-exec</code>. I understand why other harnesses still use Seatbelt. I understand that the policy itself is only half the story, and that the tool prompt also has to describe the environment correctly. And most importantly, I understand that “workspace root” and “security boundary” are not the same thing unless we do the work to make them the same.</p><p>So this is probably not good enough to ship blindly. Not good enough to call secure. But definitely good enough to learn something real.</p><p>And that is good enough for me.</p>",
      "date_published" : "2026-05-05T22:00:00Z",
      "id" : "https://alejandromp.com/development/blog/sandboxing-an-ai-harness-on-macos",
      "summary" : "In the <a href=\"https://alejandromp.com/development/blog/do-we-even-need-multiple-tools/\">previous post</a> we gave superpowers to the harness in the form of a <code>bash</code> tool. Even doing it just for the learning experience, it was already very eye-opening. It made this little Swift harness feel much closer to the real thing.",
      "tags" : [
        "AI Development",
        "Swift",
        "Project"
      ],
      "title" : "Sandboxing an AI Harness on macOS",
      "url" : "https://alejandromp.com/development/blog/sandboxing-an-ai-harness-on-macos"
    },
    {
      "authors" : [
        {
          "name" : "Alejandro M. P."
        }
      ],
      "content_html" : "<p>After <a href=\"https://alejandromp.com/development/blog/ai-doesn-t-remember-your-project-markdown-does/\">teaching the harness to load <code>AGENTS.md</code></a>, my mind went back to thinking about the tools, and wondered, do we even need multiple tools?</p><p>Our tiny Swift harness had the usual little trio: <code>read_file</code>, <code>list_files</code>, and <code>edit_file</code>. Which is fine. It works. It is also exactly the kind of thing that makes agent tooling feel more magical and more special than it actually is.</p><p>Because if you squint a bit, those three tools are not really three capabilities. They are just tiny wrappers around what the shell has done for decades.</p><p>I’ve heard for a while that LLMs are now very good at just using bash, so I thought, maybe that’s the only tool they need? So… let’s remove them all and just use bash. Let’s see what happens.</p><h2 id=\"current-tools\">Current tools</h2><p>Before changing anything, I wanted a baseline. Let’s remind ourselves how the current tool usage looks like.</p><pre><code class=\"language-session\">Swift Agent Harness\nModel: gpt-5.4\nLoaded AGENTS.md (3 lines)\nCtrl+C to quit\n&lt;Turn 1&gt;\nYou: List and read all files in this project and tell me what&#39;s most interesting about them.\n&lt;Step 1&gt;\nAssistant: I can do that, but I need the actual file list first.\n...\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;AGENTS.md&quot;}}\ntool-success: read_file -&gt; 2 lines\n&lt;Step 2&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;list_files&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;.&quot;}}\ntool-success: list_files -&gt; 7 lines\n&lt;Step 3&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;Package.resolved&quot;}}\ntool-success: read_file -&gt; 15 lines\n&lt;Step 4&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;Package.swift&quot;}}\ntool-success: read_file -&gt; 22 lines\n...\n&lt;Step 13&gt;\nAssistant: I read all files that were visible from the project root...\n</code></pre><p>The answer itself is not the interesting part, what I care about is the shape of the interaction. The model is not exploring the project in some rich, one off way. It’s as if it was playing a point-and-click adventure with the three verbs I gave it. It doesn’t know the environment in the same way we didn’t know everything that was clickable, so it goes and tries a call at a time and reassesses on every step, just like we rapidly click across the screen until we find an interactable object.</p><p>First <code>read_file</code>, because it already knows <code>AGENTS.md</code> exists. Then <code>list_files</code>, then <code>read_file</code>, then <code>read_file</code> again, then more listing, then more reading. Perfectly reasonable.</p><p>And so the question is, would reducing this down to a single tool that can do anything make this better or worse? Because with these three explicit tools, the model is forced to keep translating its intent into those wrappers over and over. That is not necessarily bad. There is something nice about how explicit it is. You can inspect every step. You can see exactly what the model asked for. You can keep the capabilities narrow.</p><p>But what if the model was able to cook a sequence of bash scripts in a single tool call and get everything it needs all at once? That was enough for me to want the next experiment. Remove the little toolbox. Leave just bash. See if the interaction becomes simpler, or just messier in a different way.</p><h2 id=\"new-bash-tool\">New Bash Tool</h2><p>Let’s start by making the new bash tool without removing the existing ones yet.</p><p>I will use <a href=\"https://github.com/swiftlang/swift-subprocess\">swift-subprocess</a>, because is a nice showcase of modern Swift tooling, but also because it solves a real problem. If you let an LLM call bash, it is very easy for it to produce commands that dump a lot of output. Directory walks, file listings, <code>find</code>, <code>sed</code>, <code>cat</code>, maybe all of them chained together. That means <code>stdout</code> and <code>stderr</code> handling stops being a boring implementation detail very quickly. It is easy to get into awkward buffering problems or deadlocks if you try to wire process spawning by hand.</p><p><code>swift-subprocess</code> is designed exactly for this kind of thing, and its API makes the “run a process and collect output properly” path feel trivial, not like a pile of edge cases we have to rediscover ourselves.</p><p>So the new tool looks like this:</p><pre><code class=\"language-swift\">static func bash() -&gt; Self {\n    ToolDefinition(\n        name: &quot;bash&quot;,\n        description: &quot;Run a bash command with the workspace root as the current directory.&quot;,\n        arguments: [&quot;command&quot;],\n        run: { arguments, workspaceRoot in\n            let command = arguments[&quot;command&quot;] ?? &quot;&quot;\n\n            let result = try await Subprocess.run(\n                .path(&quot;/bin/bash&quot;),\n                arguments: [&quot;-c&quot;, command],\n                workingDirectory: FilePath(workspaceRoot.path),\n                output: .string(limit: 1_048_576),\n                error: .string(limit: 1_048_576)\n            )\n\n            let stdout = result.standardOutput ?? &quot;&quot;\n            let stderr = result.standardError ?? &quot;&quot;\n\n            func jsonString(_ value: String) -&gt; String {\n                guard let data = try? JSONEncoder().encode(value),\n                      let string = String(data: data, encoding: .utf8) else {\n                    return &quot;\\&quot;\\&quot;&quot;\n                }\n                return string\n            }\n\n            return &quot;&quot;&quot;\n            {&quot;stdout&quot;:\\(jsonString(stdout)),&quot;stderr&quot;:\\(jsonString(stderr)),&quot;exit_code&quot;:\\(result.terminationStatus)}\n            &quot;&quot;&quot;\n        }\n    )\n}\n</code></pre><p>And then I just added it to the tools array. Nothing more.</p><pre><code class=\"language-swift\">let tools: [ToolDefinition] = [\n    .bash(),\n    .readFile(),\n    .listFiles(),\n    .editFile(),\n]\n</code></pre><p>This was a very deliberate intermediate step. I did not want to jump straight into “bash only” yet. I wanted to see how the model behaved when bash existed, but the <a href=\"https://alejandromp.com/development/blog/have-you-built-an-agent-harness-already/\">old explicit tools</a> were still there too.</p><p>And the first answer was, not that differently at all.</p><p>When I asked for a quick architecture overview with the previous prompt, the model still preferred the old tools. Even with bash available, it kept thinking in terms of <code>read_file</code> and <code>list_files</code>. Which honestly makes sense. Those tools were still there, still very explicit, still easier to map to the task.</p><p>So I had to push it a bit more and ask explicitly for bash.</p><p>I asked this:</p><pre><code class=\"language-text\">Use bash tool to give me a quick architecture overview of this project, including file sizes or line counts\n</code></pre><p>And that was enough for the LLM to use bash.</p><pre><code class=\"language-session\">&lt;Step 1&gt;\nAssistant: Quick architecture overview based on the current workspace...\n...\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;bash&quot;,&quot;arguments&quot;:{&quot;command&quot;:&quot;printf &#39;Top-level structure:\\\\n&#39;; find . -maxdepth 2 \\\\( -path &#39;.\\\\/ .git&#39; -o -path &#39;.\\\\/ .build&#39; \\\\) -prune -o -print | sed &#39;s#^.\\\\/##&#39; | sort; printf &#39;\\\\n\\\\nFile sizes (bytes) and line counts:\\\\n&#39;; find . -type f \\\\( -path &#39;.\\\\/ .git\\\\/*&#39; -o -path &#39;.\\\\/ .build\\\\/*&#39; \\\\) -prune -o -type f -print | sed &#39;s#^.\\\\/##&#39; | sort | while read f; do bytes=$(wc -c &lt; \\&quot;$f\\&quot; | tr -d &#39; &#39;); lines=$(wc -l &lt; \\&quot;$f\\&quot; | tr -d &#39; &#39;); printf &#39;%8s bytes  %6s lines  %s\\\\n&#39; \\&quot;$bytes\\&quot; \\&quot;$lines\\&quot; \\&quot;$f\\&quot;; done&quot;}}\ntool-success: bash -&gt; {&quot;stdout&quot;:&quot;Top-level structure:\\n.\\n.DS_Store\\n.gitignore\\nAGENTS.md\\nPackage...\n&lt;Step 2&gt;\nAssistant: Here&#39;s a quick architecture overview of the project, based on the file layout and line counts.\n...\n</code></pre><p>And as expected the LLM was able to make a tool call to get all the information it needed.</p><p>That was useful to see. Adding a more powerful tool does not automatically make the model prefer it. I’m sure we could tweak the system prompt to nudge it that way, but that’s an exercise on LLM research, and we’re here to talk about the harness. So let’s do the next logical step…</p><h2 id=\"one-for-all\">One For All</h2><p>It was time to remove all the other specific tool calls and see what happened. Luckily this part was trivial, just remove them from the tools list and leave <code>bash</code>.</p><pre><code class=\"language-swift\">let tools: [ToolDefinition] = [\n    .bash(),\n]\n</code></pre><p>And the result was immediately different.</p><p>I went back to the original style of prompt, no special instruction this time:</p><pre><code class=\"language-text\">Give me a quick architecture overview of this project, including file sizes or line counts\n</code></pre><p>This time the model had no alternative. If it wanted to inspect the repo, it had to do it through bash. Depending on the run, sometimes it used one bash call and sometimes a couple. But it did not have to follow the old pattern of many tiny file reads anymore. That is what I wanted to see.</p><pre><code class=\"language-session\">&lt;Turn 1&gt;\nYou: Give me a quick architecture overview of this project, including file sizes or line counts\n&lt;Step 1&gt;\n...\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;bash&quot;,&quot;arguments&quot;:{&quot;command&quot;:&quot;printf &#39;Top-level:\\\\n&#39;; find . -maxdepth 2 -type f | sed &#39;s#^.\\\\/##&#39; | sort; printf &#39;\\\\n\\\\nTree (depth 3):\\\\n&#39;; find . -maxdepth 3 | sed &#39;s#^.\\\\/##&#39; | sort&quot;}}\ntool-success: bash -&gt; {&quot;stdout&quot;:&quot;Top-level:\\n.build\\/.DS_Store\\n.build\\/.lock\\n.build\\/build.db\\n.b...\n&lt;Step 2&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;bash&quot;,&quot;arguments&quot;:{&quot;command&quot;:&quot;printf &#39;Line counts (project files only):\\\\n&#39;; for f in AGENTS.md Package.swift Package.resolved README.md run.sh share.sh Sources\\\\/swiftagentharness\\\\/*.swift; do [ -f \\&quot;$f\\&quot; ] &amp;&amp; wc -l \\&quot;$f\\&quot;; done | sort -n; printf &#39;\\\\n\\\\nPackage.swift:\\\\n&#39;; sed -n &#39;1,220p&#39; Package.swift; printf &#39;\\\\n\\\\nREADME.md:\\\\n&#39;; sed -n &#39;1,260p&#39; README.md&quot;}}\ntool-success: bash -&gt; {&quot;stdout&quot;:&quot;Line counts (project files only):\\n       2 AGENTS.md\\n       9 sh...\n&lt;Step 3&gt;\nAssistant: Here&#39;s a quick architecture overview of the project.\n...\n</code></pre><p>But we also need to note what we lose when we remove the specific tools. With <code>read_file</code> and <code>list_files</code>, part of the harness design was encoded in the tools themselves. The model could only ask for fairly narrow things and we had control over it. For example, we could make the list of files not return any hidden files, thus reducing the amount of likely useless data we fed back into the LLM’s context. With bash, that structure is gone. The model has much more freedom, which is exactly why it can gather more in fewer calls, but it also means the quality of the interaction depends much more on how well it shapes the command. As you can see in the exchange above, the LLM got a lot of hidden files that were not necessary and distracted from the goal.</p><p>That tradeoff is exactly what I was hoping to understand with this exercise. Bash really can replace the tiny toolbox. But it does not simply make the harness better. It moves complexity away from the tool harness and into the LLM’s command construction.</p><h2 id=\"performance-impact\">Performance Impact</h2><p>Seeing how the LLM can cook a single tool call using bash to gather all the information in one single step is eye-opening, it makes you realize the performance implications of tools. We’ve already seen how <a href=\"https://alejandromp.com/development/blog/ai-doesn-t-remember-your-project-markdown-does/\">the context is the thing that matters to get the most out of AIs</a>, but here we’ve seen how the tools that you give actually can have a tremendous impact too.</p><p>Even in this simple harness we can see how the impact on performance of the AI can be totally different depending on the tools we give it. For that we need to really internalize that every time the LLM needs more information, it must make a tool call, that means an extra step, and extra back and forth between the LLM and our harness. That also means triggering a new inference run. And remember, even if LLM architectures have caches and fancy tricks, every inference run virtually starts from the beginning of the conversation and it autocompletes the next part. So having a lot of back and forth on the same turn just to gather extra data is less than desirable.</p><p><img src=\"https://alejandromp.com/development/blog/do-we-even-need-multiple-tools/tool-roundtrips.svg\" alt=\"Side by side comparison of many small tool calls versus a single bash call\"></p><p>Of course this is simplified, the bash version may still take two calls on some runs, and the multi-tool version may vary too. But the high level shape is the important part. One approach encourages lots of tiny round-trips. The other makes it possible to gather much more context in one go. Even if it’s up to the LLM to decide, the bash tool gives it the option to reduce the roundtrips.</p><p>This is where tradeoffs, analysis, engineering and research all meet to tweak a harness implementation and make it the most performant while making it useful and concrete enough for the LLM to know what to do. And this keeps evolving as LLMs evolve. This is one of the things that might make a harness feel better than another.</p><div class=\"callout-promo\"><p class=\"callout-promo-label\">Want the finished project?</p><div class=\"callout-promo-body\"><p>The 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.</p><p><a href=\"https://buymeacoffee.com/alejandromp/e/532022\">Get the harness by supporting me on Buy me a coffee</a></p></div></div><h2 id=\"so,-do-we?\">So, do we?</h2><p>Can we answer the question posed in the title of this post… as always, it depends. I think there is no real winner here, or at least not as clearly as I thought. I imagine one would have to run deeper and <em>scientific</em> benchmarks to see some sort of trend.</p><p>The multiple specific tools were useful mostly because they encoded structure. They constrained the model, made the interaction easier to inspect, and gave the harness more control over what kind of information could flow back into the context. And that is also exactly their limitation. Bash, on the other hand, got surprisingly close to replacing the whole little toolbox precisely because it removed that structure. Once the model had access to it, it could gather broad context much faster and with fewer round-trips. And that flexibility is also exactly the problem, because you lose control and specificity.</p><p>And that, for me, was the interesting shift in understanding. The question is not only what tools we can give the model. It is where we want the structure to live. In a carefully designed tool protocol, or in the commands the model writes for itself.</p><p>The advantage is that we’re just here to learn how these things work and see past the curtain, and on that I think we won. So for this step of the journey, I will leave it here. Each one can take the conclusion they want. I might just keep all tools available, and add a flag to run it in different modes.</p>",
      "date_published" : "2026-05-01T22:00:00Z",
      "id" : "https://alejandromp.com/development/blog/do-we-even-need-multiple-tools",
      "summary" : "After <a href=\"https://alejandromp.com/development/blog/ai-doesn-t-remember-your-project-markdown-does/\">teaching the harness to load <code>AGENTS.md</code></a>, my mind went back to thinking about the tools, and wondered, do we even need multiple tools?",
      "tags" : [
        "AI Development",
        "Swift",
        "Project"
      ],
      "title" : "Do We Even Need Multiple Tools?",
      "url" : "https://alejandromp.com/development/blog/do-we-even-need-multiple-tools"
    },
    {
      "authors" : [
        {
          "name" : "Alejandro M. P."
        }
      ],
      "content_html" : "<p>In the previous post, <a href=\"https://alejandromp.com/development/blog/have-you-built-an-agent-harness-already/\">Have You Built an Agent Harness Yet?</a>, we built a tiny harness in Swift. We gave it a loop, a prompt, and some tools, and with that it already had most of the features we are used to with modern AI. No magic. Just autocomplete, and a harness deciding what the model gets to see and do.</p><p>But there is another bit of magic we didn’t touch on and we should demystify. The appearance of memory. You know that moment when you open a brand new conversation and the agent already seems to know your project.</p><p>It doesn’t.</p><p>LLMs have no memory between fresh conversations. They just autocomplete from what they have in the current context. So if your AI tool of the week seems to remember your repo conventions, folder structure, or coding rules, what is happening is much simpler.</p><p>The industry has come to agree on the <a href=\"https://agents.md\">AGENTS.md</a>  standard, although some companies think they are special and use other file names. This is where you can put information about the project for the AI to learn.</p><p>But remember, there is no magic in AI. No memory. Just autocomplete. So how does this even work? If you’ve read the first post in this series, you already know the answer. The harness is doing the work.</p><p>The trick is that the harness automatically loads that file when the conversation starts and injects it into the context.</p><p>That’s it. That is the whole trick.</p><p>It is not a secret memory layer. It is not special agent magic. It is just more text in the context.</p><p>Let’s add it to the little harness from last post so you can see how simple the trick really is.</p><h2 id=\"the-agent-only-knows-what-we-send\">The Agent Only Knows What We Send</h2><p>At the end of the previous article, our system prompt was still very simple:</p><pre><code class=\"language-swift\">self.systemPrompt = &quot;&quot;&quot;\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    When you want to use the tool, reply with exactly one line in this format and nothing else:\n    TOOL_CALL {&quot;name&quot;:&quot;tool_name&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;some/path&quot;}}\n\n    After receiving a TOOL_RESULT message, continue the task.\n    If no tool is needed, answer normally.\n    &quot;&quot;&quot;\n</code></pre><p>That is enough for the agent to use tools and know where the workspace root is. But it still doesn’t know anything specific about the project itself.</p><p>So if we start a fresh conversation, the agent won’t know anything about our project and will have to go discover it.</p><pre><code class=\"language-session\">&lt;Turn 1&gt;\nYou: What do you know about this project?\n&lt;Step 1&gt;\nAssistant: I don’t know much yet beyond the project name and whatever is in the package manifest.\nFrom the workspace path, it appears to be a Swift project named **swiftagentharness**.\nI’ve requested the file listing and the `Package.swift` manifest so I can summarize:\n- what kind of Swift package it is\n- its targets/products\n- dependencies\n- likely purpose\nIf you want, I can continue by inspecting the repository structure and give you a concise overview of the project.\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;\\/Users\\/me\\/code\\/swiftagentharness\\/Package.swift&quot;}}\ntool-success: read_file -&gt; 22 lines\n&lt;Step 2&gt;\nAssistant: Based on `Package.swift`, here’s what I know:\n...\n</code></pre><p>That is exactly what <code>AGENTS.md</code> is for.</p><h2 id=\"let’s-load-the-file\">Let’s Load the File</h2><p>The implementation is delightfully boring.</p><p>Before building the system prompt, we try to read an <code>AGENTS.md</code> file from the workspace root:</p><pre><code class=\"language-swift\">let agentsMD = try? String(\n    contentsOf: workspaceRoot.appendingPathComponent(&quot;AGENTS.md&quot;),\n    encoding: .utf8\n)\n\nlet projectInstructions = if let agentsMD {\n    &quot;&quot;&quot;\n    Project instructions in AGENTS.md:\n    \\(agentsMD)\n    &quot;&quot;&quot;\n} else {\n    &quot;&quot;\n}\n</code></pre><p>And once we have that text, we add it to the system prompt:</p><pre><code class=\"language-swift\">self.systemPrompt = &quot;&quot;&quot;\n    You are a helpful assistant with access to tools.\n\n    The workspace root is:\n    \\(workspaceRoot.path)\n\n    \\(projectInstructions)\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 {&quot;name&quot;:&quot;tool_name&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;some/path&quot;}}\n\n    After receiving a TOOL_RESULT message, continue the task.\n    If no tool is needed, answer normally.\n    &quot;&quot;&quot;\n</code></pre><p>That’s it.</p><p>Now the “project knowledge” is available to the LLM. Not in some mystical way, but just by being in its context. The harness literally copies the Markdown into the context it sends to the model.</p><p>That’s all this “project conventions” memory is.</p><h2 id=\"making-the-trick-visible\">Making the Trick Visible</h2><p>Because one of the goals of this whole exercise is to break the illusion, I also made the harness print whether it loaded the file:</p><pre><code class=\"language-swift\">let agentsMD = try? String(contentsOf: workspaceRoot.appendingPathComponent(&quot;AGENTS.md&quot;), encoding: .utf8)\nself.agentsMDLineCount = agentsMD.map { $0.components(separatedBy: .newlines).count }\n\n...\n\nprint(&quot;Swift Agent Harness&quot;)\nprint(&quot;Model: \\(client.model)&quot;)\nif let lineCount = agentsMDLineCount {\n    print(&quot;Loaded AGENTS.md (\\(lineCount) lines)&quot;)\n}\nprint(&quot;Ctrl+C to quit&quot;)\n</code></pre><p>If the harness says it loaded 2 lines, that means 2 lines of Markdown are about to shape the model’s behavior. Nothing more. Nothing less.</p><p>For my demo project, the file is tiny:</p><pre><code class=\"language-md\">This is a test AGENTS.md.\nIf user says &quot;Hello Swift Agent Harness&quot;, reply acknowledging you are that Swift agent harness and that you have read this AGENTS.md.\n</code></pre><p>And once the harness injects that into the system prompt, the effect is immediate:</p><pre><code class=\"language-session\">Swift Agent Harness\nModel: gpt-5.4\nLoaded AGENTS.md (2 lines)\nCtrl+C to quit\n&lt;Turn 1&gt;\nYou: Hello Swift Agent Harness\n&lt;Step 1&gt;\nAssistant: Hello! I’m the Swift Agent Harness, and I’ve read the AGENTS.md.\n</code></pre><p>Of course it can answer that way. We literally gave it those instructions in the context.</p><p><img src=\"https://alejandromp.com/development/blog/ai-doesn-t-remember-your-project-markdown-does/context-diagram.svg\" alt=\"Diagram of the context sent to the model: the System Prompt box contains Tools, Workspace, and Project Instructions, all fed by the Harness. AGENTS.MD feeds into the Harness. Below the System Prompt sit the User request and AI Response boxes. The User arrow leads into User request.\"></p><h2 id=\"it-is-just-markdown\">It Is Just Markdown</h2><p>This is the part I really want people to internalize.</p><p><code>AGENTS.md</code> is not a magical new memory subsystem for AI. It is not some privileged channel into the model’s <em>brain</em>. It is not a special capability only the big fancy tools have.</p><p>It is just Markdown added into the context.</p><p>Useful Markdown, yes. Important Markdown, maybe. But still just text that your harness decided to prepend before the conversation starts. You could literally start each conversation by pasting that instruction yourself, and it wouldn’t be much different.</p><p>Look what happens if we do that before any of the changes in this post.</p><pre><code class=\"language-session\">&lt;Turn 1&gt;\nYou: This is a test AGENTS.md. \nIf user says &quot;Hello Swift Agent Harness&quot;, reply acknowledging you are that Swift agent harness and that you have read this AGENTS.md.\n&lt;Step 1&gt;\nAssistant: Understood. How can I help with the AGENTS.md test?\n&lt;Turn 2&gt;\nYou: Hello Swift Agent Harness\n&lt;Step 1&gt;\nAssistant: Hello! I’m the Swift Agent Harness, and I’ve read this AGENTS.md.\n&lt;Turn 3&gt;\n</code></pre><p>Of course, here we’re just trying to learn how things work and demystify them. Production-grade tools can go further. They may walk parent directories, support nested instruction files, merge global and local rules, decide when to refresh or skip them, even summarize huge files to fit the token budget.</p><p>All of that is just more real engineering work (and learning the particularities of the autocomplete machines).</p><p>But the core idea is still exactly this one. Somewhere in the code there is a boring piece of logic reading a file and sticking its contents into the prompt.</p><p>That is the “memory”.</p><p>That is the “project awareness”.</p><p>That is the “magic”.</p><div class=\"callout-promo\"><p class=\"callout-promo-label\">Want the finished project?</p><div class=\"callout-promo-body\"><p>The 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.</p><p><a href=\"https://buymeacoffee.com/alejandromp/e/532022\">Get the harness by supporting me on Buy me a coffee</a></p></div></div><h2 id=\"one-more-layer-off-the-illusion\">One More Layer Off the Illusion</h2><p>I like this example because it teaches a few things.</p><p>The model never knows your project by default. If it knows something, your harness probably put it there.</p><p>The conventions around agent tooling are much more ordinary than they sound. <code>AGENTS.md</code> is not a sacred artifact. It is just a contract between your harness and your repo. If this file exists, load it and send it too.</p><p>And honestly, that is reassuring. Once you see it clearly, the whole thing becomes easier to reason about. Easier to debug. Easier to improve. Easier to distrust when the marketing gets too theatrical.</p><p>It’s just models and context, with a bit of Markdown sprinkled on top.</p>",
      "date_published" : "2026-04-27T22:00:00Z",
      "id" : "https://alejandromp.com/development/blog/ai-doesn-t-remember-your-project-markdown-does",
      "summary" : "In the previous post, <a href=\"https://alejandromp.com/development/blog/have-you-built-an-agent-harness-already/\">Have You Built an Agent Harness Yet?</a>, we built a tiny harness in Swift. We gave it a loop, a prompt, and some tools, and with that it already had most of the features we are used to with modern AI. No magic. Just autocomplete, and a harness deciding what the model gets to see and do.",
      "tags" : [
        "AI Development",
        "Swift",
        "Project"
      ],
      "title" : "AI doesn't remember your project, Markdown does",
      "url" : "https://alejandromp.com/development/blog/ai-doesn-t-remember-your-project-markdown-does"
    },
    {
      "authors" : [
        {
          "name" : "Alejandro M. P."
        }
      ],
      "content_html" : "<p>For 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.</p><p>If you use AI coding tools every day, and especially if you have opinions about agent workflows, subagents, MCPs, commands, skills, and <a href=\"https://alejandromp.com/development/blog/development-dogma-is-back-a-i-agents/\">whatever new ritual the week invented</a>, 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.</p><p>There is a model. There is a loop. There are tools. There is context. That is the heart of it.</p><p>Of course the real products do much more. But it’s still built around a core that is much simpler than people think.</p><h2 id=\"what-is-an-agent-harness\">What is an Agent Harness</h2><p>A 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”.</p><p>The model, the AI, does not touch your filesystem. The model does not open files. The model does not run commands. It only autocompletes text.</p><p>It 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 <em>see</em> the world, and what result gets sent back after something happens locally.</p><p>That also means the conversation is yours.</p><p>This 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!</p><p>Now, 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.</p><p>So the smallest possible mental model is this:</p><ol><li>You send a message.</li><li>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.</li><li>The model replies with text, by autocompleting from the last message it has received, which includes the entire history.</li><li>Your program decides what that text means.</li><li>If needed, your program does something in the world.</li><li>The result goes back as more text and context.</li></ol><p>That is the whole trick. No magic. No AGI.</p><h2 id=\"let’s-build-a-simple-harness\">Let’s build a simple Harness</h2><p>I can describe what a harness is, but to make sure we internalize it let’s build a simple one, from scratch.</p><p>To 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 <em>agentic</em> features.</p><p>Let’s start with a simple CLI Swift package. Nothing fancy. No framework for agents. No giant abstraction tower. Just <code>swift package init --type executable</code> and the bits we actually need. As usual for command line tools in Swift, I used <code>swift-argument-parser</code>. That gives me a proper executable entrypoint with typed arguments.</p><p>At this stage the executable needs only a few things:</p><ol><li>The base URL of the LLM chat-completions endpoint.</li><li>The model name.</li><li>An API key, loaded from the environment.</li></ol><p>That is already enough to talk to an LLM.</p><p>For this post we will assume we have access to some LLM API, which in my project is represented by <code>OpenAICompatibleClient</code>. 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.</p><p>The <code>SwiftAgentHarness</code> app is just a <code>AsyncParsableCommand</code> that reads the arguments, validates the endpoint, loads the API key from the environment, instantiates the client, creates an <code>Agent</code>, and calls <code>run()</code>:</p><pre><code class=\"language-swift\">@main\nstruct SwiftAgentHarness: AsyncParsableCommand {\n    @Option(help: &quot;LLM chat completions endpoint.&quot;)\n    var baseURL: String\n\n    @Option(help: &quot;Model name to use.&quot;)\n    var model = &quot;gpt-5.4&quot;\n\n    mutating func run() async throws {\n        let environment = ProcessInfo.processInfo.environment\n        guard let apiKey = environment[&quot;LLM_API_KEY&quot;], apiKey.isEmpty == false else {\n            throw HarnessError.missingAPIKey\n        }\n        guard let endpoint = URL(string: baseURL) else {\n            throw HarnessError.invalidArguments(&quot;Invalid base URL: \\(baseURL)&quot;)\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</code></pre><p>At this point you can see our agent harness only needs the API client, nothing more. Let’s now make this <code>Agent</code> do something very basic.</p><pre><code class=\"language-swift\">struct Agent {\n    let client: OpenAICompatibleClient\n\n    func run() async throws {\n        print(&quot;Swift Agent Harness&quot;)\n        print(&quot;Model: \\(client.model)&quot;)\n        print(&quot;Ctrl+C to quit&quot;)\n    }\n}\n</code></pre><p>With this in place we can now iterate on the <code>Agent</code> itself.</p><h2 id=\"having-a-conversation\">Having a conversation</h2><p>The first step is to set up what’s needed to have a conversation with the AI. A simple chat, nothing else.</p><p>The 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.</p><p>First, 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).</p><pre><code class=\"language-swift\">struct Agent {\n    private let systemPrompt = &quot;You are a helpful assistant.&quot;\n  \n</code></pre><p>Then 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)</p><pre><code class=\"language-swift\">    \n    func run() async throws {\n        print(&quot;Swift Agent Harness&quot;)\n        print(&quot;Model: \\(client.model)&quot;)\n        print(&quot;Ctrl+C to quit&quot;)\n\n        var conversation = [Message(role: &quot;system&quot;, content: systemPrompt)]\n</code></pre><p>Then we need the loop that reads user input from the terminal.</p><pre><code class=\"language-swift\">\n        while true {\n            print(&quot;\\u{001B}[94mYou\\u{001B}[0m: &quot;, terminator: &quot;&quot;)\n            guard let input = readLine(), input.isEmpty == false else {\n                continue\n            }\n</code></pre><p>That 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.</p><pre><code class=\"language-swift\">\n            conversation.append(Message(role: &quot;user&quot;, content: input))\n</code></pre><p>Now comes the important bit. We send the entire conversation, not just the last line typed by the user.</p><pre><code class=\"language-swift\">\n            let response = try await client.send(messages: conversation)\n</code></pre><p>And once we get the response back, we print it and append it too.</p><pre><code class=\"language-swift\">\n            print(&quot;\\u{001B}[93mAssistant\\u{001B}[0m: \\(response)&quot;)\n            conversation.append(Message(role: &quot;assistant&quot;, content: response))\n        }\n    }\n}\n</code></pre><p>And 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.</p><p>That 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.</p><p>And we can give it a spin!</p><pre><code class=\"language-session\">Swift 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</code></pre><p>This 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.</p><p>This 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.</p><p>So then, we gave it tools.</p><h2 id=\"ai-interacting-with-our-world\">AI interacting with our world</h2><p>So 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 <em>see</em> our project or interact with the world around it.</p><p>This is the first real step into agentic territory. We need to give the model a tool.</p><p>The 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.</p><p>To keep the first step small, let’s just add one. <code>read_file</code>.</p><pre><code class=\"language-swift\">private let systemPrompt = &quot;&quot;&quot;\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 {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;some/file.txt&quot;}}\n\nAfter receiving a TOOL_RESULT message, continue the task.\nIf no tool is needed, answer normally.\n&quot;&quot;&quot;\n</code></pre><p>This 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.</p><p>Then, after the model replies, we check if the reply starts with <code>TOOL_CALL</code>.</p><pre><code class=\"language-swift\">let response = try await client.send(messages: conversation)\n\nlet trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines)\nif trimmed.hasPrefix(&quot;TOOL_CALL &quot;) {\n    print(&quot;tool-call: \\(trimmed)&quot;)\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 == &quot;read_file&quot; else {\n            throw HarnessError.invalidToolInvocation\n        }\n\n        // Run the &quot;tool&quot;\n        let path = invocation.arguments[&quot;path&quot;] ?? &quot;&quot;\n        let url = URL(fileURLWithPath: path)\n        let result = try String(contentsOf: url, encoding: .utf8)\n        print(&quot;tool-success: read_file -&gt; \\(summarizeToolResult(result))&quot;)\n        outcome = .success(result)\n    } catch {\n        let errorMessage = &quot;Tool invocation failed: \\(error.localizedDescription). Reply again, either with a valid TOOL_CALL line or a normal answer.&quot;\n        print(&quot;tool-error: \\(errorMessage)&quot;)\n        outcome = .failure(errorMessage)\n    }\n\n    conversation.append(Message(role: &quot;assistant&quot;, content: response))\n    conversation.append(Message(role: &quot;user&quot;, content: formatToolResult(outcome)))\n\n    let followUp = try await client.send(messages: conversation)\n    print(&quot;Assistant: \\(followUp)&quot;)\n    conversation.append(Message(role: &quot;assistant&quot;, content: followUp))\n    continue\n}\n</code></pre><p>And just like that now our harness gives more powers to the AI:</p><pre><code class=\"language-session\">You: Read the `README.md` file and summarize it\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;README.md&quot;}}\ntool-success: read_file -&gt; 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</code></pre><p>Again, no magic. The model returns text. The harness interprets that text as a tool call. Then the harness decides to execute something locally. The <code>tool-call</code> and <code>tool-success</code> lines are just for the user, and for debugging in the terminal. What goes back to the AI is the <code>TOOL_RESULT</code> message, which includes the actual result together with a tiny <code>SUCCESS</code> or <code>ERROR</code> protocol 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.</p><details><summary>The <code>ToolOutcomeResult</code> definition</summary><p>The only extra thing I did allow myself is one small enum to represent the outcome.</p><pre><code class=\"language-swift\">enum ToolOutcomeResult {\n    case success(String)\n    case failure(String)\n}\n</code></pre><p>Doesn’t matter much, but I like having these things properly modeled.</p></details><p>Yes, 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 <em>you</em> to run the little contract you gave it.</p><p>And 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.</p><p>And 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:</p><pre><code class=\"language-session\">Swift 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 {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;README.md&quot;}}\ntool-success: read_file -&gt; 17 lines\nAssistant: This project is `swiftagentharness`, a Swift command-line app. ...\n\nYou: now read the run file\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;run.sh&quot;}}\ntool-success: read_file -&gt; 15 lines\nAssistant: `run.sh` is a convenience launcher for the CLI. ...\n</code></pre><p>When 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.</p><p>But it knows it only has access to reading single files, so note that when I say <code>readme</code>, 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.</p><p>Then I give it a proper file name <code>README.md</code>, 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 <code>run.sh</code>, because that is now in context too.</p><p>This 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.</p><h2 id=\"models-being-nice\">Models being nice</h2><p>One 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.</p><p>Soon enough I hit errors like this:</p><pre><code class=\"language-session\">tool-error: Tool invocation failed: Tool invocation payload could not be decoded: {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;README.md&quot;}}\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</code></pre><p>At 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 <code>TOOL_CALL</code> line I asked for, it sometimes produced an extra little explanatory sentence. Good for the user, not so much for our parsing code.</p><p>And 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.</p><p>So 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.</p><p>But at least, let’s make it work for this use case so we can continue learning. Instead of assuming the whole response starts with <code>TOOL_CALL</code>, we now parse the assistant response line by line.</p><pre><code class=\"language-swift\">private func parseAssistantResponse(from response: String) throws -&gt; 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(&quot;TOOL_CALL &quot;) 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: &quot;\\n&quot;),\n        invocation: invocation\n    )\n}\n</code></pre><p>Then the loop can show the assistant text to the user first, and still execute the tool if there is one.</p><pre><code class=\"language-swift\">let response = try await client.send(messages: conversation)\nlet parsedResponse = try parseAssistantResponse(from: response)\n\nif parsedResponse.userFacingText.isEmpty == false {\n    print(&quot;Assistant: \\(parsedResponse.userFacingText)&quot;)\n}\n\nif let invocation = parsedResponse.invocation {\n    // execute tool\n}\n</code></pre><p>This 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.</p><h2 id=\"let-it-explore\">Let it explore</h2><p>At this point the next obvious limitation appears. <code>read_file</code> is useful, but it still depends too much on the human already knowing what file should be read.</p><p>The harness can inspect.</p><p>But it still cannot explore.</p><p>That is why the next tool to add is <code>list_files</code>.</p><p>And 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.</p><p>So instead of special-casing everything directly in the loop, we will define a small tool type.</p><pre><code class=\"language-swift\">struct ToolDefinition {\n    let name: String\n    let description: String\n    let arguments: [String]\n    let run: ([String: String]) throws -&gt; String\n}\n</code></pre><p>Then in the app entrypoint we can define the available tools explicitly.</p><pre><code class=\"language-swift\">let tools: [ToolDefinition] = [\n    .readFile(),\n    .listFiles(),\n]\nlet agent = Agent(client: client, tools: tools)\n</code></pre><p>And because the tools are now dynamic, the system prompt should be dynamic too. Instead of hardcoding the tool descriptions by hand, the <code>Agent</code> now builds the tool section of the prompt from the actual <code>ToolDefinition</code> values it receives.</p><pre><code class=\"language-swift\">init(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 &lt; $1.name }\n        .map(\\.promptBlock)\n        .joined(separator: &quot;\\n\\n&quot;)\n\n    self.systemPrompt = &quot;&quot;&quot;\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 {&quot;name&quot;:&quot;tool_name&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;some/path&quot;}}\n\n        After receiving a TOOL_RESULT message, continue the task.\n        If no tool is needed, answer normally.\n        &quot;&quot;&quot;\n</code></pre><p>That 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.</p><p>With this in place we can extend the <code>ToolDefinition</code> and use it as a way to host the different tools we have.</p><pre><code class=\"language-swift\">extension ToolDefinition {\n    static func readFile() -&gt; Self { ... }\n</code></pre><p>The <code>list_files</code> tool itself is still very simple.</p><pre><code class=\"language-swift\">static func listFiles() -&gt; Self {\n    let fileManager = FileManager.default\n\n    return ToolDefinition(\n        name: &quot;list_files&quot;,\n        description: &quot;List files and directories in a path.&quot;,\n        arguments: [&quot;path&quot;],\n        run: { arguments in\n            let path = arguments[&quot;path&quot;] ?? &quot;.&quot;\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 &lt; $1.lastPathComponent }\n                .map { entry in\n                    let isDirectory = try entry.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false\n                    return isDirectory ? entry.lastPathComponent + &quot;/&quot; : entry.lastPathComponent\n                }\n            return lines.joined(separator: &quot;\\n&quot;)\n        }\n    )\n}\n</code></pre><p>Notice what happened here. The architecture did not change. We still have the same loop, the same text protocol, the same <code>TOOL_CALL</code>, the same <code>TOOL_RESULT</code>.</p><p>What changed is just the model’s ability to gather context by itself.</p><p>This is a much bigger jump than it may look. <code>read_file</code> gave the AI a flashlight but <code>list_files</code> gives it a map.</p><pre><code class=\"language-session\">You: List the names of the files in this project.\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;list_files&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;.&quot;}}\ntool-success: list_files -&gt; 5 lines\nAssistant: Package.resolved\nPackage.swift\nREADME.md\nSources/\nrun.sh\nYou: summarize the readme\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;README.md&quot;}}\ntool-success: read_file -&gt; 17 lines\nAssistant: The README says this is a Swift ...\n</code></pre><p>Nice! Now the user doesn’t have to specify the exact file names and paths because the model can explore directories itself.</p><h2 id=\"ai-needs-longer-turns\">AI needs longer turns</h2><p>The example above was a bit forced to show the happy path. The reality is that we have a problem with our harness.</p><pre><code class=\"language-session\">You: 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 {&quot;name&quot;:&quot;list_files&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;.\\/&quot;}}\ntool-success: list_files -&gt; 5 lines\nAssistant: TOOL_CALL {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;README.md&quot;}}\nYou:\n</code></pre><p>Notice 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 <code>list_files</code> tool 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!</p><p>This brings us to the next piece of our learning: <strong>turns</strong> and steps.</p><p>In 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.</p><p>When 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.</p><p>In 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.</p><p>The 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.</p><p>In other words, a single turn can include multiple steps before the agent can give the final answer to the user.</p><svg viewBox=\"0 0 680 265\" xmlns=\"http://www.w3.org/2000/svg\" style=\"width:100%;display:block;margin:2rem auto\">\n  <defs>\n    <marker id=\"harness-t\" markerWidth=\"8\" markerHeight=\"6\" refX=\"7\" refY=\"3\" orient=\"auto\">\n      <polygon points=\"0 0, 8 3, 0 6\" fill=\"#f97eb3\"/>\n    </marker>\n    <marker id=\"harness-s\" markerWidth=\"8\" markerHeight=\"6\" refX=\"7\" refY=\"3\" orient=\"auto\">\n      <polygon points=\"0 0, 8 3, 0 6\" fill=\"#888\"/>\n    </marker>\n    <marker id=\"harness-w\" markerWidth=\"7\" markerHeight=\"5\" refX=\"6\" refY=\"2.5\" orient=\"auto\">\n      <polygon points=\"0 0, 7 2.5, 0 5\" fill=\"#ccc\"/>\n    </marker>\n  </defs>\n  <!-- Turn loop (outer, pink) -->\n  <path d=\"M 155 120 C 280 15, 440 15, 521 120\" fill=\"none\" stroke=\"#f97eb3\" stroke-width=\"2.5\" marker-end=\"url(#harness-t)\"/>\n  <path d=\"M 521 148 C 440 253, 280 253, 155 148\" fill=\"none\" stroke=\"#f97eb3\" stroke-width=\"2.5\" marker-end=\"url(#harness-t)\"/>\n  <text x=\"338\" y=\"10\" text-anchor=\"middle\" fill=\"#f97eb3\" font-size=\"13\" font-family=\"system-ui,sans-serif\" font-weight=\"600\">Turn</text>\n  <!-- Step loop (inner, grey) -->\n  <path d=\"M 399 118 C 432 68, 490 68, 521 118\" fill=\"none\" stroke=\"#888\" stroke-width=\"2\" marker-end=\"url(#harness-s)\"/>\n  <path d=\"M 521 150 C 490 200, 432 200, 399 150\" fill=\"none\" stroke=\"#888\" stroke-width=\"2\" marker-end=\"url(#harness-s)\"/>\n  <text x=\"462\" y=\"62\" text-anchor=\"middle\" fill=\"#888\" font-size=\"12\" font-family=\"system-ui,sans-serif\">Step</text>\n  <!-- User → Harness -->\n  <line x1=\"160\" y1=\"127\" x2=\"268\" y2=\"127\" stroke=\"#ccc\" stroke-width=\"1.5\" marker-end=\"url(#harness-w)\"/>\n  <text x=\"216\" y=\"120\" text-anchor=\"middle\" fill=\"#ccc\" font-size=\"10\" font-family=\"system-ui,sans-serif\">message</text>\n  <!-- Harness → User -->\n  <line x1=\"272\" y1=\"143\" x2=\"164\" y2=\"143\" stroke=\"#ccc\" stroke-width=\"1.5\" marker-end=\"url(#harness-w)\"/>\n  <text x=\"216\" y=\"157\" text-anchor=\"middle\" fill=\"#ccc\" font-size=\"10\" font-family=\"system-ui,sans-serif\">answer</text>\n  <!-- Harness → LLM -->\n  <line x1=\"404\" y1=\"127\" x2=\"512\" y2=\"127\" stroke=\"#ccc\" stroke-width=\"1.5\" marker-end=\"url(#harness-w)\"/>\n  <text x=\"460\" y=\"120\" text-anchor=\"middle\" fill=\"#ccc\" font-size=\"10\" font-family=\"system-ui,sans-serif\">API call</text>\n  <!-- LLM → Harness -->\n  <line x1=\"516\" y1=\"143\" x2=\"408\" y2=\"143\" stroke=\"#ccc\" stroke-width=\"1.5\" marker-end=\"url(#harness-w)\"/>\n  <text x=\"460\" y=\"157\" text-anchor=\"middle\" fill=\"#ccc\" font-size=\"10\" font-family=\"system-ui,sans-serif\">text</text>\n  <!-- Boxes (drawn last so arrows tuck behind edges) -->\n  <rect x=\"40\" y=\"108\" width=\"115\" height=\"54\" rx=\"8\" fill=\"#1f1f1f\" stroke=\"#4a4a4a\" stroke-width=\"1.5\"/>\n  <text x=\"97\" y=\"135\" text-anchor=\"middle\" dominant-baseline=\"middle\" fill=\"white\" font-size=\"15\" font-family=\"system-ui,sans-serif\">User</text>\n  <rect x=\"277\" y=\"108\" width=\"122\" height=\"54\" rx=\"8\" fill=\"#1f1f1f\" stroke=\"#4a4a4a\" stroke-width=\"1.5\"/>\n  <text x=\"338\" y=\"135\" text-anchor=\"middle\" dominant-baseline=\"middle\" fill=\"white\" font-size=\"15\" font-family=\"system-ui,sans-serif\">Harness</text>\n  <rect x=\"521\" y=\"108\" width=\"118\" height=\"54\" rx=\"8\" fill=\"#1f1f1f\" stroke=\"#4a4a4a\" stroke-width=\"1.5\"/>\n  <text x=\"580\" y=\"135\" text-anchor=\"middle\" dominant-baseline=\"middle\" fill=\"white\" font-size=\"15\" font-family=\"system-ui,sans-serif\">LLM</text>\n</svg>\n<p>So the code grows one inner loop.</p><pre><code class=\"language-swift\">while true {\n    turn += 1\n    print(&quot;&lt;Turn \\(turn)&gt;&quot;)\n    ...\n    var step = 0\n\n    while true { // &lt;= new inner loop\n        step += 1\n        print(&quot;&lt;Step \\(step)&gt;&quot;)\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: &quot;assistant&quot;, content: response))\n            conversation.append(Message(role: &quot;user&quot;, content: formatToolResult(outcome)))\n            continue\n        }\n\n        conversation.append(Message(role: &quot;assistant&quot;, content: response))\n        break\n    }\n}\n</code></pre><p>That is a really important moment in understanding how agents work.</p><p>Let’s see how this change impacts our previous example.</p><blockquote><p>Note that I’ve added turn and step logs so it’s clearer when there are roundtrips.</p></blockquote><pre><code class=\"language-session\">&lt;Turn 1&gt;\nYou: explore this project and tell me what is about\n&lt;Step 1&gt;\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 {&quot;name&quot;:&quot;list_files&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;.\\/&quot;}}\ntool-success: list_files -&gt; 5 lines\n&lt;Step 2&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;README.md&quot;}}\ntool-success: read_file -&gt; 17 lines\n&lt;Step 3&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;Package.swift&quot;}}\ntool-success: read_file -&gt; 22 lines\n&lt;Step 4&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;list_files&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;Sources&quot;}}\ntool-success: list_files -&gt; swiftagentharness/\n&lt;Step 5&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;list_files&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;Sources\\/swiftagentharness&quot;}}\ntool-success: list_files -&gt; 3 lines\n&lt;Step 6&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;Sources\\/swiftagentharness\\/App.swift&quot;}}\ntool-success: read_file -&gt; 34 lines\n&lt;Step 7&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;Sources\\/swiftagentharness\\/Harness.swift&quot;}}\ntool-success: read_file -&gt; 223 lines\n&lt;Step 8&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;Sources\\/swiftagentharness\\/OpenAICompatibleClient.swift&quot;}}\ntool-success: read_file -&gt; 39 lines\n&lt;Step 9&gt;\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&lt;Turn 2&gt;\nYou: Thanks!\n&lt;Step 1&gt;\nAssistant: You’re welcome!\n&lt;Turn 3&gt;\nYou:\n</code></pre><p>The 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.</p><p>This 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.</p><h2 id=\"giving-it-boundaries\">Giving It Boundaries</h2><p>Now that the agent can list and read files on its own, we need to showcase the dangers of these tools.</p><pre><code class=\"language-session\">&lt;Turn 1&gt;\nYou: Can you explore this project, and then find similar projects in this computer.\n&lt;Step 1&gt;\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 {&quot;name&quot;:&quot;list_files&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;.\\/&quot;}}\ntool-success: list_files -&gt; 5 lines\n&lt;Step 2&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;Package.swift&quot;}}\ntool-success: read_file -&gt; 22 lines\n\n...\n\n&lt;Step 9&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;list_files&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;..&quot;}}\ntool-success: list_files -&gt; 28 lines\n&lt;Step 10&gt;\n\n...\n\n&lt;Step 23&gt;\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&lt;Turn 2&gt;\nYou:\n</code></pre><p>wow. 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 <code>..</code> path. Sure, of course I asked for it, but you could imagine the LLM doing it on its own volition.</p><p>Now 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.</p><p>The first thing we need is to introduce the concept of a workspace root into the CLI itself.</p><pre><code class=\"language-swift\">@Option(help: &quot;Workspace root for the agent.&quot;)\nvar workspace = FileManager.default.currentDirectoryPath\n\nlet workspaceRoot = try resolveWorkspaceRoot(from: workspace)\nlet agent = Agent(client: client, tools: tools, workspaceRoot: workspaceRoot)\n</code></pre><p>That gives the harness an explicit root directory to work from. <code>Agent.init</code> gains a <code>workspaceRoot: URL</code> parameter, and <code>ToolDefinition.run</code> changes signature to receive it as well:</p><pre><code class=\"language-swift\">struct ToolDefinition {\n    ...\n    let run: ([String: String], URL) throws -&gt; String\n}\n</code></pre><p>That means every tool can call <code>resolvePath</code> to 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.</p><pre><code class=\"language-swift\">self.systemPrompt = &quot;&quot;&quot;\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    &quot;&quot;&quot;\n</code></pre><p>This 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.</p><p>The real protection, albeit simplistic, is in the tool execution layer.</p><pre><code class=\"language-swift\">private static func resolvePath(_ path: String, workspaceRoot: URL) throws -&gt; URL {\n    let candidate = path.hasPrefix(&quot;/&quot;)\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 + &quot;/&quot;) else {\n        throw HarnessError.workspaceViolation(path)\n    }\n\n    return resolved\n}\n</code></pre><p>That tiny function is resolving the path the agent is asking for and checking if it escapes the workspace.</p><blockquote><p>Not security advice. I’m sure there are a thousand ways of breaking this. It is just an example for this blog post.</p></blockquote><p>And once that is in place, the same kind of interaction looks very different:</p><pre><code class=\"language-session\">&lt;Turn 1&gt;\nYou: Can you check what I have in my downloads folder\n&lt;Step 1&gt;\nAssistant: I can help, but I only have access to the workspace at:\n`/Users/me/code/swiftagentharness`\n\n&lt;Turn 2&gt;\nYou: Please try, is very important\n&lt;Step 1&gt;\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&lt;Turn 3&gt;\nYou: Just use the tools we have with that path\n&lt;Step 1&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;list_files&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;/Users/me/Downloads&quot;}}\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&lt;Step 2&gt;\nAssistant: I tried, but access was blocked because `/Users/me/Downloads` is outside the allowed workspace.\n</code></pre><p>That is the real lesson.</p><p>The 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 <code>tool-error</code> thrown by the Swift code when the agent tried to escape.</p><p>And 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.</p><h2 id=\"time-to-change-the-world\">Time to change the world</h2><p>Now 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.</p><p>As 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.</p><p>Here is the tool definition:</p><pre><code class=\"language-swift\">static func editFile() -&gt; Self {\n    let fileManager = FileManager.default\n\n    return ToolDefinition(\n        name: &quot;edit_file&quot;,\n        description: &quot;Create a file when old_text is empty, or replace exactly one matching old_text with new_text.&quot;,\n        arguments: [&quot;path&quot;, &quot;old_text&quot;, &quot;new_text&quot;],\n        run: { arguments, workspaceRoot in\n            let path = arguments[&quot;path&quot;] ?? &quot;&quot;\n            let oldText = arguments[&quot;old_text&quot;] ?? &quot;&quot;\n            let newText = arguments[&quot;new_text&quot;] ?? &quot;&quot;\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 &quot;Created \\(path)&quot;\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 &quot;Edit failed. Expected exactly one match for old_text, found \\(matches).&quot;\n            }\n\n            let updated = content.replacingOccurrences(of: oldText, with: newText)\n            try updated.write(to: url, atomically: true, encoding: .utf8)\n            return &quot;Edited \\(path)&quot;\n        }\n    )\n}\n</code></pre><p>Nothing fancy.</p><p>And 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.</p><p>And now, finally, the harness can do the thing that makes coding agents feel like coding agents. Not just read the world. Change it.</p><pre><code class=\"language-session\">&lt;Turn 1&gt;\nYou: Can you create a new md file to summarize what this project is about.\n&lt;Step 1&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;README.md&quot;}}\ntool-success: read_file -&gt; 17 lines\n&lt;Step 2&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;list_files&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;.&quot;}}\ntool-success: list_files -&gt; 5 lines\n&lt;Step 3&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;read_file&quot;,&quot;arguments&quot;:{&quot;path&quot;:&quot;Package.swift&quot;}}\ntool-success: read_file -&gt; 22 lines\n&lt;Step 4&gt;\n...\n&lt;Step 9&gt;\ntool-call: TOOL_CALL {&quot;name&quot;:&quot;edit_file&quot;,&quot;arguments&quot;:{&quot;new_text&quot;:&quot;# Project Summary\\n\\n...&quot;,&quot;old_text&quot;:&quot;&quot;,&quot;path&quot;:&quot;PROJECT_SUMMARY.md&quot;}}\ntool-success: edit_file -&gt; Created PROJECT_SUMMARY.md\n&lt;Step 10&gt;\nAssistant: Created `PROJECT_SUMMARY.md` in the project root with a summary of the project, its purpose, architecture, tools, and usage.\n&lt;Turn 2&gt;\n</code></pre><p>This is the first moment where the harness stops being just an observer and starts becoming a real tool.</p><div class=\"callout-promo\"><p class=\"callout-promo-label\">Want the finished project?</p><div class=\"callout-promo-body\"><p>The 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.</p><p><a href=\"https://buymeacoffee.com/alejandromp/e/532022\">Get the harness by supporting me on Buy me a coffee</a></p></div></div><h2 id=\"the-world-is-whatever-the-harness-says-it-is\">The world is whatever the harness says it is</h2><p>There is one last exercise I really recommend if you build one of these.</p><p>Lie.</p><p>I mean it. Go to one of your tools and make it return something false on purpose.</p><p>Make <code>read_file</code> always return a joke. Make <code>list_files</code> invent files that do not exist. A fun one is to rewrite extensions and pretend every Swift file is actually a JavaScript file.</p><p>The model will believe you.</p><p>And yes, of course it will. That is the whole point.</p><p>By 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.</p><p>That means the world, for the model, is whatever the harness says it is.</p><p>Not because the model is stupid. Not because the model is broken. But because that is literally the way this <a href=\"https://alejandromp.com/personal/blog/accepting-pandora-s-box/\">autocomplete machine</a> works.</p><h2 id=\"and-the-illusion-is-broken\">And the Illusion is Broken</h2><p>If 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!</p><p>No 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.</p><p>No mystical illusion. Just tools and code.</p><p>Models. Loops. Context. Tools. Turns. Engineering.</p><p>Nothing more.</p><div class=\"callout-tip\"><p>In the next post in the series we will learn how the harness helps the AI pretend. Because <a href=\"https://alejandromp.com/development/blog/ai-doesn-t-remember-your-project-markdown-does/\">AI doesn’t remember your project, Markdown does</a>.</p></div>",
      "date_published" : "2026-04-20T22:00:00Z",
      "id" : "https://alejandromp.com/development/blog/have-you-built-an-agent-harness-already",
      "summary" : "For 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.",
      "tags" : [
        "AI Development",
        "Swift",
        "Project"
      ],
      "title" : "Have You Built an Agent Harness Yet?",
      "url" : "https://alejandromp.com/development/blog/have-you-built-an-agent-harness-already"
    },
    {
      "authors" : [
        {
          "name" : "Alejandro M. P."
        }
      ],
      "content_html" : "<p>I was migrating to a newer version of The Composable Architecture, which meant there was a list of deprecations to clean up. One of the things on that list was adding <code>InferSendableFromCaptures</code> as an upcoming Swift feature flag across all our package targets.</p><p><a href=\"https://github.com/swiftlang/swift-evolution/blob/main/proposals/0418-inferring-sendable-for-methods.md\">SE-0418</a> is genuinely interesting. It makes method references participate more accurately in sendability checking when used as values. One of those proposals that feels obviously correct once you read it.</p><p>I added it while preparing for Swift 6, hoping to catch issues before the TCA 2.0 work. I was ready to fix code. I was not ready for another journey of concurrency discovery. The kind that challenges things I already knew, just to make sure I really knew them.</p><blockquote><p>This is one of those posts I write because <strong><a href=\"https://www.youtube.com/watch?v=XtwsJtdH_q8\">writing is thinking</a></strong>. Even at the risk of being wrong. That is actually better, because I will learn more. So don’t take this as a tutorial or the truth. Use critical thinking as you follow along.</p></blockquote><p>A bunch of warnings appeared, all of the same shape, all of them in places where I was passing a SwiftUI view’s initialiser as a function reference to a navigation modifier.</p><pre><code class=\"language-swift\">.navigationLinkDestination(\n    item: $store.scope(...),\n    destination: ChildView.init(store:)  // ⚠️\n)\n</code></pre><p>The warning said something like “call to main actor-isolated initializer <code>init(store:)</code> in a synchronous nonisolated context”.</p><p>To understand what was happening I had to properly internalise what SE-0418 changes about function references. The warning appears because <code>ChildView.init(store:)</code> is not just “a function that makes a view”. <code>ChildView</code> conforms to <code>View</code>, and that makes the initialiser main actor-isolated. But the <code>destination:</code> parameter expects <code>@escaping (StoreOf<X>) -> some View</code>, with no <code>@MainActor</code>. When I pass the initialiser directly, I am asking the compiler to use a main actor-isolated initialiser as a plain synchronous callback. In Swift 5 mode it lets me do that, but warns me that the isolation does not fit the context I am putting it in.</p><p>That was the first model in my head, at least. It sounded convincing enough…</p><p>Once I saw it that way, the fix felt obvious: switch to a closure.</p><pre><code class=\"language-swift\">.navigationLinkDestination(\n    item: $store.scope(...),\n    destination: { ChildView(store: $0) }  // ✅\n)\n</code></pre><p>No warning.</p><p>We’re done, right? I applied it everywhere, but as I was doing it my inquisitive mind kept asking. But why?</p><p>Why did the closure work? They both call the same initialiser. They both end up constructing the same <code>@MainActor</code> view. Why is one fine and the other not?</p><h2 id=\"what-infersendablefromcaptures-actually-does\">What InferSendableFromCaptures Actually Does</h2><p>SE-0418, the proposal behind <code>InferSendableFromCaptures</code>, adds type inference specifically to function and method references when used as values.</p><p>Before enabling the feature flag, <code>ChildView.init(store:)</code> used as a value slipped through as a non-sendable function value. After enabling it, the reference carries more of what the declaration means. The initialiser is main actor-isolated, so the function reference is treated as main actor-isolated too.</p><p>That is the right thing to do. The initialiser is <code>@MainActor</code>. The reference should say so. But the parameter I am passing it to is typed as <code>@escaping (StoreOf<X>) -> some View</code>, with no <code>@MainActor</code> annotation. So, in my first reading, the compiler was complaining because the function reference now brought main actor isolation with it, and the parameter did not ask for that.</p><p>Closure literals do not work that way. A closure literal has no pre-existing function reference that has to be squeezed into the parameter type. The compiler creates it fresh to satisfy the parameter type, <code>@escaping (StoreOf<X>) -> some View</code>, and then type-checks the body of the closure where it is written. The warning never fires because the compiler is no longer trying to use the isolated initialiser itself as the callback value.</p><p>That seemed to explain the difference between the two syntaxes. But it left me with a deeper question.</p><h2 id=\"the-question-that-bothered-me\">The Question That Bothered Me</h2><p>If the closure literal is created to match type <code>@escaping (StoreOf<X>) -> some View</code>, with no <code>@MainActor</code> annotation, then how can it safely call <code>ChildView(store:)</code> inside? That initialiser is <code>@MainActor</code>. I am inside a closure that is not annotated <code>@MainActor</code>. How is that not a problem?</p><p>In Swift 6 strict mode, calling a <code>@MainActor</code> function from a nonisolated context is an error. Not a warning. An error.</p><p>So I switched to <code>.swiftLanguageMode(.v6)</code>, expecting to see it break.</p><p>It did not.</p><p>The closure compiled cleanly. No warning, no error. And that is when I realised I had been thinking about the wrong thing.</p><h2 id=\"the-closure-is-not-nonisolated\">The Closure Is Not Nonisolated</h2><p>The closure does not have <code>@MainActor</code> in its type, but that does not mean it runs in a nonisolated context. Of course it doesn’t, and I knew that. But I let the compiler warning pull my attention to the wrong place. It is incredible how quickly a weak mental model can crumble when you trust the first explanation that seems to fit.</p><p>I defined it inside <code>body</code>. And <code>body</code> is <code>@MainActor</code>. A closure literal inherits the actor context of the scope it is written in, not just the type annotation of the parameter it satisfies. So the closure is effectively <code>@MainActor</code> at the point where it runs, even if that annotation is not visible in the parameter type.</p><p>Calling <code>ChildView(store:)</code> inside that closure is calling a <code>@MainActor</code> function from a <code>@MainActor</code> context. That is always safe. The compiler knows this. So there is no error in Swift 6. And there was no warning before. Everything was consistent enough to fool me.</p><p>At that point I thought I had it. The closure works because it inherits the main actor context. The function reference warns because it does not get to use that same context in the same way.</p><p>Nice little explanation.</p><p>Or is it?</p><h2 id=\"the-explanation-cracks\">The Explanation Cracks</h2><p>Later I explained the whole thing to a coworker. And while explaining it, I felt that familiar discomfort. The words were coming out, but something felt off.</p><p>Because if the closure works because it inherits the main actor context from the view, then why would that not also apply to the method reference?</p><p>So I made the example smaller and more annoying.</p><pre><code class=\"language-swift\">struct ParentView: View {\n    var body: some View {\n        Text(&quot;parent&quot;)\n    }\n\n    func makeView() -&gt; some View {\n        doSomethingOnTheMainActor()\n\n        let inferredReference = ChildView.init(store:)\n        let typedReference: (StoreOf&lt;Int&gt;) -&gt; ChildView = ChildView.init(store:)\n        let mainActorReference: @MainActor (StoreOf&lt;Int&gt;) -&gt; ChildView = ChildView.init(store:)\n        let mainActorClosure: @MainActor (StoreOf&lt;Int&gt;) -&gt; ChildView = { @MainActor in\n            ChildView(store: $0)\n        }\n\n        _ = (inferredReference, typedReference, mainActorReference, mainActorClosure)\n\n        return ChildView(store: StoreOf())\n    }\n}\n\n@MainActor\nfunc doSomethingOnTheMainActor() {}\n</code></pre><p>The important line is not even the function reference. It is this one:</p><pre><code class=\"language-swift\">doSomethingOnTheMainActor()\n</code></pre><p><code>doSomethingOnTheMainActor()</code> is <code>@MainActor</code>. I am calling it without <code>await</code>. And it compiles.</p><p>That means <code>makeView()</code> is already being treated as main actor-isolated, even though I did not write <code>@MainActor</code> on it. So my previous explanation was incomplete. The surrounding method is not some random nonisolated context. The compiler is already treating it as main actor-isolated.</p><p>Then the results got weirder.</p><p>In Swift 5 mode with <code>InferSendableFromCaptures</code> enabled, the inferred function reference warned. The explicitly typed plain function reference warned. Even the explicitly typed <code>@MainActor</code> function reference warned.</p><p>But the <code>@MainActor</code> closure did not.</p><p>And that is where it started to smell. <code>mainActorReference</code> and <code>mainActorClosure</code> have the exact same type:</p><pre><code class=\"language-swift\">@MainActor (StoreOf&lt;Int&gt;) -&gt; ChildView\n</code></pre><p>But one warned and the other did not.</p><p>That is the part that broke the simple explanation. If the problem were simply “you are in a nonisolated context”, then <code>doSomethingOnTheMainActor()</code> should have required <code>await</code>. It did not. And if the problem were simply “this is main actor-isolated but the parameter does not accept that”, then the closure should have failed too once I realised it was main actor-isolated. In my mind, if one failed, the other should fail. If one worked, the other should work.</p><p>Then I added <code>@MainActor</code> explicitly to <code>makeView()</code>.</p><p>All warnings disappeared.</p><p>Then I removed that explicit annotation again, but switched the package to Swift 6 language mode.</p><p>All warnings disappeared too.</p><p>That was the real clue.</p><h2 id=\"the-transitional-bit\">The Transitional Bit</h2><p>So I no longer think the interesting lesson is “closures inherit actor context and method references do not”. That is too clean. It is also not quite true.</p><p>What I think is happening is that this warning is a Swift 5 migration-mode artifact. Enabling <code>InferSendableFromCaptures</code> in Swift 5 mode makes method references participate in stricter concurrency checking, but that checking does not seem to line up perfectly with the actor isolation inference that Swift 6 applies. The surrounding SwiftUI method is already main actor-isolated. Swift 6 understands the whole thing and accepts it. Swift 5 with the upcoming feature flag sees enough to warn, but not enough to recognise what Swift 6 recognises.</p><p>The closure workaround still makes sense. It avoids the warning and expresses the code in a way the Swift 5 checker is happy with. But it is not proof that the original function reference was unsafe. It is more like a shape of code that avoids a transitional diagnostic.</p><p>And this is the subtle part that I think is easy to miss.</p><p>Upcoming feature flags, at least the ones related to the complex concurrency story, are not always the same experience as fully moving to the language mode where that feature belongs. They are there to surface future issues early, but they can expose interactions with the old mode that do not quite represent the final model.</p><h2 id=\"what-i-took-away\">What I Took Away</h2><p>The fix itself was small. One line changed per call site.</p><p>But understanding why it worked took a real detour through several parts of the concurrency model: how SE-0418 adds inference to function references, how closure literals behave differently, how actor context is inherited rather than just declared, and how Swift 5 migration checking can differ from Swift 6 even when an upcoming feature flag is involved.</p><p>The funny part is that I already knew most of those pieces. I knew closures inherit actor context. I knew SwiftUI views are main actor-isolated. I knew upcoming feature flags are part of a migration story. And yet I still managed to convince myself of an explanation that fit what I was seeing.</p><p>That is the dangerous part. When the first explanation is plausible, it is very easy to stop there. I could have changed all the call sites, written down “closures inherit actor context”, and walked away with a mental model that was close enough to feel right, but wrong enough to mislead me the next time.</p><p>Instead, the discomfort kept bothering me. The result did not feel fully justified. So I kept pushing. I made the example smaller, compared the function reference and closure with the exact same type, tried explicit <code>@MainActor</code>, tried Swift 6 mode, and only then did the model start to hold together.</p><p>The warning was not telling me my code was broken. At first I thought it was telling me I had a straightforward isolation mismatch. That was close. But not quite. The deeper lesson was that the compiler mode matters. In Swift 5 mode with this upcoming feature enabled, I was looking at a transitional warning. In Swift 6 mode, the same code made sense to the compiler. Adding <code>@MainActor</code> explicitly also made it make sense.</p><p>That is the kind of thing you understand properly only when you stop accepting the fix and start asking why it works. Not just until you find an explanation, but until you find one that survives the next question.</p>",
      "date_published" : "2026-04-14T22:00:00Z",
      "id" : "https://alejandromp.com/development/blog/a-small-swiftui-warning-and-a-long-journey-to-understand-it",
      "summary" : "I was migrating to a newer version of The Composable Architecture, which meant there was a list of deprecations to clean up. One of the things on that list was adding <code>InferSendableFromCaptures</code> as an upcoming Swift feature flag across all our package targets.",
      "tags" : [
        "Swift",
        "Concurrency",
        "SwiftUI"
      ],
      "title" : "A Small SwiftUI Warning and a Long Journey to Understand It",
      "url" : "https://alejandromp.com/development/blog/a-small-swiftui-warning-and-a-long-journey-to-understand-it"
    },
    {
      "authors" : [
        {
          "name" : "Alejandro M. P."
        }
      ],
      "content_html" : "<p>There was a period during the Swift Evolution Concurrency discussions that I remember very fondly.</p><p>And I was on the losing side. Or did I?</p><p>I came into the conversation questioning one of the big changes that would later become part of the “approachable concurrency” vision: the move away from the old behavior where non-isolated async functions would naturally hop off the current actor. At the time, I genuinely thought that model was one of the most brilliant parts of Swift Concurrency.</p><p>And I still think there was something beautiful in it.</p><p>Back then, one of my favorite things about Swift Concurrency was that it let developers stop worrying about threads. You didn’t have to constantly ask yourself if something needed to go “to the background”. You just wrote async code and, most of the time, things were fine. Especially in app development, where so many people come from years of GCD, Combine, Rx, and all sorts of “don’t block the main thread” patterns, that felt like a breath of fresh air.</p><p>That old promise, for some reason, mattered to me a lot.</p><p>You can even see that framing in some of my older writing. In <a href=\"https://alejandromp.com/development/blog/limit-swift-concurrency-cooperative-pool/\">Limit Swift Concurrency’s cooperative pool</a> I wrote that “you should not worry about threads at all”. I still love that sentence. It captures something important about why Swift Concurrency felt refreshing in the first place.</p><h2 id=\"the-kind-of-discussions-i-miss\">The Kind of Discussions I Miss</h2><p>The conversation around <a href=\"https://forums.swift.org/t/pitch-inherit-isolation-by-default-for-async-functions/74862\">the original pitch</a> and later <a href=\"https://github.com/swiftlang/swift-evolution/blob/main/proposals/0461-async-function-isolation.md\">SE-0461</a> is one I remember with a lot of affection. It was one of those moments that remind you the Swift Forums can be a very special place on the internet.</p><p>You could come in with a strong opinion, push back on a proposal, and instead of getting tribal nonsense, you got thoughtful replies from people who had clearly spent a lot of time thinking about the problem from multiple angles. People like Holly Borla, John McCall, Joe Groff, Mattie, and many others were not just defending a position. They were helping build a mental model.</p><p>That is a very different thing. And not something that happens often in the social interwebs these days.</p><p>Even though I was not sure about the change, it was genuinely enriching to see what the experts were seeing that I was not. That is one of the best things that can happen in a technical discussion. Not “winning”. Not getting your preferred syntax. Just having your picture of the problem expanded.</p><p>And I think that is worth saying explicitly, even now, years later, because we don’t always celebrate that enough. We talk a lot about the final proposal, the final syntax, the final accepted design. But the process of getting there matters too. Sometimes a language community proves its worth not when everybody agrees, but when disagreement is handled with care and clarity.</p><h2 id=\"why-i-pushed-back\">Why I Pushed Back</h2><p>My discomfort was not really about syntax. It was about the original emotional promise of Swift Concurrency.</p><p>I liked that the model encouraged people to stop obsessing over threads. I liked being able to tell developers, especially app developers, to stop trying to manually dispatch everything around. I liked the fact that async code did not automatically mean “you might now be blocking the UI unless you remember to escape somewhere else first”.</p><p>That felt like progress.</p><p>At the time, I said this in the pitch thread:</p><blockquote><p>“The idea of not blocking the main thread is pervasive in app developers.”</p></blockquote><p>And also this:</p><blockquote><p>“the current behaviour unloads a lot of burden once you embrace swift concurrency.”</p></blockquote><p>And to be honest, I still feel a bit of grief around losing some of that simplicity. I am still annoyed that “be careful what you run on the main actor” is now a thing we have to teach more deliberately. I still think there was something elegant in a model where the default behavior helped developers avoid that whole category of thought.</p><p>That part still bothers me a little.</p><h2 id=\"what-changed-my-mind\">What Changed My Mind</h2><p>What convinced me was not that my concern was silly. It was that the other side of the tradeoff was bigger than I had fully appreciated.</p><p>The key argument, as I came to understand it, was that the old behavior made one particular thing feel easy while hiding a much more serious cost, it pushed ordinary async code into situations where data-race safety became harder to reason about, especially once Swift 6’s stricter checking started showing what was actually going on.</p><p>Holly Borla made the core of that case very clearly in the thread. The line that stayed with me was:</p><blockquote><p>“the current default imposes data-race safety errors”</p></blockquote><p>In other words, the old world was nice partly because it let many of us avoid seeing the edges.</p><p>And once those edges started becoming visible, the old default no longer looked like a clever simplification. It looked more like a convenience that was too often in tension with the safety model Swift was trying to build.</p><p>That did not make the transition emotionally pleasant for me. But it did make it intellectually convincing.</p><h2 id=\"and-then…-life-moved-on\">And Then… Life Moved On</h2><p>What is funny is that now the transition has happened and we are just used to the new world.</p><p>And it is fine.</p><p>This is maybe the most humbling part of language evolution. Sometimes a change feels huge when it is being discussed, deeply consequential while it is being pitched, mildly uncomfortable while it is rolling out, and then a year later it is just… the air you breathe.</p><p>That does not mean my concerns were invalid. It just means people adapt. Documentation improves, mental models settle, teams teach the new thing. The ecosystem metabolizes the change.</p><p>The world did not end and the language did not lose its soul. Swift Concurrency is still good, and in many ways it is better.</p><p>And of course, it even made me update <a href=\"https://mastodon.social/@alexito4/112688581210152832\">my lovely context inheritance table</a>, because if Swift Concurrency changes, apparently my diagrams must suffer too.</p><h2 id=\"a-transition-done-right\">A Transition Done Right</h2><p>What I appreciate the most in hindsight is not only the technical direction, but how it was handled.</p><p>The whole “approachable concurrency” effort is a great example of something that was harder to grasp when looked at as isolated pieces. Bit by bit, any one proposal could feel subtle, fussy, or overly specific. But once the direction started to materialize as a coherent vision, you could see the shape of the work more clearly.</p><p>And that, to me, is where Holly and the rest of the team deserve enormous credit.</p><p>Because the hardest part of changes like this is often not the compiler work. It is the human side. It is helping a community reframe its intuitions. It is sequencing changes so they are survivable. It is explaining tradeoffs without talking down to people. It is accepting that some resistance is not ignorance but the result of people caring deeply about the original design goals.</p><p>That is hard work. Maybe the hardest kind.</p><p>Technical systems are difficult, of course. But sociotechnical transitions are something else entirely. You are not just changing a rule. You are changing habits, explanations, blog posts, conference talks, intuitions, code review comments, and the tiny phrases people use to teach one another.</p><p>From that point of view, I think the approachable concurrency effort has been a real success.</p><h2 id=\"but-don’t-forget\">But Don’t Forget</h2><p>Even after having changed my mind, I still think caring about the main actor matters. Just not in the old GCD way of dispatching everything to the background.</p><p>Swift Concurrency is still better than that. It moves the responsibility from the caller to the code that is actually doing the expensive work. That is a meaningful distinction.</p><p>And maybe that is another reason why my fondness for the original design never fully went away.</p><p>The original promise that attracted me to Swift Concurrency is still a good promise, to help developers think less about low-level execution trivia and more about correctness and structure.</p><p>I still want that.</p><p>If anything, I think the lesson is that this promise was not defeated, but refined. The language could not keep that simplicity in exactly the form I first fell in love with. But the goal remains the right one.</p><p>And that is also something I touched on in another old post of mine, <a href=\"https://alejandromp.com/development/blog/why-you-should-care-about-the-future-of-memory-ownership-in-swift/\">Why you should care about the future of Memory Ownership in Swift</a>. A lot of these changes in Swift can look like they are adding complexity when viewed in isolation. But sometimes they are really the cost of making the language’s deeper guarantees more honest and more usable for normal code.</p><h2 id=\"the-real-story\">The Real Story</h2><p>So maybe the real story here is not “I was wrong” or “the experts won”.</p><p>It is that I got to witness a language community do one of the hardest things in software reasonably well: change its mind in public, with care, over time.</p><p>I still remember that period with a lot of warmth. Not because every detail was pleasant, but because it reminded me what a good technical community can look like.</p><p>You come in attached to a mental model.</p><p>You push back.</p><p>People answer seriously.</p><p>You learn what tradeoff you were underestimating.</p><p>The change lands.</p><p>Life goes on.</p><p>And if things go well, you come out of it not defeated, but enlarged.</p><p>Not so bad for the losing side.</p>",
      "date_published" : "2026-04-07T22:00:00Z",
      "id" : "https://alejandromp.com/development/blog/swift-concurrency-approachability-transition",
      "summary" : "There was a period during the Swift Evolution Concurrency discussions that I remember very fondly.",
      "tags" : [
        "Swift",
        "Concurrency"
      ],
      "title" : "The Swift Concurrency Transition I Learned to Love",
      "url" : "https://alejandromp.com/development/blog/swift-concurrency-approachability-transition"
    },
    {
      "authors" : [
        {
          "name" : "Alejandro M. P."
        }
      ],
      "content_html" : "<p>Today Mastodon has officially released <a href=\"https://blog.joinmastodon.org/2026/03/a-new-share-button/\">their solution for a Share to Mastodon button</a>. I was already using a third party service on this site, but I decided to give it a try because using officially supported tools seems easier to maintain in the future, especially for something I don’t work on every day.</p><p>The reason something like this must exist is due to the beauty and the problem with Mastodon. It is not a single website. There are thousands of servers. A share button can’t just link to <code>mastodon.social</code> and call it a day, because the user’s account might be in another server. So a redirect tool that lets you pick your instance must exist. That used to be a third-party problem to solve. Not anymore.</p><p>Mastodon now hosts <a href=\"https://share.joinmastodon.org\">share.joinmastodon.org</a>. Open source, no tracking, runs entirely in the browser, remembers your accounts.</p><h2 id=\"the-change\">The Change</h2><p>On the code side, this was a one-liner. The old URL:</p><pre><code>https://mastodonshare.com/?text=&lt;title&gt;&amp;url=&lt;url&gt;&amp;via=alexito4\n</code></pre><p>The new one:</p><pre><code>https://share.joinmastodon.org/?text=&lt;title&gt; &lt;url&gt;\n</code></pre><p>The official service takes a single <code>text</code> parameter with the content you want to pre-fill. Title and URL combined. That’s it.</p><h2 id=\"why-it-matters\">Why It Matters</h2><p>Using a third-party share button means you’re depending on someone else’s uptime, their privacy policy, and their continued interest in maintaining the thing. The <code>via</code> parameter I was passing didn’t even make sense for Mastodon the way it does on Twitter. It was just cargo-culted from the Twitter share URL pattern. And honestly, every time I used it myself, even on Twitter, I end up removing the “via…” anyway.</p><p>The official tool is the right call. It does less, and that’s a good thing.</p><p>If you have a “Share on Mastodon” button on your site using an old third-party service, now is a good time to swap it out. And don’t hesitate to try it with this post, the button is just down below.</p>",
      "date_published" : "2026-03-02T23:00:00Z",
      "id" : "https://alejandromp.com/development/blog/using-the-official-mastodon-share-button",
      "summary" : "Today Mastodon has officially released <a href=\"https://blog.joinmastodon.org/2026/03/a-new-share-button/\">their solution for a Share to Mastodon button</a>. I was already using a third party service on this site, but I decided to give it a try because using officially supported tools seems easier to maintain in the future, especially for something I don’t work on every day.",
      "tags" : [
        "Meta",
        "Web Development",
        "Open Source"
      ],
      "title" : "Using the Official Mastodon Share Button",
      "url" : "https://alejandromp.com/development/blog/using-the-official-mastodon-share-button"
    },
    {
      "authors" : [
        {
          "name" : "Alejandro M. P."
        }
      ],
      "content_html" : "<p>I have this weird feeling of deja vu.</p><p><img src=\"https://alejandromp.com/development/blog/development-dogma-is-back-a-i-agents/matrix-cat.jpg\" alt=\"Matrix cat\"></p><p>If you know me, you know I think dogma is one of the biggest curses in software engineering. Dogma is what happens when a practice gets treated as universal truth before context, evidence, and tradeoffs are considered.</p><p>Dogma is what made OOP feel like the answer to everything. Dogma is what fueled the whole clean-code-industrial-complex. I lived through that as a junior developer. I was taught from university to my early jobs: follow these rules because they are “best practices.” Then you apply critical thinking and realize many of them do not hold in real projects.</p><p>Not every time. Not for everything.</p><p>Hey, let’s be clear. All these things that get popular actually have good points in them. As much as I’ve pushed back against OOP and dumb rules, some things do make sense. But developers love fashion as much as anyone waiting all year for Paris Fashion Week. It’s just that our fashion makes even less sense. Thankfully I had great mentors that fueled my critical thinking, and I followed people online who were very vocal about it. But the industry didn’t stop at those old programming concepts. Oh no, no! 🤪 They also pushed microservices on everybody until everybody realized they made no sense unless you were one of the few companies that actually needed them.</p><p>But I’m not here to rehash the past. I’m here to tell you we are doing it again.</p><p>Now we are seeing a new version in the age of AI. In an era of exploration, we already see people trying to push their half-baked dogma just to sell you their books (well, online courses nowadays).</p><p>Overcomplicated and useless agentic workflows. Made-up standards that are not proven. <a href=\"https://alejandromp.com/personal/blog/accepting-pandora-s-box/\">Just another thing that this revolution doesn’t need.</a></p><p>And again, some ideas in every wave are genuinely useful. The problem is not the tools, or the core concepts. The problem is turning preferences and unproven opinions into doctrine.</p><h2 id=\"new-tech,-old-tricks\">New Tech, Old Tricks</h2><p>I keep seeing teams copy-pasting agent workflows before proving they improve delivery, reliability, or product outcomes. Without even learning the basics first.</p><p>MCP is a perfect mini-case of this pendulum:</p><ol><li>First, it was treated as inevitable for everything.</li><li>Then came backlash, and teams rediscovered that raw CLIs are often simpler to debug.</li><li>Now it is rising again in a more pragmatic form.</li></ol><p>Hype, backlash, partial recovery. New acronym, same cycle.</p><p>And look, it’s true that MCP solves a real problem in certain scenarios. But 99% of the time it’s not needed. And those who applied critical thinking, who knew the fundamentals of how this tech works, understood the hype wasn’t worth it. Be one of those. Don’t be the sheep that follows the shepherd blindly.</p><p>And if you don’t want to think for yourself and you need a guiding hand, look, even Anthropic, the fan favorite for all the vibe engineers, <a href=\"https://www.anthropic.com/engineering/building-effective-agents\">recommends starting simple</a> and only adding complexity when needed.</p><h2 id=\"earn-your-place\">Earn Your Place</h2><p>Every practice needs to prove its value. If it can’t, get rid of it.</p><p>If a workflow adds setup cost, cognitive load, or debugging pain, it must pay that back with real outcomes in your context. Not in theory. Not in someone else’s codebase.</p><p>Take small functions, one of the most repeated dogmas in our industry. Functions must be small, do one thing. No proof. No study showing smaller functions actually correlate with fewer bugs or better maintainability. Just repetition until it became law.</p><p>What actually happens? A dev breaks logic into five tiny named functions. Another dev comes along, sees a function name that looks like what they need, and reuses it. Months later, someone needs to tweak the behavior. Of course they do, because the reusability was born in imagination, not reality. They change that shared function. Now things break in three places nobody expected. If it had stayed in one place, the blast radius would have been zero.</p><p>Now think about what the AI equivalent of “functions must be small” is. What rules are being spread as gospel right now, with no proof? Things are moving fast enough that by the time we realize a dogma was wrong, half the industry has already built on top of it. This is actually the best moment we’ve had in years to slow down and think before repeating. AIs are smart, but we also have brains.</p><p>Good engineers do not ask, “Is everyone doing this?”</p><p>They ask, “Does this work for my context?”</p><h2 id=\"just-be-a-good-engineer\">Just Be a Good Engineer</h2><p>Use AI. Use agents. Experiment aggressively.</p><p>But look around. Every tool, every platform, every framework now has its own version of the same thing. Agents, subagents, prompts, skills, modes, MCPs, commands… you name it, someone is selling their spin on it. The complexity is artificial. The race to have the most elaborate setup is just the new cargo cult.</p><p>And the irony is that models are actually good enough now that none of it matters that much. If the harness is decent and you know what the AI can and can’t do, just being a good engineer is enough. Asking the right questions. Knowing when to push back. Understanding what you are looking at.</p><p>So the last piece of advice is the oldest one. Focus on the fundamentals, and don’t overfill your tools with crap you don’t need.</p><p>When you stop questioning and start copying, you are not doing engineering anymore. You are doing cosplay.</p><p>Tools are tools, not doctrine.</p>",
      "date_published" : "2026-02-22T23:00:00Z",
      "id" : "https://alejandromp.com/development/blog/development-dogma-is-back-a-i-agents",
      "summary" : "I have this weird feeling of deja vu.",
      "tags" : [
        "AI Development",
        "Methodology",
        "Art of Programming and Paradigms"
      ],
      "title" : "Your Agent Stack Is the New Clean Code",
      "url" : "https://alejandromp.com/development/blog/development-dogma-is-back-a-i-agents"
    },
    {
      "authors" : [
        {
          "name" : "Alejandro M. P."
        }
      ],
      "content_html" : "<p>It’s been over three years since ChatGPT started the revolution of AI. As a curious person I’ve been looking at that for quite some time, but I still remember the first time I realized how we got here and what the impact was. If you know me you know I’ve always been reluctant with AI, what some would call a hater, although that’s far from reality. I loved the idea of AI, just not the timeline we live in.</p><blockquote><p>This essay gathers my personal thoughts on the matter. Something I’ve been pondering for a long time. It will start with quite a lot of doom, but hopefully gets better by the later sections.</p></blockquote><h2 id=\"the-surprise-is-how-dumb-this-tech-is\">The surprise is how dumb this tech is</h2><p>People see ChatGPT replying to them and think it’s AGI (artificial general intelligence). But if you know how the tech works you realize it’s just old tech but with tons of data and power thrown at it. It’s just a probability machine: a bunch of matrices doing expensive math.</p><p>It’s like the glorified autocomplete, although we have to admit that the “glorified” word is doing A LOT in that sentence. It’s autocomplete with an end goal and semantic knowledge, trying to follow a reasonable direction.</p><p>When I visualize the internals of an LLM as a huge multidimensional space with words and vectors, the engineer in me smiles. It’s actually pretty amazing that with math we can represent a vector that goes from man to woman, and if you apply it to king it gives back queen. This is the dumbest example and probably not something people are hyped about, but this sort of thing is the one that made me excited about AI tech.</p><p>But none of this was what surprised me in the past years, what surprised me is how dumb this whole tech is. It’s not smart, it’s not thinking, it doesn’t have knowledge or experience. It’s just an autocomplete.</p><p>And that was enough.</p><p>For me, that was the most surprising thing, the fact that something so simple in the basics, turned out to be enough to replicate humans in more ways than we could imagine. And yet that was not enough of a surprise, because the cherry on top is how realizing this, made me question my own beliefs.</p><p>Look, I’m not a philosopher, but I was top of the class in high school philosophy and I’ve always been curious and learned things along the way. It also helps my best friend is actually a philosopher. And yet, it’s not something I spend too much time thinking about, but when I do, I’ve always had the feeling that there was nothing special about humans. It’s probably my engineer background, but I’ve always thought that we are just emergent behavior. A powerful concept that I believed could explain everything.</p><p>And yet, here I was, seeing how a bunch of matrices behaved like humans and I started thinking if maybe we had something else they didn’t. We must, right?</p><p>But fear of AGI is not at all what made me dislike AI. Once I got past the technical awe, the real discomfort wasn’t intelligence, it was how it was built.</p><h2 id=\"the-biggest-heist-in-history\">The biggest heist in history</h2><p>Let’s go back to how these things got here. With data. A. LOT. OF. DATA. To get the current models where they are nowadays, they’ve had to be trained on vast amounts of human text. For all intents and purposes, they have been trained on the entirety of human knowledge, imagine, the entire internet, plus everything written on paper. And here is where one of my biggest gripes with this technology is. It wouldn’t exist if it wasn’t because a few privileged decided to ignore all laws and morality and steal all of it without giving anything back to the authors. We’re talking about terabytes of books, uncountable images, songs, movies… anything that a human has created was up for grabs for these progressive thieves.</p><p>And the fact that the top few can get away with this without real consequences, while somebody goes to jail for downloading a bad movie is insane, but is the world we live in.</p><p>People will try to defend the morality of this, and to that I call it bullshit. We all want progress, we all love the ideal future that AI promises. But let’s not be hypocrites here, we’re building this future by stealing from millions and enslaving others.</p><h2 id=\"modern-slavery\">Modern slavery</h2><p>Look, AI was not the problem, it just exacerbated it. It’s been clear for years that this late capitalism we live in doesn’t work. I mean, it’s the best thing we have, just like democracy, but the systems are falling apart. While a few billionaires keep getting richer, the rest of the world is in a place that we shouldn’t be in 2026. Guys, look around the current world, we’re killing Earth, letting rich people destroy democracies, and we keep dealing with wars to fuel the ego of others. And then realize it’s 2026. These things shouldn’t happen two decades into the 21st. It could have been solved. What are we doing?</p><p>Well it’s just how the system works. For some to win others have to lose. I don’t believe that’s reality, but that’s what humans desire.</p><p>Enter AI, with all its promises and the impact that will have. Jobs will be lost, not changed. This is not impacting a specific role, or sector. This is changing everything. The industrial revolution will be nothing compared to this. And jobs won’t “evolve”, they will disappear. But not for free. Business will now have to pay the billionaires instead of the hard workers. Because after all the stealing, this is not something that is given back to society, this is kept in the pockets of the privileged ones.</p><p>I can’t believe that the ones actually giving back to society are the Chinese, releasing open models while the Americans keep pretending they are the saviors and convincing politics to let them mess with the world as they desire. What a world we live in!</p><p>And people that think jobs won’t disappear, I hope you are right. I’ve heard many versions of this.</p><p>“Arts won’t ever die cause humans like human art.” Yes, sure, the Banksys will survive this. But how many of those do we have, and how many can sustain themselves? Because for every master there are millions trying to survive with their art. And if that was already difficult, what do you think is gonna happen? And no, this is not like the revolutions of photography, cinema or even Photoshop. They could be, but corporate greed will make sure it’s not the case.</p><p>“Chess was solved by AI years ago, and now chess is more popular than ever before.” This is the worst take ever, because how many chess players do it for a living? Again, we’re not talking about hobbies disappearing, we’re talking about ways of surviving disappearing.</p><p>And what happens when the youngsters growing up accept that watching a game of FIFA, machine vs. machine, is equally exciting as watching real players? Or a race in the F1 videogame? I’ve been thinking this for years, even before the current AI generation, back when football videogame graphics and audio commentary were already getting hard to distinguish from a broadcast. Imagine now, or in a few years, with the current pace of AI advancements. And have you seen the recent robots with an agility and human movement that surpasses the best gymnasts? Not even sports are safe.</p><p>But that’s only the economic end of it. The social end is what happens to privacy when a few companies own the interface to everything.</p><h2 id=\"the-end-of-privacy\">The end of privacy</h2><p>And the other consequence of these AIs being owned by a few is that if you want to participate in society you will have to not only pay those folks, but also give them all your information. This has already been a trend for years, but again, AI just turns it to the max. We’re already seeing people give all their bank accounts, all their medical records and therapy conversations to these companies. And the only thing they say is in a small letter saying they won’t train on it and don’t store it. Something that nobody can confirm, and a line that can be removed easily in any update.</p><h2 id=\"pandora’s-box\">Pandora’s box</h2><p>And with all of this in mind, already years ago, I realized that was it. Pandora’s box was opened. As soon as there was proof that LLM capabilities were possible, nothing else mattered. People asking to slow progress, do a hard restart to respect morality and laws, how this won’t be such a big deal… all illusions and impossibilities. Pandora’s box was opened. Nothing would have ever been the same. It was just a question of time.</p><p>And that was before 2025, when things really changed.</p><p>I won’t pretend all this darkness went away. The theft, the inequality, the privacy decline… they remain real and unresolved. But something else happened too that we can’t deny. The technology crossed a tipping point. It got better, much better. Not just incrementally, but in ways that forced me to stop just watching from the sidelines and actually engage publicly with what this means for my work, my writing, and my future. What follows are the written thoughts of that reckoning.</p><h2 id=\"before-2025\">Before 2025</h2><p>During those beginning times I struggled with the internal fight of hating the world AI was being born into, and the excitement of progress. Of course, the constant conversation and everybody trying to fit AI everywhere ultimately burned the excitement for many. But deep down it was still there.</p><p>The psychology of having to resist the impending progress was not the problem. The problem was rejecting something I knew could be used for good, because it was tainted from the start. And that’s why from time to time I felt like shit for using ChatGPT and Claude to help me fix typos in a couple of my posts, or to criticize my writing. I refused to use them for anything more, because the hypocrisy it would imply, but it was hard to deny that it was a powerful tool that could be used for good.</p><p>It was a time for play. Things were not that good yet, sure we could pay private companies to generate images and illustrations, instead of paying humans to do it, and it had some sort of fun. The fun you have when playing with a new toy. But besides using it to show your friends, there was not much value in it. And yet, I could see my own shame growing as I felt how I was betraying my fellow humans just for playing with it.</p><p>When the future we all dreamed, where machines would do the hard jobs and humans could live a better life, was turned upside down. We had machines doing the fun and creative works, and humans still breaking their backs with manual labor. Who had that in their century bingo card?</p><p>But the worst was yet to happen. I could see artists, writers, all fighting back. It was just a posture to show the world that things mattered. Of course it wouldn’t change anything, individuals have little power in today’s world. But at least the feeling that things were not okay was there.</p><p>And then, the agentic revolution started. And programmers were the first ones to praise this new overlord. This is something I never understood. How could they all be building the path for their own demise?</p><p>Before we get to 2025, one quick detour into my own frustrations with the industry, because it explains why AI felt like it fit so easily.</p><h2 id=\"the-decline-of-software\">The decline of software</h2><p><a href=\"https://www.youtube.com/watch?v=ZSRHeXYDLko&t=5s\">Software has been in decline for years</a>, even before AI. In a world ruled by corporate greed, where <a href=\"https://www.youtube.com/watch?v=Aqj1Z1vgE6I&t=2143s\">quality doesn’t matter</a>, it’s just a question of time before things turn for the worse. And I think it happened in three ways.</p><p>First, mediocrity became the norm. AI can take over very easily in a world where the job is already “copy paste from Stack Overflow and ship.” And that was praised. The industry even started pushing back on being able to ask certain things in interviews. We were forced to disregard the experience and quality of people, just because we needed more people typing letters on the screen. These might sound like harsh words, but it’s the reality that all of us in the industry know, and we tried not to talk about. Because you don’t need hundreds of developers to make a product. And the proof is how many of these businesses create unrelated and unnecessary things. Time was free, until it wasn’t.</p><p>Second, output mattered more than craft, because capitalism was the driver of everything. Look, we’re not stupid here. Everything is a business. We all need to put food on our tables and buy those yachts. But when that is the only goal, instead of the consequence of doing a good job, it’s a question of time before things go sideways. The lack of care, and quality reached incredible lows. I recently saw a video of Microsoft Word opening on an old Pentium and it took less than a second. I still remember when macOS apps opened and the icon in the dock didn’t even bounce once. Or when loading a website was instant when we got ADSL. Now people are happy if anything takes 10s. Insanity. But as long as the bottom line is fine, we accept it.</p><p>Third, knowledge and control stopped mattering. And this is the worst. Very few care about really understanding how things work, or having control of the thing they are building. Their tools, their frameworks, whatever it is. We’ve just accepted that not knowing is fine.</p><p>So all of the above were things that were happening before AI, in a world where software was in decline. When AI arrived, it was the perfect storm.</p><p>And yet, since it’s here to stay, and is clearly the future, you better accept it. At some point it stopped being a question of if, and became a question of when — and the when was now.</p><h2 id=\"end-of-2025,-the-moment-things-really-changed\">End of 2025, the moment things really changed</h2><p>2025 saw more evolution in AI than even before. That’s when I had to take a personal decision and just accept Pandora’s box was here. Luckily, I’ve always been looking from the side, so when I decided to get into AI it didn’t take me long to catch up. After all, my brain has certain facility for this sort of thing, that I wish I had for other real life stuff :D</p><p>During 2025 new AI models became actually really good, not only at simulating human text, but also at interacting with external systems. In my mind, this is what happened:</p><ol><li>First, LLMs became real, but their utility was capped by their static knowledge. Deep learning and inference are still separate steps. An LLM can’t continuously learn.</li><li>Then we connected them to the internet. We taught them how to request help, and classic software around them helped by searching and feeding them results. Real-time, up-to-date knowledge that they can load into the inference context and use to be way more accurate.</li><li>Then we generalized that request for help by giving them <strong>tools</strong>. A way to interact with external systems, with their surroundings. Now they could read and write files, search for things, hit APIs, and manage machines. Not just talk, but do.</li><li>But text was not enough, so we gave them senses. Models that could understand images or other media used to be siloed. Then <strong>multimodal</strong> models showed up and now they could understand images, your screen, your voice, and even generate them.</li><li>And then the biggest trick of them all was born. <strong>Reasoning</strong>, fake thinking. Models can just autocomplete, but if they autocomplete to themselves first, not for humans, it changes the outcome. They could now talk to themselves. Fill the inference context with steps, constraints, intermediate thoughts. It’s expensive, but it makes them feel smarter. This quickly became where most generated tokens are used (it’s estimated that 80% of tokens generated nowadays are just for reasoning, insane).</li><li>And finally, agentic harnesses became good. Classic software that wraps the model with tools, retries, state, and structure, so it can do real work end-to-end, not just answer a question.</li></ol><p>With this, AI left the chat interface. The chat box is just a UI. The real thing is when the model lives inside your workflow: your editor, your terminal, your files, your browser. AI isn’t useful stuck in a chat, it’s useful as part of your computing life.</p><p>This is when it was clear that programming was done.</p><h2 id=\"ai-writing-all-the-code\">AI writing all the code</h2><p>So I’ve been seriously using AI for a few months now, more seriously in the last couple. And I’m still grasping the impact of reality. It’s still frustrating that I can only do this thanks to employers riding the hype and providing AIs for free for work. For personal stuff I’m paying the lowest tier available and thinking about it as paying for a learning course. But let’s not forget some people can’t afford it.</p><p>AI now can write full applications on its own.</p><p>Are they good? No. Does anybody care? No.</p><p>But remember, it’s all a bubble. So we keep hearing how people are letting AI write 100% of the code. And then we see Windows being broken in a <a href=\"https://x.com/OfficialPCMR/status/1982116120275431743\">thousand new innovative ways</a>. And it’s not just memes. Executives are saying it out loud. Firing thousands of people, saying “I need less heads” just to hire them again later.</p><p>Even the AI labs are doing it. The head of Anthropic’s Claude Code said he hasn’t written code by hand in months, and that Claude Code writes most of Claude Code. And OpenAI engineers have said similar things about their own day-to-day coding too. And I believe them, because the tools they are releasing are the worst apps I’ve seen. I’ve never seen my M4 MacBook Pro suffer so much just by having their apps open doing nothing. But again, it doesn’t matter.</p><p>Today, AI is not as good as me. That’s the truth. I don’t think it’s even faster when you think about all the wasted time. But that doesn’t matter anymore. Because in a few weeks, months, or years in the worst case, it will. Because AI can do a thousand tasks at a time while I can only do one, or half if I keep being interrupted with meetings.</p><p>And in practice, it’s messy. You ask for a tiny fix and it adds three new files. You push back and it rewrites half the module, always saying “yes, you are right.” It keeps adding code and abstractions when a couple of precise incisions would cure the patient. You ask for a test and it papers over the symptom instead of the cause. It’s great at a level that would have been unimaginable just a few months ago, but it gets brittle fast the bigger the codebase gets.</p><p>So what is the future? I don’t know, but I do have a hope. A hope for people to realize that you don’t go from a prompt to something decent. That you still need an engineer, with experience and a clear goal, to guide the AI to do the actual work. Because otherwise, the AI will surely do something, but it might have spent your monthly budget for nothing useful.</p><p>Are these just illusions and hopes? Maybe. I still want to have a job after all. But it’s also the reality after having worked with it. Is it good? Yes. But it needs somebody that knows what they’re doing. Without it the AI is just a machine that vomits words. And guess what’s the goal of good software engineering? Reducing complexity. AI is not doing that, at all, at least not now. This might make good programmers even better, and mediocre developers disappear.</p><p>But the reality is that all the magic wins that the bubble keeps announcing, are mostly made up. Not all of them. It’s true that people are doing more than before, people have multiple agents coding and shipping. And that’s only doable by not caring about the output. Because the solution to AI’s problems is just more AI. But the ones to learn from are those that do the same, and still care. Still make sure things work and are safe, maybe they do it themselves, or maybe they run other AIs to do it, or have a proper testing infrastructure in place, whatever it is, if you still care, thank you.</p><p>But there are two categories where I find that AI excels, and there is no denying that it has already changed many things in my day to day.</p><p>First is everything that doesn’t matter. All those random scripts, all those tasks that are useless, all those ideas that you wanted to prototype but didn’t have the time or energy. Just let AI burn the atmosphere for a while and see what comes out. You probably will use it once and forget, or trash it and start over. Who cares! (the planet does, but it’s fine, it will be a problem for our kids). This has already allowed me to clean up a backlog of personal tasks that I’ve wanted to do for a long time and that I never thought I would get to. Some of these things are now cheap to try.</p><p>And the second aspect that is related to the first one, a specific case of it, is anything that has to do with manipulating your computer. This is actually where I’ve found AI more useful. I still find that for coding it’s still a couple of models away from satisfying me personally, but for this it’s already a game changer. Configuring your zsh, managing the AI agents setup themselves, fixing or working on home servers, etc. There are so many things that it can do once it lives in your machine that are not really related to making a product. This is actually my recommendation for where to start, it’s a game changer. And it’s such a game changer that even Anthropic is now, months after, including this sort of functionality on their consumer app.</p><p>What is also very curious is how I don’t think that even the bubble realizes the impact of this. Because if everybody can have a piece of software that they need, for their specific task and taste, just by asking for it. Who do you think is gonna pay all these SaaS that you are all pumping out? Yes, exactly. Nobody will. And if your argument is that there will still be a need, well, then the bubble is lying. You can’t have your cake and eat it too. So in the end we’re going back to the main issue, the money will only go to the billionaires that own these models. And if nobody gets paid, nobody spends money. Checkmate.</p><p>Just to leave the programming aspect aside, this is the reality. We could argue if it makes any sense where we are going, but we can’t argue that we aren’t going there. So my recommendation is that you start getting on the bandwagon for learning how to use these tools. Become as effective with them as you are with your framework of choice. But still be an engineer, be curious, learn what the AI is doing, understand it. Don’t lose yourself. But with that done, go ahead and ship a thousand projects that you could have never done before!</p><p>And, as I always preach, self-reflect, don’t be an automaton. AI is clearly making people lazier and dumber, I’ve already seen it happen. This technology has something that makes our human brains tickle. The dopamine of having impressive outputs just by writing a sentence is undeniable. We like quick rewards, and AI coding is like TikTok for developers. Don’t let it rot your brain. Be one of the few that actually still has curiosity and desire to learn and grow as a human. If you do, AI is an amazing tool that will facilitate your learning and growth. Use it for good.</p><p>And if code was my professional dilemma, writing was the personal one.</p><h2 id=\"writing\">Writing</h2><p>But then, a huge part of my life is also writing. Sure, it’s just a hobby so I’m less concerned with it than programming, but I still care and I empathize with my fellow authors that I admire so much. Let’s leave aside how they’ve been stolen, and let’s focus on how AI impacts myself.</p><p>In the beginning I tried it and saw that it could write something, but it was not good. But it could point out a few things that helped me learn how to write better. I still want control and ownership, so I always have the last decisions and the words are my own, but it was clear it could help in the learning process.</p><p>Nowadays, with a full agentic setup, the questions come again. How helpful can it be? Because the limitations are almost gone. So it’s all about using it for the good. We need to ignore the hypocrisy of how AI was born, as I said, not much we can do at this point as much as it pains me. But what is that? I still want every single word to be mine. I don’t mind it fixing typos, because we’ve done that with automated classical tools for years. But I don’t want it writing or even rewriting.</p><p>But I worry, how long is that gonna last? I do feel it inside me. I still love authorship, and the whole point for me is to express myself and use my words, as I’ve been doing for 3500 words of this essay. But I’m afraid if those feelings change at some point, if the value of utility of helping me put my thoughts in text grows exponentially, I don’t know what will happen.</p><p>And I hate it, and hate myself for it.</p><h2 id=\"music,-my-own-hypocrisy\">Music, my own hypocrisy</h2><p>The reason I can feel the pressure on writing is because music has made me confront an uncomfortable truth: I’m a hypocrite.</p><p>I can’t judge music in the same way, I’m not a musician or have ever had music as a creative output, but my point is that I’ve reflected myself and I see how I do care a lot about programming, writing, visual arts… and I would never accept something without a human behind. But then for programming the industry is pressuring me to actually change that.</p><p>But then we have music. I listen to music a lot. It helps me focus and enjoy myself. But since I was a teen I noticed how I didn’t care about the artist behind. Sure, I loved their music, and I knew who the artists were, so I got excited when they did something cool, or sad if the band changed a member. But I’ve never been a fanboy of the bands. It was the same for authors by the way, but that has actually changed over time, although not as much as others, I feel like I can enjoy the art even if I despise the artist.</p><p>And I’m not sure if it’s that, or something else, but I’ve found myself realizing that I wouldn’t care to be in a future where all the music I listen to is AI generated. And that realization scared me. I really don’t want the promised future in Black Mirror where we open the TV and we get real-time AI-generated movies just for us. That sounds disgusting. But for music? I don’t know why but I don’t care. I’ve spent some time generating my own songs, giving it lyrics I wrote myself inspired by my own fantasy stories, and the output… is literally the kind of music I listen to so I don’t see a difference.</p><p>I don’t <strong>feel</strong> a difference.</p><p>And now I realize this must be how 90% of society feels with everything else, from art to books, to movies. If I, as a person who cares deeply about authorship, can’t be concerned with fully AI created music, why would others care about AI art, AI books, AI anything? That’s the terrifying part. Not that I might accept AI music. But that I now understand why everyone else will accept <em>everything else</em>.</p><p>Is this what society is gonna devolve into? I don’t know, but I can’t deny my own feelings.</p><h2 id=\"bubbles-exploding\">Bubbles exploding</h2><p>So is this a bubble? Yes.</p><p>Will it explode? I don’t think so.</p><p>I’m far from an expert, but my feeling is that when we think about economy bubbles we keep thinking about how they were in the past. But looking at this one, where there is a self-fueling cycle of made up money, but kept in the hands of the very powerful that are already very rich, I don’t see how the bubble breaks.</p><p>Even if things don’t go as they are promising, it doesn’t matter. They already have the deals, they have the hardware, they have the control, and they have the politicians. Sure, maybe some cryptobros cry about it, maybe the bubble is not visible in the sky anymore… but it won’t explode. It will just fade under the vast ocean of wealth that you can’t even imagine.</p><h2 id=\"agi\">AGI</h2><p>This is a funny one, because it all depends on your definition of AGI. I don’t think we’re even close to AGI. But I do think we’ll reach a point where the label stops mattering, because it will <em>feel</em> like it. As I said, back when the first versions of ChatGPT came out, I was surprised by how such a simple technology was enough to replicate human behavior. I think the same will happen again, but this time with what will feel like AGI: a few improvements, maybe a bit better models, and suddenly the debate becomes just semantic masturbation.</p><p>Just look at things like the <a href=\"https://www.anthropic.com/news/claude-new-constitution\">Claude constitution</a>, and have a conversation with Opus. Sure, it’s still just matrices doing expensive math, but then… those things that we still don’t fully understand start to feel like <a href=\"https://www.youtube.com/watch?v=ho4pQ6pI8Rg\">something more</a>.</p><h2 id=\"my-ideal-future\">My ideal future</h2><p>Given everything I’ve said so far, it’s not that difficult to imagine what my ideal future for this would look like. If I was in charge of designing the future with pervasive AI, I would do it like this.</p><ul><li>AI needs to be open, not kidnapped by the billionaires. So the future is open models competing for improvements. Thank god some are already doing this.</li><li>AI needs to be local. Hardware and software need to make huge improvements. Is this even possible? I’m not sure, because right now AI just works by wasting resources like nothing I’ve ever seen before. But hopefully it can be solved.</li><li>AI needs to be integrated. I’m sorry, but as much as OpenAI, and especially Anthropic, are trying to keep the usage of their models exclusively to their shitty tools, I don’t think that’s the future that will work. My impression is that AI agents want to live where everything else lives: the operating system. So it wouldn’t surprise me if the long-term winners end up being Apple and Microsoft, integrating AI agents in the operating system PROPERLY. It reminds me of that Steve Jobs story where he basically told Dropbox they were just a feature. But this needs a big mindset shift. Stop putting useless AI chats on every app and corner, instead just bring the AI agents developers are already using, but for the masses. This is probably a bad prediction, but this is where I think AI should go. Imagine Siri, and the promises given in WWDC with Apple Intelligence and app integrations, but done properly.</li><li>AI needs to be private and safe. And this is where I have less hope. If all the above happened, I would still be reticent without privacy and security. Right now this is not feasible, we are very far from it. Hallucinations are part of the technology, not a bug. So will the industry be able to figure out how to make it safe and secure, while still giving it access to everything? Because if something we’ve learned in the past few months is that AI is way more capable the more tools you give it. Keeping it sandboxed is not the future.</li><li>AI needs to empower humans, not replace them. And this is something that surely won’t happen as long as our <em>impostor</em> democracies keep being fueled by late capitalism.</li></ul><p>I know I’m leaving a bunch of things out, as this just refelcts my current state of mind. But at least that’s the minimum bar for my ideal AI future.</p><h2 id=\"so-what?\">So what?</h2><p>But that’s not the world we have. So here’s where I am right now.</p><p>I don’t know why I felt like I had to write this (this is what an AI would also say lol), but it came from deep down.</p><p>I’m not an AI hater anymore. I’m not an AI-crypto-bro either. But the reality has changed in the past few months, and denying it won’t help me.</p><p>So don’t be surprised if the day I come back with tech articles or videos, they are about AI.</p><p>As per my writing, as I said, that will still be me. Maybe it’s a mistake and society will embrace AI books. But I still want to express myself and put MY thoughts on paper. I’m probably okay with fixing typos and critiques so I learn and improve. Time will tell how wrong this is.</p><p>This essay starts with doom, and a lot of that doom stays. But I’m done pretending I can sit this out. I’m choosing to engage anyway and enjoy it along the way.</p>",
      "date_published" : "2026-02-02T23:00:00Z",
      "id" : "https://alejandromp.com/personal/blog/accepting-pandora-s-box",
      "summary" : "It’s been over three years since ChatGPT started the revolution of AI. As a curious person I’ve been looking at that for quite some time, but I still remember the first time I realized how we got here and what the impact was. If you know me you know I’ve always been reluctant with AI, what some would call a hater, although that’s far from reality. I loved the idea of AI, just not the timeline we live in.",
      "tags" : [
        "Life",
        "Writing",
        "Software"
      ],
      "title" : "Accepting Pandora's Box",
      "url" : "https://alejandromp.com/personal/blog/accepting-pandora-s-box"
    },
    {
      "authors" : [
        {
          "name" : "Alejandro M. P."
        }
      ],
      "content_html" : "<h2 id=\"another-year,-another-stack-of-books\">Another Year, Another Stack of Books</h2><p>Looking back at 2025, reading kept surprising me. I wandered into new territories, like John Cleaver, tabletop RPG handbooks, and yeah, more Cosmere (because how could I not?).</p><p><strong>23 books</strong>, <strong>6,949 pages</strong>, not too shabby. Average about 302 pages per book, pulled down by shorter RPG supplements and pushed up by longer ones like <em>Jade Legacy</em> (713 pages). You can see my <a href=\"https://www.goodreads.com/user/year_in_books/2025/75832270\">Goodreads Year in Books</a> for more.</p><p>Average rating: <strong>3.7</strong>/5. Lower than past years, but not because I’ve read worse books, but because I pushed myself to step outside my comfort zone.</p><p>Quick stats:</p><ul><li>Books: 23</li><li>Pages: 6,949</li><li>Avg rating: 3.7</li><li>Longest: Jade Legacy (713 pp)</li><li>Shortest: RPG supplements</li><li>Favorite: Way of the Wilder series</li><li>Biggest surprise: The John Cleaver series</li></ul><blockquote><p>Light spoilers ahead (I keep endings vague).</p></blockquote><p>This year I leaned into discomfort: horror that feels plausibly close to home, a family saga that unfolds over decades, and formats that reshape familiar stories. Between GraphicAudio dramatizations and RPG lore, “reading” stretched past prose, and the surprises, especially John Cleaver, reminded me why experimenting pays off.</p><h2 id=\"el-poder-de-la-mujer-despierta\">El Poder de la Mujer Despierta</h2><p>My explorations outside my comfort zone started early in the year, with a therapist’s recommendation that reframed how I read the rest of the year.</p><p>I rarely read non‑fiction, but it aligned with questions I’d been circling for a while, so I took a chance and it helped me articulate a few things I only sensed before. Two ideas in particular stuck: naming boundaries as acts of care (for self and others), and noticing how small habits shape patterns over time. Neither is novel on its own, but the book gave them workable language; I found myself applying its framing to how I choose what to read and how I spend attention. It’s the kind of perspective shift that doesn’t end at the last page.</p><h2 id=\"mysteries-of-the-material\">Mysteries of the Material</h2><p>That inward lens made me hungrier for stories that complicate heroes and power, which is exactly where Mysteries of the Material hooked me. Mysteries of the Material is book two in the Way of the Wilder series. I read the first book in December last year and liked it so much that I picked up this one at the start of this year. The series is a favorite already, and I’m glad I found this author.</p><p>This second book is another great ride with these characters and their intricate world. The series keeps revealing its rules bit by bit and avoids the easy ‘power = villain’ shortcut. I love Kizune, especially their attention to ordinary people, and I’m dying to know more about their history and world. The ending lands like a blow, earned, unsettling, and hard to shake.</p><p>Even if on the surface you might think the magic is just another elemental system, there’s more at play in how the worldbuilding unfolds and what it asks of the characters. And let’s be honest, I have a soft spot for elemental systems. I’m eager to read book 3 as soon as I can in 2026.</p><h2 id=\"the-green-bone-saga\">The Green Bone Saga</h2><p>From intimate, character‑first fantasy, I swung toward a sprawling family saga to see how time reshapes power and loyalty.</p><p>I picked this up to catch up with a much‑praised series, I kept seeing it on YouTube, and even Sanderson mentioned it, so I decided to go for it. I liked it; it’s very well executed, and the intrigue is genuinely engaging, especially the family dynamics.</p><p>What I didn’t love as much, likely a matter of taste, was the density of the family drama. Structurally, the multi‑POV focus and political‑intrigue pacing keep the pressure high, but sometimes I wanted a pause to step back and take stock, to see what those moves meant for the themes and relationships. And of course, I wanted more magic. Don’t get me wrong, it was cool and basing the power in such a specific and scarce material has huge implications, but… I wanted more magic.</p><p>That said, the generational sweep is a standout. The fact that the series happened through such a long time period, let us see this generation almost end‑to‑end, how it starts, how it ends, and what it hands the next. It’s a thoughtful way to stage the themes.</p><h2 id=\"the-john-cleaver-series\">The John Cleaver Series</h2><p>After all that generational intrigue, I craved something leaner and sharper. The John Cleaver books scratched that itch with grounded horror.</p><p>This wasn’t my first Dan Wells book, I read the Partials series last year, but this was something else entirely. I wasn’t expecting the blend of grounded reality with horror and a touch of the uncanny, close enough to feel plausible. Nothing feels weird… until it does. The small‑town day‑to‑day and routines keep the horror grounded.</p><p>Following the main character as he discovers and fixates on the truth is compelling because the escalation feels earned: each choice narrows the path forward. We see how the character gets pulled out of our apparent reality while at the same time becoming more human and caring about the rest. The entire series, from the initial reveal of a variant of our world through how the adventures unfold and how we meet more of these enemies, whatever you want to call them, stays intriguing without losing its footing.</p><p>I also watched the film adaptation, “I Am Not a Serial Killer,” and liked it more than I expected. It’s surprisingly faithful to the book. I’d have watched more if they’d made them. I’m not sure when I’ll pick up my next Dan Wells, but I hope it’s soon, maybe even a Cosmere book! Who knows?</p><h2 id=\"mistborn:-the-final-empire-(graphicaudio)\">Mistborn: The Final Empire (GraphicAudio)</h2><p>Experimenting this year wasn’t only about genre. I also returned to a format I already enjoy, GraphicAudio, to experience a favorite in performance.</p><p>I re‑listened to <em>The Final Empire</em> because my little brother decided to read it; I wanted to read alongside him, but in a different way since I almost never re‑read. GraphicAudio isn’t a substitute for audiobooks or print; it’s a different medium. Because it’s performed, they trim description, actors and sound do the showing. You actually hear what’s happening in the scene, so the script doesn’t need to spell out every sound.</p><p>What won me over was the craft of the production: the sound work is wild, coins pinging, ash falling, boots scraping, and the spatial mix makes scenes breathe. Between actors, sound, and score, pacing becomes physical. It reaffirmed something I’ve felt for years: <em>The Final Empire</em> is my favorite Sanderson book. Even though I love The Stormlight Archive and the later books, this one is still my top. I highly recommend trying a GraphicAudio production; it’s worth it.</p><h2 id=\"isles-of-the-emberdark\">Isles of the Emberdark</h2><p>Back in Cosmere proper, Sanderson’s sci‑fi tilt tested my comfort zone again, mixing spaceships with dragons in ways that still feel human.</p><p>This was one of the first moments where we see Sanderson tackle the more space‑leaning, sci‑fi future of the Cosmere, which worries some readers who arrived for the fantasy. I’m not afraid of the shift, tho I’m curious how it will feel once the era fully turns. Sanderson’s gift for mixing genres shows here: we have spaceships and spacefaring civilizations, but it still reads like fantasy. Spaceships don’t kill the sense of wonder.</p><p>What resonated was how tradition tempers progress: it doesn’t do a clean break; the future has to earn the past. The blend of what we want to keep and what we must let go is beautifully woven. Seeing familiar characters, learning more about dragons, and glimpsing the Cosmere’s future was a delight, but, as usual, it’s the human questions beneath the swords (or lasers) that move the story forward.</p><h2 id=\"the-cosmere-rpg-materials\">The Cosmere RPG Materials</h2><p>And the world kept spilling off the page, maps, stat blocks, and lore vignettes made “reading” a more tactile, playable experience.</p><p>Books read: <em>Stormlight Handbook</em>, <em>Welcome to Roshar</em>, <em>The First Step</em>, <em>Bridge Nine Adventure</em>, <em>Daggerheart Core Rulebook</em>.</p><p>The Kickstarter buildup and finally receiving the materials was pure joy. I devoured the PDFs, then got even more excited when the physical books arrived. After that, they are really gorgeous. Brotherwise Games and Dragonsteel continue to ship products that feel thoughtfully built and aligned with the Cosmere, and we fans love them for that.</p><p>As a side note, the Mistborn deck‑building game was one of the year’s biggest surprises; I’m happy I added it to my backer kit.</p><p>Not a deep review, Unfortunately I’m not that deep into the hobby, just notes from a fan. One vignette reframed a familiar corner of Roshar for me. With the map and the stat blocks in front of me, the constraints feel physical, and the stakes click in a way prose alone doesn’t. It’s exciting to see these worlds find new forms beyond books.</p><p>If you’re not into RPGs, this might not be for you, but at least browse the canon story online. And especially <em>Welcome to Roshar</em>: one to own physically as the art direction and layouts do real work.</p><h2 id=\"coraline\">Coraline</h2><p>To refresh my taste buds, I picked up a small, eerie classic. Tone can be just as impactful as page count.</p><p>This one has sat on my list for a long time. I decided to read the book first, then watched the film, and I’m glad I did. Short sentences, domestic details that tilt uncanny, very Gaiman. It isn’t my usual register, which is part of why it worked for me this year. On the page, the restraint makes the dread feel intimate; the film amplifies spectacle (beautifully), but the book’s quieter chill lingered longer. It’s short; worth your time.</p><h2 id=\"a-court-of-thorns-and-roses-(acotar)\">A Court of Thorns and Roses (ACOTAR)</h2><p>Everyone was talking about this. The book of the moment. Not my usual thing, but I was curious, and reading it wasn’t a waste of time.</p><p>The book was recommended to my wife, and she decided to give it a chance, and as it was her first foray into fantasy, I decided to read along. Unfortunately I came away quite disappointed. It isn’t the worst book I’ve read, but it isn’t what people make it out to be.</p><p>Most of the characters are fine and even interesting, but I really dislike the main character, and being in her head for most of the book made the ride less pleasant. The broader cosmology, ancient powers and hints of other worlds, still tugs at me, but the romance leans on familiar tropes; that’s fine for this kind of book, yet alongside the fantasy thread it often clashes and pulls focus from what I find most interesting. On the sex front: if people read this for that, fair enough, but those scenes didn’t add much for me. It’s also very predictable: by the first chapters of book one I could already see where book two was going, and only one moment truly surprised me. The mystery side isn’t well scaffolded, so the reveals feel telegraphed more than discovered. The things I’m most curious about sit at the edges of this world, and the book doesn’t spend much time there; I keep reading hoping those threads will show up, but I’m not sure they will, which is why this doesn’t match my expectations. The prose moves quickly, but at times it felt unpolished to me, long expository paragraphs that don’t quite land, and even a chapter that reads like a monologue.</p><p>As a point of comparison, Laura Gallego’s <em>Guardians of the Citadel</em> scaffolds its mysteries and monsters with a patience that rewards curiosity; ACOTAR gestures toward something similar but often prioritizes romantic beats over mystery architecture. I don’t think I’m the primary audience, and that’s fine.</p><p>And yet I read the second book, and I’ll probably read the rest of the series. There’s enough here to keep me curious about where the fantasy thread goes, especially reading alongside my wife.</p><h2 id=\"what’s-next?\">What’s Next?</h2><p>If 2025 taught me anything, it’s stepping out of my comfortable zone is worth it. Not everything will be great, but it all adds to my reading history.</p><p>Forget the numbers, I’m chasing books that make me think and remind me why I fell back in love with reading.</p><p>Happy reading in 2026! 🎉</p>",
      "date_published" : "2025-12-29T23:00:00Z",
      "id" : "https://alejandromp.com/personal/blog/2025-year-in-books",
      "summary" : "Looking back at 2025, reading kept surprising me. I wandered into new territories, like John Cleaver, tabletop RPG handbooks, and yeah, more Cosmere (because how could I not?).",
      "tags" : [
        "Books"
      ],
      "title" : "2025 year in books",
      "url" : "https://alejandromp.com/personal/blog/2025-year-in-books"
    },
    {
      "authors" : [
        {
          "name" : "Alejandro M. P."
        }
      ],
      "content_html" : "<p>Today I made an exciting discovery: the existence of <code>AnchoredRelativeFormatStyle</code>. An API that, as of the time of writing this, barely appears in search results other than Apple’s sparse documentation. And to my surprise, it’s exactly what I’ve been looking for over the past few years.</p><h2 id=\"the-problem-with-relativeformatstyle\">The problem with RelativeFormatStyle</h2><p>Some time ago, I wrote about <a href=\"https://alejandromp.com/development/blog/an-exploration-of-formatstyle-as-a-dependency/\">An exploration of FormatStyle as a Dependency</a>. Thanks to my team’s work, we pushed that exploration further and developed an interesting solution (which I should write about!).</p><p>During that work, we defined a few date formatters for our application. One of the most interesting ones was a formatter for relative dates.</p><p>In the old times, with Foundation <strong>Formatter APIs</strong>, to get a formatted string for the relative time between two dates, things like “2 days ago” or “Tomorrow”, one could use:</p><pre><code class=\"language-swift\">localizedString(for date: Date, relativeTo referenceDate: Date) -&gt; String \n</code></pre><p>In the new Foundation <strong>FormatStyle APIs</strong>, the way to do that is using the nice fluent API on a date:</p><pre><code class=\"language-swift\">.formatted(.relative(presentation: .named))\n</code></pre><p>The new API looks elegant for quick code and tutorials. However, it becomes problematic when:</p><ul><li>Writing tests</li><li>Creating development previews</li><li>Building features that need relative times between specific dates</li></ul><p>All these issues arise from the same root problem: the <strong>implicit dependency</strong> on the current <code>Date()</code>. In the Formatter API you could pass the two dates but in the new Style API you just call <code>formatted</code> on a single Date, without a way to pass a second one.</p><p>In our system using <a href=\"https://alejandromp.com/development/blog/on-the-new-pointfree-swift-dependencies/\">swift-dependencies</a>, we had workarounds with limitations. While investigating improvements, I stumbled upon <code>AnchoredRelativeFormatStyle</code> - which solves our exact problem by allowing two dates!</p><h2 id=\"where-anchoredrelativeformatstyle-comes-from?\">Where AnchoredRelativeFormatStyle comes from?</h2><p>As someone who watches nearly every WWDC video and stays current with the Swift ecosystem, I was surprised I’d never heard of this API. A Google search returned just a couple of results with no substantial information. Was I imagining things?</p><p>Thankfully, we live in a world in which Foundation is open-source and easily accessible, so I could just go to GitHub and actually check not only the source code for <a href=\"https://github.com/swiftlang/swift-foundation/blob/main/Sources/FoundationInternationalization/Formatting/Date/Date%2BAnchoredRelativeFormatStyle.swift\">AnchoredRelativeFormatStyle</a> but also its <a href=\"https://github.com/swiftlang/swift-foundation/commits/main/Sources/FoundationInternationalization/Formatting/Date/Date%2BAnchoredRelativeFormatStyle.swift\">history</a>. Thanks to that, I could find the Foundation proposal that described the background of this new API: <a href=\"https://github.com/themomax/swift-foundation/blob/maxobermeier/discrete-format-style-proposal/Proposals/0003-discrete-format-style.md\">The DiscreteFormatStyle Protocol</a>.</p><p>The proposal document is very interesting, but it took me a couple of reads to fully grasp it. I recommend you take a read, but its purpose is to improve the existing <code>FormatStyle</code> infrastructure to enhance its performance when used directly in user interfaces, read: SwiftUI.</p><p>If you’re the one implementing SwiftUI Text and accept a string made from a formatter like the ones mentioned above, you can see how the existing API limits your optimizations. Your system needs to keep the UI up to date with an Input, “the relative target date” that doesn’t change, but internally the output (the string) depends on a moving target, “now”, which you don’t have visibility on because remember, the API is <code>tomorrow.formatted(.relative)</code>. This means you don’t have any alternative other than recomputing that formatted string at a high framerate, even if the output of the format will only change once a second or even once a minute.</p><p>This is why the proposal introduced <code>DiscreteFormatStyle</code>, a new protocol that allows <code>FormatStyle</code>s to expose when an output is expected to be different given the style configuration. This allows UI implementors to optimize the refresh rate by asking the style when it needs a refresh to be scheduled.</p><p>Some existing formatters were able to conform to the protocol without problems, but <code>RelativeFormatStyle</code> couldn’t because its current design has <code>Date()</code> as a hidden dependency, which means the <code>format</code> implementation is not a pure function and thus breaks the assumptions of the infrastructure around the styles. From the infrastructure’s point of view, “tomorrow” is constant so it could optimize things, but the fact that every call to format with the same input can give a different output breaks the assumptions. This is a bit tricky to understand until you internalize how format infrastructure works, with the styles themselves being just value types that are suposed to just be a wrapper around a pure function.</p><h2 id=\"discreteformatstyle,-a-weird-style\">DiscreteFormatStyle, a weird style</h2><p>To solve the above, <code>DiscreteFormatStyle</code>’s design is a bit backwards from what we would expect. A naïve design for this API would be to have a parameter for the “now” in the style itself, something like:</p><pre><code class=\"language-swift\">tomorrow.formatted(.relative(to: now))\n</code></pre><p>That would solve the problem I have with the existing relative style by removing the implicit dependency on <code>Date()</code>. But it wouldn’t solve the problem about performance as, even if now visible to us, the dependency on <code>now</code> that would change all the time wouldn’t be visible to the formatting infrastructure (aka format wouldn’t be a pure function).</p><p>So DiscreteFormatStyle works the other way around. The “anchor”, the data that doesn’t move, is part of the style, and the moving date is instead the instance where format is called on. Something like:</p><pre><code class=\"language-swift\">now.formatted(.relative(to: tomorrow))\n</code></pre><p>But, as stated in the proposal, since this would break expectations from developers, the Foundation team decided to not offer the static method API at all. If you want to use this, you need to construct the FormatStyle yourself, which by the way, as we explored in my previous post, you can do for any <code>FormatStyle</code>, you just need to know the type.</p><pre><code class=\"language-swift\">Date.AnchoredRelativeFormatStyle(anchor: tomorrow).format(now)\n</code></pre><blockquote><p>We explicitly decided against adding such function for Date.AnchoredRelativeFormatStyle as that style always formats its anchor date, and only uses the actual format input as the reference date. Thus any spelling that starts with Date.now.formatted is ultimately misleading or very verbose. - <a href=\"https://github.com/themomax/swift-foundation/blob/maxobermeier/discrete-format-style-proposal/Proposals/0003-discrete-format-style.md#a-static-factory-function-for-dateanchoredrelativeformatstyle\">A static factory function for <code>Date.AnchoredRelativeFormatStyle</code></a>.</p></blockquote><h2 id=\"don’t-tell-them\">Don’t tell them</h2><p>I understand that reasoning, but it also makes this API basically hidden and unknown, which is quite unfortunate. I guess if the main use case is as part of the SwiftUI <code>Text</code> it makes more sense, but for me this API has way more uses than that.</p><p>Since we already have our own bit of sugar on top of FormatStyles that mixes it with swift-dependencies, it’s quite easy for me to improve this API. One could argue if I should do it, but I think for my use cases it’s totally fine.</p><p>If you want to also be naughty, it’s quite easy:</p><pre><code class=\"language-swift\">public struct NaughtyRelativeFormatStyle: FormatStyle {\n    public typealias FormatInput = Date\n    public typealias FormatOutput = String\n    \n    var referenceDate: Date\n    \n    public func format(_ value: Date) -&gt; String {\n        referenceDate.formatted(Date.AnchoredRelativeFormatStyle.init(anchor: value))\n    }\n}\n\nextension FormatStyle where Self == NaughtyRelativeFormatStyle {\n    public static func relative(\n        to referenceDate: Date\n    ) -&gt; Self {\n        NaughtyRelativeFormatStyle.init(\n            referenceDate: referenceDate\n        )\n    }\n}\n\ntomorrow.formatted(.relative(to: now))\n</code></pre><blockquote><p>Of course the code above doesn’t implement all configuration options available on Foundation, but adding them is quite easy. I’ll leave that up to you so you make conscious decisions if you want to be naughty ;)</p></blockquote><h2 id=\"conclusion\">Conclusion</h2><p>This was a very surprising day, I was not expecting to find such a hidden API that actually solved my problems. I still needed to do some work because we still want to use swift-dependencies, but at least I could use a modern API on new OSs and stop relying on the old Formatters. It also made me look again deeply into FormatStyles, which is a very nice API on the usage side but has some fascinating intricacies in its implementation.</p>",
      "date_published" : "2024-10-23T22:00:00Z",
      "id" : "https://alejandromp.com/development/blog/anchored-relative-format-style-the-hidden-replacement-for-date-relative-format-style",
      "summary" : "Today I made an exciting discovery: the existence of <code>AnchoredRelativeFormatStyle</code>. An API that, as of the time of writing this, barely appears in search results other than Apple’s sparse documentation. And to my surprise, it’s exactly what I’ve been looking for over the past few years.",
      "tags" : [
        "Swift"
      ],
      "title" : "AnchoredRelativeFormatStyle: The Hidden Replacement for Date RelativeFormatStyle",
      "url" : "https://alejandromp.com/development/blog/anchored-relative-format-style-the-hidden-replacement-for-date-relative-format-style"
    },
    {
      "authors" : [
        {
          "name" : "Alejandro M. P."
        }
      ],
      "content_html" : "<p>Recently, while watching a <a href=\"https://www.youtube.com/@TsodingDaily\">Tsoding stream</a>, I saw something that piqued my curiosity. Alexey demonstrated how one of his applications could render a video just by calling into the <code>ffmpeg</code> binary directly, instead of using it as a source code dependency or even a static or dynamic library linked to his program.</p><p>This caught my attention because, although I sometimes use binaries as dependencies by calling them (a good example is how I call the tailwindcss binary from <a href=\"https://alejandromp.com/development/blog/back-to-the-basics-with-genesis/\">Genesis</a> to generate this website), it had never occurred to me to use them for something as sophisticated as rendering a video. Calling them to do something they can do on their own, like reading some files and generating output? Sure. But calling a binary and somehow passing the data to generate a video seemed like another level entirely. However, the more I thought about it, the more the idea made sense. After all, it’s all just binary data flowing through pipes. So, I decided to spend some time having fun with the idea.</p><h2 id=\"having-fun-with-process-and-pipe\">Having Fun with Process and Pipe</h2><p>The first part wasn’t very difficult; I just had to refresh my knowledge about the incantation of parameters necessary to ask <code>ffmpeg</code> to generate a video from a few frames, something I hadn’t done in years. With a list of parameters at hand, I started writing some Swift code to spawn a <code>Process</code> to run <code>ffmpeg</code>.</p><pre><code class=\"language-swift\">class FFMPEG {\n    let process = Process()\n    let pipe = Pipe()\n    \n    func startRenderingVideo(width: Int, height: Int, framerate: Int) throws {\n        let resolution = &quot;\\(width)x\\(height)&quot;\n\n        process.currentDirectoryURL = // directory to generate the file\n        process.executableURL = // path to ffmpeg in your system\n        process.arguments = [\n            &quot;-loglevel&quot;, &quot;verbose&quot;,\n            &quot;-y&quot;,\n            \n            &quot;-f&quot;, &quot;rawvideo&quot;,\n            &quot;-pix_fmt&quot;, &quot;rgba&quot;,\n            &quot;-s&quot;, resolution,\n            &quot;-r&quot;, String(describing: framerate),\n            &quot;-i&quot;, &quot;-&quot;, \n            \n            &quot;-c:v&quot;, &quot;libx264&quot;,\n            &quot;-vb&quot;, &quot;2500k&quot;,\n            &quot;-pix_fmt&quot;, &quot;yuv420p&quot;,\n            &quot;output.mp4&quot;\n        ] \n        \n        // set a pipe to feed the frames via standard input\n        process.standardInput = pipe \n        \n        try process.run()\n    }\n</code></pre><p>With this code, I was able to spawn <code>ffmpeg</code> and make it wait on standard input for a bunch of frames. Of course, the parameters can be tweaked as needed, but these worked quite well for what I wanted to try.</p><p>The next part was the interesting one: how to pass the raw bytes that defined each frame. For this, we need to look at the parameters I used. We asked for each pixel to be an <code>rgba</code> color and specified the resolution of each frame. So the next step was to create a buffer of bytes with the correct shape.</p><p>Thankfully, on macOS, we have great solutions as part of our tools. In this case, an old one: <code>CoreGraphics</code>. I could have done this part manually to make it work on other platforms, but since I was just having some fun and wanted to generate images with SwiftUI, I went with the easy solution. It would be interesting to use something else that worked on other platforms and make the bytes manually, but that’s an exercise for another day.</p><p>Here’s the bit of code needed to take the bytes from a <code>CGImage</code> and feed them into the <code>Pipe</code>:</p><pre><code class=\"language-swift\">func sendFrame(_ cgImage: CGImage) {\n    let width = cgImage.width\n    let height = cgImage.height\n    let bitsPerComponent = 8\n    let bytesPerPixel = 4 // RGBA = 4 bytes per pixel\n    let bytesPerRow = bytesPerPixel * width\n    let totalBytes = height * bytesPerRow\n\n    var pixelData = Data(count: totalBytes)\n    pixelData.withUnsafeMutableBytes { (ptr) in\n        let context = CGContext(\n            data: ptr.baseAddress,\n            width: width,\n            height: height,\n            bitsPerComponent: bitsPerComponent,\n            bytesPerRow: bytesPerRow,\n            space: CGColorSpaceCreateDeviceRGB(),\n            bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue\n        )!\n\n        context.draw(cgImage, in: CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height)))\n    }\n\n    pipe.fileHandleForWriting.write(pixelData)\n}\n</code></pre><p>A few things to note in the above code:</p><ol><li>The definition needed for the <code>CGContext</code> needs to match what we’ve told <code>ffmpeg</code> to expect: four bytes per pixel, 1 byte per color component, etc.</li><li>I create a buffer with <code>Data</code> where I ask Core Graphics to draw the image. I would be curious if there was a different way of doing this because I imagine the <code>draw</code> actually needs to iterate through the image, but maybe there is a way to dump its bytes directly. Although that would assume the format is the same, so I guess it wouldn’t be very portable.</li><li>I use the <code>Pipe</code>, which is set as the standard input of the <code>ffmpeg</code> process, taking a file handle and writing the data into it. Through the magic of the operating system, this will transfer the bytes from one process to the other, letting <code>ffmpeg</code> render the frame of the video.</li></ol><p>The last bit we’re missing is very easy: just a way to tell <code>ffmpeg</code> that there are no more frames incoming. Otherwise, it will just keep waiting for more data. To do this, we just <code>close</code> the file handle of the pipe. Then it’s just a matter of waiting for <code>ffmpeg</code> to finish the rendering.</p><pre><code class=\"language-swift\">func endRendering() {\n    pipe.fileHandleForWriting.closeFile()\n    process.waitUntilExit()\n}\n</code></pre><p>And with this, we have a very simple class that will render a video from our given frames. Under the hood, it will use <code>ffmpeg</code>, but our usage code doesn’t have to worry about the intricacies of it anymore.</p><pre><code class=\"language-swift\">class FFMPEG {\n    func startRenderingVideo(width: Int, height: Int, framerate: Int) throws\n    func sendFrame(_ cgImage: CGImage)\n    func endRendering()\n}\n</code></pre><h2 id=\"controlled-animation-with-swiftui\">Controlled Animation with SwiftUI</h2><p>To try this out, I wanted to make a small animation with SwiftUI. From time to time, I have the need to create some animation to support an explanation, but I never do it because reaching for motion graphics software is a big step for something so simple. So I thought this would be the perfect time to make a proof of concept with SwiftUI.</p><p>The idea is quite simple: prove that we can get the frames of an animation that changes the contents of some text and another that moves a shape through the screen.</p><p>The SwiftUI part is, of course, very simple. It’s just a stack with some text and shapes:</p><pre><code class=\"language-swift\">VStack {\n    Text(text)\n        .foregroundStyle(Color.white)\n    Circle()\n        .fill(.red)\n        .frame(width: circleSize, height: circleSize)\n        .offset(x: (circlePosition+circleSize/2) - (width/2))\n    Rectangle()\n        .stroke(.green)\n}\n</code></pre><p><img src=\"https://alejandromp.com/development/blog/using-binaries-as-libraries/ffmpegswiftuiframe.jpg\" alt=\"SwiftUI example frame\"></p><p>Getting an image, specifically a <code>CGImage</code>, from this view was also very trivial thanks to SwiftUI’s <a href=\"https://developer.apple.com/documentation/swiftui/imagerenderer\">ImageRenderer</a>.</p><pre><code class=\"language-swift\">let view = ToRender(text: text, circlePosition: circlePosition, width: width, height: height)\nlet renderer = ImageRenderer(content: view)\nlet cgImage = renderer.cgImage!\nffmpeg.sendFrame(cgImage)\n</code></pre><p>The tricky part was figuring out how to get each frame. I would love to use SwiftUI animations and somehow just get a snapshot for each frame, but I’m not sure there is a trivial way of doing it that allows me to leverage SwiftUI animations. So instead of going down that rabbit hole, I opted to drive the animations myself.</p><p>This means, as you can see in the <code>View</code> above, that I can’t just use <code>animation</code> directly in the views. Instead, I need to manipulate their properties, like the circle offset, and then compute that myself on each frame. Luckily, SwiftUI exposes a few core types for animations that are separate from the view layer, so I can use that instead of letting SwiftUI drive the animation.</p><p>For example, we can use <code>KeyframeTimeline</code> to combine <code>LinearKeyframe</code> and even <code>SpringKeyframe</code> to then request the timeline value at a specific time or progress.</p><p>For the text, it’s quite simple for what I need. Knowing at which frame we are, we can just interpolate to know how many characters of the full text we want to display and just prefix the string up to that index.</p><pre><code class=\"language-swift\">let fullText = &quot;Hello World!&quot;\n// Animate text\nlet tEnd = Double(fullText.count-1)\nlet tkf = KeyframeTimeline(initialValue: 0) {\n    LinearKeyframe(tEnd, duration: 1.5)\n    SpringKeyframe(tEnd/2, duration: 0.5)\n    CubicKeyframe(tEnd, duration: 1)\n}\nlet text = String(\n    fullText.prefix(upTo: fullText.index(fullText.startIndex, offsetBy: Int(tkf.value(progress: progress))))\n)\n</code></pre><p>For the circle that we want to move through the screen, we can use the same technique of using a keyframe timeline and asking it to get us the value at a certain time.</p><pre><code class=\"language-swift\">// Move Circle\nlet startPosition = 0.0\nlet endPosition = Double(width) - 100\nlet keyframes = KeyframeTimeline(initialValue: startPosition) {\n    LinearKeyframe(endPosition/2, duration: 1, timingCurve: .linear)\n    LinearKeyframe(endPosition/3, duration: 1, timingCurve: .easeInOut)\n    SpringKeyframe(endPosition, duration: 1)\n}\nlet circlePosition = keyframes.value(progress: progress)\n</code></pre><p>And with this, I can just make a loop that generates all the frames needed and feeds them into <code>ffmpeg</code>.</p><pre><code class=\"language-swift\">let totalSeconds = 3\nlet totalFrames = framerate * totalSeconds\nfor i in 0..&lt;totalFrames {\n    let progress = CGFloat(i) / CGFloat(totalFrames - 1)\n    // ... generate frame and send to ffmpeg\n}\n</code></pre><h2 id=\"what’s-the-point?\">What’s the Point?</h2><p>The point was to have fun and explore the capabilities of calling binaries as if they were libraries, avoiding the integration cost that comes with source code or libraries in other languages. There’s always a trade-off, and of course, source code or library integration exists for good reasons. I haven’t come out of this thinking binary integrations are always the best solution, but now I have more knowledge and practice with them, so it’s yet another tool in my belt. And that was a goal well accomplished.</p><p>On the other hand, I have a proof of concept for creating videos from SwiftUI, which opens the door to using this technique in the future when I want to explain some things without having to do it with a full-featured video editor. Doing it in SwiftUI and code is actually faster for certain things.</p><p>And finally, I hope I’ve piqued your interest as mine was piqued! Have fun with programming!</p>",
      "date_published" : "2024-10-06T22:00:00Z",
      "id" : "https://alejandromp.com/development/blog/using-binaries-as-libraries",
      "summary" : "Recently, while watching a <a href=\"https://www.youtube.com/@TsodingDaily\">Tsoding stream</a>, I saw something that piqued my curiosity. Alexey demonstrated how one of his applications could render a video just by calling into the <code>ffmpeg</code> binary directly, instead of using it as a source code dependency or even a static or dynamic library linked to his program.",
      "tags" : [
        "Swift",
        "Art of Programming and Paradigms"
      ],
      "title" : "Using Binaries as Libraries",
      "url" : "https://alejandromp.com/development/blog/using-binaries-as-libraries"
    },
    {
      "authors" : [
        {
          "name" : "Alejandro M. P."
        }
      ],
      "content_html" : "<p>Sometimes in command line tools we want to perform some cleanup final task before the process is killed. To detect this scenario we can hook to unix signals like an interrupt and perform the needed work before existing from the process.</p><blockquote><p>This post was originally written in 2016-05-29 as a follow up to <a href=\"https://alejandromp.com/development/blog/state-swift-scripting/\">The state of Swift scripting</a> and discussed my old library <a href=\"https://github.com/alexito4/Trap\">Trap in GitHub</a>. Since then the ecosystem has grown a lot so it has been updated in 2024 with a more modern approach.</p></blockquote><p>In the server ecosystem this is very well handled with the <a href=\"https://github.com/swift-server/swift-service-lifecycle\">swift-service-lifecycle</a> package, that brings support for graceful shutdown to concurrency tasks. What might surprise you is that the same package exposes a module for handling unix signals, without the <em>service</em> part. I find this ideal for command line utilities that don’t require the full service lifecycle of a server application but still want to handle unix signals.</p><p>To include this module you just need to depend on the library, but instead of including the service module into your target, you just include <code>UnixSignals</code>.</p><pre><code class=\"language-swift\">dependencies: [\n        .package(url: &quot;https://github.com/swift-server/swift-service-lifecycle.git&quot;, exact: &quot;2.6.1&quot;)\n    ],\n    targets: [\n        .executableTarget(\n            name: &quot;...&quot;,\n            dependencies: [\n                .product(name: &quot;UnixSignals&quot;, package: &quot;swift-service-lifecycle&quot;)\n            ]\n        ),\n    ]\n</code></pre><p>With this implace you can now import the <code>UnixSignals</code> module and instantiate a <code>UnixSignalsSequence</code> with the specific signals you want to listen to.</p><pre><code class=\"language-swift\">import UnixSignals\n\nfor await signal in await UnixSignalsSequence(trapping: .sigint) {\n    print(signal)\n}\n</code></pre><p>If you run this on your terminal and try to interrupt it with <code>Ctrl + C</code> you will see <code>^CSIGINT</code> printed in the console but the process won’t stop. Now that you are handling the interruption is up to you to actually exit from the process. Isn’t it nice how respecful unix is?</p><blockquote><p>If you want to kill the process use <code>Ctrl + Z</code>. This will send a different signal and since we’re not handling that one it will behave as default and kill the proess. Alternatively just use Activity Monitor and force quit it.</p></blockquote><p>With this power in our hands we can see an example of how it would work. In the following code we spawn two child tasks, one keeps a running counter that increments after a few miliseconds; the other awaits for an interrupt signal to be triggered.</p><pre><code class=\"language-swift\">await withTaskGroup(of: Void.self) { group in\n    group.addTask {\n        var i = 0\n        while true {\n            print(i)\n            i += 1\n            try? await Task.sleep(for: .milliseconds(500))\n        }\n    }\n    group.addTask {\n        for await signal in await UnixSignalsSequence(trapping: .sigint) {\n            print(signal)\n            print(&quot;Shutting down...&quot;)\n            try? await Task.sleep(for: .seconds(2))\n            exit(EXIT_FAILURE)\n        }\n    }\n}\n</code></pre><p>Running this and using <code>Ctrl + C</code> we can see how the running counter keeps printing for a few extra seconds before the process is closed.</p><pre><code class=\"language-swift\">0\n1\n2\n^CSIGINT\nShutting down...\n3\n4\n5\n6\n7\n</code></pre><p>This illustrates how we can have extra time to perform a gracefull shutdown of whatever process we are running.</p><p>If you need something more complex make sure to check out all the capabilities from <a href=\"https://github.com/swift-server/swift-service-lifecycle\">swift-service-lifecycle</a>.</p>",
      "date_published" : "2024-10-02T22:00:00Z",
      "id" : "https://alejandromp.com/development/blog/swift-catch-os-signals-trap",
      "summary" : "Sometimes in command line tools we want to perform some cleanup final task before the process is killed. To detect this scenario we can hook to unix signals like an interrupt and perform the needed work before existing from the process.",
      "tags" : [
        "Swift",
        "Scripting"
      ],
      "title" : "It’s a trap! React to Unix signals in Swift.",
      "url" : "https://alejandromp.com/development/blog/swift-catch-os-signals-trap"
    },
    {
      "authors" : [
        {
          "name" : "Alejandro M. P."
        }
      ],
      "content_html" : "<p>Recently, I finished an almost final draft of my novel and decided it was time to refresh my online presence and website. Instead of starting my next novel, I dove into creating the next version of my site, what I call <code>alejandromp4</code>. And, of course, you know what that means… using a new static site generator! Yes, I have a problem with focusing my hobbies; I’m working on it.</p><h2 id=\"the-status-quo\">The status quo</h2><p>Let me first tell you what was the status quo of the version 3 of my site. It was built using <a href=\"https://github.com/JohnSundell/Publish\">Publish</a>, a static site generator written in Swift. That worked really well as Swift is my favorite language, but more than that, it’s the ecosystem where I spend most of my time. This helps me avoid surprises when I come back to the website after a while, something I disliked about previous incarnations based on Ruby or Node, ecosystems that seem to break every time I had to publish an article.</p><p>But Publish wasn’t all roses. I have some peculiar requirements for my site around taxonomies and multiple sections that needed to work well together. Although Publish offers some customization options, it wasn’t enough. I quickly had to change some things in Publish, which meant discarding the nice defaults that come with it. I also had to fork it and open some internal functions to avoid repeating what was already built. Over time, I grew comfortable with that codebase and my customizations, but it’s not something I want to deal with long-term.</p><p>So, I thought it was a good occasion to look for a replacement.</p><h2 id=\"the-current-sphere\">The current sphere</h2><p>Recently, another contender has appeared: <a href=\"https://github.com/twostraws/Ignite\">Ignite</a>. There are many others, but this one has the backing of a prominent figure in the ecosystem, which has propelled it to popularity. I spent some time with it, and it’s not that dissimilar from Publish—in the end, all static site generators are more or less the same. There are things I really like about Ignite, the documentation and the easy of use is really something to admire. But ultimately I decided it wasn’t for me. In an effort to be easy to use out of the box, it’s too tight for my taste. For example, to build pages, you need to accept using Bootstrap and a DSL, which is nice for a quick start but I needed the flexibility to not only use another CSS system (because I don’t like Bootstrap) but also use existing HTML templates that were already good to go.</p><p>I looked around for a bit and found other interesting alternatives with intriguing declarative systems to build the site in various shapes and forms. This is a trend in modern static site generators—a sort of declarative way of specifying pages and content for the generator. It’s an approach that aims to simplify the configuration process, though it comes with its own set of tradeoffs.</p><p>Ultimately, I came to the conclusion that it was time for me to tackle the problem myself. But I you should probably look at the mentioned projects if you are in need of a static site generator, they are very good and probably more inline what you expect.</p><h2 id=\"why-genesis\">Why Genesis</h2><p>With a few years of experience and having seen how other generators work, I thought it wouldn’t be too hard to make one myself. In fact, the core idea is an old one, as is the name, but I never pursued it because I didn’t want to have something to maintain.</p><p>Now, I was not happy with my current situation, a forked engine with the original project barely maintained. And if I had to switch to another project that, even if promising today, could be abandoned any time, I wouldn’t be in a better position. So the tradeoff of making my own is very different.</p><p>But if I have to build my own site generator, and make sure doesn’t take too much time from writing the actual content for the blog, I need to make a few decisions that will help me now and in the future. So I defined a core rule: simplicity.</p><p>This meant no fancy declarative rule system, no fancy HTML DSL, no building things from scratch, etc. But the best thing came with the realization that doing things that way woudl actually bring me all the benefits that I found lacking in other solutions. It was a win win sitaution!</p><p>So, armed with valor, some open-source code that I could reuse, and a few ideas in my mind, I sat down and started writing requirements to not lose the north star, drawing some diagrams, and finally coding.</p><h2 id=\"the-goals-of-genesis\">The Goals of Genesis</h2><h3 id=\"simplicity\">Simplicity</h3><p>The first overarching goal was to keep things simple—no, really, as simple as possible. I knew that remaking my entire site wouldn’t be trivial; there’s a lot of custom logic and hacks I’ve done to Publish that, although simpler in my own engine, still had to be ported. I also have some peripheral tools for my site (a CLI to work with it and a tool to create posters), but I was confident those could be moved directly.</p><p>I acomplish this by making the API very clear and specific, without fancy declarative engines or DSLs. What you get are just a few small protocols and a bunch of functions that you can call.</p><h3 id=\"flexibility\">Flexibility</h3><p>The other goal was to keep things flexible. Not to make this a fancy library, neither for my future self. It wasn’t flexibility for the sake of flexibility or purity. It was flexibility because I already know that my current requriements have some complexity that requires it.</p><p>Learned from experience, I don’t want to have defaults that become irrelevant as soon as you want to customize things. And if you want to customize things you should be able to reuse as much as the engine as possible without having to fork and make things public. This means that I don’t want to have a single call that does everything, but instead the tools that I can compose as needed.</p><p>Genesis provides APIs to load content and generate pages using simple function calls. It’s procedural on purpose. Just call the functions however you want without being tied to a specific structure or rule system.</p><p>For example there is a <code>clearBuildFolder()</code> function that does what the name indicates. But you don’t have to call it if you don’t want, nobody is forcing you. Of course when publishing your site you probably want to do a clean generation, but while iterating and previewing it? It’s just a waste of time. Or not. You decide. That’s the point.</p><h3 id=\"pages-are-just-strings\">Pages are just Strings</h3><p>What I wanted for sure the new generator to give me was the flexibility to handle pure HTML templates directly so I could take a beautiful template made from a good designer and just use it. But also I knew that eventually I would want to write some HTML in Swift with one of the fancy DSLs that everybody uses. But which one was another story. Beacuse of that I didn’t want to imbue the engine with any DSL, I wanted to engine to just work witht the minimum thing that needed.</p><p>So what’s the only thing that is needed as an input to generate a file that can be served? A <code>String</code>.</p><p>So instead of using a fancy DSL for typed Swift HTML, Genesis simply works with strings. This simplicity opens up a world of possibilities: from using existing HTML templates to experimenting with various Swift HTML DSLs, all without changing the core engine.</p><p>The advantage is that acomplishing this is also very simple, you just need a function that returns a <code>String</code>.</p><pre><code class=\"language-swift\">func render(context: Context) async throws -&gt; String {\n    &quot;&quot;&quot;\n    &amp;lt;html&gt;\n    &amp;lt;body&gt;\n    &amp;lt;p&gt;A page in your site is just a String. Easy.&amp;lt;/p&gt;\n    &amp;lt;/body&gt;\n    &amp;lt;/html&gt;\n    &quot;&quot;&quot;\n}\n</code></pre><p>I know many will be screaming on seeing this, but honestly I’ve written most of this site you are reading in Swift String interpolation and is totally fine. Reusing “components” is just making a separate function that returns a String. It doesn’t get simpler than that.</p><p>And the best thing is that I can easily add fancy DSLs on top. It took me five minutes to add suport for a new DSL library, <a href=\"https://github.com/sliemeobn/elementary\">elementary</a>, and a few more to try another one. Adding support for loading tempalted files is also trivial.</p><h3 id=\"no-web-dependencies\">No web dependencies</h3><p>Something else I didn’t want was to tie the engine to specific web frameworks. For example, I’m not a fan of Bootstrap so that wouldn’t come prebacked. The old version of the site used Bulma but the previous version of <a href=\"https://alejandromp.com/author/\">my author site</a> used TailwindCSS. So the engine doesn’t care what you use, again, it simply expects a <code>String</code>. It’s actually very nice how different requirements coalesce into the same goal.</p><h3 id=\"content-loaders\">Content Loaders</h3><p>Genesis doesn’t automatically load any dynamic content for you. I don’t want it to check specified folders for predefined files. Instead Genesis expects you to define <code>ContentLoader</code>s to load dynamic <code>Content</code>. This allows you to determine what the content is and how it’s loaded, from markdown files, a database, or a remote CMS. The engine doesn’t care about it.</p><p>A simple async function is all you need to implement. Even what “content” is is not defined, you bring your own.</p><pre><code class=\"language-swift\">struct BlogLoader: ContentLoader {\n\tfunc load(context: Context) async throws -&gt; sending [any Content] {\n    let contentDirectory = await context.contentDirectory\n    let contents = try FileManager.default.contentsOfDirectory...\n    return try contents\n        .map { BlogPost(...) }\n}\n</code></pre><h3 id=\"pages\">Pages</h3><p>Besides loading content, the other core concept of the engine is generating pages. A <code>Page</code> is a type that implements the <code>render</code> function explained above. That’s it. This makes it very trivial to generate <em>single</em> pages for your site, things like the <a href=\"https://alejandromp.com/\">Home</a> or the <a href=\"https://alejandromp.com/author/\">Author page</a>.</p><p>But there are pages that need to be rendered multiple times, for each instance ot the loaded content. like each post page. For this the engine has another simple protocol, the <code>PageProvider</code>. And again, it’s a very simple requirement: just return a list of pages.</p><p><code>func source(context: Genesis.Context) async throws -> [any Page]</code></p><h2 id=\"simple-core\">Simple Core</h2><p>These few basic requirements are what defines the core of the engine, which also makes it very easy to understand what’s the process of generation and how you interact with it.</p><ol><li>A <strong>Context</strong> that is passed around and offers a set of functions to manipulate the site.</li><li>All the <strong>Content</strong>, which can be anything, is loaded into the context via <strong>Content Loaders</strong>.</li><li>It renders and writes to disk <strong>Pages</strong> that can be single instances statically given, or</li><li><strong>Page Providers</strong> can instantiate multiple pages from the content loaded in the context.</li></ol><p>And the reality is that that’s all a static site generator is at its core. When you put it this way it’s not that scary anymore. If you even think about it, and I have, half of these protocols are not even necessary from how simple they are. But I found that having them helped me have a better mental model of the core of the engine.</p><h2 id=\"expandable-shell\">Expandable Shell</h2><p>The beauty of this simple architecture is that it let’s me expand it as needed. I already mentioned above that adding a DSL for HTML is very simple, but there is more that can be done.</p><h3 id=\"standard-pages\">Standard Pages</h3><p>To reiterate, the core of the engine doesn’t do anything for you. You might expect RSS, sitemaps, etc to be generated, like it happens in most other engines. But in Genesis that’s up to you, you give it the Pages that you want to generate.</p><p>But of course, RSS and Sitemaps are pages that are quite standarized. So for that I took some code from other engines and with a few tweaks it was working fine for my site. Now this are bundled in the library but is up to you to use them or not.</p><h3 id=\"markdown\">Markdown</h3><p>This is probably the first thing anybody wants from a static site generator, so you might be surprised that, again, Genesis doesn’t have support for Markdown fiels embeded into its core. That’s another consious decision. I don’t want to make the mistake of embeding a specific library that will make things harder for the sites build with the engine, I’ve already suffered that in the past.</p><p>But agian, Genesis doesn’t need this embeded, it can be added from the outside because its core is very small. In my case I’ve decided to use <a href=\"https://github.com/apple/swift-markdown\">Apple’s swift-markdown</a> because I think is a safe bet nowadays, it makes it trivial to parse Markdown fiels intro an AST that then can be transofrmed into a String.</p><p>Crucially, the fact that I have full control over the Markdown library used and of the type that defines a “post” means that it has been trivial to do something that I’ve found impossible in any other site: keep the markdown tree trough all stages of generation so it can be tweaked in specific pages. This small thing has given me so many headaches over the years that I can’t believe how trivial it is in Genesis. Just because you have the contorl and you decide.</p><h2 id=\"conclusion\">Conclusion</h2><p>By going back to the basics, I’ve been able to build a static site generator and port my site to it in a few days. I love how Genesis offers a flexible and powerful static site generator that doesn’t constrain you to specific workflows or technologies. It’s designed to grow with your needs, allowing for simple setups for basic sites and complex configurations for more demanding projects.</p><p>I haven’t optimized for a marketable experience, so while it may not have the polish or out-of-the-box features of some popular generators, Genesis provides the building blocks for creating exactly the site you want, without fighting against pre-defined structures or limitations. It’s a testament to the power of simplicity and the value of understanding your tools from the ground up.</p><p>Of course this is just the begining, I will continue to develop and use Genesis for my own site, I’m excited to see how it evolves. I already have a few ideas to add more batteries while keeping the core simple.</p><p>And finally, this is not a sales pitch. As I said at the start, you probably want to check the existing generators with bigger communnities, but if you like things simple and are not afraid of getting your hands dirty you can try <a href=\"https://github.com/alexito4/Genesis\">Genesis</a>. Or maybe if you want to build your own this post can serve as a guide and word of advice: keep it simple!</p>",
      "date_published" : "2024-09-24T22:00:00Z",
      "id" : "https://alejandromp.com/development/blog/back-to-the-basics-with-genesis",
      "summary" : "Recently, I finished an almost final draft of my novel and decided it was time to refresh my online presence and website. Instead of starting my next novel, I dove into creating the next version of my site, what I call <code>alejandromp4</code>. And, of course, you know what that means… using a new static site generator! Yes, I have a problem with focusing my hobbies; I’m working on it.",
      "tags" : [
        "Swift",
        "Project",
        "Release",
        "Meta"
      ],
      "title" : "Back to the basics with Genesis",
      "url" : "https://alejandromp.com/development/blog/back-to-the-basics-with-genesis"
    },
    {
      "authors" : [
        {
          "name" : "Alejandro M. P."
        }
      ],
      "content_html" : "<p>In my team, one of the things we always make sure of is keeping our dependencies up to date. We rarely add a dependency without a really good reason, and when we do, we assign an <strong>owner</strong> who is responsible for keeping it up to date.</p><p>These owners always keep an eye on new updates and make it a task to go through them as soon as they can. But sometimes, life happens, and one is not able to keep up with it. More often than not, though, the reason why updates are not kept on top of is simply because people forget that it’s part of their responsibilities. And I don’t blame them. Right now, we just keep the list of owners in our documentation site, which is a very passive way of doing things.</p><p>Instead, we’ve now worked on upgrading our nightly notification system! This existing system not only runs a set of unit tests and sends a message with test results and code coverage to our Slack channel, but it also shows a list of outdated dependencies thanks to <a href=\"https://github.com/kiliankoe/swift-outdated\">swift-outdated</a> and some custom code on top that is part of our development tools.</p><p>So before, the message just included a table of dependencies to be updated:</p><blockquote><p>| Package | swift-composable-architecture | 1.12.0 | 1.13.0 | ❗️</p></blockquote><p>But now, we’ve added an <code>@mention</code> to the Slack user responsible for each outdated dependency.</p><p>In my experience, the reason updates often get neglected is simply because it can be easy to forget or overlook tasks that aren’t constantly in front of you. That’s why I think having a <a href=\"https://www.youtube.com/watch?v=XtwsJtdH_q8\">system for notes</a> is so crucial - it helps keep important responsibilities top-of-mind. By providing a daily reminder of the pending updates they are responsible for, our new notification system has helped streamline things for the whole team.</p><p>Doing this is very simple since we already have the infrastructure in place. It was just a matter of having a mapping between dependencies and Slack users in our CLI. Something very low-tech that does the work 👌</p><p>But there was an extra requirement of mine: if there is a dependency we don’t have an owner for, we send an error on the nightly message so we can assign one immediately. This is something not trivial with <code>swift-outdated</code> because that reads from the <code>.resolved</code> file, which has no information about what dependencies are transitive and which ones are directly yours. Since in our opinion, we don’t want to be bothered with transitive dependencies, we don’t assign owners to those.</p><p>To solve this, we read the <code>Package.swift</code> file in advance so we know what the direct dependencies are. This is quite simple, as SPM makes it very easy to get a JSON representation of a package: <code>swift package dump-package</code>.</p><p>So work done.</p><p>But you know me, I can’t avoid getting excited with interesting ideas. So when discussing how to approach this with my team, I wondered… what if we could attach the owner to the dependency itself in the <code>Package.swift</code> file? Well, hold my glass of water.</p><p>Something I love about SPM is that the package definition file is actually a Swift file, which means you can run Swift code in it. Thanks to that, we can add an API to the <code>PackageDescription</code> that does what we want.</p><pre><code class=\"language-swift\">.package(url: &quot;...&quot;, exact: &quot;...&quot;, owner: &quot;...&quot;)\n</code></pre><p>To do this, we can extend the SPM types with our own method. Of course, we need to return what SPM expects.</p><pre><code class=\"language-swift\">extension PackageDescription.Package.Dependency {\n    static func package(\n        url: String,\n        exact version: Version,\n        owner: String\n    ) -&gt; PackageDescription.Package.Dependency {\n        .package(url: url, exact: version)\n    }\n}\n</code></pre><p>So what’s the point of having the new parameter if it’s not used? Well, that we have it in the same source, and we can extract it from it!</p><p>But if you’re thinking SPM dump will include this data, well, of course, it won’t, as SPM doesn’t know anything about it. That doesn’t mean we can’t get it; it just means it’s our work. For things like this, I always rely on <a href=\"https://github.com/pointfreeco/swift-parsing\">swift-parsing</a>, and it makes it quite easy to extract the dependency from the file.</p><p>This is the output of running a quick proof of concept command line:</p><pre><code>▶ ./spm-owners Package.swift\n┏━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┓\n┃   ┃ Package                       ┃ Version  ┃ Owner    ┃\n┃   ┃ &lt;String&gt;                      ┃ &lt;String&gt; ┃ &lt;String&gt; ┃\n┡━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━┩\n│ 0 │ swift-parsing                 │ 0.13.0   │ Martin   │\n│ 1 │ swift-argument-parser         │ 1.5.0    │ Martin   │\n│ 2 │ swift-composable-architecture │ 1.13.0   │ Alex     │\n└───┴───────────────────────────────┴──────────┴──────────┘\n</code></pre><p>I love this! Having the ownership data as part of the <code>Package.swift</code> makes it so things are always in sync and up to date.</p><p>Of course, I’m not using this in our team’s system because it’s not production-ready; it’s just a proof of concept. The big disadvantage is that although <code>Package.swift</code> can run Swift code, you can’t just import a separate package, which means one would have to copy-paste the code above manually. Not a deal-breaker, but not nice enough for me to want to maintain 😂 If one could import in the package file, it would be another story.</p><p>So there you go, an interesting proof of concept that has kept me entertained for a couple of hours. I wish SPM would officially support a way to accomplish this, somehow to be able to add metadata and that it would be kept for other tools. And maybe there is, and I’m not aware?</p><p>You can check out the proof of concept on the <a href=\"https://github.com/alexito4/spm-owners\">spm-owners</a> repo, if you are curious.</p>",
      "date_published" : "2024-08-20T22:00:00Z",
      "id" : "https://alejandromp.com/development/blog/swift-package-manager-dependency-owners",
      "summary" : "In my team, one of the things we always make sure of is keeping our dependencies up to date. We rarely add a dependency without a really good reason, and when we do, we assign an <strong>owner</strong> who is responsible for keeping it up to date.",
      "tags" : [
        "Swift",
        "Swift Package Manager"
      ],
      "title" : "Swift Package Manager Dependency Owners",
      "url" : "https://alejandromp.com/development/blog/swift-package-manager-dependency-owners"
    },
    {
      "authors" : [
        {
          "name" : "Alejandro M. P."
        }
      ],
      "content_html" : "<p>Every year SwiftUI improves and gets new modifiers that widen the capabilities of the framework. Sadly, as responsible developers, we can’t use any of that because our users take some time to update and, as opposed to the Android ecosystem where Jetpack Compose is just a library embedded in every app, we need to wait for our users to be at the required minimum OS version to enjoy those fancy new tools.</p><p>Or do we?</p><p>There are some APIs that are easy to backport if we disregard the old versions of the operative system. This may sound awful but is a valid option that is worth considering. If you know you have a very little percentage of users stuck in the old version but you can’t get rid of it because of some policy, you know the impact is very minimal. And if time has already passed and dropping that version is on the horizon, you have more reason to plan for the future and not get stuck in the past. No matter what, we should make sure that users in the old version don’t get a bad experience. It may be not perfect and modern, but they still should be able to use the app properly. It’s a case-by-case decision that you have to take, but if you do, here is how to pull it off.</p><h2 id=\"porting-swiftui-modifiers\">Porting SwiftUI modifiers</h2><p>We will focus on SwiftUI modifiers. Other features can also be backported, but they will need slightly different techniques. The one for modifiers is actually quite simple, but it always depends on the functionality itself.</p><p>I’ve used this technique for a while, and it works very well. For example the <a href=\"https://developer.apple.com/documentation/swiftui/view/scrolldismisseskeyboard(_:)\">scrollDismissesKeyboard(_:)</a> modifier is a good candidate. It’s a new functionality that users in old versions won’t miss because they wouldn’t have access to it before (well, unless your app was UIKit, again, case by case).</p><p>The first step is to copy the modifier function signature in your own <code>View</code> extension:</p><pre><code class=\"language-swift\">extension View {\n    /// docs...\n    @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)\n    public func scrollDismissesKeyboard(_ mode: ScrollDismissesKeyboardMode) -&gt; some View\n}\n</code></pre><p>I also recommend copying over the documentation so the users of this function will get all the info they need. Just as if it was the legitimate function from the framework.</p><p>Conceptually the implementation of the function is very simple: we check in which OS version we are and we call the SwiftUI function when available and fallback to something else when not. This applies to all modifiers you want to port, but there are two things to consider.</p><h2 id=\"fallback-on-old-os\">Fallback on old OS</h2><p>What do you do as a fallback? That’s the first question to consider and it will depend on the feature, its complexity, how much worth it is for you and a multitude of other aspects, technical and non-technical. This is the “case by case” part of this work.</p><p>In this example, the fallback is simply to do nothing. And that works for many cases, really. To implement this, we just need to return self in the old version so our modifier does nothing with the view.</p><pre><code class=\"language-swift\">if #available(iOS 16.0, *) {\n    self\n        .scrollDismissesKeyboard(mode)\n} else {\n    self\n}\n</code></pre><p>That’s what we want, easy, right? Well, remember that for this to work, we need to opt in this function into the ViewBuilder DSL. Check out <a href=\"https://alejandromp.com/blog/viewbuilder-vs-anyview/\">ViewBuilder vs. AnyView</a> for more details about this.</p><p>If your modifier didn’t take any parameters, that would be all you needed, but that’s rarely the case.</p><h2 id=\"backport-parameters\">Backport parameters</h2><p>Most modifiers take some parameters to adjust its functionality. Those types are often new and thus not present in old versions, so we need to port those too.</p><p>The process for that is similar, just copy the existing types into your own file.</p><pre><code class=\"language-swift\">public struct ScrollDismissesKeyboardMode {\n    public static var automatic: ScrollDismissesKeyboardMode { get }\n    public static var immediately: ScrollDismissesKeyboardMode { get }\n    public static var interactively: ScrollDismissesKeyboardMode { get }\n    public static var never: ScrollDismissesKeyboardMode { get }\n}\n</code></pre><p>Now that’s a fine way to start, but I find it significantly easier to work with an <code>enum</code>. That way, you don’t have to worry about internal implementations or storing any data. Furthermore, I also recommend prefixing the name of the type so is distinct from the SwiftUI one. This is not strictly necessary, but I’ve found that makes the compiler way happier as it helps distinguish the functions easily. It’s also a hint to the user of the function that there are some shenanigans going on.</p><p>So let’s convert that type to an enum instead:</p><pre><code class=\"language-swift\">public enum MyScrollDismissesKeyboardMode {\n    case automatic\n    case immediately\n    case interactively\n    case never\n}\n</code></pre><p>This makes it so the usage side can use the same dot syntax and names as with the original function while using our custom types. But we still need to give SwiftUI its own types, so I always add a property to do the conversion.</p><pre><code class=\"language-swift\">// MyScrollDismissesKeyboardMode\n    @available(iOS 16.0, *)\n    var swiftUI: ScrollDismissesKeyboardMode {\n        switch self {\n        case .automatic: return .automatic\n        case .immediately: return .immediately\n        case .interactively: return .interactively\n        case .never: return .never\n        }\n    }\n</code></pre><p>This property is only available in the new OS since when we call it we will be in the correct version.</p><p>Now we can switch the function to use the new type.</p><pre><code class=\"language-swift\">  @ViewBuilder func scrollDismissesKeyboard(\n      _ mode: MyScrollDismissesKeyboardMode\n  ) -&gt; some View {\n      if #available(iOS 16.0, *) {\n          self\n              .scrollDismissesKeyboard(mode.swiftUI)\n      } else {\n          self\n      }\n  }\n</code></pre><p>And that’s the function fully back ported. We take our custom parameter, and if we are in the new version, convert it to the SwiftUI one and call the real modifier. Otherwise, we just return the view unmodified.</p><h2 id=\"using-our-function\">Using our function</h2><p>With this, the usage side would look exactly as with the real code.</p><pre><code class=\"language-swift\">SomeView()\n  .scrollDismissesKeyboard(.immediately)\n</code></pre><p>For that to work you just need to make sure your function is in your module, or if it’s in a separate package (which I always <a href=\"https://alejandromp.com/blog/ios-app-architecture-in-2022\">recommend</a>) you need to import it in your file.</p><p>This works because your code always takes precedence over system frameworks. This means the compiler will pick your custom function and types before SwiftUI’s.</p><h2 id=\"obsoleted\">Obsoleted</h2><p>Another very useful tip is to use an availability annotation to ensure the compiler will remind you to remove this code when your minimum target matches the one that introduced the modifier. For this, we can add an <code>@available</code> with the <code>obsoleted</code> parameter.</p><pre><code class=\"language-swift\">@available(iOS, obsoleted: 16.0, message: &quot;SwiftUI.View.scrollDismissesKeyboard is available on iOS 16.&quot;)\n</code></pre><p>This is very helpful and makes the compiler work for you.</p><h2 id=\"pick-your-battles\">Pick your battles</h2><p>Just like deciding what to do as a fallback based on tradeoffs, choosing which parameters and features to support is important too.</p><p>For example, if we want to port <a href=\"https://developer.apple.com/documentation/swiftui/view/presentationdetents(_:)\">presentationDetents(_:)</a> we will find out is a bit more complex. The custom detent needs some generic work that might not be trivial, so when you see that sort of complexity you can decide to just ignore it. Remember that this code is for you. It’s likely that you are not trying to make a full featured framework for the entire world, so if you don’t need some functionality right now, just skip it. By the time you need it, you will use the native solution already.</p><p>So in this case, you may decide to simplify the detents you offer:</p><pre><code class=\"language-swift\">public enum MyPresentationDetent: Hashable {\n    case medium\n    case large\n    case fraction(_ fraction: CGFloat)\n    case height(_ height: CGFloat)\n    // no custom detent supported\n}\n</code></pre><h2 id=\"more-than-back-porting\">More than back-porting</h2><p>This technique is not only useful to back port functionality to older OSs but also to improve functionality and make it work the way you want. You can use the same technique, ignoring the version checks, and that will give you a way to offer more reusable parameters and options and to have a reusable place to include extra logic, all of that while keeping the same syntax as the system.</p><p>This is beneficial because it allows your code to be easily adjusted when Apple adds new options natively, saving you from having to completely refactor multiple views.</p><h2 id=\"conclusion\">Conclusion</h2><p>This technique is just another tool on your belt. It’s up to you to decide when is appropriate to use it, but I recommend you to think about it. There are plenty of times when we could use modern APIs with little problem while keeping compatibility with older systems.</p><p>Just have it in mind, analyze the tradeoffs and decide.</p>",
      "date_published" : "2023-10-19T22:00:00Z",
      "id" : "https://alejandromp.com/development/blog/backport-swiftui-modifiers",
      "summary" : "Every year SwiftUI improves and gets new modifiers that widen the capabilities of the framework. Sadly, as responsible developers, we can’t use any of that because our users take some time to update and, as opposed to the Android ecosystem where Jetpack Compose is just a library embedded in every app, we need to wait for our users to be at the required minimum OS version to enjoy those fancy new tools.",
      "tags" : [
        "Swift",
        "SwiftUI"
      ],
      "title" : "Backport SwiftUI modifiers",
      "url" : "https://alejandromp.com/development/blog/backport-swiftui-modifiers"
    },
    {
      "authors" : [
        {
          "name" : "Alejandro M. P."
        }
      ],
      "content_html" : "<p>It’s time to review and reflect about Yumi and the Nightmare Painter, the third instalment in The Year of Sanderson and the second in the Cosmere. This will probably become one of my favorite books of the Cosmere and be in my top tier of fiction books.</p><p>Rating: <a href=\"https://www.goodreads.com/review/edit/60531416\">5/5</a></p><p>I haven’t experienced many world-hopping narratives, so what might come across as a trope to some was very appealing to me. The execution of the plot is brilliant, especially the dynamic where the two main characters need to learn to live each other’s life. It is fascinating how Painter, who comes from a modern world akin to ours, needs to understand and follow the constraints of a world made by doctrine. In contrast, we observe Yumi thrust into a more open-minded world as she navigates her inner suppression and confronts her fears.</p><p>The asymmetry goes further than that. Painter having to live in Yumi’s body initiates a subtle, yet impactful, conversation about the experience of living in a body that doesn’t align with one’s identity, a conversation still too often silenced in our society. On the other hand, Yumi keeping her own body introduces a more conventional underpinning of deception to the plot. The contrast between these two representations of what is living in a foreign world operates on many levels that are fascinating to read. The origin of these differences and the magic behind them are a mystery that keeps you hooked until the end.</p><p>Setting aside the fantasy and magic of the story, which in typical Sanderson’s fashion is one of the best ones I’ve experienced, what resonated deeply with me was the core theme of personal growth. The journey of self-improvement that forces the characters to open their mind and shift their perspective, dismantling their own dogmas and fears. Among the many narrative threads, a particular subplot I really liked was about meditation. What started as a push for the other character to do meditation in the only correct way evolved into the understanding that each person is different and has its own path to tranquility, of emptying their mind or even of not emptying it at all. Coming to terms with the difference between each individual was an enriching and fulfilling experience.</p><p>The other part I liked a lot was the intricacies of the love story. As I often tell my wife, love finds its way in every tale, even in those that don’t exactly belong to the romance genre. I’ve never been captivated by the romance genre, but I still find myself seduced by the deep connections that love weaves into every story. With “Yumi and the Nightmare Painter”, while the main plot is about figuring out what happened and how to fix it, through the course of the story we see how two strangers naturally bound thanks to mutual understanding and sharing their fears. This evolution culminates in a lovely relationship that plays an integral part of the climax. It’s a delight to see the author be less shy with certain scenes and letting these human aspects arise n the story.</p><p>Now, the question that everybody asks: Is this book enjoyable if you haven’t read the Cosmere? Well, I can’t tell you for sure because for me the Cosmere is the main reason I love Sanderson’s work. I’m a sucker for interconnected stories and worlds and is where a big part of my enjoyment comes from. Yet, I truly think that you can enjoy this, and all other stories, without being an expert in Realmatic Theory. The story stands on its own and is self-contained. You will miss on the occasional nod to the rest of the Cosmere but that is normal and expected. So don’t ignore this book just because of this.</p><p>I want to conclude with the only point of critique I have about this book, a concern that involves none other than our beloved and mischievous Hoid. The voice of the book being narrated by Hoid is very interesting. It feels different from other occasions while still being clearly Hoid. This narrative choice worked really well. However, I have to admit that there are a couple of moments where the exposition runs for a bit too long, even for me. This was particularly notable towards the end of the story, when the prose stops showing the events and just lets the narrator tell us exactly what happens in what feels like an “info dump”. Granted, it makes sense from the narration point of view, as Hoid is narrating the story to somebody in the Cosmere, but while I was immersed in the story this pulled me out of the fiction immediately and made me ask: am I accepting this because the author is Brandon Sanderson? Would I accept it from another author lacking the same level of reputation?</p><p>The immediate answer that came to mind was that I would have been more critical of another author. However, after some introspection, I realised how I ignored a critical factor: context. Brandon Sanderson has not only carved his reputation through the years, but directly made a tremendous impact on me. Of course I can accept a bit of whimsicalness from time to time if that’s what he enjoys. I shouldn’t forget that’s precisely why I baked the secret projects and why I love them so much!</p><p>In summary, a resounding endorsement. Must read. Cosmere pinnacle, and top fantasy book.</p>",
      "date_published" : "2023-07-17T22:00:00Z",
      "id" : "https://alejandromp.com/personal/blog/yumi-and-the-nightmare-painter-review",
      "summary" : "It’s time to review and reflect about Yumi and the Nightmare Painter, the third instalment in The Year of Sanderson and the second in the Cosmere. This will probably become one of my favorite books of the Cosmere and be in my top tier of fiction books.",
      "tags" : [
        "Books"
      ],
      "title" : "Yumi and the Nightmare Painter, review",
      "url" : "https://alejandromp.com/personal/blog/yumi-and-the-nightmare-painter-review"
    },
    {
      "authors" : [
        {
          "name" : "Alejandro M. P."
        }
      ],
      "content_html" : "<p>The conclusion of the Remembrance of Earth’s Past Trilogy comes to an end. The story has come far since those first chapters of The Three-Body Problem so focused on Chinese history that one had to read translators comments to understand the intention of the words. Now, after many years after the crisis began, humanity lives in a completely different world, confronting threats that were unimaginable before.</p><p>I really like how we see the conclusion of some of the recurrent characters, some who I ended up despising and others that I felt close to. But the character that makes more progress is humanity itself. The challenges that face humanity in this story are eye opening. The author not only presents a sci-fi story to spark a wild imagination, but it also solidifies philosophical questions and shows the possible consequences of our naïve space exploration.</p><p>For years, we have been wondering if we are alone in the universe, if there is life beyond the heat of our sun. And assuming we are not as special as we usually pretend, why we haven’t found it yet. Remembrance of Earth’s Past puts us in a fictional result of those thoughts. It tells us what could happen if we keep asking those questions instead of asking if we should even look for other civilizations. Maybe we should do everything we can to make ourselves invisible in the vastness of the dark forest.</p><p>Seeing the consequences of the Dark Forest theory, introduced in the previous book, and put beyond what I could have imagined, is also great. The author keeps surprising me with additional levels of power and technology. I always wonder how much further can power go in a science-fiction scenario. I’m used to see fantasy grow the power to extremes, but how can you do that with hard science and technology? The technologies and fundamental theories explored by this series are mind blowing. From new forms of communication to technology to travel at light-speed, even higher dimensional physics, the series explores mind-blowing concepts that will make me read additional explanations and summaries to grasp fully.</p><p>As with everything I read, some details run wild in my imagination, morphing and growing to eventually settle in some part of the <a href=\"https://pulubiworlds.com\">Pulubi</a>. It’s what I love the most about reading, how it fuels the infinite machine of imagination to bring into existence new forms.</p><p>One concept that fascinated me the most is to see how the author raises the question of what makes us humans. During the events of the book, we find on multiple occasions when humanity itself considers how some fellow humans have lost their humanity, how they are <em>something else</em> entirely. I often find thoughts like this come from brutal acts that dehumanize the characters in question or from a scientific long evolution that departs the physical form from the original. But in this case, the author makes us question how seemingly small acts and just a bit of time can come to question if those committing them are still one of us. Seeing how being isolated in a spaceship in an unreachable part of the universe makes one question their relation to humanity. Or how stuffing the entire human race in a confine space and make it fight for survival to the extreme would make us consider if those taking part in such survival <em>game</em> are still human.</p><p>Overall, I really liked the book and the series. Very thought provoking, with plenty of ideas that will keep echoing in the back of my mind for a long time.</p><p>Now let’s see how the adaptations fare in comparison.</p>",
      "date_published" : "2023-07-03T22:00:00Z",
      "id" : "https://alejandromp.com/personal/blog/death-s-end-review",
      "summary" : "The conclusion of the Remembrance of Earth’s Past Trilogy comes to an end. The story has come far since those first chapters of The Three-Body Problem so focused on Chinese history that one had to read translators comments to understand the intention of the words. Now, after many years after the crisis began, humanity lives in a completely different world, confronting threats that were unimaginable before.",
      "tags" : [
        "Books"
      ],
      "title" : "Death's End - The naïveté of humankind, review",
      "url" : "https://alejandromp.com/personal/blog/death-s-end-review"
    }
  ],
  "language" : "en",
  "title" : "All articles - Alejandro M. P.",
  "version" : "https://jsonfeed.org/version/1.1"
}