<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title></title>
    <description>Personal Blog where I write about things I learn or discover.</description>
    <link>https://muhammadraza.me/</link>
    <atom:link href="https://muhammadraza.me/feed.xml" rel="self" type="application/rss+xml"/>
    <pubDate>Thu, 06 Aug 2026 05:31:34 +0000</pubDate>
    <lastBuildDate>Thu, 06 Aug 2026 05:31:34 +0000</lastBuildDate>
    <generator>Jekyll v3.10.0</generator>
    
      <item>
        <title>What Is an Agent Harness?</title>
        <description>
          <![CDATA[
            
            <p>An agent harness is the software wrapped around a language model that turns it into an agent: the loop that calls the model repeatedly, the tools it can execute, the context management that decides what the model sees, and the guardrails that decide what it’s allowed to do. The model predicts text. The harness is everything that makes those predictions add up to work getting done.</p>

<p>Anthropic’s Claude Code documentation puts it in one line: <a href="https://code.claude.com/docs/en/glossary">“Claude Code is the harness; Claude is the model inside it.”</a> OpenAI uses the same word for the shared execution core behind every Codex surface (CLI, IDE extension, web), all of them <a href="https://openai.com/index/unlocking-the-codex-harness/">powered by the same Codex harness</a>. When both labs independently settle on a term, it’s worth pinning down what it means.</p>

<p>One thing it doesn’t mean: <a href="https://www.harness.io/">Harness.io</a>, the CI/CD and software delivery company. Same word, coincidental collision, unrelated domain. If you searched “harness devops” and landed here expecting deployment pipelines, that’s them. This post is about the LLM concept — though if you build deployment pipelines for a living, stick around, because you already have most of the mental model.</p>

<h2 id="model-scaffold-harness-agent">Model, Scaffold, Harness, Agent</h2>

<p>These four words get used interchangeably, and they shouldn’t be. A <a href="https://huggingface.co/blog/agent-glossary">Hugging Face glossary essay from May 2026</a>, itself a sign the terminology had gotten muddy enough to need one, draws the lines this way:</p>

<ul>
  <li><strong>Model</strong>: the LLM. It can <em>express intent</em> to call a tool. It cannot execute anything.</li>
  <li><strong>Scaffold</strong>: the behavioral configuration. System prompt, tool descriptions, how responses get parsed, what carries over between steps.</li>
  <li><strong>Harness</strong>: the execution layer. It calls the model, runs the tool calls, feeds results back, and decides when to stop.</li>
  <li><strong>Agent</strong>: model + harness. Something that acts, not just responds.</li>
</ul>

<p>In casual usage “harness,” “scaffold,” and “framework” blur together, and for a blog post that’s usually fine. The distinction that actually matters is model versus everything-else, because the everything-else is where most of the engineering lives — and, as we’ll see, where a surprising amount of benchmark performance comes from.</p>

<h2 id="where-the-term-came-from">Where the Term Came From</h2>

<p>“Harness” is old software vocabulary. A <em>test harness</em> is code that sets up conditions, drives the thing under test, and scores the output. LLM research inherited that sense directly: EleutherAI’s <a href="https://github.com/EleutherAI/lm-evaluation-harness/">lm-evaluation-harness</a>, started in the GPT-3 era, became the standard way to benchmark models and the backend of Hugging Face’s Open LLM Leaderboard. In that world the harness was deliberately boring: standardized scaffolding, so that what you measured was the model, not an accident of prompt engineering.</p>

<p>Then models learned to use tools, and the word migrated. Once an LLM is taking actions in a real environment, the code that drives it stops being a measurement device and becomes a runtime. A datable marker of the shift: in September 2025, Anthropic <a href="https://claude.com/blog/building-agents-with-the-claude-agent-sdk">renamed the Claude Code SDK to the Claude Agent SDK</a>, on the reasoning that “the agent harness that powers Claude Code can power many other types of agents, too.” By early 2026 the term was everywhere. Mitchell Hashimoto’s <a href="https://mitchellh.com/writing/my-ai-adoption-journey">February 2026 post</a> crystallized “harness engineering” as a named practice, and <a href="https://openai.com/index/harness-engineering/">OpenAI published an essay with that exact title</a> the same month, describing a team that shipped a product with zero manually-written code. Their framing of the new engineering job: “design environments, specify intent, and build feedback loops.”</p>

<p>A fact-check aside, since I nearly repeated this myself: the term is often attributed to Andrej Karpathy. His widely-cited <a href="https://karpathy.bearblog.dev/year-in-review-2025/">2025 year in review</a> doesn’t contain the word “harness” at all. The vocabulary came out of the labs and the eval community, not a single coinage.</p>

<h2 id="whats-actually-inside-a-harness">What’s Actually Inside a Harness</h2>

<p>Every serious harness is built around the same loop (gather context, take action, verify the result, repeat), but the implementations differ in revealing ways. A quick tour of the ones I use or have studied:</p>

<p><strong>The loop and tools.</strong> <a href="https://code.claude.com/docs/en/glossary">Claude Code</a> ships file access, shell execution, and search as first-class tools, plus subagents: child instances with their own context window and restricted tool access, so exploration doesn’t pollute the main conversation. Codex runs <a href="https://openai.com/index/unlocking-the-codex-harness/">one shared Rust core</a> under every product surface.</p>

<p><strong>Context management.</strong> The context window is the scarcest resource, and each harness spends it differently. <a href="https://aider.chat/docs/repomap.html">Aider</a> builds a “repo map” — a graph-ranked summary of your codebase’s important symbols, compressed into a token budget, sent with every request. Claude Code compacts: when the window fills, older tool outputs get cleared and the conversation gets summarized. <a href="https://docs.cursor.com/context/rules">Cursor</a> assembles context from a workspace index plus rules files that activate by file-glob.</p>

<p><strong>Memory files.</strong> Nearly every harness converged on the same idea: a Markdown file in your repo that gets injected at session start. Claude Code reads <code class="language-plaintext highlighter-rouge">CLAUDE.md</code>; Codex, Cursor, and twenty-plus other tools read <a href="https://agents.md/"><code class="language-plaintext highlighter-rouge">AGENTS.md</code></a>, now an open format under the Linux Foundation. The harnesses differ; the convention is shared.</p>

<p><strong>Permissions and guardrails.</strong> This is where harnesses look most like infrastructure. Claude Code layers permission rules (deny → ask → allow) over sandboxed shell execution. It’s IAM policy thinking applied to a model’s tool calls.</p>

<p><strong>Hooks.</strong> Deterministic scripts that fire at lifecycle points: before a tool runs, after an edit, at session start. On my machine, a hook rewrites git and other CLI calls through a token-optimizing proxy before they execute, and another injects a reminder to persist session learnings into my local memory store. The model never decides whether those run. That determinism is the point — hooks are the part of the harness you control completely.</p>

<p>If that list reads like a platform engineering backlog (isolation, resource budgets, policy, lifecycle events, observability), that’s not an accident. I’ve argued before that <a href="/2026/harness-engineering-devops-perspective/">harness engineering is a DevOps skill</a>; this is the anatomy behind that claim.</p>

<h2 id="more-harness-isnt-better">More Harness Isn’t Better</h2>

<p>Here’s the part that surprised me. Given how much engineering goes into these harnesses, you’d expect the elaborate ones to decisively beat simple scaffolds. The measured answer is: not reliably.</p>

<p><a href="https://metr.org/notes/2026-02-13-measuring-time-horizon-using-claude-code-and-codex/">METR tested this directly in February 2026</a>, running the same models under production harnesses and under deliberately simple scaffolds. Claude Code against bare-bones ReAct (an agent that just takes an action, sees the result, and repeats) was a statistical coin flip: Claude Code won in 50.7% of bootstrap samples. Codex against METR’s generic Triframe scaffold actually <em>lost</em> most of the time, winning only 14.5% of samples. And <a href="https://github.com/SWE-agent/mini-swe-agent">mini-swe-agent</a>, a harness in roughly 100 lines of Python, scores above 74% on SWE-bench Verified — competitive with systems orders of magnitude more complex.</p>

<p>So the harness doesn’t matter? No, the opposite. Swapping scaffolds changes what the same model scores, which is exactly why METR controls for it when measuring capability. What the elaborate harness buys you just isn’t raw benchmark points. It’s everything a benchmark doesn’t measure:</p>

<ul>
  <li><strong>Safety</strong>: permission gates, sandboxes, and cost caps that make it survivable to let an agent run unattended. A 100-line loop with full shell access benchmarks fine right up until it doesn’t.</li>
  <li><strong>Ergonomics</strong>: memory files, hooks, and skills that encode <em>your</em> project’s conventions, so you stop re-explaining them every session.</li>
  <li><strong>Recoverability</strong>: compaction, session resumption, observable tool traces. The difference between an agent you can debug and one you re-run and hope.</li>
</ul>

<p>One line from a <a href="https://news.ycombinator.com/item?id=48881393">Hacker News thread on harness engineering</a> sums up the practitioner view: “A decent model with a great harness beats a great model with a bad harness.” The benchmark data says the sophistication isn’t free capability. The lived experience says it’s what makes the capability usable. Both are true, and the tension between them is basically the design brief for every harness team right now.</p>

<p>That brief keeps expanding. Anthropic’s latest iteration lets Claude <a href="https://claude.com/blog/a-harness-for-every-task-dynamic-workflows-in-claude-code">generate its own orchestration harness per task</a> — the harness stops being a fixed artifact a human designs once and becomes something the agent composes on the fly. Whether that’s the future or a detour, it tells you where the labs think the leverage is.</p>

<h2 id="why-this-matters-to-you">Why This Matters to You</h2>

<p>If you’re choosing between coding agents, you’re mostly choosing between harnesses. The frontier models are closer to each other than the scaffolding around them is. Compare them on harness terms: how they manage context, what their permission model lets you safely automate, whether their memory and hooks let you encode your conventions once.</p>

<p>And if you build one — even a script that collects CI failure logs, asks a model what broke, and posts the answer to Slack — you’re doing harness engineering. The model is the part you rent. The harness is the part you own, and it’s where your effort compounds. For how to approach building them with the infrastructure skills you already have, see <a href="/2026/harness-engineering-devops-perspective/">Harness Engineering: The DevOps Skill Nobody Told You About</a>.</p>

          ]]>
        </description>
        <pubDate>Thu, 06 Aug 2026 00:00:00 +0000</pubDate>
        <link>https://muhammadraza.me/2026/what-is-an-agent-harness/</link>
        <guid isPermaLink="true">https://muhammadraza.me/2026/what-is-an-agent-harness/</guid>
        
        <category>ai</category>
        
        <category>devops</category>
        
        <category>automation</category>
        
        
        
        <dc:creator>{&quot;name&quot;=&gt;&quot;Muhammad Raza&quot;}</dc:creator>
        <dc:rights></dc:rights>
      </item>
    
      <item>
        <title>I Built a Native macOS Transcription App with Codex in Five Days</title>
        <description>
          <![CDATA[
            
            <p>On July 23, 2026, I opened an empty repository and started building Minute, a
private meeting notetaker for macOS. Five days and 124 commits later, version
0.7.0 was available as a private beta for Apple Silicon and Intel Macs.</p>

<p>I built the application through Codex. I set the product direction, chose the
constraints, reviewed the work, and decided what counted as done. Codex
inspected the repository, planned implementation slices, wrote and revised the
code, ran the test suites, diagnosed failures, captured screenshots, and
prepared the release.</p>

<p>The result is a native desktop application with a React and TypeScript
interface, a Rust backend, live local transcription, local LLM summaries,
system-audio capture, meeting detection, search, playback, accessibility
coverage, and a checksummed private release pipeline.</p>

<p>This is how the work happened, including the parts that broke.</p>

<p><img src="/assets/images/minute/hero.webp" alt="Minute showing a meeting summary, decisions, action items, transcript, and local ask" /></p>

<h2 id="what-minute-does">What Minute does</h2>

<p>Minute records a meeting, transcribes it with Whisper, and turns the transcript
into a summary, decisions, and action items with a local language model. It can
answer questions about a meeting and cite the exact timestamps that support its
answer.</p>

<p>The complete workflow runs on the Mac. Minute has no account system, cloud
backend, analytics service, or local HTTP server. Its only deliberate network
request downloads a model when the user chooses one.</p>

<p>That privacy constraint shaped the product:</p>

<ul>
  <li>Audio, transcripts, summaries, and Markdown stay in ordinary folders on
disk.</li>
  <li>Whisper and llama.cpp run as linked libraries in the application process.</li>
  <li>Fonts ship with the app.</li>
  <li>The content security policy allows no network origin.</li>
  <li>A meeting remains recordable, searchable, and summarizable with Wi-Fi off.</li>
</ul>

<p>Each note stays readable without Minute:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;note-id&gt;/
├── audio.wav
├── transcript.json
├── summary.json
└── note.md
</code></pre></div></div>

<p>The architecture is compact:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>React 19 + TypeScript
          │
    typed Tauri IPC
          │
       Rust process
     ├── microphone capture
     ├── system-audio capture
     ├── whisper.cpp
     ├── llama.cpp
     └── folder-based note store
</code></pre></div></div>

<p>Tauri gave me a webview for the interface and direct access to native Rust code.
The backend uses <code class="language-plaintext highlighter-rouge">cpal</code> for microphone input, ScreenCaptureKit bindings for
system audio, whisper.cpp for speech recognition, and llama.cpp for summaries
and questions. Metal accelerates inference on Apple Silicon.</p>

<p>Keeping these pieces in one process removed a server from the architecture. It
also put audio callbacks, native frameworks, model inference, file persistence,
and a webview inside the same failure surface. That became the hard part of the
build.</p>

<h2 id="what-built-with-codex-meant">What “built with Codex” meant</h2>

<p>I did not ask Codex for the whole application in one prompt.</p>

<p>Each unit of work had a concrete outcome. A typical request included the user
problem, the relevant repository context, the constraints, and the evidence
needed before completion. Codex then worked through the repository and returned
with code, tests, and the result of running them.</p>

<p>A recording task, for example, included requirements such as:</p>

<ul>
  <li>keep the audio callback free from blocking file or model work;</li>
  <li>write recoverable audio even when transcription falls behind;</li>
  <li>show the real microphone name reported by the backend;</li>
  <li>keep pause time out of the meeting duration;</li>
  <li>leave the note usable if summarization fails; and</li>
  <li>test the pure timing, resampling, buffering, and persistence logic.</li>
</ul>

<p>That level of specificity mattered more than prompt length. “Add recording”
invites a demo. “Preserve captured audio when the transcription worker fails”
defines a product behavior.</p>

<p>The working loop stayed consistent:</p>

<ol>
  <li>Describe one user-visible outcome and its constraints.</li>
  <li>Let Codex inspect the current implementation before proposing a change.</li>
  <li>Implement the smallest coherent slice.</li>
  <li>Run focused tests, then the wider verification suite.</li>
  <li>Inspect the diff and exercise the behavior.</li>
  <li>Capture screenshots or native evidence when tests cannot show the result.</li>
  <li>Record the remaining gaps instead of hiding them.</li>
</ol>

<p>This resembles OpenAI’s current <a href="https://learn.chatgpt.com/guides/best-practices">Codex best-practices
guidance</a>, especially its
emphasis on goals, context, constraints, completion criteria, and validation.
In practice, the last item carried the most weight. Codex could move quickly
because every task had to leave proof behind.</p>

<h2 id="the-bugs-that-made-the-workflow-useful">The bugs that made the workflow useful</h2>

<p>The feature list shows what Codex produced. The failures show whether the
workflow could handle real engineering.</p>

<h3 id="appkit-crashed-on-the-detector-thread">AppKit crashed on the detector thread</h3>

<p>Minute can notice that Zoom, Teams, FaceTime, or another meeting app is using
the microphone and offer a small recording prompt. The detector runs on a
background Rust thread so it does not block the interface.</p>

<p>Version 0.5.0 crashed when that thread tried to show the prompt. The macOS crash
report ended inside <code class="language-plaintext highlighter-rouge">NSPanel setFloatingPanel</code>, called through
<code class="language-plaintext highlighter-rouge">tauri-nspanel</code>, with Minute’s detector thread lower in the stack.</p>

<p>Codex traced the call path from the crash report into my code and then into
the pinned dependency source. Converting the Tauri window into a panel already
mutated AppKit state, so moving only the final <code class="language-plaintext highlighter-rouge">show</code> call would have left the
bug in place.</p>

<p>The fix routed panel creation, conversion, configuration, positioning, showing,
and hiding through Tauri’s main-thread dispatcher. Debug assertions using
<code class="language-plaintext highlighter-rouge">MainThreadMarker</code> now guard each native boundary. The detector still does its
polling away from the interface thread.</p>

<p>The useful output was the explanation of the complete native boundary. A
smaller patch could have stopped one crash while leaving the same threading
mistake elsewhere.</p>

<h3 id="a-microphone-stream-could-succeed-and-still-record-silence">A microphone stream could succeed and still record silence</h3>

<p>My early preflight asked CoreAudio for an input stream. On macOS, that stream
can open while microphone permission is unresolved or denied and then deliver
silence. Minute could claim it was ready even though it had no usable audio.</p>

<p>I found this by launching an isolated app identity with a fresh permission
state. Codex added an explicit AVFoundation authorization check, a visible
“Allow microphone” action, and backend gates around preview and recording.
When permission is denied, the preflight stays open and points to the exact
System Settings location. It does not expose an input meter or pretend
recording has started.</p>

<p>The frontend component tests passed, but the decisive evidence came from the
native bundle with a reset permission state. That distinction became a rule for
the rest of the project. Simulate logic in tests and verify operating-system
behavior on the operating system.</p>

<h3 id="the-intel-app-contained-apple-silicon-libraries">The Intel app contained Apple Silicon libraries</h3>

<p>The first Intel build had an <code class="language-plaintext highlighter-rouge">x86_64</code> executable and <code class="language-plaintext highlighter-rouge">arm64</code> ggml and llama
dynamic libraries. Code signing passed. The application architecture was still
wrong.</p>

<p>The staging script always copied libraries from one local target directory,
even during a cross-architecture build. Codex updated it to respect Tauri’s
target triple and added <code class="language-plaintext highlighter-rouge">lipo</code> checks for the executable and every nested
library. The release workflow now fails when a single binary does not match the
intended architecture.</p>

<p>This bug changed my definition of a valid macOS bundle. A signature checks
integrity and identity. It does not prove that every binary can run on the
recipient’s processor.</p>

<h2 id="product-taste-still-required-a-person">Product taste still required a person</h2>

<p>The first complete interface worked, but it looked like a competent collection
of panels. I wanted Minute to feel closer to a notebook: warm paper surfaces,
ink-like text, thin rules, and one oxide accent reserved for recording.</p>

<p>I supplied that direction and judged the screenshots. Codex translated it into
design tokens, rebuilt the major views, captured the application in light and
dark appearances, and corrected the details exposed by each pass. I repeated
the process at the minimum window width, at 200 percent text size, and with
reduced motion enabled.</p>

<p>One review found that the recording details rail clipped at the native minimum
width. The fix allowed the secondary rail to recede below 1280 pixels while the
capture source remained visible. Another pass found that VoiceOver announced
recording health every second. The visible status stayed live, while the
assistive announcement changed only when the health category changed.</p>

<p>Codex could measure overflow, inspect the accessibility tree, compare
screenshots, and implement the correction. I still had to decide whether the
screen felt calm, which information deserved priority, and whether a tradeoff
fit the product.</p>

<p><img src="/assets/images/minute/recording.webp" alt="Minute recording a meeting with the capture source and live transcript visible" /></p>

<h2 id="verification-became-part-of-the-product">Verification became part of the product</h2>

<p>Fast code generation creates a review problem. More code arrives before a
person can inspect every path with equal care. Minute handled that by turning
acceptance criteria into executable checks wherever possible.</p>

<p>On July 28, a fresh <code class="language-plaintext highlighter-rouge">npm run verify</code> completed with:</p>

<ul>
  <li>676 passing frontend tests across 32 test files;</li>
  <li>384 passing Rust tests, with 10 hardware, model, and network tests explicitly
ignored;</li>
  <li>a clean lint run;</li>
  <li>a successful TypeScript and Vite production build; and</li>
  <li>valid release metadata for Minute 0.7.0.</li>
</ul>

<p>The broader release work added a three-hour logical recording soak, a
large-library performance scenario, automated axe checks, screenshot diffs,
keyboard-only review, and a native VoiceOver pass. I tested light and dark
appearances, minimum width, enlarged text, and reduced motion.</p>

<p>Failure paths received their own acceptance criteria. A simulated disk-write
failure must preserve samples already written, keep transcription forwarding
alive, and mark the final note as needing review. A failed summary must leave
the audio and transcript usable. Deleting a note moves it into local recovery
and exposes Undo.</p>

<p>These checks influenced the design. Once a recovery behavior had to be
asserted, vague states such as “something went wrong” stopped being acceptable.
The app had to say what survived and what the user could do next.</p>

<h2 id="the-division-of-work">The division of work</h2>

<p>Codex handled a large share of execution:</p>

<ul>
  <li>repository inspection and implementation planning;</li>
  <li>React, TypeScript, Rust, and macOS framework code;</li>
  <li>unit, component, accessibility, soak, and regression tests;</li>
  <li>dependency and crash-source investigation;</li>
  <li>repeated build, lint, and test runs;</li>
  <li>browser and native visual inspection;</li>
  <li>release scripts, CI workflows, and documentation; and</li>
  <li>small commits that kept each change reviewable.</li>
</ul>

<p>My work centered on judgment:</p>

<ul>
  <li>choosing an offline product and keeping that promise narrow;</li>
  <li>deciding what Minute should show before, during, and after a recording;</li>
  <li>rejecting placeholder actions and claims the app could not prove;</li>
  <li>setting the visual direction;</li>
  <li>choosing which failures required device evidence;</li>
  <li>accepting tradeoffs around model size, latency, and memory; and</li>
  <li>deciding that an ad-hoc private beta was honest while a public release was
premature.</li>
</ul>

<p>Agentic development increased the number of decisions I could test in working
software while leaving their ownership with me.</p>

<h2 id="where-the-approach-worked">Where the approach worked</h2>

<p>Codex performed best when the repository could answer whether a change was
correct. Rust’s type system caught unsafe boundaries. Tests pinned timing and
persistence behavior. Screenshots exposed layout regressions. Native crash
reports and accessibility trees gave the agent evidence beyond source code.</p>

<p>Small commits also helped. The repository reached 124 commits in five days, but
the history remains readable because most commits contain one feature, fix, or
verification step. When a later failure appeared, Codex could trace the
relevant decision without reconstructing one enormous change.</p>

<p>The workflow was especially effective for tasks that crossed layers. Adding a
marker touched keyboard handling, recording state, Tauri commands, Rust
persistence, Markdown generation, post-recording editing, Undo behavior, and
tests. Keeping one agent responsible for the complete outcome reduced the
handoff errors I would expect from splitting those layers into isolated coding
tasks.</p>

<h2 id="where-it-struggled">Where it struggled</h2>

<p>Native desktop work still depends on physical state. A test cannot unplug a
microphone that I do not have. A controller running on the Mac cannot put its
own machine to sleep and continue observing the result. An architecture check
cannot prove that an Intel user can complete a real transcription.</p>

<p>Long sessions also accumulated assumptions. Starting a fresh task for each
coherent outcome, preserving decisions in repository documents, and asking
Codex to inspect the current code reduced that drift.</p>

<p>Visual work needed several passes. Codex could reproduce a reference and
measure the output, but phrases such as “make it feel native” produced weak
results. Concrete direction about hierarchy, spacing, material, color, and
state produced much better work.</p>

<p>The agent also needed explicit permission to stop. Without that, it could keep
polishing a passing implementation while a higher-risk release gap remained.
A written backlog made unfinished work visible and gave me a place to draw the
line.</p>

<h2 id="what-i-would-repeat">What I would repeat</h2>

<p>I would use the same approach for another product, with a few rules in place
from the first commit:</p>

<ol>
  <li>Write the product constraints before choosing the architecture.</li>
  <li>Give each task a user-visible outcome and a completion check.</li>
  <li>Ask for tests and implementation in the same task.</li>
  <li>Use screenshots, logs, crash reports, and native inspection as agent input.</li>
  <li>Test failure recovery before adding secondary features.</li>
  <li>Keep commits small enough to explain in one sentence.</li>
  <li>Maintain an honest list of work that automation cannot verify.</li>
  <li>Reserve release decisions and product taste for the human directing the
work.</li>
</ol>

<p>Minute’s current release reflects those limits. The Apple Silicon and Intel
private builds are ad-hoc signed, checksummed, and architecture-verified. They
are not notarized because I do not yet have the required Apple Developer
credentials. Physical Intel launch and transcription, removable-microphone
recovery, sleep and wake, an overnight recording, clean-Mac installation, and
the signed updater remain open release checks.</p>

<p>You can inspect the <a href="https://github.com/mraza007/minute">Minute source</a> and the
<a href="https://github.com/mraza007/minute/releases/tag/v0.7.0-private.1">0.7.0 private beta</a>.
The repository includes the test suites, visual baselines, reliability matrix,
accessibility review, release checklist, and commit history behind the claims
in this post.</p>

          ]]>
        </description>
        <pubDate>Tue, 28 Jul 2026 00:00:00 +0000</pubDate>
        <link>https://muhammadraza.me/2026/building-minute-with-codex/</link>
        <guid isPermaLink="true">https://muhammadraza.me/2026/building-minute-with-codex/</guid>
        
        <category>ai</category>
        
        <category>macos</category>
        
        <category>rust</category>
        
        <category>tools</category>
        
        
        
        <dc:creator>{&quot;name&quot;=&gt;&quot;Muhammad Raza&quot;}</dc:creator>
        <dc:rights></dc:rights>
      </item>
    
      <item>
        <title>What Actually Happens During an ECS Rolling Deployment</title>
        <description>
          <![CDATA[
            
            <p>You push a new image, update an ECS service, and the console says <code class="language-plaintext highlighter-rouge">Deployment in progress</code>. Then it sits there.</p>

<p>A new task appears in <code class="language-plaintext highlighter-rouge">PENDING</code>. It changes to <code class="language-plaintext highlighter-rouge">RUNNING</code>, but the old task does not go away. A minute later the old one starts draining. Eventually it disappears and the service returns to steady state. If you only watch the task count, the whole thing looks oddly slow and a little random.</p>

<p>It is neither. Four systems are working on the deployment at the same time:</p>

<ul>
  <li>The ECS scheduler is enforcing minimum and maximum task counts.</li>
  <li>The ECS agent or Fargate runtime is provisioning the task and starting its containers.</li>
  <li>The load balancer is deciding whether the new target should receive traffic.</li>
  <li>The old container is trying to finish requests before ECS kills it.</li>
</ul>

<p>Most of the confusing failures I see come from treating those four systems as one thing. A task can be running but not ready. It can be healthy in ECS but unhealthy in the target group. It can be removed from the load balancer and still spend another minute shutting down.</p>

<p>We are going to follow one deployment from the API call to the final stopped task. At each pause, we will look at which system owns the wait and what condition it needs before the rollout can continue.</p>

<h2 id="the-example-service">The example service</h2>

<p>We will use a small Fargate service behind an Application Load Balancer:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>service: api
desired count: 4
current task definition: api:41
new task definition: api:42
minimumHealthyPercent: 100
maximumPercent: 200
healthCheckGracePeriodSeconds: 60
</code></pre></div></div>

<p>The service begins with four healthy tasks running revision <code class="language-plaintext highlighter-rouge">api:41</code>. Our pipeline registers <code class="language-plaintext highlighter-rouge">api:42</code>, then calls <code class="language-plaintext highlighter-rouge">UpdateService</code> to point the service at the new revision.</p>

<p>The deployment settings create a range:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>minimum healthy = ceil(4 × 100%) = 4
maximum running or pending = floor(4 × 200%) = 8
</code></pre></div></div>

<p>ECS must keep at least four healthy tasks available, but it may temporarily run as many as eight. With enough Fargate or EC2 capacity, the scheduler can start all four replacements before touching the old tasks.</p>

<p>When a rollout behaves strangely, I calculate this range before looking anywhere else.</p>

<h2 id="the-service-revision-changes-first">The service revision changes first</h2>

<p>Registering a task definition does not deploy it. It only creates an immutable blueprint such as <code class="language-plaintext highlighter-rouge">api:42</code>.</p>

<p>Updating the service is what starts the deployment:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws ecs update-service <span class="se">\</span>
  <span class="nt">--cluster</span> production <span class="se">\</span>
  <span class="nt">--service</span> api <span class="se">\</span>
  <span class="nt">--task-definition</span> api:42
</code></pre></div></div>

<p>ECS records the configuration it is leaving and the configuration it is trying to reach. In the current ECS deployment model, these are the source and target service revisions. A service revision includes more than the task definition: it records the workload configuration ECS is attempting to deploy and gives rollback a known previous state.</p>

<p>No customer traffic has moved yet. ECS has only changed desired state:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>source: api:41, four healthy tasks
target: api:42, zero healthy tasks
deployment: IN_PROGRESS
</code></pre></div></div>

<p>The service scheduler now begins reconciling reality with the new declaration.</p>

<h2 id="the-scheduler-finds-room-for-the-new-tasks">The scheduler finds room for the new tasks</h2>

<p>The scheduler does not blindly replace one task at a time. It works inside the range created by <code class="language-plaintext highlighter-rouge">minimumHealthyPercent</code> and <code class="language-plaintext highlighter-rouge">maximumPercent</code>.</p>

<p><code class="language-plaintext highlighter-rouge">minimumHealthyPercent</code> is the availability floor. ECS rounds it up.</p>

<p><code class="language-plaintext highlighter-rouge">maximumPercent</code> is the concurrency ceiling for tasks in <code class="language-plaintext highlighter-rouge">RUNNING</code> or <code class="language-plaintext highlighter-rouge">PENDING</code>. ECS rounds it down.</p>

<p>The same four-task service behaves quite differently as those settings change:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: right">Minimum</th>
      <th style="text-align: right">Maximum</th>
      <th style="text-align: right">Allowed range</th>
      <th>Likely behavior</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">100%</td>
      <td style="text-align: right">200%</td>
      <td style="text-align: right">4 to 8 tasks</td>
      <td>Start new tasks before stopping old tasks</td>
    </tr>
    <tr>
      <td style="text-align: right">50%</td>
      <td style="text-align: right">100%</td>
      <td style="text-align: right">2 to 4 tasks</td>
      <td>Stop up to two old tasks to make room</td>
    </tr>
    <tr>
      <td style="text-align: right">75%</td>
      <td style="text-align: right">125%</td>
      <td style="text-align: right">3 to 5 tasks</td>
      <td>Replace roughly one task at a time</td>
    </tr>
  </tbody>
</table>

<p>These percentages affect availability, but on ECS with EC2 they also decide how much spare capacity a deployment needs. A service configured for <code class="language-plaintext highlighter-rouge">100/200</code> may run the old and new revisions together. If the cluster is already full, those new tasks have nowhere to go. Fargate hides the hosts, but account quotas, IP availability, and platform capacity can still block placement.</p>

<p>There is also a rounding trap. With a desired count of three and <code class="language-plaintext highlighter-rouge">maximumPercent: 125</code>, the upper limit is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>floor(3 × 1.25) = 3
</code></pre></div></div>

<p>ECS cannot start a fourth task. If <code class="language-plaintext highlighter-rouge">minimumHealthyPercent</code> also prevents it from stopping an old task, the deployment has no legal move. ECS emits a service event telling you that the deployment configuration cannot start or stop a task.</p>

<h2 id="the-first-task-pins-the-image">The first task pins the image</h2>

<p>Task definition <code class="language-plaintext highlighter-rouge">api:42</code> may say:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"image"</span><span class="p">:</span><span class="w"> </span><span class="s2">"123456789012.dkr.ecr.us-east-1.amazonaws.com/api:production"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>That tag is mutable. It can point to a different image tomorrow.</p>

<p>By default, ECS resolves image tags to digests during a deployment so that every task in the service runs identical image content. For a service with multiple tasks, the first new task is used to establish the image digest, and the remaining tasks use that digest.</p>

<p>Overwriting a tag does not change a running task. ECS needs a new deployment before it resolves and launches the new image. Unique tags, preferably the Git SHA, also save a lot of guesswork during an incident.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>api:production                         mutable pointer
api@sha256:8d6c...                     actual image content
</code></pre></div></div>

<p>The task then moves through the early lifecycle:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PROVISIONING → PENDING → ACTIVATING → RUNNING
</code></pre></div></div>

<p>Depending on the launch type and configuration, this includes finding capacity, attaching an ENI, pulling images, creating containers, configuring networking, registering targets, and starting essential containers.</p>

<p><code class="language-plaintext highlighter-rouge">RUNNING</code> is one of the most misleading words in the ECS console. It means the containers are running. It does not mean the application is ready for customer traffic, and ECS may not count the task as healthy yet.</p>

<h2 id="running-is-followed-by-another-wait"><code class="language-plaintext highlighter-rouge">RUNNING</code> is followed by another wait</h2>

<p>A task can be evaluated by two separate health systems:</p>

<ol>
  <li>A container health check defined in the ECS task definition.</li>
  <li>A load-balancer target group health check.</li>
</ol>

<p>If an essential container has an ECS health check and the service uses a load balancer, both must pass before the scheduler counts the task as healthy for the deployment.</p>

<p>A container health check runs inside the container:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"healthCheck"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="s2">"CMD-SHELL"</span><span class="p">,</span><span class="w">
      </span><span class="s2">"curl -f http://localhost:8080/health || exit 1"</span><span class="w">
    </span><span class="p">],</span><span class="w">
    </span><span class="nl">"interval"</span><span class="p">:</span><span class="w"> </span><span class="mi">10</span><span class="p">,</span><span class="w">
    </span><span class="nl">"timeout"</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="p">,</span><span class="w">
    </span><span class="nl">"retries"</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span><span class="w">
    </span><span class="nl">"startPeriod"</span><span class="p">:</span><span class="w"> </span><span class="mi">30</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">startPeriod</code> belongs to the container health check. Failed checks during that bootstrap window do not count toward the retry limit. If a check succeeds during the start period, the container becomes healthy and later failures count normally.</p>

<p><code class="language-plaintext highlighter-rouge">healthCheckGracePeriodSeconds</code> is different. It belongs to the ECS service and tells the service scheduler to ignore unhealthy container, load-balancer, or VPC Lattice health status for a period after each task starts.</p>

<p>The two timers sound interchangeable, but they sit at different layers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>container startPeriod
  protects the container health-check retry counter during startup

service health-check grace period
  prevents the ECS scheduler from replacing a new task during startup
</code></pre></div></div>

<p>Neither setting sends traffic to an unhealthy target. The Application Load Balancer still follows its own target health state. The grace period only changes how the ECS scheduler reacts to an unhealthy result.</p>

<p>For a newly registered ALB target, one successful health check is enough to mark it healthy. The target group’s healthy-threshold count applies when a previously unhealthy target is recovering. With the default 30-second health-check interval, even a healthy application may spend noticeable time waiting for the next probe.</p>

<p>Before the first <code class="language-plaintext highlighter-rouge">api:42</code> task counts toward the rollout, all of this must be true:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ECS task state: RUNNING
container health: HEALTHY
ALB target health: healthy
counts toward deployment minimum: yes
receiving customer traffic: yes
</code></pre></div></div>

<p>Now the new revision has proved that it can serve traffic. Until this point, stopping an old task would spend availability on a replacement that had not earned it.</p>

<h2 id="new-tasks-come-in-old-tasks-drain">New tasks come in, old tasks drain</h2>

<p>As new tasks become healthy, the scheduler gains room to remove old ones without crossing the availability floor.</p>

<p>With our <code class="language-plaintext highlighter-rouge">100/200</code> settings, the rollout may look like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>time     api:41 healthy     api:42 starting/healthy     total
t0              4                     0                   4
t1              4                     4 starting          8
t2              4                     4 healthy           8
t3              0                     4 healthy           4
</code></pre></div></div>

<p>Do not read that table as a promise that all four tasks move together. The scheduler chooses the batches. Placement capacity, startup time, health results, and throttling can turn the same configuration into a more incremental rollout.</p>

<p>When ECS decides to stop an old task behind an ALB, the task does not jump directly from <code class="language-plaintext highlighter-rouge">RUNNING</code> to <code class="language-plaintext highlighter-rouge">STOPPED</code>.</p>

<p>It moves through the shutdown side of the lifecycle:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>RUNNING → DEACTIVATING → STOPPING → DEPROVISIONING → STOPPED
</code></pre></div></div>

<p>During <code class="language-plaintext highlighter-rouge">DEACTIVATING</code>, ECS deregisters the task from the target group. The target enters draining, and the load balancer stops assigning it new requests while allowing existing connections time to complete according to the target group’s deregistration delay.</p>

<p>Then ECS stops the containers. On Linux, the container receives the signal defined by its image <code class="language-plaintext highlighter-rouge">STOPSIGNAL</code>, which is <code class="language-plaintext highlighter-rouge">SIGTERM</code> by default. ECS waits for the container’s <code class="language-plaintext highlighter-rouge">stopTimeout</code>. If the process is still alive after that window, it receives <code class="language-plaintext highlighter-rouge">SIGKILL</code>.</p>

<p>For graceful shutdown, all three layers need to agree:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ALB deregistration delay
    ≥ longest request or connection you intend to preserve

ECS stopTimeout
    ≥ time the application needs after SIGTERM

application shutdown handler
    stops accepting work and exits before stopTimeout
</code></pre></div></div>

<p>If the application ignores <code class="language-plaintext highlighter-rouge">SIGTERM</code>, no ECS setting can make its shutdown graceful. ECS will eventually kill it.</p>

<p>Long-lived WebSocket connections, streaming responses, background jobs, and queue consumers require special attention. A web server can stop accepting new requests and finish active ones. A worker may need to stop polling, return an in-flight message to the queue, or extend its visibility timeout. Rolling deployment safety is partly an application concern.</p>

<h2 id="ecs-reaches-steady-state">ECS reaches steady state</h2>

<p>The deployment completes after the target revision reaches the desired count and the old revision no longer has active tasks in the rollout.</p>

<p>For our service:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>api:41: 0 running tasks
api:42: 4 running and healthy tasks
desired count: 4
deployment: SUCCESSFUL
</code></pre></div></div>

<p>Your CI system does not perform the rollout. A deployment action usually registers the task definition, updates the service and, if configured, polls ECS until the service stabilizes. That is what the official GitHub action does when <code class="language-plaintext highlighter-rouge">wait-for-service-stability</code> is set to <code class="language-plaintext highlighter-rouge">true</code>. The scheduler still runs the deployment.</p>

<p>So when a pipeline appears frozen, the useful evidence is often somewhere else. ECS may be waiting for a health check, placement capacity, a draining connection, or the circuit-breaker threshold.</p>

<p>Start with ECS service events and deployment details, not the CI runner logs.</p>

<p>Useful commands include:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws ecs describe-services <span class="se">\</span>
  <span class="nt">--cluster</span> production <span class="se">\</span>
  <span class="nt">--services</span> api

aws ecs list-service-deployments <span class="se">\</span>
  <span class="nt">--cluster</span> production <span class="se">\</span>
  <span class="nt">--service</span> api

aws ecs describe-service-deployments <span class="se">\</span>
  <span class="nt">--service-deployment-arns</span> SERVICE_DEPLOYMENT_ARN

aws ecs list-tasks <span class="se">\</span>
  <span class="nt">--cluster</span> production <span class="se">\</span>
  <span class="nt">--service-name</span> api
</code></pre></div></div>

<p>Service deployment history includes the source and target revisions, deployment state, failed-task count, alarm state, timestamps, and rollback details. ECS retains recent deployment history, which is much more useful than reconstructing a rollout from a few console event messages.</p>

<h2 id="what-happens-when-the-new-tasks-fail">What happens when the new tasks fail?</h2>

<p>Failed deployments usually split into two groups. The difference tells you where to start looking.</p>

<h3 id="the-task-never-reaches-running">The task never reaches <code class="language-plaintext highlighter-rouge">RUNNING</code></h3>

<p>The task may have no capacity, fail to pull its image, or start with an execution role that cannot retrieve a secret. ENI attachment failures also happen here. So does an essential container that exits during startup.</p>

<p>These are launch failures. In its first stage, the deployment circuit breaker counts consecutive tasks that fail to reach <code class="language-plaintext highlighter-rouge">RUNNING</code>.</p>

<h3 id="the-task-runs-but-never-becomes-healthy">The task runs but never becomes healthy</h3>

<p>Sometimes the process starts but listens on the wrong port, or the health endpoint returns a failing status. The ALB security group may not reach the task. A slow application can run past its grace period. I have also seen health checks fail because the image did not contain <code class="language-plaintext highlighter-rouge">curl</code>, even though the application itself was fine.</p>

<p>These are health failures. After at least one new task reaches <code class="language-plaintext highlighter-rouge">RUNNING</code>, the circuit breaker moves to its second stage and watches container, load-balancer, and service-discovery health.</p>

<p>The circuit breaker threshold is based on half the desired count, bounded to a minimum of 3 and a maximum of 200:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>threshold = ceil(0.5 × desired count), bounded to [3, 200]
</code></pre></div></div>

<p>For a service with a desired count of one, the threshold is still three. A tiny service may launch the same broken task several times before ECS finally calls the deployment failed.</p>

<p>Enable rollback explicitly:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"deploymentCircuitBreaker"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"enable"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"rollback"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"minimumHealthyPercent"</span><span class="p">:</span><span class="w"> </span><span class="mi">100</span><span class="p">,</span><span class="w">
  </span><span class="nl">"maximumPercent"</span><span class="p">:</span><span class="w"> </span><span class="mi">200</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>When the deployment fails, ECS can roll back to the last service revision that completed successfully. CloudWatch alarms cover a different failure mode: the tasks are technically healthy, but latency or error rate gets worse after the release.</p>

<p>I use both because they answer different questions:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>circuit breaker
  catches tasks that cannot launch or become healthy

CloudWatch deployment alarms
  catch applications that are running but behaving badly
</code></pre></div></div>

<p>A <code class="language-plaintext highlighter-rouge">/health</code> endpoint returning <code class="language-plaintext highlighter-rouge">200</code> cannot tell you that checkout latency tripled or every database write is failing. The scheduler only knows what you expose to it.</p>

<h2 id="why-healthy-deployments-still-cause-errors">Why healthy deployments still cause errors</h2>

<p>ECS can execute a perfect rolling deployment and users can still see failures.</p>

<h3 id="the-new-and-old-versions-are-incompatible">The new and old versions are incompatible</h3>

<p>During a rolling deployment, both versions receive traffic. A destructive database migration, incompatible queue payload, or changed cache format can break one revision while the other is still alive.</p>

<p>Database changes should generally follow expand-and-contract:</p>

<ol>
  <li>Add the new schema while keeping the old schema valid.</li>
  <li>Deploy code that can work with both representations.</li>
  <li>Migrate data.</li>
  <li>Remove the old schema in a later deployment.</li>
</ol>

<h3 id="readiness-is-too-shallow">Readiness is too shallow</h3>

<p>Returning <code class="language-plaintext highlighter-rouge">200</code> because the HTTP process started is not enough if the application cannot reach a dependency required for serving requests. But checking every downstream system can also cause a cascading failure by removing all targets during a shared dependency outage.</p>

<p>The useful question is narrower: what condition means this particular task should stop receiving traffic? Write the health check around that contract.</p>

<h3 id="shutdown-is-not-graceful">Shutdown is not graceful</h3>

<p>If the process exits immediately on <code class="language-plaintext highlighter-rouge">SIGTERM</code>, active requests die even though the ALB is draining the target. If it waits longer than <code class="language-plaintext highlighter-rouge">stopTimeout</code>, ECS kills it anyway.</p>

<h3 id="sticky-sessions-or-local-state-hide-the-overlap">Sticky sessions or local state hide the overlap</h3>

<p>Rolling deployments assume tasks are replaceable. Sessions stored only in process memory, local uploads, or singleton background work make replacement unsafe. The scheduler cannot protect state it does not know exists.</p>

<h2 id="the-deployment-settings-i-start-with">The deployment settings I start with</h2>

<p>For a normal stateless HTTP service on Fargate behind an ALB, I start here:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>desired count: at least 2
minimumHealthyPercent: 100
maximumPercent: 200
deployment circuit breaker: enabled with rollback
CloudWatch alarms: 5xx rate and latency, with rollback
health-check grace period: measured startup time plus margin
image tag: immutable Git SHA
container stopTimeout: matched to graceful shutdown behavior
</code></pre></div></div>

<p>I keep the ALB health check cheap and local. I test <code class="language-plaintext highlighter-rouge">SIGTERM</code> handling outside production. I also alarm on <code class="language-plaintext highlighter-rouge">SERVICE_DEPLOYMENT_FAILED</code> through EventBridge because an automatic rollback that nobody notices is still a failed release.</p>

<p>If doubling task count is too expensive or the EC2 cluster cannot hold it, lower <code class="language-plaintext highlighter-rouge">maximumPercent</code> carefully and verify that the scheduler still has room to make progress. Saving temporary capacity is not useful if it creates a deployment deadlock or reduces availability below what the application can tolerate.</p>

<h2 id="a-practical-debugging-order">A practical debugging order</h2>

<p>When a deployment is slow or stuck, inspect it in this order:</p>

<ol>
  <li><strong>Deployment state:</strong> What are the source and target service revisions?</li>
  <li><strong>Scheduler math:</strong> Do the minimum and maximum percentages allow a task to start or stop?</li>
  <li><strong>Service events:</strong> Is placement, capacity, IAM, image pulling, or networking failing?</li>
  <li><strong>Task lifecycle:</strong> Are new tasks stuck in <code class="language-plaintext highlighter-rouge">PENDING</code>, stopping before <code class="language-plaintext highlighter-rouge">RUNNING</code>, or running and then replaced?</li>
  <li><strong>Stopped-task reason:</strong> What did ECS report for the failed task and essential container?</li>
  <li><strong>Target health:</strong> Is the ALB target initial, unhealthy, healthy, or draining? What reason does the target group report?</li>
  <li><strong>Application logs:</strong> Did the process bind the expected port and finish startup?</li>
  <li><strong>Timers:</strong> Are the container start period, service grace period, ALB interval, deregistration delay, and stop timeout consistent?</li>
  <li><strong>Rollback controls:</strong> Is the circuit breaker enabled, and are application alarms attached?</li>
</ol>

<p>The order matters. There is no point staring at application logs if ECS never started the container. Likewise, changing a health endpoint will not fix a target group that cannot reach the task through its security group.</p>

<h2 id="what-to-remember">What to remember</h2>

<p>An ECS rolling deployment is a constrained reconciliation loop:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>declare a new service revision
        ↓
calculate the legal task-count range
        ↓
start tasks from the new revision
        ↓
prove they can run and receive traffic
        ↓
drain traffic from the old tasks
        ↓
give old processes time to exit
        ↓
stop the old revision and reach steady state
</code></pre></div></div>

<p>The scheduler protects task counts. Health checks decide which tasks receive traffic. Draining gives active requests time to finish. The circuit breaker catches a rollout that cannot converge. CloudWatch alarms catch the more annoying case, where the rollout converges and the application still gets worse.</p>

<p>When the ECS console sits on <code class="language-plaintext highlighter-rouge">Deployment in progress</code>, I check three things: the task state, the target state, and whichever timer is active. So far, the pause has always been hiding in one of them.</p>

<h2 id="sources-and-further-reading">Sources and further reading</h2>

<ul>
  <li><a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/deployment-type-ecs.html">Deploy Amazon ECS services by replacing tasks</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-lifecycle-explanation.html">Amazon ECS task lifecycle</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/deployment-circuit-breaker.html">How the ECS deployment circuit breaker detects failures</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-deployment.html">Amazon ECS service deployment history</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-revision.html">Amazon ECS service revisions</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/load-balancer-healthcheck.html">Optimize load-balancer health checks for ECS</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/healthcheck.html">Determine task health with container health checks</a></li>
  <li><a href="https://aws.amazon.com/blogs/containers/automate-rollbacks-for-amazon-ecs-rolling-deployments-with-cloudwatch-alarms/">Automate rollback with CloudWatch alarms</a></li>
</ul>

          ]]>
        </description>
        <pubDate>Mon, 13 Jul 2026 00:00:00 +0000</pubDate>
        <link>https://muhammadraza.me/2026/what-actually-happens-during-an-ecs-rolling-deployment/</link>
        <guid isPermaLink="true">https://muhammadraza.me/2026/what-actually-happens-during-an-ecs-rolling-deployment/</guid>
        
        <category>aws</category>
        
        <category>devops</category>
        
        <category>ecs</category>
        
        <category>containers</category>
        
        
        
        <dc:creator>{&quot;name&quot;=&gt;&quot;Muhammad Raza&quot;}</dc:creator>
        <dc:rights></dc:rights>
      </item>
    
      <item>
        <title>How ECS Actually Works: A Visual Guide for People Who Know Kubernetes</title>
        <description>
          <![CDATA[
            
            <p>Every few months I have the same conversation. A small team, three to eight engineers, is containerizing their app, and someone says “we should use Kubernetes, that’s the industry standard.” Six months later they’re maintaining a small distributed systems platform on the side, and the app they were supposed to ship is still competing for attention with CNI upgrades.</p>

<p>I’ve written before about <a href="/2025/ecs-decisions-that-waste-6-weeks/">the ECS decisions that waste six weeks</a>. This post is the prequel: what ECS actually is, how it maps onto the Kubernetes concepts you already know, and what you stop carrying on your pager when you choose it. There are a few interactive diagrams below. Click around in them; they teach the model faster than prose does.</p>

<p>One thing before we start: this is not a “Kubernetes bad” post. EKS is the right choice for some teams, and I’ll tell you exactly which ones at the end. But I’ve watched too many three-person teams default to EKS because it felt like the serious choice, without anyone explaining what they were signing up to operate.</p>

<h2 id="ecs-is-an-orchestrator-thats-it">ECS is an orchestrator. That’s it.</h2>

<p>Strip away the branding and every container orchestrator does the same job: you declare what should be running, and a control loop makes reality match the declaration. Kubernetes does this. Nomad does this. ECS does this.</p>

<p>ECS just exposes far fewer moving parts to you. Here’s the whole object model. Click each piece:</p>

<style>
/* ---- ecsx shared widget styles (scoped) ---- */
.ecsx{background:#0e131b;border:1px solid #2a3447;border-radius:8px;padding:18px;margin:1.6em 0;
  font-family:"JetBrains Mono",monospace;color:#dbe2ee;font-size:13px;line-height:1.5}
.ecsx *{box-sizing:border-box}
.ecsx-title{font-size:11px;letter-spacing:.18em;color:#7d8aa3;margin-bottom:14px;text-transform:uppercase}
.ecsx button{font-family:inherit;font-size:12px;background:#1a2333;color:#dbe2ee;border:1px solid #36435c;
  border-radius:5px;padding:7px 12px;cursor:pointer;transition:all .15s}
.ecsx button:hover{border-color:#ffb454;color:#ffb454}
.ecsx button:disabled{opacity:.4;cursor:default}
.ecsx-badge{display:inline-block;font-size:10px;padding:2px 8px;border-radius:99px;border:1px solid #4b79c4;
  color:#8db8f8;margin-left:8px;white-space:nowrap}
.ecsx-flex{display:flex;gap:16px;flex-wrap:wrap}
@media(max-width:640px){.ecsx{font-size:12px}}
/* anatomy */
.ecsx-anat-box{border:1.5px solid;border-radius:7px;padding:10px;cursor:pointer;transition:background .15s}
.ecsx-anat-box:hover{background:rgba(255,180,84,.06)}
.ecsx-anat-box.sel{background:rgba(255,180,84,.12)}
.ecsx-anat-label{font-size:11px;letter-spacing:.08em;margin-bottom:8px;font-weight:700}
.ecsx-info{flex:1;min-width:240px;border-left:2px solid #ffb454;padding:4px 0 4px 14px;align-self:center}
.ecsx-info h4{margin:0 0 6px;font-size:14px;color:#ffb454;font-family:inherit}
.ecsx-info p{margin:0;color:#aab4c8;font-size:12.5px}
/* recon */
.ecsx-taskgrid{display:flex;gap:10px;flex-wrap:wrap;min-height:84px;margin:12px 0}
.ecsx-task{width:118px;border:1.5px solid #3e9c5a;border-radius:6px;padding:8px;cursor:pointer;
  transition:opacity .4s, transform .4s}
.ecsx-task .id{font-size:11px;color:#7d8aa3}
.ecsx-task .st{font-size:11px;font-weight:700;margin-top:4px}
.ecsx-task.RUNNING{border-color:#3e9c5a}.ecsx-task.RUNNING .st{color:#79d68a}
.ecsx-task.PROVISIONING{border-color:#b98a3c;animation:ecsxpulse 1s infinite}.ecsx-task.PROVISIONING .st{color:#ffb454}
.ecsx-task.DRAINING{border-color:#5c677c;opacity:.55}.ecsx-task.DRAINING .st{color:#8b96ab}
.ecsx-task.STOPPED{border-color:#c44f5e;opacity:.25;transform:scale(.92)}.ecsx-task.STOPPED .st{color:#ff6b7d}
.ecsx-ver{display:inline-block;font-size:10px;padding:1px 7px;border-radius:99px;margin-top:5px}
.ecsx-ver.v1{background:#16344e;color:#7fc4ff}.ecsx-ver.v2{background:#33234e;color:#c9a6ff}
@keyframes ecsxpulse{50%{background:rgba(255,180,84,.08)}}
.ecsx-log{background:#0a0e15;border:1px solid #232c3d;border-radius:6px;padding:10px 12px;font-size:11.5px;
  height:118px;overflow:hidden;display:flex;flex-direction:column;justify-content:flex-end;color:#94a0b8}
.ecsx-log .t{color:#525e75;margin-right:8px}
.ecsx-log .hl{color:#ffb454}.ecsx-log .ok{color:#79d68a}.ecsx-log .bad{color:#ff6b7d}
.ecsx-svchead{display:flex;gap:18px;flex-wrap:wrap;font-size:12px;color:#aab4c8;margin-bottom:4px}
.ecsx-svchead b{color:#dbe2ee}
/* stack */
.ecsx-cols{display:flex;gap:14px;flex-wrap:wrap;margin-top:12px}
.ecsx-col{flex:1;min-width:230px}
.ecsx-colhead{text-align:center;font-weight:700;font-size:13px;padding:8px;border-bottom:2px solid #36435c;margin-bottom:8px}
.ecsx-cell{border-radius:5px;padding:8px 10px;margin-bottom:6px;font-size:12px;border:1px solid;min-height:54px}
.ecsx-cell .who{font-size:10px;font-weight:700;letter-spacing:.1em;display:block;margin-bottom:2px}
.ecsx-cell.aws{background:rgba(62,156,90,.10);border-color:#2c5e3e}.ecsx-cell.aws .who{color:#79d68a}
.ecsx-cell.you{background:rgba(255,180,84,.10);border-color:#7a5a28}.ecsx-cell.you .who{color:#ffb454}
.ecsx-cell.na{background:rgba(120,130,150,.05);border-color:#2a3447;color:#67738c}.ecsx-cell.na .who{color:#67738c}
.ecsx-score{margin-top:10px;padding:10px 12px;background:#0a0e15;border:1px solid #232c3d;border-radius:6px;
  font-size:12.5px;color:#aab4c8}
.ecsx-score b{color:#ffb454}
.ecsx-toggle{display:inline-flex;border:1px solid #36435c;border-radius:6px;overflow:hidden;margin-left:10px}
.ecsx-toggle button{border:none;border-radius:0;padding:5px 12px;font-size:11px}
.ecsx-toggle button.on{background:#ffb454;color:#1a1206}
</style>

<div class="ecsx" id="ecsx-anatomy">
  <div class="ecsx-title">The entire ECS object model — click anything</div>
  <div class="ecsx-flex">
    <div style="flex:1.4;min-width:280px">
      <div class="ecsx-anat-box" data-k="cluster" style="border-color:#4b79c4">
        <div class="ecsx-anat-label" style="color:#8db8f8">CLUSTER</div>
        <div class="ecsx-anat-box" data-k="service" style="border-color:#3e9c5a">
          <div class="ecsx-anat-label" style="color:#79d68a">SERVICE — web · desired: 3</div>
          <div style="display:flex;gap:8px;flex-wrap:wrap">
            <div class="ecsx-anat-box" data-k="task" style="border-color:#ffb454;flex:1;min-width:110px">
              <div class="ecsx-anat-label" style="color:#ffb454">TASK</div>
              <div class="ecsx-anat-box" data-k="container" style="border-color:#c9a6ff;font-size:11px;color:#c9a6ff">container: app</div>
              <div class="ecsx-anat-box" data-k="container" style="border-color:#c9a6ff;font-size:11px;color:#c9a6ff;margin-top:6px">container: nginx</div>
            </div>
            <div class="ecsx-anat-box" data-k="task" style="border-color:#ffb454;flex:1;min-width:110px">
              <div class="ecsx-anat-label" style="color:#ffb454">TASK</div>
              <div class="ecsx-anat-box" data-k="container" style="border-color:#c9a6ff;font-size:11px;color:#c9a6ff">container: app</div>
              <div class="ecsx-anat-box" data-k="container" style="border-color:#c9a6ff;font-size:11px;color:#c9a6ff;margin-top:6px">container: nginx</div>
            </div>
            <div class="ecsx-anat-box" data-k="task" style="border-color:#ffb454;flex:1;min-width:110px">
              <div class="ecsx-anat-label" style="color:#ffb454">TASK</div>
              <div class="ecsx-anat-box" data-k="container" style="border-color:#c9a6ff;font-size:11px;color:#c9a6ff">container: app</div>
              <div class="ecsx-anat-box" data-k="container" style="border-color:#c9a6ff;font-size:11px;color:#c9a6ff;margin-top:6px">container: nginx</div>
            </div>
          </div>
        </div>
      </div>
      <div class="ecsx-anat-box" data-k="taskdef" style="border-color:#e06c75;margin-top:10px">
        <div class="ecsx-anat-label" style="color:#e06c75">TASK DEFINITION — web:42 <span style="color:#67738c;font-weight:400">(the blueprint the service stamps tasks from)</span></div>
      </div>
    </div>
    <div class="ecsx-info" id="ecsx-anat-info">
      <h4>Click a component</h4>
      <p>Every box on the left has a direct Kubernetes equivalent. Click to see what it is and what it maps to.</p>
    </div>
  </div>
</div>

<script>
(function(){
  var INFO = {
    cluster:  ['Cluster', 'Kubernetes equivalent: cluster',
      'A logical boundary for compute and workloads. Unlike a Kubernetes cluster, there is no control plane living inside it that you can see, version, or break — the scheduler and state store are an AWS regional service. There is nothing to upgrade. Ever.'],
    service:  ['Service', 'Kubernetes equivalent: Deployment + Service',
      'Holds the declaration: "keep N copies of this task definition running, registered behind this load balancer target group." It is the reconciliation loop — it replaces dead tasks, performs rolling deployments, and hooks into autoscaling. One ECS object does what a Deployment, ReplicaSet, and Service do together in Kubernetes.'],
    task:     ['Task', 'Kubernetes equivalent: Pod',
      'One running copy of your workload: one or more containers scheduled together on the same host, sharing a network namespace and an IAM role. With the awsvpc network mode every task gets its own ENI and private IP — same mental model as a pod IP.'],
    container:['Container', 'Kubernetes equivalent: container',
      'Exactly what you think it is. Sidecars work the same way as in a pod — an nginx or log-router container scheduled next to your app container inside the same task.'],
    taskdef:  ['Task Definition', 'Kubernetes equivalent: pod spec (+ a bit of Deployment)',
      'A versioned, immutable JSON document: images, CPU/memory, env vars, ports, volumes, IAM role. Every revision gets a number (web:41, web:42). A deployment is literally "point the service at a new revision." No Helm, no templating layer — which is both the good news and the bad news.'],
  };
  var root = document.getElementById('ecsx-anatomy');
  var info = document.getElementById('ecsx-anat-info');
  root.addEventListener('click', function(e){
    var box = e.target.closest('.ecsx-anat-box');
    if(!box) return;
    e.stopPropagation();
    root.querySelectorAll('.ecsx-anat-box').forEach(function(b){b.classList.remove('sel')});
    box.classList.add('sel');
    var d = INFO[box.dataset.k];
    info.innerHTML = '<h4>'+d[0]+'<span class="ecsx-badge">'+d[1]+'</span></h4><p>'+d[2]+'</p>';
  }, true);
})();
</script>

<p>If you know Kubernetes, the translation table is short enough to memorize over coffee:</p>

<table>
  <thead>
    <tr>
      <th>ECS</th>
      <th>Kubernetes</th>
      <th>What it is</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Cluster</td>
      <td>Cluster</td>
      <td>Logical boundary for compute + workloads</td>
    </tr>
    <tr>
      <td>Service</td>
      <td>Deployment + ReplicaSet + Service</td>
      <td>“Keep N running, behind this LB”</td>
    </tr>
    <tr>
      <td>Task</td>
      <td>Pod</td>
      <td>Co-scheduled containers, shared network + identity</td>
    </tr>
    <tr>
      <td>Task definition</td>
      <td>Pod spec</td>
      <td>Versioned blueprint for a task</td>
    </tr>
    <tr>
      <td>Capacity provider</td>
      <td>Node group / Karpenter</td>
      <td>Where compute comes from</td>
    </tr>
    <tr>
      <td>Fargate</td>
      <td>— (closest: virtual kubelet)</td>
      <td>Serverless compute, no nodes at all</td>
    </tr>
    <tr>
      <td>Task IAM role</td>
      <td>ServiceAccount + IRSA</td>
      <td>Per-workload cloud credentials</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">awsvpc</code> mode</td>
      <td>CNI</td>
      <td>Every task gets its own ENI/IP — not a choice, a default</td>
    </tr>
  </tbody>
</table>

<p>That last column is where the story actually lives. In Kubernetes, “where compute comes from” and “how pods get IPs” and “how workloads get cloud credentials” are all <em>decisions</em> with an ecosystem of competing answers. In ECS they’re defaults. You don’t pick a CNI. You don’t install an IRSA webhook. There’s one way, it’s boring, and it works.</p>

<h2 id="the-reconciliation-loop--same-idea-fewer-layers">The reconciliation loop — same idea, fewer layers</h2>

<p>The core idea both systems share: you declare desired state, a control loop enforces it. This is the part I find people understand instantly once they <em>watch</em> it instead of reading about it.</p>

<p>Below is an ECS service with <code class="language-plaintext highlighter-rouge">desired count: 4</code>. Click a task to kill it, then watch the scheduler notice and replace it. Then hit deploy and watch a rolling deployment do exactly what a Kubernetes Deployment rollout does: bring up new tasks, drain old ones, never drop below healthy.</p>

<div class="ecsx" id="ecsx-recon">
  <div class="ecsx-title">Service reconciliation — click a task to kill it</div>
  <div class="ecsx-svchead">
    <span>service: <b>web</b></span>
    <span>desired: <b>4</b></span>
    <span>running: <b id="ecsx-running">4</b></span>
    <span>revision: <b id="ecsx-rev">web:41</b></span>
  </div>
  <div class="ecsx-taskgrid" id="ecsx-tasks"></div>
  <div style="display:flex;gap:10px;margin-bottom:12px;flex-wrap:wrap">
    <button id="ecsx-kill">⚡ kill a task</button>
    <button id="ecsx-deploy">🚀 deploy web:42</button>
    <button id="ecsx-reset">↺ reset</button>
  </div>
  <div class="ecsx-log" id="ecsx-loglines"></div>
</div>

<script>
(function(){
  var grid = document.getElementById('ecsx-tasks');
  var logEl = document.getElementById('ecsx-loglines');
  var runEl = document.getElementById('ecsx-running');
  var revEl = document.getElementById('ecsx-rev');
  var DESIRED = 4, tasks = [], logs = [], t0 = Date.now(), deploying = false, timers = [];

  function now(){ return ((Date.now()-t0)/1000).toFixed(1)+'s'; }
  function log(msg, cls){
    logs.push('<div><span class="t">'+now()+'</span><span class="'+(cls||'')+'">'+msg+'</span></div>');
    logs = logs.slice(-7); logEl.innerHTML = logs.join('');
  }
  function id(){ return Math.random().toString(16).slice(2,8); }
  function later(fn, ms){ timers.push(setTimeout(fn, ms)); }

  function render(){
    grid.innerHTML = tasks.map(function(t){
      return '<div class="ecsx-task '+t.st+'" data-id="'+t.id+'">'+
        '<div class="id">'+t.id+'</div>'+
        '<div class="st">'+t.st+'</div>'+
        '<span class="ecsx-ver '+t.ver+'">web:'+(t.ver==='v1'?41:42)+'</span></div>';
    }).join('');
    runEl.textContent = tasks.filter(function(t){return t.st==='RUNNING'}).length;
  }

  function spawn(ver, cb){
    var t = { id:id(), ver:ver, st:'PROVISIONING' };
    tasks.push(t); render();
    log('scheduler: starting task <span class="hl">'+t.id+'</span> ('+(ver==='v1'?'web:41':'web:42')+')');
    later(function(){
      t.st = 'RUNNING'; render();
      log('task <span class="hl">'+t.id+'</span> RUNNING — registered with target group','ok');
      if(cb) cb(t);
    }, 1900 + Math.random()*700);
  }

  function reconcile(){
    if (deploying) return;
    var alive = tasks.filter(function(t){return t.st==='RUNNING'||t.st==='PROVISIONING'}).length;
    if (alive < DESIRED){
      log('service web: running ('+alive+') below desired ('+DESIRED+')','hl');
      spawn(tasks.some(function(t){return t.ver==='v2'}) ? 'v2' : 'v1');
    }
  }

  function kill(tid){
    var t = tasks.find(function(x){return x.id===tid && x.st==='RUNNING'});
    if(!t) return;
    t.st='STOPPED'; render();
    log('task <span class="bad">'+t.id+'</span> stopped (essential container exited)','bad');
    later(function(){ tasks = tasks.filter(function(x){return x!==t}); render(); reconcile(); }, 900);
  }

  grid.addEventListener('click', function(e){
    var el = e.target.closest('.ecsx-task'); if(el) kill(el.dataset.id);
  });
  document.getElementById('ecsx-kill').onclick = function(){
    var r = tasks.filter(function(t){return t.st==='RUNNING'});
    if(r.length) kill(r[Math.floor(Math.random()*r.length)].id);
  };

  document.getElementById('ecsx-deploy').onclick = function(){
    if (deploying || tasks.some(function(t){return t.ver==='v2'})) return;
    deploying = true;
    revEl.textContent = 'web:42';
    log('deployment started: web:41 → web:42 (rolling, min healthy 100%)','hl');
    (function step(){
      var olds = tasks.filter(function(t){return t.ver==='v1' && t.st==='RUNNING'});
      if (!olds.length){ deploying=false; log('deployment completed: 4/4 tasks on web:42','ok'); return; }
      spawn('v2', function(){
        var old = tasks.find(function(t){return t.ver==='v1' && t.st==='RUNNING'});
        if (old){
          old.st='DRAINING'; render();
          log('task <span class="hl">'+old.id+'</span> draining connections…');
          later(function(){
            tasks = tasks.filter(function(x){return x!==old}); render();
            log('task '+old.id+' deregistered + stopped');
            step();
          }, 1400);
        } else step();
      });
    })();
  };

  function reset(){
    timers.forEach(clearTimeout); timers=[]; tasks=[]; logs=[]; deploying=false; t0=Date.now();
    revEl.textContent='web:41';
    for (var i=0;i<DESIRED;i++) tasks.push({id:id(), ver:'v1', st:'RUNNING'});
    render(); log('service web: steady state — 4/4 running','ok');
  }
  document.getElementById('ecsx-reset').onclick = reset;
  setInterval(reconcile, 1200);
  reset();
})();
</script>

<p>That’s a Deployment rollout and a ReplicaSet self-heal, except nobody installed anything to get it. There’s no controller manager to version. You get all of this the moment you create a service.</p>

<p>When I help teams ship on ECS, this is where it clicks: you already understand ECS. If you can reason about desired state and reconciliation, the orchestration knowledge transfers completely. What doesn’t transfer is the operational surface area, and that’s the actual argument.</p>

<h2 id="what-you-stop-operating">What you stop operating</h2>

<p>This is the comparison that matters for a small team, and it’s the one nobody draws. The question isn’t which scheduler is smarter. They’re both fine. The question is whose pager each layer lands on.</p>

<p>Toggle ECS between Fargate and EC2 to see the middle ground:</p>

<div class="ecsx" id="ecsx-stack">
  <div class="ecsx-title">Who operates each layer
    <span class="ecsx-toggle"><button id="ecsx-fg" class="on">ECS · Fargate</button><button id="ecsx-ec2">ECS · EC2</button></span>
  </div>
  <div class="ecsx-cols">
    <div class="ecsx-col"><div class="ecsx-colhead" style="color:#8db8f8">EKS</div><div id="ecsx-col-eks"></div></div>
    <div class="ecsx-col"><div class="ecsx-colhead" style="color:#79d68a">ECS <span id="ecsx-mode">· Fargate</span></div><div id="ecsx-col-ecs"></div></div>
  </div>
  <div class="ecsx-score" id="ecsx-score"></div>
</div>

<script>
(function(){
  /* rows: [layer, EKS cell, ECS-Fargate cell, ECS-EC2 cell]; who: aws|you|na */
  var ROWS = [
    ['Control plane (API, scheduler, state store)',
      {who:'aws', txt:'AWS runs it — you pay $0.10/hr per cluster'},
      {who:'aws', txt:'AWS runs it — free'},
      {who:'aws', txt:'AWS runs it — free'}],
    ['Version upgrade treadmill',
      {who:'you', txt:'You initiate + test a cluster upgrade ~every 12–14 months, or pay 6× for extended support'},
      {who:'na',  txt:'Does not exist — there is no version'},
      {who:'na',  txt:'Does not exist — there is no version'}],
    ['Cluster add-ons (CNI, CoreDNS, kube-proxy)',
      {who:'you', txt:'You choose, install, and upgrade them — and they break during cluster upgrades'},
      {who:'na',  txt:'Built in (awsvpc networking). Not configurable, not breakable'},
      {who:'na',  txt:'Built in (awsvpc networking). Not configurable, not breakable'}],
    ['Ingress / load balancing',
      {who:'you', txt:'You install + upgrade the AWS Load Balancer Controller'},
      {who:'aws', txt:'Native ALB target-group integration'},
      {who:'aws', txt:'Native ALB target-group integration'}],
    ['Node OS, AMIs, patching',
      {who:'you', txt:'Yours — managed node groups help, but the reboot schedule is still your problem'},
      {who:'aws', txt:'No nodes. AWS patches the compute under you'},
      {who:'you', txt:'Yours — ASG AMI rotation, drain hooks, the works'}],
    ['Capacity planning + node autoscaling',
      {who:'you', txt:'Karpenter or Cluster Autoscaler — you configure and tune it'},
      {who:'aws', txt:'Per-task. You declare CPU/memory, AWS finds room'},
      {who:'you', txt:'Capacity providers + ASG sizing — bin-packing is back on you'}],
    ['Workload identity (cloud credentials)',
      {who:'you', txt:'RBAC + OIDC provider + IRSA annotations per service account'},
      {who:'aws', txt:'A plain IAM role on the task definition'},
      {who:'aws', txt:'A plain IAM role on the task definition'}],
  ];
  var eksCol = document.getElementById('ecsx-col-eks');
  var ecsCol = document.getElementById('ecsx-col-ecs');
  var score  = document.getElementById('ecsx-score');
  var modeEl = document.getElementById('ecsx-mode');
  var WHO = {aws:'AWS MANAGES', you:'YOU OPERATE', na:'— GONE —'};

  function cell(layer, c){
    return '<div class="ecsx-cell '+c.who+'"><span class="who">'+WHO[c.who]+'</span><b>'+layer+'</b><br>'+c.txt+'</div>';
  }
  function draw(fargate){
    var idx = fargate ? 2 : 3;
    eksCol.innerHTML = ROWS.map(function(r){ return cell(r[0], r[1]); }).join('');
    ecsCol.innerHTML = ROWS.map(function(r){ return cell(r[0], r[idx]); }).join('');
    var ye = ROWS.filter(function(r){return r[1].who==='you'}).length;
    var yc = ROWS.filter(function(r){return r[idx].who==='you'}).length;
    modeEl.textContent = fargate ? '· Fargate' : '· EC2';
    score.innerHTML = 'Layers on <b>your</b> pager — EKS: <b>'+ye+' of '+ROWS.length+'</b> · ECS '+
      (fargate?'on Fargate':'on EC2')+': <b>'+yc+' of '+ROWS.length+'</b>';
    document.getElementById('ecsx-fg').classList.toggle('on', fargate);
    document.getElementById('ecsx-ec2').classList.toggle('on', !fargate);
  }
  document.getElementById('ecsx-fg').onclick = function(){ draw(true); };
  document.getElementById('ecsx-ec2').onclick = function(){ draw(false); };
  draw(true);
})();
</script>

<p>Look at the EKS column. Six of the seven layers are yours. None of them are your product.</p>

<p>The upgrade treadmill deserves special attention because it’s the one that quietly eats small teams. Kubernetes ships about three releases a year, and EKS standard support for each lands around 14 months. That means a recurring, unskippable project roughly once a year, forever: test the control plane upgrade, upgrade the add-ons in the right order, chase whatever deprecated APIs your manifests use, then roll the nodes. Skip it and AWS moves you to extended support at six times the control plane price. For a platform team of 15, that’s Tuesday. For a team of four, it’s a sprint per year spent running to stand still. And there’s a quieter cost on top: you have to stay the kind of team that can do this safely.</p>

<p>ECS doesn’t have a version. I want to make sure that lands. There is no upgrade, no deprecation cycle, no “v1.29 removes the API your ALB controller depends on.” The control plane changed under you a hundred times last year and you never noticed. I have ECS services from 2021 that have never needed a maintenance commit. Infrastructure that doesn’t generate homework is worth more to a small team than anything on the Kubernetes feature list. It’s the same reason I tell teams to <a href="/2025/ecs-decisions-that-waste-6-weeks/">pick boring options everywhere else in the stack</a>: boring means you debug your app, not your platform.</p>

<p>On raw cost, the EKS control plane is about $73 a month per cluster and ECS’s is free, and that’s the least interesting line in the comparison. Run the numbers on engineering time instead. One sprint of one engineer’s time per year on cluster maintenance is $10-20k. The <a href="/2025/aws-cost-optimization-case-study/">biggest AWS savings I’ve ever found</a> came from deleting complexity, not from rightsizing it.</p>

<h2 id="what-you-give-up">What you give up</h2>

<p>If this were one-sided, EKS wouldn’t exist. Here’s what you actually lose.</p>

<p>The big one is the operator ecosystem. Kubernetes has operators for Postgres, Kafka, cert-manager, external-dns, ArgoCD, all debugged by thousands of teams over a decade. ECS has no CRDs and no operator pattern. The AWS answer is “use the managed service”: RDS instead of a Postgres operator, MSK instead of Strimzi. That works right up until you need something AWS doesn’t sell.</p>

<p>Tooling in general follows the same line. Vendors ship a Helm chart, not a task definition. Kustomize, the CNCF landscape, none of it targets ECS. And your deployment layer is AWS-native, so a future move off AWS means rewriting it. Your containers move unchanged, but the wiring around them doesn’t.</p>

<p>There’s also the hiring thing, and I won’t pretend it isn’t real. Engineers want Kubernetes on their CV. ECS knowledge is real orchestration knowledge and the concepts transfer completely, as the diagrams above show, but nobody’s career was ever advanced by the phrase “task definition.”</p>

<p>And ECS has a control ceiling. Custom schedulers, topology spread, network policy, the more exotic probe and init semantics: Kubernetes gives you knobs ECS simply doesn’t have. Most web products never touch them. If yours genuinely does, you’ll feel the ceiling and you’ll resent it.</p>

<h2 id="so-when-is-eks-the-right-call">So when is EKS the right call?</h2>

<p>EKS earns its keep when at least one of these is true:</p>

<ul>
  <li>Someone owns the platform. You have, or are hiring, people whose actual job is cluster operations, so the pager layers above land on a team that exists.</li>
  <li>You’re running stateful infrastructure on-cluster that AWS doesn’t offer as a managed service, and you need the operator ecosystem for it.</li>
  <li>Multi-cloud or on-prem is a real requirement: contractual, regulatory, or your customers deploy your software into their clusters.</li>
  <li>Your team is already fluent. K8s veterans ship faster on EKS than they would learning anything else. The tax is only a tax if you haven’t already paid it.</li>
</ul>

<p>If none of those describe you, and for most sub-ten-engineer teams shipping a web product none do, then Kubernetes isn’t buying you capability. It’s buying you a second job.</p>

<h2 id="the-takeaway">The takeaway</h2>

<p>ECS is not “Kubernetes for beginners.” It’s the same control loop idea with a deliberately smaller operational surface. Same desired state, same reconciliation, same rolling deploys, minus the version treadmill, the add-on stack, and the node fleet. You’ve seen the whole object model in this post. There is no part two where the hidden complexity lives.</p>

<p>Small teams don’t lose because they picked the wrong orchestrator. They lose because their best engineers spent the year operating infrastructure the product didn’t need. Pick the tool that generates the least homework, ship, and revisit when you have the head count to afford opinions.</p>

<p>If you’re starting an ECS build-out, the companion post on <a href="/2025/ecs-decisions-that-waste-6-weeks/">the 5 ECS decisions that waste 6 weeks</a> covers the concrete choices: Fargate vs EC2, service discovery, CI/CD, secrets, and monitoring.</p>

<hr />

<p><em>If this post saved you a meeting, it did its job. I write about AWS, DevOps, and building things from scratch. Subscribe via <a href="/feed.xml">RSS</a>, or find me on <a href="https://twitter.com/muhammad_o7">Twitter</a>.</em></p>

          ]]>
        </description>
        <pubDate>Tue, 09 Jun 2026 00:00:00 +0000</pubDate>
        <link>https://muhammadraza.me/2026/ecs-explained-visually/</link>
        <guid isPermaLink="true">https://muhammadraza.me/2026/ecs-explained-visually/</guid>
        
        <category>aws</category>
        
        <category>devops</category>
        
        <category>kubernetes</category>
        
        
        
        <dc:creator>{&quot;name&quot;=&gt;&quot;Muhammad Raza&quot;}</dc:creator>
        <dc:rights></dc:rights>
      </item>
    
      <item>
        <title>GGUF vs MLX: A Decision Guide, Not Another Benchmark</title>
        <description>
          <![CDATA[
            
            <p>Every few weeks someone downloads the GGUF build and the MLX build of the same model, runs both, screenshots the tokens-per-second counter, and posts it as proof that one format wins. The replies split down the middle. Half the thread says MLX is obviously faster, the other half says the test was rigged.</p>

<p>They are both right, which is the problem. The number on the screen is real and it is also not the number you actually wait for. And the format you should pick was never really about that number anyway.</p>

<p>I have gone through this decision enough times now, on my own machine and for clients standing up local inference, that I want to write down the part nobody puts in the comparison tables: GGUF versus MLX is a five-question decision, and only one of those questions is about speed.</p>

<h2 id="what-you-are-actually-choosing-between">What you are actually choosing between</h2>

<p>GGUF is the file format from the llama.cpp project. One file holds the quantized weights, the tokenizer, the chat template, and the metadata, and any runtime that can load it will run the model. That includes llama.cpp itself, Ollama, LM Studio, KoboldCPP, and a handful of others. It runs on basically everything: CPU, NVIDIA, AMD, Apple Metal, even a Raspberry Pi if you are patient. Portability is the whole point of the format.</p>

<p>MLX is not a file format. It is Apple’s array framework, the rough equivalent of PyTorch built specifically for Apple Silicon. An MLX model is a directory of safetensors files plus a config that the runtime reads directly. You convert and quantize a model in one command with <code class="language-plaintext highlighter-rouge">mlx_lm.convert</code>. The catch is in the name: MLX runs on Apple Silicon and nowhere else.</p>

<p>One thing worth clearing up before we go further, because it shows up in half the comparisons and it is out of date: people say GGUF does clever mixed-precision quantization while MLX is stuck on flat uniform 4-bit. The first half is true. The second half is not. Apple walked through per-layer mixed precision in their WWDC25 session on running large language models with MLX, including the trick of keeping the embedding and output layers at 6-bit while the rest of the model sits at 4-bit. MLX can do it. It is just that most of the MLX builds floating around Hugging Face do not bother, so in practice you often are comparing GGUF’s mixed precision against a uniform MLX quant. Worth knowing when you read someone else’s quality benchmark.</p>

<h2 id="the-number-on-the-screen-is-lying-to-you">The number on the screen is lying to you</h2>

<p>Quick detour, because it poisons most of the benchmarks you will find.</p>

<p>The tokens-per-second figure your runtime prints while text is streaming measures decode speed, the rate at which the model emits new tokens. It does not include prefill, the time the model spends reading your prompt before it says anything. For a chatty exchange with a short prompt that does not matter much. For an agent that stuffs tool output, a chunk of a file, and a system prompt into every turn, prefill is most of what you wait for, and the streaming counter never sees it.</p>

<p>There is a benchmark writeup that made the rounds on r/LocalLLaMA where the author’s UI proudly reported nearly twice the tokens per second on MLX as on GGUF, and then the actual wall-clock time had GGUF finishing first on most of the real tasks. Same machine, same model. The counter was not wrong. It was just answering a different question than the one that mattered.</p>

<p>Keep that in your head for the whole rest of this post. When I say one format is “faster” below, I mean wall-clock on a real workload, not the number that scrolls past while tokens stream.</p>

<h2 id="five-questions-that-actually-decide-it">Five questions that actually decide it</h2>

<h3 id="1-how-big-is-the-model-relative-to-your-ram">1. How big is the model relative to your RAM?</h3>

<p>This is the question that quietly settles a lot of arguments. Token generation is bounded by memory bandwidth, not compute. To emit one token the GPU has to read the entire model out of memory. On an M4 Pro with roughly 273 GB/s of bandwidth, a 4-bit 27B model weighing about 17 GB caps out near 16 tokens per second no matter what software you run. MLX cannot fetch bytes faster than the hardware allows, and neither can llama.cpp.</p>

<p>So for large models, the ones that fill most of your unified memory, the format barely matters for speed. They both hit the same wall. The interesting differences show up on smaller models, under roughly 8 to 14B, where the model fits comfortably and the bottleneck shifts from bandwidth to framework overhead. That is where MLX’s tighter, Apple-specific kernels pull ahead, often in the 15 to 40 percent range on single-user decode, and wider still on very small models that lean hardest on framework efficiency.</p>

<p>Small model, want it snappy: MLX has something real to offer. Big model that barely fits: pick on the other four questions, because speed is a wash.</p>

<h3 id="2-will-this-ever-need-to-run-somewhere-other-than-a-mac">2. Will this ever need to run somewhere other than a Mac?</h3>

<p>If there is any chance the same artifact has to run on a Linux box, a cloud GPU, or a teammate’s non-Apple machine, you want GGUF. The same file moves between all of them. MLX does not leave Apple Silicon, full stop. If you ship MLX as your only build and then need a CUDA fallback, you are re-quantizing under pressure.</p>

<p>This one overrides almost everything else. Portability is not a performance feature, but it is the feature you miss most when it is gone.</p>

<h3 id="3-what-does-your-workload-actually-look-like">3. What does your workload actually look like?</h3>

<p>Not “what model,” but the shape of the traffic. Specifically the ratio of input to output.</p>

<p>Workloads that feed the model a lot and ask for a little (classification, tool-calling agents with short replies, RAG with a big injected context) lean toward GGUF. llama.cpp has more battle-tested prompt caching and FlashAttention, and MLX’s prefix caching has historically been the less reliable of the two, especially on newer hybrid-attention models. When prefill dominates the wall clock, that maturity wins.</p>

<p>Workloads that take a short prompt and generate a lot (summaries, long-form chat, brainstorming) lean toward MLX. Once the model is past prefill and just streaming tokens, MLX’s decode advantage compounds, and the longer the reply the more it pays off.</p>

<p>There is a crossover point that depends on both context size and reply length. With a small prompt, MLX needs a couple hundred output tokens before its faster decode makes up for slower prefill. With a few thousand tokens of context, it needs several hundred more. If your agent’s replies are 150 tokens and its context keeps growing, you are living on the wrong side of that crossover, and GGUF is the better call.</p>

<h3 id="4-do-you-want-to-train-or-just-run">4. Do you want to train, or just run?</h3>

<p>GGUF is an inference format. You download it, you run it, that is the relationship. If you want to fine-tune, you convert back to safetensors, find a GPU, do the work, and convert forward again.</p>

<p>MLX is a full framework. You can fine-tune with LoRA or QLoRA directly on the Mac, merge adapters, and run speculative decoding with a small draft model, all natively. If part of your reason for going local is to actually adapt models and not just serve them, MLX is the only serious option on Apple Silicon, and this question alone can decide the whole thing.</p>

<h3 id="5-how-much-do-you-care-about-ecosystem-and-exact-fit">5. How much do you care about ecosystem and exact fit?</h3>

<p>Two practical edges for GGUF here. First, coverage: every open model gets GGUF builds within hours of release, including the obscure ones. MLX coverage is good for popular models and lags for everything else. Second, granularity. GGUF gives you a long ladder of quant levels, Q4_K_M, Q5_K_M, Q6_K, the I-quants, and so on, so when you have exactly 16 GB to work with you can usually find a quant that fits. MLX builds are mostly published at 4-bit and 8-bit, so you sometimes get a 4-bit that is a hair too small for the quality you want and an 8-bit that will not fit.</p>

<p>The edge on MLX’s side: it tends to get support for new Apple hardware features first, because Apple ships the metal abstraction in MLX before llama.cpp catches up.</p>

<h2 id="the-flowchart">The flowchart</h2>

<p>Put the five questions in order and most decisions fall out in about ten seconds.</p>

<ul>
  <li><strong>Need to run on anything other than Apple Silicon, now or later?</strong> → <strong>GGUF</strong>. Stop here, portability wins.</li>
  <li><strong>Staying on Apple Silicon. Do you want to fine-tune or train on-device?</strong> → <strong>MLX</strong>.</li>
  <li><strong>Inference only. Is your workload short-output and prefill-heavy</strong> (agents, RAG, classification)? → <strong>GGUF</strong>.</li>
  <li><strong>Long outputs, interactive, single user, latency you can feel?</strong> → <strong>MLX</strong>.</li>
  <li><strong>Need a precise quant to fit tight RAM, or running a just-released or obscure model?</strong> → <strong>GGUF</strong>.</li>
  <li><strong>Still undecided?</strong> → <strong>GGUF</strong>. It is the conservative default. Ship it, and A/B an MLX build later if throughput becomes the constraint.</li>
</ul>

<p>The short version: GGUF is what you pick when you are not sure, because it is the one that is hard to regret. MLX is what you pick when you own the hardware, run single-user, and have a specific reason, throughput on long outputs or on-device training, to want it.</p>

<h2 id="once-you-have-picked-pick-a-quant-level">Once you have picked, pick a quant level</h2>

<p>The format is half the decision. The bit width is the other half, and the defaults are good but not always right.</p>

<p>Start at <strong>Q4_K_M</strong> for GGUF or <strong>4-bit</strong> for MLX. Q4_K_M is the community default for a reason. It keeps most tensors at 4-bit, then bumps the quality-sensitive ones to 6-bit: the attention value weights and the feed-forward down-projection, on a portion of the layers. That holds quality better than a flat 4-bit quant at a small size cost. The reported quality loss against FP16 on MMLU is model-dependent but small: well under a point on a big model, creeping up toward a point or so on something under 8B, and a little more again for a uniform 4-bit MLX build. On a 30B-plus model that gap is noise. On something under 8B, especially on coding tasks where attention precision matters, it is visible, and you have two outs: stay on GGUF Q4_K_M, or move to MLX 6-bit, which closes the gap for roughly a 30 percent larger file.</p>

<p>If RAM is genuinely tight, GGUF’s <strong>I-quants</strong> with an importance matrix are the quality-per-byte champions at low bit widths. The cost is slower decode on CPU, so they make more sense when you are squeezing a model onto limited memory than when you are chasing speed.</p>

<p>One rule regardless of format: do not drop below roughly 3-bit without measuring quality on your own task. The aggregate benchmarks stop predicting what you will actually see down there.</p>

<h2 id="two-traps-that-will-flip-your-results">Two traps that will flip your results</h2>

<p><strong>The bf16 trap on M1 and M2.</strong> A lot of MLX builds ship as bf16, and on the M1 and M2 that data type does not get the accelerated path that fp16 does. During prefill those weights run un-accelerated and the penalty multiplies across every input token, which is part of why some “MLX is slow” reports come from older hardware. The fix is a one-minute reconvert with <code class="language-plaintext highlighter-rouge">--dtype float16</code>. If you are on an M1 or M2 and MLX feels sluggish, check this before you blame the format.</p>

<p><strong>Caching is the real variable.</strong> The biggest swings I have seen between runtimes were not about GGUF versus MLX at all, they were about whether prompt and KV caching actually worked for that model on that runtime. A runtime that reprocesses the full conversation every turn will lose to one that caches the prefix, regardless of format. Test caching with your real context lengths before you commit, and do not trust the streaming counter to tell you about it, because it never measures the part that caching fixes.</p>

<h2 id="so-which-one">So which one</h2>

<p>If you want the one-line version: GGUF is the conservative default, and you should reach for it whenever you are uncertain, need portability, or want a specific quant. Reach for MLX when you are locked to Apple Silicon, run single-user interactive workloads with long outputs, or want to fine-tune on the machine you already own.</p>

<p>And if you are choosing this for a team rather than a laptop, treat it as the architecture decision it is. The format you standardize on shapes your model coverage, your fallback options, and your serving setup for as long as the stack lives, and re-quantizing a fleet after the fact is the kind of avoidable week of work I keep getting hired to clean up. Decide it on the five questions, not on a screenshot.</p>

          ]]>
        </description>
        <pubDate>Wed, 03 Jun 2026 00:00:00 +0000</pubDate>
        <link>https://muhammadraza.me/2026/gguf-vs-mlx-decision-guide/</link>
        <guid isPermaLink="true">https://muhammadraza.me/2026/gguf-vs-mlx-decision-guide/</guid>
        
        <category>ai</category>
        
        <category>llm</category>
        
        <category>devops</category>
        
        <category>mac</category>
        
        
        
        <dc:creator>{&quot;name&quot;=&gt;&quot;Muhammad Raza&quot;}</dc:creator>
        <dc:rights></dc:rights>
      </item>
    
      <item>
        <title>Building CodeWiki: Compiling Codebases Into Living Wikis With LLMs</title>
        <description>
          <![CDATA[
            
            <p>Every coding agent session starts from zero. The agent doesn’t know how your code is organized, which files matter, how the pieces connect. It has to rediscover the architecture from scratch. Grep around, read some files, build a mental model, start working. That mental model disappears the moment the session ends.</p>

<p>I kept watching this happen. Ten minutes of exploration before any real work, every single time. If you work across multiple repos or come back to a project after a couple weeks, it’s worse. The agent is essentially reading the codebase for the first time, again.</p>

<p>I wanted to fix this.</p>

<h2 id="the-idea">The idea</h2>

<p>A few weeks ago Karpathy <a href="https://x.com/karpathy/status/2039805659525644595">tweeted</a> about using LLMs to build personal knowledge bases. The workflow: collect raw sources, have an LLM compile them into a structured wiki of markdown files, then query and build on that wiki over time. Every query makes the wiki richer. The knowledge adds up.</p>

<p>The part that stuck with me: he’s not using fancy RAG. The LLM maintains its own index files and summaries, and at his scale (~100 articles, ~400K words) it just works. The LLM reads its own compiled knowledge to answer questions.</p>

<p>Codebases are raw data too. Source files are unstructured information that happens to be executable. What if the LLM compiled a codebase into a wiki the same way, with module overviews, architecture docs, concept articles, and then used that wiki as its starting point for every session?</p>

<p>That’s <a href="https://github.com/mraza007/codewiki">CodeWiki</a>.</p>

<h2 id="how-it-works">How it works</h2>

<p>CodeWiki is a thin Rust CLI called <code class="language-plaintext highlighter-rouge">cw</code> paired with a Claude Code skill. The CLI handles git ops, directory scaffolding, and metadata. The agent does all the actual reading and writing. No API keys, no LLM calls from the CLI. Your agent is the intelligence.</p>

<p>When you run <code class="language-plaintext highlighter-rouge">cw init</code> in a repo, it creates a wiki directory at <code class="language-plaintext highlighter-rouge">~/.codewiki/&lt;project&gt;/</code> with this structure:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>~/.codewiki/my-project/
├── _index.md         # master index
├── _architecture.md  # system overview
├── _patterns.md      # recurring patterns
├── _meta.yaml        # last compiled commit
├── modules/          # one article per module
├── concepts/         # cross-cutting concerns
├── decisions/        # why things are the way they are
├── learnings/        # bugs fixed, patterns discovered
└── queries/          # past Q&amp;A, filed back
</code></pre></div></div>

<p>The first time you start a Claude Code session after init, the skill kicks in. The agent walks your codebase, reads the source files, and writes wiki articles. Module articles describe what each part of the code actually does. Not what it’s supposed to do, what it does. Key files, functions, data flow, connections to other modules.</p>

<p>Concept articles cut across modules. “How does error handling work across the system” or “how does data flow from request to response.” These are the questions that normally require reading eight files across four directories. The wiki answers them in one place.</p>

<h2 id="keeping-it-fresh">Keeping it fresh</h2>

<p>The wiki is only useful if it stays current. Every article has YAML frontmatter with a <code class="language-plaintext highlighter-rouge">source_files</code> field:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">title</span><span class="pi">:</span> <span class="s">Authentication Module</span>
<span class="na">type</span><span class="pi">:</span> <span class="s">module</span>
<span class="na">source_files</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">src/auth/middleware.py</span>
  <span class="pi">-</span> <span class="s">src/auth/tokens.py</span>
<span class="na">tags</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">auth</span><span class="pi">,</span> <span class="nv">middleware</span><span class="pi">,</span> <span class="nv">jwt</span><span class="pi">]</span>
<span class="nn">---</span>
</code></pre></div></div>

<p>The CLI tracks which commit the wiki was last compiled against. When you start a new session, <code class="language-plaintext highlighter-rouge">cw status</code> diffs against that commit and cross-references changed files against every article’s <code class="language-plaintext highlighter-rouge">source_files</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ cw status
Changed since last compile (4964c23):
  M src/auth/middleware.py
  M src/auth/tokens.py

Stale articles:
  ! modules/auth.md
</code></pre></div></div>

<p>The agent sees this and knows exactly what to re-read and update. No guessing, no full recompile.</p>

<p>At session end, the agent writes learnings and decisions back into the wiki. Fixed a bug? That becomes <code class="language-plaintext highlighter-rouge">learnings/auth-token-race-condition.md</code>. Made a design decision? That’s <code class="language-plaintext highlighter-rouge">decisions/switched-to-redis-sessions.md</code>. Then it updates <code class="language-plaintext highlighter-rouge">_meta.yaml</code> with the current commit hash.</p>

<p>Next session picks up where this one left off.</p>

<h2 id="the-cli">The CLI</h2>

<p>About 400 lines of Rust. Here are the commands:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cw init                <span class="c"># scaffold wiki for current repo</span>
cw status              <span class="c"># what changed since last compile</span>
cw path                <span class="c"># print wiki path</span>
cw projects            <span class="c"># list all wikis</span>
cw index               <span class="c"># rebuild _index.md from article frontmatter</span>
cw meta update         <span class="c"># record current commit as compiled</span>

cw setup claude-code   <span class="c"># install skill into Claude Code</span>
cw setup codex         <span class="c"># install instructions for Codex</span>
cw setup qmd           <span class="c"># register wiki as QMD search collection</span>
</code></pre></div></div>

<p>The CLI doesn’t make any LLM calls. It handles the things agents are bad at: tracking git state, knowing which files changed, maintaining timestamps. The agent handles what it’s good at: reading code and writing about it.</p>

<h2 id="search-with-qmd">Search with QMD</h2>

<p>For larger wikis, <a href="https://github.com/tobi/qmd">QMD</a> by Tobi Lutke adds proper search. It’s a local search engine for markdown with hybrid BM25 plus vector search plus a small reranker model. Running <code class="language-plaintext highlighter-rouge">cw setup qmd</code> registers your wiki as a searchable collection. The agent can then query the wiki through QMD’s MCP server during a session.</p>

<p>At the scale of most repos people actually work in, you probably don’t need it. A well organized wiki with an index file is enough for the LLM to navigate on its own. But when the wiki gets large, QMD keeps retrieval fast.</p>

<h2 id="viewing-with-obsidian">Viewing with Obsidian</h2>

<p>All wiki articles live at <code class="language-plaintext highlighter-rouge">~/.codewiki/</code>. Open that directory as an Obsidian vault and you get a browsable knowledge graph of all your projects. Articles use <code class="language-plaintext highlighter-rouge">[[backlinks]]</code> so modules connect to each other. The auth article links to <code class="language-plaintext highlighter-rouge">[[database]]</code> and <code class="language-plaintext highlighter-rouge">[[api]]</code>. You never have to write or edit these articles yourself. The agent maintains everything.</p>

<h2 id="why-not-rag">Why not RAG</h2>

<p>Traditional RAG chunks your code, embeds it, retrieves fragments when you ask a question. You get decontextualized snippets and hope the LLM can stitch them together.</p>

<p>CodeWiki is different. The LLM reads the code and writes structured articles about it. The auth article already connects the middleware to the token service to the database layer. That connection doesn’t exist in any single source file. It exists in the compiled understanding.</p>

<p>Karpathy found the same thing with his research wiki. You don’t need vector search over raw data when you have a well organized collection of articles. The LLM reads the index, finds the relevant articles, reads those. Simple and it works.</p>

<h2 id="getting-started">Getting started</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/mraza007/codewiki.git
<span class="nb">cd </span>codewiki
cargo <span class="nb">install</span> <span class="nt">--path</span> <span class="nb">.</span>

<span class="nb">cd </span>your-project
cw init
cw setup claude-code
</code></pre></div></div>

<p>Start a Claude Code session and the skill handles the rest. The project is MIT licensed and on <a href="https://github.com/mraza007/codewiki">GitHub</a>.</p>

          ]]>
        </description>
        <pubDate>Fri, 03 Apr 2026 00:00:00 +0000</pubDate>
        <link>https://muhammadraza.me/2026/building-codewiki-compiling-codebases-into-living-wikis/</link>
        <guid isPermaLink="true">https://muhammadraza.me/2026/building-codewiki-compiling-codebases-into-living-wikis/</guid>
        
        <category>ai</category>
        
        <category>rust</category>
        
        <category>tools</category>
        
        <category>devops</category>
        
        
        
        <dc:creator>{&quot;name&quot;=&gt;&quot;Muhammad Raza&quot;}</dc:creator>
        <dc:rights></dc:rights>
      </item>
    
      <item>
        <title>I Built an Orchestrator That Watches GitHub Issues and Sends Agents to Fix Them</title>
        <description>
          <![CDATA[
            
            <p>I have too many issues and not enough time. Same as everyone. The usual loop is: pick an issue, context switch into it, write the code, open a PR, pick the next one. Do that until the sprint ends or you lose the will.</p>

<p>Coding agents help with this. I can point Claude Code at an issue and let it work while I do something else. But that’s still one agent, one issue, one terminal. If I have 10 issues labeled “agent-ready,” I’m not babysitting 10 terminal tabs.</p>

<p>I wanted something that just watches for new issues and sends agents after them. Then OpenAI released their <a href="https://github.com/openai/symphony/blob/main/SPEC.md">Symphony spec</a>, an orchestrator pattern for their Codex agent. The architecture was solid: poll an issue tracker, dispatch agents into isolated workspaces, reconcile when issues close. But it was built around Codex and Linear, and I use Claude Code and GitHub Issues.</p>

<p>So I took the ideas I liked from Symphony and built my own. That’s <a href="https://github.com/mraza007/baton">Baton</a>.</p>

<h2 id="what-it-does">What it does</h2>

<p>Baton is a Python daemon. You start it in your repo, it polls GitHub Issues matching your configured labels, creates an isolated git worktree per issue, and runs Claude Code CLI as a subprocess. When the agent finishes and opens a PR, Baton releases the claim and grabs the next issue.</p>

<p>One config file. One command. Go do something else.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>WORKFLOW.md -&gt; Orchestrator -&gt; Worker (per issue)
                  |              |
                  |              +-- git worktree create
                  |              +-- hooks (before_run)
                  |              +-- claude -p "&lt;prompt&gt;"
                  |              +-- check issue state
                  |              +-- hooks (after_run)
                  |
                  +-- Poller (gh issue list)
                  +-- Dispatcher (concurrency control)
                  +-- Reconciler (stale run detection)
</code></pre></div></div>

<p>The name comes from relay races. You hand off the baton and the runner goes.</p>

<h2 id="the-config">The config</h2>

<p>Everything lives in <code class="language-plaintext highlighter-rouge">WORKFLOW.md</code>. YAML front matter for configuration, Jinja2 template below for the prompt. Baton reloads this file on every poll cycle, so you can change settings without restarting.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">tracker</span><span class="pi">:</span>
  <span class="na">kind</span><span class="pi">:</span> <span class="s">github</span>
  <span class="na">labels</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">agent"</span><span class="pi">]</span>
  <span class="na">exclude_labels</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">blocked"</span><span class="pi">]</span>

<span class="na">polling</span><span class="pi">:</span>
  <span class="na">interval_ms</span><span class="pi">:</span> <span class="m">30000</span>

<span class="na">agent</span><span class="pi">:</span>
  <span class="na">max_concurrent</span><span class="pi">:</span> <span class="m">3</span>
  <span class="na">max_turns</span><span class="pi">:</span> <span class="m">5</span>
  <span class="na">command</span><span class="pi">:</span> <span class="s">claude</span>
  <span class="na">permission_mode</span><span class="pi">:</span> <span class="s">bypassPermissions</span>

<span class="na">hooks</span><span class="pi">:</span>
  <span class="na">before_run</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">git fetch origin main &amp;&amp; git rebase origin/main</span>
  <span class="na">timeout_ms</span><span class="pi">:</span> <span class="m">60000</span>
<span class="nn">---</span>

<span class="s">You are an autonomous software engineer working on issue</span> <span class="c1">#{{ issue.number " }}: {{ issue.title " }}.</span>

<span class="pi">{{</span> <span class="nv">issue.body "</span> <span class="pi">}}</span>

<span class="pi">{</span><span class="err">%</span> <span class="nv">if attempt %</span><span class="pi">}</span>
<span class="s">This is continuation attempt {{ attempt " }}. Review what was done and continue.</span>
<span class="pi">{</span><span class="err">%</span> <span class="nv">endif %</span><span class="pi">}</span>

<span class="c1">## Instructions</span>

<span class="s">1. Understand the issue requirements</span>
<span class="s">2. Write clean, well-tested code</span>
<span class="s">3. Run existing tests to make sure nothing breaks</span>
<span class="s">4. Commit your changes with a descriptive message</span>
<span class="s">5. Push the branch and create a pull request linking to</span> <span class="c1">#{{ issue.number " }}</span>
</code></pre></div></div>

<p>Labels filter which issues get picked up. <code class="language-plaintext highlighter-rouge">max_concurrent</code> controls parallel agents. <code class="language-plaintext highlighter-rouge">max_turns</code> is the retry limit per issue. Hooks run shell commands at different points. I use <code class="language-plaintext highlighter-rouge">before_run</code> to rebase on main so the agent starts from fresh code.</p>

<p>The prompt template gets <code class="language-plaintext highlighter-rouge">issue.number</code>, <code class="language-plaintext highlighter-rouge">issue.title</code>, <code class="language-plaintext highlighter-rouge">issue.body</code>, <code class="language-plaintext highlighter-rouge">issue.labels</code>, and <code class="language-plaintext highlighter-rouge">attempt</code> for retries. Standard Jinja2.</p>

<h2 id="why-worktrees">Why worktrees</h2>

<p>Each issue gets its own worktree under <code class="language-plaintext highlighter-rouge">.symphony/worktrees/</code>, with a branch name slugified from the issue title: <code class="language-plaintext highlighter-rouge">baton/fix-login-redirect-42</code>.</p>

<p>I thought about Docker containers and temp directories but worktrees won out. They share the git object database so creating one is almost instant, unlike a full clone. They’re real checkouts, so linters and test runners and build scripts all work without any path hacking. And they’re isolated. If one agent trashes its branch, the others don’t care.</p>

<h2 id="why-gh-cli-instead-of-the-github-api">Why <code class="language-plaintext highlighter-rouge">gh</code> CLI instead of the GitHub API</h2>

<p>Baton shells out to <code class="language-plaintext highlighter-rouge">gh issue list</code> and <code class="language-plaintext highlighter-rouge">gh pr create</code> instead of using PyGitHub or the REST API. Seems odd, but think about setup.</p>

<p>With the API, you need a personal access token. You need to configure it somewhere. You need to handle rate limits.</p>

<p>With <code class="language-plaintext highlighter-rouge">gh</code>, you authenticate once (<code class="language-plaintext highlighter-rouge">gh auth login</code>) and everything on your machine uses the same credentials. No token management in the orchestrator. The tradeoff is speed, but Baton polls every 30 seconds. The overhead of a subprocess call doesn’t matter at that pace.</p>

<h2 id="the-permission-problem">The permission problem</h2>

<p>This tripped me up. Claude Code has permission modes: <code class="language-plaintext highlighter-rouge">default</code> asks for everything, <code class="language-plaintext highlighter-rouge">acceptEdits</code> auto-approves file edits but prompts for shell commands, and <code class="language-plaintext highlighter-rouge">bypassPermissions</code> auto-approves everything.</p>

<p>I started with <code class="language-plaintext highlighter-rouge">acceptEdits</code> because it felt like the right balance. Let the agent write code freely, but make it ask before running commands. Problem: “ask” means a human clicking yes, and in an autonomous orchestrator there’s no human. The agent just blocks forever waiting for a prompt nobody will answer.</p>

<p>I wasted about 20 minutes watching it hang before I figured this out. For autonomous operation you need <code class="language-plaintext highlighter-rouge">bypassPermissions</code>, which maps to <code class="language-plaintext highlighter-rouge">--dangerously-skip-permissions</code>. The flag name is honest about the risk. I’m comfortable with it because the agents run in isolated worktrees on disposable branches, not in my main checkout.</p>

<h2 id="auto-releasing-on-pr-creation">Auto-releasing on PR creation</h2>

<p>My first version had a dumb problem. The agent would finish its work, create a PR on turn 2 of 5, and Baton would keep scheduling continuation turns for the remaining 3. The slot was occupied but nobody was doing anything useful.</p>

<p>The fix: after each worker finishes, check if a PR exists for that issue’s branch. If yes, release the claim immediately and free up the slot. If not, schedule a short retry.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pr_exists</span> <span class="o">=</span> <span class="k">await</span> <span class="bp">self</span><span class="p">.</span><span class="n">tracker</span><span class="p">.</span><span class="n">check_pr_exists</span><span class="p">(</span><span class="n">issue</span><span class="p">.</span><span class="n">number</span><span class="p">)</span>
<span class="k">if</span> <span class="n">pr_exists</span><span class="p">:</span>
    <span class="n">log</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s">"PR_READY #</span><span class="si">{</span><span class="n">issue</span><span class="p">.</span><span class="n">number</span><span class="si">}</span><span class="s"> -- PR found, releasing claim"</span><span class="p">)</span>
    <span class="k">return</span> <span class="s">"pr_created"</span>
<span class="k">return</span> <span class="s">"no_pr"</span>
</code></pre></div></div>

<p>Small change, but it meant the orchestrator stopped wasting time on finished work.</p>

<h2 id="extensibility-through-skills-and-mcp-servers">Extensibility through skills and MCP servers</h2>

<p>Baton itself is deliberately simple. It polls, dispatches, and manages worktrees. The interesting part is what you put in the prompt and what tools you give the agent.</p>

<p>Claude Code supports MCP servers, which means you can wire up external tools and the agent can use them during its run. Baton passes MCP server config through to each worker:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">agent</span><span class="pi">:</span>
  <span class="na">mcp_servers</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">playwright</span>
      <span class="na">command</span><span class="pi">:</span> <span class="s">npx @playwright/mcp@latest</span>
</code></pre></div></div>

<p>That means the agent has access to a headless browser while it works. It can open a page, click around, take screenshots, verify that the UI renders correctly. You don’t have to build that into Baton. You just declare which MCP servers you want and the agent figures out when to use them.</p>

<p>Same idea with CLI tools. If <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a> is installed on the machine, you can tell the agent to use it in the prompt template. “Before creating a PR, open the app with agent-browser and verify the acceptance criteria.” The agent spins up a local server, opens the page, clicks buttons, fills inputs, takes snapshots. All from instructions in WORKFLOW.md, nothing hardcoded in the orchestrator.</p>

<p>Claude Code also has skills, which are reusable prompt fragments that teach the agent specific capabilities. If you have a code review skill or a testing skill installed, the agent can use them during its run. Baton’s config supports a <code class="language-plaintext highlighter-rouge">skills</code> list for this:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">agent</span><span class="pi">:</span>
  <span class="na">skills</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">code-reviewer</span>
    <span class="pi">-</span> <span class="s">accessibility-checker</span>
</code></pre></div></div>

<p>You can also override skills per issue by adding a <code class="language-plaintext highlighter-rouge">## Skills</code> section to the issue body. If one issue needs Playwright but the others don’t, just add it to that issue.</p>

<p>The point is that Baton doesn’t need to know about browsers or test runners or linters. It just needs to dispatch agents with the right config. The prompt and the tools do the rest.</p>

<h2 id="putting-it-together-a-todo-app-from-scratch">Putting it together: a todo app from scratch</h2>

<p>To see all of this working end to end, I had Baton build a todo app. Fresh repo, no code. I created three GitHub issues labeled <code class="language-plaintext highlighter-rouge">baton</code>:</p>

<ol>
  <li>Create basic HTML structure</li>
  <li>Add JavaScript for create/delete</li>
  <li>Add localStorage persistence</li>
</ol>

<p>The WORKFLOW.md prompt told the agent to use agent-browser for verification before opening PRs. I ran <code class="language-plaintext highlighter-rouge">baton start</code> and went to make coffee.</p>

<p>Baton picked up issue #1, created a worktree on <code class="language-plaintext highlighter-rouge">baton/create-basic-todo-app-html-structure-1</code>, and dispatched Claude Code. The agent wrote <code class="language-plaintext highlighter-rouge">index.html</code>, spun up a local server with <code class="language-plaintext highlighter-rouge">npx serve</code>, opened it with agent-browser, confirmed the layout rendered, then committed, pushed, and opened a PR. The PR description included what agent-browser found:</p>

<blockquote>
  <p>Opened <code class="language-plaintext highlighter-rouge">http://localhost:3456</code> and confirmed the page renders correctly.
Ran <code class="language-plaintext highlighter-rouge">agent-browser snapshot -i</code> confirming interactive elements: textbox and button.</p>
</blockquote>

<p>I merged it. The issue auto-closed (the PR had <code class="language-plaintext highlighter-rouge">Closes #1</code>). Baton saw the issue was gone on the next poll, released the slot, and picked up issue #2. Same cycle. Then #3.</p>

<p>Three issues, three PRs, three merges. I didn’t write a line of the todo app. The agent-browser verification wasn’t built into Baton. It was just instructions in the prompt and a CLI tool on my machine.</p>

<h2 id="getting-started">Getting started</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install</span> <span class="nt">-e</span> <span class="nb">.</span>
<span class="nb">cp </span>WORKFLOW.md.example WORKFLOW.md
<span class="c"># Edit WORKFLOW.md: set your labels, tweak the prompt</span>
baton start
</code></pre></div></div>

<p>You need Python 3.11+, Claude Code CLI (<code class="language-plaintext highlighter-rouge">claude</code>), GitHub CLI (<code class="language-plaintext highlighter-rouge">gh</code>) authenticated, and Git.</p>

<p>The code is at <a href="https://github.com/mraza007/baton">github.com/mraza007/baton</a>. MIT licensed. About 10 Python modules, no external services, no databases. State lives in memory with JSON persistence for the status command.</p>

<h2 id="what-i-want-to-add-next">What I want to add next</h2>

<ul>
  <li>A proper TUI instead of <code class="language-plaintext highlighter-rouge">baton status</code> reading a JSON file</li>
  <li>Issue dependency ordering so issue 3 waits for issue 2 if it needs to</li>
  <li>Cost tracking per issue, so I can see what automating the backlog actually costs in tokens</li>
  <li>More trackers besides GitHub Issues (Linear, Jira, GitLab)</li>
</ul>

<p>If you’ve got a repo with a pile of issues sitting there, try pointing Baton at it. Start with one label and <code class="language-plaintext highlighter-rouge">max_concurrent: 1</code>. See what it does. The setup takes about five minutes and the worst case is you get a bad PR that you close. The code is MIT licensed, the whole thing is ten files, and there’s nothing weird in it. Fork it, break it, rip out the parts you don’t like.</p>

<p>If you try it, I want to hear what breaks.</p>

<hr />

<p>I write a newsletter called <a href="https://devconsole.substack.com/">Dev Console</a> where I cover what’s actually happening in AI, minus the hype. New tools, real use cases, stuff I’m building. If this post was interesting, you’ll probably like it.</p>

          ]]>
        </description>
        <pubDate>Fri, 27 Mar 2026 00:00:00 +0000</pubDate>
        <link>https://muhammadraza.me/2026/building-baton-autonomous-agent-orchestrator/</link>
        <guid isPermaLink="true">https://muhammadraza.me/2026/building-baton-autonomous-agent-orchestrator/</guid>
        
        <category>ai</category>
        
        <category>python</category>
        
        <category>tools</category>
        
        <category>automation</category>
        
        
        
        <dc:creator>{&quot;name&quot;=&gt;&quot;Muhammad Raza&quot;}</dc:creator>
        <dc:rights></dc:rights>
      </item>
    
      <item>
        <title>Harness Engineering: The DevOps Skill Nobody Told You About</title>
        <description>
          <![CDATA[
            
            <p>I’ve written before about how <a href="/2026/ai-agents-devops-perspective/">AI agents are just CI pipelines with an LLM plugged in</a>. That post mapped agent concepts to infrastructure patterns you already know. But there’s a discipline forming around the infrastructure side of agents that deserves its own name.</p>

<p>Harness engineering. It’s the practice of building everything around the LLM — the execution environment, tool definitions, safety boundaries, observability, and lifecycle management. The stuff that turns a chatbot into a production system. (If you want the full definition of what an agent harness is and where the term came from, I’ve written <a href="/2026/what-is-an-agent-harness/">a dedicated post on that</a>.)</p>

<p>If you work in DevOps, you’ve been doing this for years. You just called it something else.</p>

<h2 id="why-harnesses-matter-more-than-models">Why Harnesses Matter More Than Models</h2>

<p>Pick any AI agent demo. Strip out the model. What’s left?</p>

<p>A container or sandbox. A set of callable tools. A loop that reads output and decides what happens next. Logging. Timeouts. Cleanup.</p>

<p>That’s the harness. And it’s where agents succeed or fail. A great model in a bad harness hallucinates, loops forever, leaks secrets, or silently does nothing useful. A decent model in a good harness stays bounded, recovers from errors, and produces auditable results.</p>

<p>DevOps engineers already think this way. You don’t just pick a good application — you build the infrastructure that makes it reliable. Same thing here.</p>

<h2 id="the-five-parts-of-a-harness">The Five Parts of a Harness</h2>

<p>Here’s how I break down harness engineering into components. Each one maps directly to something you’ve built before.</p>

<p><strong>1. Execution environment.</strong> Where does the agent run? A container, a VM, a temporary directory, a git worktree. You need isolation so the agent can’t corrupt shared state. You need reproducibility so runs are consistent. This is the same problem as CI job runners. Docker, Firecracker, nsjail — pick your isolation boundary.</p>

<p><strong>2. Tool definitions.</strong> Tools are the agent’s API surface. Read a file. Run a command. Query a database. Call an endpoint. Each tool needs input validation, output formatting, error handling, and permission scoping. Think of it like designing an API — you wouldn’t expose raw database access through a REST endpoint. Don’t give an agent raw shell access either. The tool layer is your contract.</p>

<p><strong>3. Control loop.</strong> Observe, decide, execute, verify. The loop is what makes an agent an agent instead of a one-shot prompt. Your job as a harness engineer is to decide: how many iterations? What’s the timeout per step? What happens when a tool call fails? When does the loop escalate to a human? This is the same logic you put in health check loops and deployment rollback controllers.</p>

<p><strong>4. Guardrails.</strong> Cost caps. Token limits. Command allowlists. File path restrictions. Rate limiting on external calls. Without guardrails, an agent can burn through your API budget in minutes or write to paths it shouldn’t touch. Every guardrail is a policy decision — same as IAM policies, network rules, and resource quotas you already manage.</p>

<p><strong>5. Observability.</strong> If you can’t see what the agent did, you can’t debug it, audit it, or trust it. Log every tool call, every LLM response, every decision point. Capture diffs, timing, token usage, and cost. This is no different from structured logging in any production system. The difference is that agent traces are longer and less predictable than HTTP request traces, so you need good tooling to navigate them.</p>

<h2 id="where-devops-context-overlaps">Where DevOps Context Overlaps</h2>

<p>Here’s where your existing skills plug in directly.</p>

<p><strong>Infrastructure as code.</strong> Agent harnesses should be declarative and version-controlled. The tool definitions, policies, and environment specs should live in config files, not hardcoded in application logic. When you change a tool’s behavior, that change should be reviewable in a PR.</p>

<p><strong>Pipeline orchestration.</strong> Multi-agent systems look a lot like multi-stage pipelines. One agent does research, passes context to a planning agent, which passes a plan to an implementation agent. You’re managing handoffs, shared artifacts, and failure propagation — the same coordination problem as CI/CD stages.</p>

<p><strong>Incident response.</strong> When an agent goes wrong, you need the same muscle memory. Check the logs. Find the failing step. Understand the input that caused it. Roll back if needed. The debugging workflow is identical.</p>

<p><strong>Security boundaries.</strong> Least privilege applies to agents just like it applies to services. What tools can this agent access? What files can it read? Can it make network calls? Can it spend money? Every agent needs a security boundary, and DevOps engineers already think in terms of boundaries.</p>

<h2 id="getting-started">Getting Started</h2>

<p>If you want to start building harnesses, you don’t need a new framework. Start with what you have.</p>

<p>Take a simple task — say, analyzing a failed CI build. Write a script that collects the logs, sends them to an LLM with a prompt, parses the response, and posts a summary to Slack. That’s a harness. A minimal one, but it has all the components: environment setup, tool use (log collection, Slack posting), a control flow, and output handling.</p>

<p>Then add complexity. Let the LLM decide which logs to fetch. Add a retry loop. Add a cost cap. Add structured logging. Each addition is a harness engineering decision.</p>

<p>You don’t need to learn ML. You don’t need to fine-tune models. You need to build the infrastructure that makes models useful — and that’s the job you already do.</p>

<p>Harness engineering isn’t a new discipline. It’s DevOps applied to a new kind of workload. The sooner you see it that way, the faster you’ll build agents that actually work in production.</p>

          ]]>
        </description>
        <pubDate>Sat, 14 Mar 2026 00:00:00 +0000</pubDate>
        <link>https://muhammadraza.me/2026/harness-engineering-devops-perspective/</link>
        <guid isPermaLink="true">https://muhammadraza.me/2026/harness-engineering-devops-perspective/</guid>
        
        <category>ai</category>
        
        <category>devops</category>
        
        <category>automation</category>
        
        
        
        <dc:creator>{&quot;name&quot;=&gt;&quot;Muhammad Raza&quot;}</dc:creator>
        <dc:rights></dc:rights>
      </item>
    
      <item>
        <title>I Built Local Memory for Coding Agents Because They Keep Forgetting Everything</title>
        <description>
          <![CDATA[
            
            <p>Here’s something that frustrates me about coding agents. They forget everything. Every single session starts from scratch. The agent that spent 45 minutes yesterday figuring out your authentication flow? Gone. The decision to use JWT over sessions? Gone. The bug it found in your ORM’s lazy loading? Gone.</p>

<p>You start a new session and it re-discovers the same patterns. Repeats the same mistakes. Asks the same questions. It’s like working with a brilliant colleague who gets amnesia every night.</p>

<p>I got tired of this. So I built <a href="https://github.com/mraza007/echovault">EchoVault</a> — a local memory system that gives coding agents persistent memory across sessions. No cloud. No API keys. No cost. Just a SQLite database and some Markdown files on your machine.</p>

<h2 id="the-problem-is-real">The Problem Is Real</h2>

<p>I use coding agents daily across multiple client projects. Claude Code, Cursor, Codex — I switch between them depending on the task. Every time I start a session, I’m repeating context that the agent should already know.</p>

<p>“We chose FastAPI over Flask because of async support.”
“The deploy script needs –no-cache or the CSS breaks.”
“Don’t touch the legacy auth module — it’s being replaced next sprint.”</p>

<p>I was copy-pasting this stuff into every session. That’s not how tools should work.</p>

<p>I tried existing solutions. Supermemory announced their MCP and I was tempted, but it saves everything in the cloud. I work with multiple companies as a consultant — I don’t want codebase decisions stored on someone else’s servers. Claude Mem was the first tool I tried, but it was eating too much memory in my sessions and became a bottleneck when running multiple agents at the same time.</p>

<p>So I built my own.</p>

<h2 id="how-echovault-works">How EchoVault Works</h2>

<p>EchoVault runs as an MCP server. When your agent starts a session, it has three tools available:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">memory_context</code> — load prior decisions, bugs, and context for the current project</li>
  <li><code class="language-plaintext highlighter-rouge">memory_search</code> — find specific memories by keyword or semantic similarity</li>
  <li><code class="language-plaintext highlighter-rouge">memory_save</code> — persist a decision, bug fix, pattern, or learning</li>
</ul>

<p>The agent calls these tools like it calls any other tool. No hooks. No shell scripts. No prompt injection. The MCP protocol handles everything.</p>

<p>Here’s what happens in practice:</p>

<p><strong>Session start.</strong> The agent sees <code class="language-plaintext highlighter-rouge">memory_context</code> in its available tools. The tool description says “You MUST call this at session start.” The agent calls it and gets back a list of prior memories for the project. Now it knows what happened yesterday.</p>

<p><strong>During work.</strong> You ask about authentication. The agent calls <code class="language-plaintext highlighter-rouge">memory_search</code> with “authentication” and gets back the decision to use JWT, the bug with token refresh, and the migration plan. It has context before writing a single line of code.</p>

<p><strong>Session end.</strong> The agent just fixed a tricky race condition. The tool description says “You MUST call memory_save before ending any session where you made changes.” It saves the root cause, the fix, and what to watch for.</p>

<p>Next session, that knowledge is there. Every session builds on the last one.</p>

<h2 id="the-architecture">The Architecture</h2>

<p>I kept it simple. The whole system is four things:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>~/.memory/
├── vault/                    # Obsidian-compatible Markdown
│   └── my-project/
│       └── 2026-02-01-session.md
├── index.db                  # SQLite: FTS5 + sqlite-vec
└── config.yaml               # Optional embedding config
</code></pre></div></div>

<p><strong>Markdown vault.</strong> Every memory gets written to a session file — one file per day per project. These are valid Markdown with YAML frontmatter. You can point Obsidian at <code class="language-plaintext highlighter-rouge">~/.memory/vault/</code> and browse your agent’s memory visually. You can read them in any editor. They’re not locked in a proprietary format.</p>

<p><strong>SQLite index.</strong> This is where search happens. FTS5 handles keyword search out of the box — no configuration needed. If you want semantic search (where “authentication” matches a memory titled “JWT token setup”), add an embedding provider. I use Ollama with <code class="language-plaintext highlighter-rouge">nomic-embed-text</code> locally. You can also use OpenAI or OpenRouter if you prefer cloud.</p>

<p><strong>MCP server.</strong> The agent talks to EchoVault through the Model Context Protocol. Three tools, stdio transport, nothing fancy. The server starts when the agent needs it and stops when the session ends. Zero idle cost.</p>

<p><strong>Secret redaction.</strong> Three layers. Explicit <code class="language-plaintext highlighter-rouge">&lt;redacted&gt;</code> tags for things you mark yourself. Pattern detection that catches API keys, passwords, and credentials automatically. And <code class="language-plaintext highlighter-rouge">.memoryignore</code> rules for custom patterns. Nothing sensitive hits disk.</p>

<h2 id="making-agents-actually-save">Making Agents Actually Save</h2>

<p>Here’s the thing about MCP tools — the agent <em>can</em> call them, but will it? Retrieval works well because agents tend to grab context at the start. Saving is the hard part. The agent finishes its work and moves on. It doesn’t naturally think “I should save what I learned.”</p>

<p>The trick is the tool descriptions. When you register an MCP tool, you include a description. Agents read these descriptions and treat them as instructions. So instead of:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>"Save a memory for future sessions. Call this when you make decisions."
</code></pre></div></div>

<p>I wrote:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>"Save a memory for future sessions. You MUST call this before ending
any session where you made changes, fixed bugs, made decisions, or
learned something. This is not optional — failing to save means the
next session starts from zero."
</code></pre></div></div>

<p>That “MUST” language makes a real difference. It’s not 100% reliable — nothing with LLMs is — but agents follow strong tool descriptions much more consistently than passive ones.</p>

<h2 id="cross-agent-memory">Cross-Agent Memory</h2>

<p>One of the things I wanted was a single vault for all my agents. A memory saved by Claude Code should be searchable from Cursor or Codex. They’re all working on the same codebase. Why should they have separate memories?</p>

<p>EchoVault stores everything in one place. The MCP server is the same regardless of which agent connects to it. Setup is one command per agent:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>memory setup claude-code   <span class="c"># writes ~/.claude.json</span>
memory setup cursor        <span class="c"># writes .cursor/mcp.json</span>
memory setup codex         <span class="c"># writes .codex/config.toml + AGENTS.md</span>
memory setup opencode      <span class="c"># writes opencode.json</span>
</code></pre></div></div>

<p>Each agent has its own config format and conventions. Claude Code uses JSON with <code class="language-plaintext highlighter-rouge">mcpServers</code>. Cursor uses the same schema but different file paths. Codex uses TOML with <code class="language-plaintext highlighter-rouge">[mcp_servers]</code>. OpenCode uses JSON with a <code class="language-plaintext highlighter-rouge">mcp</code> key and a different command format (<code class="language-plaintext highlighter-rouge">command</code> as an array instead of separate <code class="language-plaintext highlighter-rouge">command</code> + <code class="language-plaintext highlighter-rouge">args</code>).</p>

<p>I wrote shared helpers so each agent’s setup is just a thin wrapper around <code class="language-plaintext highlighter-rouge">_install_mcp_servers()</code> or <code class="language-plaintext highlighter-rouge">_install_toml_mcp()</code>. Adding a new agent takes maybe 20 lines of code.</p>

<h2 id="what-gets-saved">What Gets Saved</h2>

<p>Not everything should be a memory. Trivial changes don’t need to be persisted. Information that’s obvious from reading the code doesn’t need a memory. The goal is to capture what a future agent wouldn’t know from just looking at the codebase.</p>

<p>Good memories:</p>

<ul>
  <li><strong>Decisions.</strong> “Chose JWT over sessions because the API needs to be stateless.” A future agent reading the code sees JWT but doesn’t know <em>why</em>.</li>
  <li><strong>Bugs.</strong> “The ORM lazy-loads relationships by default, causing N+1 queries in the user list endpoint. Fixed by adding <code class="language-plaintext highlighter-rouge">.options(joinedload(...))</code>. Root cause: SQLAlchemy default behavior.” A future agent won’t hit the same bug.</li>
  <li><strong>Patterns.</strong> “All API endpoints follow the pattern: validate input, check permissions, execute, return response. Don’t add business logic in the route handler.” A future agent follows the existing patterns instead of inventing new ones.</li>
  <li><strong>Context.</strong> “The legacy auth module is being replaced. Don’t modify it — changes go into the new auth service at <code class="language-plaintext highlighter-rouge">src/auth/v2/</code>.” A future agent doesn’t waste time on dead code.</li>
</ul>

<p>Each memory has a title, a “what happened” summary, optional “why” and “impact” fields, tags, and a category. Search returns compact ~50-token summaries. Full details are fetched on demand so context windows don’t get bloated.</p>

<h2 id="the-technical-bits">The Technical Bits</h2>

<p>A few implementation details that might be useful if you’re building something similar.</p>

<p><strong>FTS5 for keyword search.</strong> SQLite’s FTS5 extension is fast and works with zero configuration. No external service needed. It handles stemming, phrase matching, and ranking. For most use cases, this is all you need.</p>

<p><strong>sqlite-vec for semantic search.</strong> When you want “authentication” to match “JWT token rotation”, you need vectors. I use <code class="language-plaintext highlighter-rouge">sqlite-vec</code> to store embeddings right in the same SQLite database. No vector database needed. Embedding providers are pluggable — Ollama for local, OpenAI or OpenRouter for cloud.</p>

<p><strong>Hybrid search.</strong> The search pipeline runs FTS5 first (fast, precise), then semantic search (slower, fuzzy), and merges the results. This gives you the best of both worlds — exact keyword matches and semantic similarity.</p>

<p><strong>TOML parsing with fallbacks.</strong> Codex writes some non-standard TOML — unquoted filesystem paths as table keys, dotted version strings as key names. Standard <code class="language-plaintext highlighter-rouge">tomllib</code> chokes on these. I added a fallback that appends the MCP section directly via string operations when parsing fails. It’s not pretty but it handles real-world config files.</p>

<p><strong>Symlink handling.</strong> Some agents create symlinks in their skill directories. <code class="language-plaintext highlighter-rouge">shutil.rmtree()</code> crashes on symlinks. Small thing but it bit me in production.</p>

<h2 id="setting-it-up">Setting It Up</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install </span>git+https://github.com/mraza007/echovault.git
memory init
memory setup claude-code
</code></pre></div></div>

<p>That’s it. Three commands. The agent has memory now.</p>

<p>If you want semantic search, configure an embedding provider:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>memory config init
<span class="c"># Edit ~/.memory/config.yaml to set your provider</span>
memory reindex
</code></pre></div></div>

<p>For fully local operation with no external API calls, use Ollama:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">embedding</span><span class="pi">:</span>
  <span class="na">provider</span><span class="pi">:</span> <span class="s">ollama</span>
  <span class="na">model</span><span class="pi">:</span> <span class="s">nomic-embed-text</span>
</code></pre></div></div>

<h2 id="what-ive-learned">What I’ve Learned</h2>

<p>Building this taught me a few things about agent tooling.</p>

<p><strong>Tool descriptions are instructions.</strong> Agents read them and follow them. Strong, directive language in tool descriptions is more effective than passive documentation. “You MUST” works better than “You can.”</p>

<p><strong>Local-first matters.</strong> Not because of ideology, but because of practical constraints. Consultants work with multiple clients. Sensitive decisions shouldn’t leave the machine. And when your internet goes out, local tools still work.</p>

<p><strong>MCP is the right abstraction.</strong> Instead of writing agent-specific hooks, skills, and config formats, I write one MCP server and each agent connects to it. When a new agent comes along, I add a setup function for its config format. The memory logic doesn’t change.</p>

<p><strong>Simple storage wins.</strong> Markdown files you can read in any editor. SQLite you can query with any tool. No custom binary formats. No daemon to keep running. The system is completely inspectable and debuggable.</p>

<p>The code is at <a href="https://github.com/mraza007/echovault">github.com/mraza007/echovault</a>. It’s MIT licensed. If you’re tired of your agents forgetting everything, give it a shot.</p>

          ]]>
        </description>
        <pubDate>Tue, 17 Feb 2026 00:00:00 +0000</pubDate>
        <link>https://muhammadraza.me/2026/building-local-memory-for-coding-agents/</link>
        <guid isPermaLink="true">https://muhammadraza.me/2026/building-local-memory-for-coding-agents/</guid>
        
        <category>ai</category>
        
        <category>python</category>
        
        <category>tools</category>
        
        
        
        <dc:creator>{&quot;name&quot;=&gt;&quot;Muhammad Raza&quot;}</dc:creator>
        <dc:rights></dc:rights>
      </item>
    
      <item>
        <title>AI Agents Are Just CI Pipelines With an LLM Plugged In</title>
        <description>
          <![CDATA[
            
            <p>In this post, I’ll show you how to think about AI agents through the infrastructure patterns you already use. Think about your CI runner. It spins up an environment. Runs some steps. Reads files. Runs tests. Captures output. Decides what to do next. Knows when to stop.</p>

<p>Now swap out the hardcoded logic for an LLM. That’s it. That’s an AI agent in simpler terms. The fancy demos want you to think it’s magic. Some brand new thing you need to learn from scratch. It’s not. When you take away the hype, an agent is just a controlled automation loop. The LLM handles the reasoning and everything else is infrastructure you’ve built a hundred times.</p>

<p>Here’s what matters, the agent itself isn’t the hard part but The harness is, the execution environment, tooling, guardrails, and observability. It’s all the important stuff that makes automation work in production.</p>

<p>DevOps engineers have been building harnesses forever. CI runners. Deployment pipelines. Infrastructure automation. The patterns are the same. The skills transfer directly.</p>

<p>So if you’re wondering whether AI agents are worth learning, here’s the short answer. You’re already halfway there.</p>

<h2 id="what-an-agent-actually-looks-like">What an Agent Actually Looks Like</h2>

<p>Let’s forget the marketing hype around AI agents and understand from a DevOps engineer’s point of view, what an agent actually looks like. An AI agent has six parts.</p>

<ol>
  <li>
    <p><code class="language-plaintext highlighter-rouge">An LLM</code>: Now LLM is the most important part of an agent as this acts as a brain. It reads context and decides what to do next. It doesn’t touch anything directly.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">A workspace</code>: Think of it as a sandboxed environment. A cloned repo. A container. A temp directory. Same as any CI job.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">A set of tools</code>: These are the actions it can request. Read a file. Run a command. Call an API. Query logs. The agent doesn’t run these itself. It asks for them.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">A control loop</code>: This is the core pattern. Observe the current state. Decide an action. Execute it. Check the result. Keep going until you’re done.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">Policies and limits</code>: Timeouts. Permission boundaries. Rate limits. Cost caps. Without these, agents can spin forever or do things they shouldn’t.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">A termination condition</code>: The agent needs to know when to stop. Task complete. Error threshold hit. Human review needed. Something has to end the loop.</p>
  </li>
</ol>

<p>Now none of this is new as you’ve built systems with all these components. The only difference is the LLM sitting in the decision seat.</p>

<h2 id="the-harness-does-the-heavy-lifting">The Harness Does the Heavy Lifting</h2>

<p>Everyone focuses on the LLM. They miss the important part. The harness is what makes an agent actually work.</p>

<p>The harness is everything around the model. It spins up the environment. Exposes tools. Executes commands on the agent’s behalf. Captures logs and diffs. Enforces limits. Decides when the loop should stop.</p>

<p>Sound familiar? It should. This is what CI runners do.</p>

<p>GitHub Actions. GitLab runners. Jenkins agents. They all follow the same pattern. Spin up an isolated environment. Run steps. Capture output. Handle success and failure. Clean up.</p>

<p>An agent harness does the exact same thing. The only twist is the steps aren’t hardcoded in YAML. They come from the LLM at runtime.</p>

<p>This is why DevOps engineers are perfect for this work. You already think about isolation, execution, logging, and cleanup. You already build systems that run untrusted code safely. Agent harnesses are the same problem with a new input source.</p>

<h2 id="tool-use-is-the-safety-mechanism">Tool Use Is the Safety Mechanism</h2>

<p>Agents don’t touch systems directly. This matters. The LLM never runs a command itself. Never writes a file itself. It requests actions through tools.</p>

<p>The harness gets the request. Validates it. Executes it in a controlled way. Returns a structured result.</p>

<p>This is how you keep agents safe.</p>

<p>Say the agent wants to run a shell command. The harness can check it against an allowlist. Run it in a sandbox. Set a timeout. Capture stderr. The agent never gets raw shell access.</p>

<p>Same thing for file operations. The agent requests a file write. The harness checks the path. Validates the content. Writes the file and returns confirmation.</p>

<p>You control what tools exist. You control how they behave. You control what the agent can even ask for.</p>

<p>This is the same idea behind least privilege. The agent only gets access to what it needs. The harness enforces the boundary.</p>

<h2 id="the-control-loop-in-practice">The Control Loop in Practice</h2>

<p>The core of any agent is the control loop. It looks like this.</p>

<ol>
  <li>
    <p><code class="language-plaintext highlighter-rouge">Observe</code>: The agent reads the current state. Test output. Log files. Diffs. Error messages. Whatever context it needs.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">Decide</code>: The LLM looks at the state and picks an action. Run another test. Edit a file. Ask for more information. Give up.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">Execute</code>: The harness runs the requested action and returns the result.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">Verify</code>: The agent checks if the action worked. Did the test pass? Did the error go away? Is the task done?</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">Repeat</code>: If the task isn’t complete, go back to observe.</p>
  </li>
</ol>

<p>This loop keeps running until a termination condition hits—success, failure, timeout, max iterations, or human intervention.</p>

<p>You’ve seen this before: build, test, fix, rebuild. CI pipelines do this, deployment rollbacks do this, and health check loops do this.</p>

<p>Agents just make the “decide” step dynamic instead of scripted, and here’s where they actually help in DevOps work.</p>

<p><strong>CI failure analysis.</strong> When a test fails, the agent reads the logs, checks the diff, identifies the cause, and suggests a fix—maybe even applying it and rerunning the test.</p>

<p><strong>Terraform drift detection.</strong> The agent compares actual state to declared state, flags the drift, and proposes a remediation plan while a human approves before anything changes.</p>

<p><strong>Kubernetes manifest review.</strong> The agent checks YAML against best practices (missing resource limits, no liveness probes, exposed secrets) catching the stuff humans miss in review.</p>

<p><strong>Cost anomaly investigation.</strong> When spending spikes, the agent queries cost explorer, correlates with recent deployments, and surfaces the likely cause, saving an hour of digging.</p>

<p><strong>Incident log triage.</strong> Faced with pages of logs, the agent reads them, extracts the relevant lines, and summarizes what went wrong (not replacing the engineer, but getting them to the answer faster).</p>

<p>Notice the pattern: the agent assists and handles the tedious parts while the human stays in control of decisions that matter.</p>

<p>AI agents sound complicated with their new frameworks, new terminology, and new paradigms.</p>

<p>But look past the hype and you’ll see something familiar.</p>

<p>An agent is an automation loop where the LLM picks the next step, the harness executes it safely, tools provide controlled access to systems, and policies keep things bounded.</p>

<p>This is CI/CD architecture, infrastructure thinking, the stuff you already do.</p>

<p>When you read about agent frameworks or watch demos of coding assistants, you now have a lens to see the harness underneath, spot the control loop, and ask the right questions: what tools does it expose, what limits exist, and how does it handle failure?</p>

<p>You don’t need to become an ML engineer to understand agents—you just need to recognize the infrastructure patterns you’ve been using all along.</p>

<p>The LLM is the new part. Everything else is your domain.</p>

          ]]>
        </description>
        <pubDate>Sat, 03 Jan 2026 00:00:00 +0000</pubDate>
        <link>https://muhammadraza.me/2026/ai-agents-devops-perspective/</link>
        <guid isPermaLink="true">https://muhammadraza.me/2026/ai-agents-devops-perspective/</guid>
        
        <category>ai</category>
        
        <category>devops</category>
        
        <category>automation</category>
        
        
        
        <dc:creator>{&quot;name&quot;=&gt;&quot;Muhammad Raza&quot;}</dc:creator>
        <dc:rights></dc:rights>
      </item>
    
  </channel>
</rss>
