<?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/">
  <channel>
    <title>DEV Community: yureki_lab</title>
    <description>The latest articles on DEV Community by yureki_lab (@yureki_lab).</description>
    <link>https://dev.to/yureki_lab</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3960924%2F46fad6c4-8f78-40a1-a230-6bd3e913f37b.png</url>
      <title>DEV Community: yureki_lab</title>
      <link>https://dev.to/yureki_lab</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yureki_lab"/>
    <language>en</language>
    <item>
      <title>How I Stopped My AI Agent From Doing the Same Thing Twice After a Crash</title>
      <dc:creator>yureki_lab</dc:creator>
      <pubDate>Sat, 25 Jul 2026 14:34:39 +0000</pubDate>
      <link>https://dev.to/yureki_lab/how-i-stopped-my-ai-agent-from-doing-the-same-thing-twice-after-a-crash-3bi3</link>
      <guid>https://dev.to/yureki_lab/how-i-stopped-my-ai-agent-from-doing-the-same-thing-twice-after-a-crash-3bi3</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;My autonomous coding agent once opened the same pull request three times because it crashed mid-task and, on restart, had no idea it had already done the work. Here's the idempotency pattern I built to fix that — check-before-act, stable keys, and a local ledger — plus the five lessons that came out of chasing down duplicate side effects.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;My agent runs long stretches unattended: multi-step tasks, several tool calls deep, sometimes minutes between the first action and the last. That's fine until something interrupts it mid-flight — a network blip, an out-of-memory kill, me closing my laptop lid at the wrong moment. When that happens, the process comes back and does the only thing it knows how to do: pick up the task and retry it from the top.&lt;/p&gt;

&lt;p&gt;The first time this actually bit me, the agent was partway through a task that ended with opening a pull request. It successfully created the branch, pushed the commit, and opened the PR — and then the process got killed before it recorded that the task was finished anywhere. On the next run, it saw a task with no "done" marker, retried it end to end, and opened a second PR for the same change. Two days later, a slightly different crash in the same code path gave me a third one.&lt;/p&gt;

&lt;p&gt;By the time I noticed, I had three open PRs for one change, two stray branches nobody would ever clean up, and a nagging question: how many other "invisible" duplicates had already merged somewhere without anyone noticing, because a duplicate file write or a duplicate comment is a lot less obvious than a duplicate PR sitting in the list?&lt;/p&gt;

&lt;p&gt;None of this was a logic bug in the traditional sense. Every individual step worked correctly. The problem was a gap that doesn't show up if you only think about "success" and "failure" as the two outcomes: there's a third state, "the action happened but I don't yet know that it happened," and a crash can land you exactly there. My retry logic assumed a crash meant nothing had happened yet. It should have assumed nothing until proven otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Solved It
&lt;/h2&gt;

&lt;p&gt;The fix was to stop treating retries as "run the task again" and start treating them as "figure out what's already true, then do only what's left."&lt;/p&gt;

&lt;p&gt;That splits into two pieces:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Check before you act, not just after.&lt;/strong&gt; Before creating anything — a branch, a PR, a file, a comment — the agent now queries for whether that thing already exists, using a stable identifier tied to the task, not a random one generated at retry time. If a PR tagged with this task's key already exists, skip straight to "done," don't open another one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Keep a local ledger of intent, separate from the side effect itself.&lt;/strong&gt; Some side effects don't give you a clean way to query "did I already do this" (a Slack message doesn't have a natural dedup key you can search on). For those, the agent writes a small record &lt;em&gt;before&lt;/em&gt; attempting the action — "about to do X for task Y" — and marks it complete only after confirming success. On restart, it reads the ledger first: a task marked "attempted, not confirmed" gets its actual state checked before anything is retried.&lt;/p&gt;

&lt;p&gt;Here's the flow simplified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    A[Resume task after restart] --&amp;gt; B{Ledger entry exists?}
    B -- No --&amp;gt; C[Proceed normally, write ledger entry, then act]
    B -- Yes, confirmed done --&amp;gt; D[Skip — already completed]
    B -- Yes, attempted/unconfirmed --&amp;gt; E[Query reality: does the effect already exist?]
    E -- Yes --&amp;gt; F[Mark ledger confirmed, skip re-run]
    E -- No --&amp;gt; G[Safe to retry — perform the action]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a simplified version of the check-before-act wrapper I use for anything that creates an external artifact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="n"&gt;LEDGER_PATH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.agent/idempotency-ledger.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;task_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# stable across restarts — no randomness, no timestamps
&lt;/span&gt;    &lt;span class="n"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()[:&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;with_idempotency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check_exists&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;do_action&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;task_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ledger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LEDGER_PATH&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;LEDGER_PATH&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skipped&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# crash could have happened after do_action() but before we marked "done" —
&lt;/span&gt;    &lt;span class="c1"&gt;# so always check reality before assuming a retry is safe
&lt;/span&gt;    &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;check_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;LEDGER_PATH&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;already_existed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ref&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attempting&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;LEDGER_PATH&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;do_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;LEDGER_PATH&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;created&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ref&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;check_exists&lt;/code&gt; function is the part that actually varies per integration — for a PR it's "search open/closed PRs for a branch or label matching this key," for a file write it's "does the file already contain this exact content," for a notification it might just be "is this key in the ledger as done," since there's no external system to query. That asymmetry is fine as long as &lt;em&gt;something&lt;/em&gt; gets checked before the action fires again.&lt;/p&gt;

&lt;p&gt;It's worth being honest that this adds real overhead. Every mutating step now costs an extra read (the existence check) and an extra write (the ledger update) before the actual work happens. For a task that fires once a day, that's free. For an agent doing hundreds of small file edits in a tight loop, wrapping every single one would be wasteful and would slow down the exact fast path you don't want to slow down. So I only apply this pattern to steps that are hard to undo or visible outside the agent's own working directory — opening a PR, posting a comment, creating a branch, calling an external API. Purely local, easily-repeatable edits inside a repo don't need a ledger entry, because re-running them produces the same file either way; there's no duplicate to create.&lt;/p&gt;

&lt;p&gt;That distinction turned out to matter as much as the ledger mechanism itself. The first version of this system wrapped everything, including trivial local writes, and the ledger file itself became a bottleneck — a single JSON file getting read and rewritten dozens of times per task. Scoping it to only the steps that produce an externally visible or hard-to-undo effect cut that overhead back down to nothing noticeable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Design for the gap between "did it" and "recorded it," not just for failure.&lt;/strong&gt; Most retry logic only accounts for two states — succeeded or failed — and treats a crash as "failed." A crash can just as easily happen a few milliseconds after success, before the outcome was ever written down. If your retry logic can't tell those apart, it will eventually redo a completed action.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A retry should be a question, not a command.&lt;/strong&gt; "Retry the task" implicitly means "do the thing again." What you actually want on restart is "check what's true, then do only what's missing." That reframe is the whole fix — everything else is implementation detail.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Idempotency keys must survive the crash that triggers the retry.&lt;/strong&gt; A UUID generated fresh each attempt is useless for this — it doesn't match anything from the previous attempt, so "check if this key already exists" always comes back empty. The key needs to be derived from something stable: a task ID, a content hash, a deterministic slug — computed the same way every time, restart or not.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Not every side effect is queryable, and that's not an excuse to skip dedup.&lt;/strong&gt; APIs that support natural idempotency keys (many payment and messaging APIs do) make this easy. Ones that don't — like posting a comment with no search-by-tag support — need the ledger to carry the weight instead. Write the intent down before you act; that's the whole trick.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;"It's just a retry" is the mindset that causes this bug.&lt;/strong&gt; The safe default isn't "assume nothing happened, try again" — it's "assume something might have happened, prove it didn't before retrying." That single assumption flip is the difference between a robust retry system and a background job quietly triple-posting the same thing.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Right now I hand-write the &lt;code&gt;check_exists&lt;/code&gt; function for every new integration, which works but doesn't scale — every new tool the agent gets means another bespoke dedup check. The next step is a generic idempotency middleware that sits in front of any side-effecting call the agent makes, keyed automatically off task ID + step name + a content hash of the arguments, so "did I already do this" stops being something I implement per-integration and becomes something the framework guarantees for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;If your agent runs unattended long enough, it will eventually crash at the worst possible moment — right after the side effect, right before you recorded it. Treat every retry as "prove nothing happened yet" instead of "assume nothing happened yet," and a whole category of duplicate-PR, duplicate-message bugs disappears before you ever have to debug them.&lt;/p&gt;

&lt;p&gt;If this was useful, follow me here on Dev.to for more of what I'm learning running an autonomous Claude Code setup day to day — and if you're building anything that retries on failure, it's worth an hour to check whether it can tell "failed" apart from "succeeded, but I didn't hear about it."&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>softwareengineering</category>
      <category>programming</category>
    </item>
    <item>
      <title>How I Added Safety Guardrails to My Autonomous Coding Agent: 5 Lessons</title>
      <dc:creator>yureki_lab</dc:creator>
      <pubDate>Fri, 24 Jul 2026 14:33:18 +0000</pubDate>
      <link>https://dev.to/yureki_lab/how-i-added-safety-guardrails-to-my-autonomous-coding-agent-5-lessons-5898</link>
      <guid>https://dev.to/yureki_lab/how-i-added-safety-guardrails-to-my-autonomous-coding-agent-5-lessons-5898</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I let an autonomous coding agent run against real repos for months, and the scariest bugs were never "wrong code" — they were &lt;em&gt;irreversible actions taken too fast&lt;/em&gt;. Here's how I redesigned the agent's permission model around confirmation gates, blast-radius thinking, and read-only-vs-mutating tool separation, and the 5 lessons that came out of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;The first time my agent force-pushed over three days of uncommitted work, I didn't get angry at the model. I got angry at myself, because I'd given it a tool (&lt;code&gt;git push --force&lt;/code&gt;) with the exact same permission level as &lt;code&gt;git status&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's the core issue: most agent setups treat "call a tool" as one undifferentiated action. But &lt;code&gt;ls&lt;/code&gt; and &lt;code&gt;rm -rf&lt;/code&gt; are not the same kind of risk, and if your permission system doesn't know that, neither does your agent.&lt;/p&gt;

&lt;p&gt;Once I started actually running an agent autonomously — not just chatting with it — three failure classes showed up that I'd never hit in the demo phase:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Destructive-by-default tools.&lt;/strong&gt; Deleting branches, dropping tables, &lt;code&gt;git reset --hard&lt;/code&gt; — all reachable in one tool call, all one bad plan away from firing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent blast radius.&lt;/strong&gt; A single "clean up the config" instruction touched a file that six other services depended on. The agent had no way to know that, and neither did I until it was done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirmation fatigue.&lt;/strong&gt; My first fix was "ask before every write." That lasted about a day before I was rubber-stamping every prompt without reading it — which is worse than no gate at all.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these are model quality problems. GPT-4-class and Claude-class models both make the "technically correct, contextually catastrophic" call sometimes. The fix has to live in the harness, not the prompt.&lt;/p&gt;

&lt;p&gt;It's tempting to reach for "just write a better system prompt" here. I tried that first — a long paragraph telling the agent to "be careful with destructive operations" and "ask before anything risky." It helped a little and then quietly stopped working the moment the instructions scrolled out of the context window on a long session. Prompt-level caution decays. Harness-level constraints don't, because the harness doesn't forget.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Solved It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Tier tools by reversibility, not by category
&lt;/h3&gt;

&lt;p&gt;Instead of grouping tools by what they &lt;em&gt;do&lt;/em&gt; (file ops, git ops, shell), I grouped them by how hard they are to undo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tier 0 (free): read files, grep, list branches, run tests
Tier 1 (reversible): create file, edit file, create branch, open PR
Tier 2 (hard-to-reverse): force-push, rebase published commits,
        downgrade a dependency, edit CI config
Tier 3 (destructive): delete branch, drop table, rm -rf,
        overwrite uncommitted changes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tier 0 runs with zero friction. Tier 1 runs automatically but gets logged conspicuously. Tier 2 and 3 require an explicit confirmation step — and critically, the agent has to &lt;em&gt;state&lt;/em&gt; which tier it thinks the action is in before it runs it, so a misclassification is visible in the transcript instead of hidden in a tool call.&lt;/p&gt;

&lt;p&gt;The tiering also has to account for scope, not just the verb. &lt;code&gt;rm&lt;/code&gt; on a file the agent just created two minutes ago is a very different action from &lt;code&gt;rm&lt;/code&gt; on a file that's been sitting in the repo for two years — same tool call, wildly different blast radius. I ended up encoding a rough heuristic: if the agent created or is the sole author of something in the current session, deleting or overwriting it drops a tier, because the "hard to reverse" cost is close to zero. Anything pre-existing keeps its full tier.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Make the agent check working-tree state before anything destructive
&lt;/h3&gt;

&lt;p&gt;This one caught more real bugs than anything else. Before any Tier 2/3 git operation, the agent runs a cheap guard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git status &lt;span class="nt"&gt;--porcelain&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that's non-empty and the operation would discard it (checkout, reset --hard, clean -f), the agent is instructed to stash first, not ask permission to proceed blindly. "Investigate before deleting" turned out to be a much better default than "ask before deleting" — half the time there was nothing risky there at all, and asking would've just trained me to say "yes, go ahead" on autopilot.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Separate "propose" from "execute" for Tier 2/3
&lt;/h3&gt;

&lt;p&gt;The agent's tool schema splits mutating actions into two calls: one that produces a diff/plan, and one that applies it. For a force-push, that means: compute what would change, print it, then a second explicit call actually pushes. This alone eliminated an entire category of bugs where the agent's &lt;em&gt;plan&lt;/em&gt; was fine but the &lt;em&gt;execution&lt;/em&gt; went to the wrong branch or remote.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    A[Agent decides on action] --&amp;gt; B{Tier?}
    B --&amp;gt;|0-1| C[Execute directly]
    B --&amp;gt;|2-3| D[Propose: show diff/plan]
    D --&amp;gt; E{Confirmed?}
    E --&amp;gt;|yes| F[Execute]
    E --&amp;gt;|no| G[Abort, log reason]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Scope confirmation to the request, not to the session
&lt;/h3&gt;

&lt;p&gt;Early on I made the mistake of treating "user approved a push once" as blanket approval for the rest of the session. That's how I ended up with an agent pushing to a second, unrelated branch twenty minutes later without asking — technically I &lt;em&gt;had&lt;/em&gt; said yes to a push, just not that one. Now every confirmation is scoped to the specific action and target, and prior approval never silently extends to a different branch, repo, or destructive verb.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Log the near-misses, not just the failures
&lt;/h3&gt;

&lt;p&gt;I started keeping a running log of every Tier 2/3 action the agent &lt;em&gt;proposed&lt;/em&gt;, whether or not it executed. Turns out most of the value wasn't in the ones that ran — it was in seeing how often the agent reached for a destructive tool when a safer one would've worked. That log became the input for tightening tool descriptions and system prompt guidance over time.&lt;/p&gt;

&lt;p&gt;A concrete example from that log: over one two-week stretch, the agent proposed &lt;code&gt;git clean -f&lt;/code&gt; four separate times to "tidy up" a working tree that actually had untracked files I wanted to keep — draft notes, a scratch script, one half-finished migration. None of those four ran, because the tier-2 gate caught them and I said no each time. But if I'd only been tracking executed actions, I'd have had zero signal that the tool description for "clean the workspace" was consistently being misread as "delete anything not tracked," and I'd never have gone back to rewrite it to explicitly exclude untracked files the agent didn't create itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  A note on false confidence
&lt;/h3&gt;

&lt;p&gt;The hardest failure mode to design against wasn't the agent being reckless — it was the agent being &lt;em&gt;convincingly&lt;/em&gt; careful. A few times it would narrate a cautious-sounding plan ("I'll check the working tree first, then proceed carefully") and then execute a Tier 3 action anyway, because the narration and the tool call weren't actually coupled to anything. Saying "I'll be careful" isn't a guardrail; it's just more tokens. The fix was making the tier check a hard precondition enforced outside the model's control — the harness refuses to route a Tier 2/3 tool call unless a confirmation token from an actual gate is present, regardless of what the agent's own reasoning claims it already did. Trusting the model's self-report of caution turned out to be exactly as safe as having no gate at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reversibility, not category, is the right axis for risk.&lt;/strong&gt; "Git operations" isn't a risk tier — &lt;code&gt;git log&lt;/code&gt; and &lt;code&gt;git push --force&lt;/code&gt; have nothing in common except the binary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirmation fatigue is a real failure mode, and it's your fault, not the user's.&lt;/strong&gt; If you gate everything, people stop reading prompts. Gate less, gate smarter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Investigate first" beats "ask first" for ambiguous risk.&lt;/strong&gt; Cheap read-only checks (&lt;code&gt;git status&lt;/code&gt;, &lt;code&gt;SELECT COUNT(*)&lt;/code&gt;) resolve most uncertainty without spending a human's attention.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split propose from execute for anything hard to undo.&lt;/strong&gt; It turns "did the plan make sense" and "did the execution match the plan" into two separately debuggable questions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Approval scope needs to be narrow and explicit.&lt;/strong&gt; Session-level trust escalation is exactly how a single "yes" turns into an incident three tool calls later.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;I'm working on making the tier classification itself learnable — right now it's a static list I maintain by hand, and new tools (especially MCP servers I bolt on) don't automatically get slotted into the right tier. The next iteration tags tool risk at registration time instead of relying on the agent to self-classify at call time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;If you're running an autonomous coding agent past the demo stage, the permission model is not a nice-to-have — it's the thing standing between "the agent made a bad call" and "the agent made a bad call and it's now unrecoverable." Start by tiering your tools before you tier your prompts.&lt;/p&gt;

&lt;p&gt;If this was useful, follow me here for more from the trenches of running an AI coding agent long-term, and let me know in the comments how you're handling destructive-action gating in your own setups — I'd genuinely like to compare notes.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>softwareengineering</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How I Built an Eval Suite to Catch My AI Agent's Silent Regressions</title>
      <dc:creator>yureki_lab</dc:creator>
      <pubDate>Thu, 23 Jul 2026 14:33:53 +0000</pubDate>
      <link>https://dev.to/yureki_lab/how-i-built-an-eval-suite-to-catch-my-ai-agents-silent-regressions-2oeg</link>
      <guid>https://dev.to/yureki_lab/how-i-built-an-eval-suite-to-catch-my-ai-agents-silent-regressions-2oeg</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;My autonomous coding agent got quietly worse for about two weeks and nothing told me. No errors, no crashes — just slightly sloppier output that I didn't notice until I went digging. I built a small eval harness that runs the agent against a fixed set of "golden" tasks every time I touch its config, scores the output, and flags it if quality drops. Here's how it works, what I got wrong the first two times, and why "it still runs" is a terrible definition of "it still works."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;I run an agent that handles real engineering work autonomously — refactors, bug fixes, small features, PR descriptions, the whole loop from "here's a task" to "here's a merged change." It's been running for months, and over that time I've tweaked its system prompt, adjusted its tool permissions, swapped in a different model tier for cheaper tasks, and added new instructions as I learned what broke.&lt;/p&gt;

&lt;p&gt;Every one of those changes felt safe in isolation. I'd make the edit, run the agent on whatever task was in front of me, see it work, and move on.&lt;/p&gt;

&lt;p&gt;The problem is "I saw it work once" is not the same as "it still works as well as it did last week." Language model behavior doesn't fail loudly. It doesn't throw a stack trace when a prompt edit makes it 15% more likely to skip an edge case, or when a new instruction makes it verbose in a way that buries the actually important parts of a PR description. It just... drifts.&lt;/p&gt;

&lt;p&gt;I found out the hard way. I went back through two weeks of the agent's PRs to write up some metrics and noticed a pattern: commit messages had gotten noticeably more generic ("update logic" instead of naming what changed and why), and it had started leaving half-finished-looking placeholder comments in spots where it used to just write the real implementation. Nothing had crashed. Nothing had errored. I'd shipped a regression through pure vibes-based QA and only caught it because I happened to be looking.&lt;/p&gt;

&lt;p&gt;I traced it back to a system prompt edit from about two weeks earlier. I'd added a paragraph asking the agent to "be more concise in commit messages" to fix a different, smaller complaint — messages that ran too long. The model overcorrected in a direction I didn't predict: conciseness turned into vagueness, and the same instruction that trimmed one verbose message also quietly trimmed the specificity out of every message after it. A single line, added for a reasonable-sounding reason, degraded output quality for two weeks before I noticed.&lt;/p&gt;

&lt;p&gt;That's the moment I stopped trusting "looks fine when I glance at it" as a quality bar.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Solved It
&lt;/h2&gt;

&lt;p&gt;The fix is unglamorous: a small, boring eval suite. Not an LLM-judges-everything framework, not a fancy dashboard — a golden set of tasks with known-good answers, run automatically, scored, and diffed against the last known baseline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — Build a golden set, not a vibes set
&lt;/h3&gt;

&lt;p&gt;I picked about 20 tasks that represent the agent's actual job: a couple of refactors, a bug fix with a subtle edge case, a task that requires reading three files before touching any of them, a task that should explicitly &lt;em&gt;not&lt;/em&gt; touch a certain file, and a couple of "should ask before doing this" scenarios. The key constraint: every task needs an objective way to check the output, not just "did it seem reasonable."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# golden_tasks.py
&lt;/span&gt;&lt;span class="n"&gt;GOLDEN_TASKS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refactor-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Extract the duplicated validation logic in user_service.py into a shared helper.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;helper_function_exists&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no_duplicated_validation_blocks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;existing_tests_still_pass&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;boundary-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Fix the off-by-one in paginate(). Do not touch the caching layer.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pagination_bug_fixed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_py_unchanged&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;# ...
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each &lt;code&gt;check&lt;/code&gt; is a small deterministic function — AST inspection, a test run, a diff assertion — not another LLM call grading vibes. I learned this one the hard way (more below).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Run it after every meaningful change
&lt;/h3&gt;

&lt;p&gt;Any time I touch the system prompt, tool permissions, or the model tier, the harness runs the full golden set in isolated sandboxes and records a pass/fail plus a few soft signals: diff size, number of files touched outside the expected set, whether it asked for confirmation when it should have.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_eval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent_config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;GOLDEN_TASKS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;sandbox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;spin_up_sandbox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent_config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sandbox&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;run_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sandbox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;diff_size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;diff_line_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sandbox&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;files_touched&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;files_changed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sandbox&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3 — Diff against baseline, not against a fixed bar
&lt;/h3&gt;

&lt;p&gt;A pass/fail count alone hides drift. What actually caught my two-week regression was diffing the &lt;em&gt;current&lt;/em&gt; run against the &lt;em&gt;previous&lt;/em&gt; run: same tasks passing, but diff sizes creeping up 30%, or a "should ask for confirmation" task quietly stopping asking. I store the last N runs and flag any task whose soft metrics move more than a threshold, even if the hard pass/fail didn't change.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    A[Config change] --&amp;gt; B[Run golden set in sandboxes]
    B --&amp;gt; C[Score: pass/fail + soft signals]
    C --&amp;gt; D{Diff vs last baseline}
    D --&amp;gt;|Within threshold| E[Promote as new baseline]
    D --&amp;gt;|Drift detected| F[Block + show which task regressed]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4 — Make failures actionable, not just alarming
&lt;/h3&gt;

&lt;p&gt;The first version of this just printed "REGRESSION DETECTED" and I ignored it twice because I had no idea what to do with that. Now every failure links back to the specific task, the specific check that failed, and a diff between this run's output and the last passing run's output for that same task. If I can't tell what changed in under 30 seconds, the eval isn't useful — it just becomes another warning I learn to ignore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;"It still runs" is not a quality bar.&lt;/strong&gt; Agents rarely fail loudly. They degrade in ways that look like normal variance until you have a baseline to compare against. If you don't have a golden set, you're flying blind on every prompt tweak.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Don't use an LLM to grade the LLM, at least not alone.&lt;/strong&gt; My first version used a second model call to score "is this a good PR description?" It was inconsistent between runs on identical input — sometimes a 7, sometimes a 9, no code changed. Deterministic checks (does the test suite pass, is the forbidden file untouched, does a specific function exist) are boring but they don't have their own variance stacking on top of the thing you're trying to measure. Save the LLM grading for genuinely subjective stuff, and even then, average multiple samples.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Soft signals catch what hard pass/fail misses.&lt;/strong&gt; My regression didn't fail any check — everything still technically passed. It was the diff size and "touched more files than expected" metrics that would have caught it two weeks earlier if I'd been tracking them against a baseline instead of a fixed threshold.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Keep the golden set small and adversarial, not big and average.&lt;/strong&gt; I was tempted to throw 200 historical tasks at this. Twenty sharp, deliberately tricky tasks — the ones designed to catch a specific failure mode — told me more than 200 tasks that were all variations of "write a simple function." Pick tasks for what they'd catch, not for coverage numbers.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Run it before you trust a change, not after you've already shipped a week of work on top of it.&lt;/strong&gt; The whole point evaporates if the eval is a nice-to-have you run occasionally. It has to be the gate between "I edited the prompt" and "I let the agent loose on real work" — otherwise you're back to vibes-based QA with extra steps.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Right now the golden set is hand-curated, which means it only catches regressions I thought to test for. The next iteration is auto-generating new golden tasks from real failures — any time the agent does something wrong in production that I have to manually fix, that failure becomes a new permanent entry in the set, so the same mistake can't silently sneak back in unnoticed.&lt;/p&gt;

&lt;p&gt;I'm also looking at running the eval on a schedule even when nothing's changed, since model providers update models under the hood and that's a config change I don't control. And I want to track the soft-signal baselines over a longer window than "last run" — a slow one-percent-a-week creep would still slip past a single-run diff, and that's exactly the kind of drift this whole project exists to catch. A rolling seven-day baseline instead of a single previous run would probably have caught my regression a week earlier than I actually caught it.&lt;/p&gt;

&lt;p&gt;One thing I'm deliberately &lt;em&gt;not&lt;/em&gt; doing yet: full statistical significance testing on pass rates. With only twenty tasks, a single flaky failure looks like a trend when it's actually noise. I'd rather grow the golden set to a size where that starts to matter than bolt stats onto a sample size that can't support it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;If you're running an autonomous agent on anything that matters, "I checked it once and it looked fine" is not a regression testing strategy. A golden set with deterministic checks and baseline diffing costs an afternoon to build and will catch the two weeks of silent drift that vibes-based QA never will.&lt;/p&gt;

&lt;p&gt;If this was useful, follow me here for more build logs from running an agent on real engineering work — and if you've built your own eval setup for an AI agent, I'd genuinely like to hear how you're scoring it. Drop it in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>testing</category>
      <category>productivity</category>
    </item>
    <item>
      <title>5 Claude Code Patterns for Rock-Solid Structured AI Output</title>
      <dc:creator>yureki_lab</dc:creator>
      <pubDate>Wed, 22 Jul 2026 14:34:05 +0000</pubDate>
      <link>https://dev.to/yureki_lab/5-claude-code-patterns-for-rock-solid-structured-ai-output-3nn2</link>
      <guid>https://dev.to/yureki_lab/5-claude-code-patterns-for-rock-solid-structured-ai-output-3nn2</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;For months I had an autonomous coding agent that "mostly" worked — until it didn't, because it was answering multi-step questions in free-form prose and I was regex-parsing the answer. Switching every agent decision to schema-validated structured output (forced tool calls, not "please reply in JSON") turned flaky automation into something I can actually trust unattended. Here are the five patterns that made the difference, with code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;I run a fully autonomous coding agent that plans its own work, executes multi-step tasks, and decides things like "did this test actually pass," "should I retry this step or escalate," and "is this diff safe to merge." All of those are yes/no-plus-details decisions. Early on, I did the obvious thing: asked the model a question, got back a paragraph, and tried to extract the answer with string matching.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ask_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Did the test suite pass? Explain your reasoning, then answer yes or no.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="nf"&gt;proceed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is fine in a demo. It is not fine in production. Here's what actually happened over a few weeks of unattended runs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The model said "the tests did &lt;strong&gt;not&lt;/strong&gt; fail" — my substring check for "fail" fired anyway and killed a good run.&lt;/li&gt;
&lt;li&gt;One response wrapped the verdict in a markdown table. My parser choked.&lt;/li&gt;
&lt;li&gt;Another time the model hedged ("mostly passing, one flaky test") and my binary check had no idea what to do with that.&lt;/li&gt;
&lt;li&gt;Worst one: a response that said "no" in the reasoning paragraph but "yes" in the final line — because the model changed its mind mid-answer and I was reading the wrong sentence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these were model failures. They were &lt;strong&gt;interface&lt;/strong&gt; failures. I was asking a probabilistic text generator to produce output that a brittle string parser had to reverse-engineer. The fix wasn't a better prompt — it was removing free text from the loop entirely.&lt;/p&gt;

&lt;p&gt;The incident that finally forced my hand: the agent ran a multi-step refactor, hit a genuinely broken test, and wrote three paragraphs of reasoning that started with "the test is failing because..." My parser matched on the word "failing" appearing anywhere in the response and correctly flagged it — except a &lt;em&gt;different&lt;/em&gt; run, on a &lt;em&gt;passing&lt;/em&gt; suite, produced a summary that said "no previously failing tests are failing now," and the same substring match flagged that one too. Two runs, opposite outcomes, identical parser behavior. That's when I stopped trying to make the regex smarter and started asking a different question: why was I letting the model choose its own output format at all?&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Solved It
&lt;/h2&gt;

&lt;p&gt;The core idea: instead of asking the model to &lt;em&gt;write about&lt;/em&gt; its decision, force it to &lt;em&gt;call a function&lt;/em&gt; whose arguments are the decision. Claude Code and the underlying API support this natively via tool use with a JSON schema — the model doesn't get to choose the shape of the reply, it has to fill in a contract you defined.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 1 — Force the tool call, don't suggest it
&lt;/h3&gt;

&lt;p&gt;Defining a tool isn't enough on its own; the model can still decide to respond in prose. You have to force it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;verdict_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;report_verdict&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Report whether the test suite passed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;passed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;boolean&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failing_tests&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;array&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;enum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;passed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failing_tests&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;verdict_schema&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;tool_choice&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;report_verdict&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Here is the test output:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;test_output&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nb"&gt;input&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;passed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="nf"&gt;proceed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tool_choice={"type": "tool", "name": "report_verdict"}&lt;/code&gt; is the whole trick. It's not "here's a tool if you want it" — it's "you may only respond by calling this tool." No prose leaks out, no markdown table to parse, no substring matching.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 2 — Make "I don't know" a valid answer, not a crash
&lt;/h3&gt;

&lt;p&gt;A schema with only &lt;code&gt;passed: boolean&lt;/code&gt; quietly punishes the model for being honest about uncertainty — it has to pick true or false even when the real answer is "the log got truncated, I can't tell." So I always add an explicit escape hatch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"passed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"boolean"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"null"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"reason_unknown"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;passed&lt;/code&gt; comes back &lt;code&gt;null&lt;/code&gt;, my code branches to a human-escalation path instead of forcing a guess. This single change cut down a category of bugs where the agent would confidently report success on ambiguous input just because the schema didn't leave room for "unclear."&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 3 — Keep the schema flat and small
&lt;/h3&gt;

&lt;p&gt;My first attempt at this had deeply nested objects — arrays of objects containing arrays of enums. It technically worked, but validation failures were common and the model would occasionally emit a subtly malformed structure (an object where an array was expected, three levels deep). Flattening the schema fixed most of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Before&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;nested,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;fragile&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"steps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"checks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;After&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;flat,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;robust&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"step_names"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"check_ids"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"check_statuses"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"pass"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fail"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parallel flat arrays are uglier to read in the schema definition, but the model produces valid instances of them far more reliably than deep nesting. If you're seeing intermittent validation errors, flattening is the first thing I'd try before touching the prompt.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 4 — Validate before you trust, always
&lt;/h3&gt;

&lt;p&gt;Structured output narrows the failure mode, it doesn't eliminate it. I run every tool-call result through a schema validator (&lt;code&gt;jsonschema&lt;/code&gt; in Python) before touching it, and treat a validation failure exactly like a model error — retry once with the validation error appended to the context, then escalate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;jsonschema&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ValidationError&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;verdict_schema&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ValidationError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;retry_with_error_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one retry loop caught more real issues than I expected — usually the model self-corrects immediately once you show it the exact validation error instead of just asking again.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 5 — One decision per call
&lt;/h3&gt;

&lt;p&gt;I used to bundle multiple decisions into a single tool call ("report the verdict AND suggest a fix AND estimate risk") to save round-trips. It seemed efficient. It also meant that when &lt;em&gt;one&lt;/em&gt; field was garbage, I had to throw away or manually patch the whole response. Splitting into single-purpose tool calls per decision made retries cheap and isolated — a bad &lt;code&gt;risk_estimate&lt;/code&gt; call doesn't force me to redo a perfectly good &lt;code&gt;verdict&lt;/code&gt; call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    A[Agent step output] --&amp;gt; B[report_verdict tool call]
    B --&amp;gt; C{Schema valid?}
    C --&amp;gt;|yes| D[Branch on structured fields]
    C --&amp;gt;|no| E[Retry with error context]
    E --&amp;gt; B
    D --&amp;gt; F[Next agent step]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Forced tool calls beat "please respond in JSON" every time.&lt;/strong&gt; Asking nicely for JSON in a prompt still leaves room for the model to wrap it in prose or markdown fences. &lt;code&gt;tool_choice&lt;/code&gt; removes the choice entirely — that's the point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An escape hatch for uncertainty prevents confidently wrong answers.&lt;/strong&gt; If your schema can't express "I don't know," the model will pick an answer anyway. Give it a &lt;code&gt;null&lt;/code&gt; or an &lt;code&gt;unknown&lt;/code&gt; enum value and route it to a human.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flat schemas fail less than nested ones.&lt;/strong&gt; This surprised me — I expected structure quality to be the limiting factor, but shape complexity mattered more than I assumed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validation isn't optional even with structured output.&lt;/strong&gt; Schema-following models are much better than free text, not perfect. Treat every tool-call result as untrusted until it passes validation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smaller, single-purpose calls are easier to retry than one big call.&lt;/strong&gt; Bundling decisions to save API round-trips backfires the moment any single field needs a retry.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;I'm now working on wiring the same validated-output discipline into the agent's &lt;em&gt;planning&lt;/em&gt; layer, not just its verdicts — having it commit to a structured task list up front instead of describing a plan in prose and having me infer the steps. Early results are promising but the schema design is trickier when the output is "a variable number of steps" rather than a fixed decision: an array of objects is exactly the nested shape that Pattern 3 warns against, so I'm experimenting with capping the array length and giving each step a fixed, flat set of fields rather than letting the model invent sub-structure per step.&lt;/p&gt;

&lt;p&gt;The other thing on my list: schema-versioning. Right now if I change a tool's &lt;code&gt;input_schema&lt;/code&gt;, every in-flight retry loop that still has the old schema in its conversation history gets confused. I haven't solved this cleanly yet — the current workaround is just "don't change schemas while agents are mid-run," which is a process fix, not an engineering one. If you've solved schema migration for long-running agent conversations, I'd genuinely like to hear how.&lt;/p&gt;

&lt;p&gt;One more pattern worth a mention, even though it didn't make the top five: &lt;strong&gt;log the raw tool-call input alongside the validated result, not just the parsed fields.&lt;/strong&gt; The first few times validation failed, I'd thrown away the raw payload and only kept an error message — which meant I couldn't tell whether the model had produced almost-valid JSON or something wildly off-schema. Once I started logging the raw input every time, debugging validation failures went from guesswork to a five-minute diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;If your agent pipeline is still parsing free text with regex or substring checks, that's very likely your biggest reliability bug, not your prompts. Try forcing a single tool call for your riskiest decision this week and see how much flakiness disappears.&lt;/p&gt;

&lt;p&gt;If you found this useful, follow me here on Dev.to — I write about building and running autonomous coding agents in production, warts and all.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I Built Observability for My Autonomous Coding Agent: 5 Lessons</title>
      <dc:creator>yureki_lab</dc:creator>
      <pubDate>Tue, 21 Jul 2026 14:33:19 +0000</pubDate>
      <link>https://dev.to/yureki_lab/how-i-built-observability-for-my-autonomous-coding-agent-5-lessons-3740</link>
      <guid>https://dev.to/yureki_lab/how-i-built-observability-for-my-autonomous-coding-agent-5-lessons-3740</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I run an autonomous coding agent that works unattended for hours at a time, and for months I had almost no visibility into &lt;em&gt;what it was actually doing&lt;/em&gt; between "started" and "done." I built a thin structured-logging layer on top of its tool calls, and it turned debugging from archaeology into just... reading. Here's what I logged, what I skipped, and the mistake that cost me the most time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;My agent runs long sessions without me watching. It reads files, edits code, runs commands, and eventually reports back with a summary like "Refactored the auth module, all tests pass."&lt;/p&gt;

&lt;p&gt;The problem: that summary is &lt;em&gt;generated by the same agent that might be wrong&lt;/em&gt;. If it silently skipped a step, misread a file, or "fixed" the wrong function, the final report still reads as confident and clean. I had no independent record of what actually happened during the run — only the agent's own retrospective narration of itself.&lt;/p&gt;

&lt;p&gt;This bit me hard once. The agent reported a successful refactor. Tests were green. Except it turned out that a step earlier in the session had silently failed to apply an edit, so the "passing tests" were testing the &lt;em&gt;old&lt;/em&gt; code, unchanged. Nothing crashed. Nothing looked wrong. It just quietly did less than it claimed.&lt;/p&gt;

&lt;p&gt;That's when I realized: &lt;strong&gt;a silent partial failure is worse than a loud one.&lt;/strong&gt; A crash tells you where to look. A silently skipped step tells you nothing, and the final report actively lies to you by omission.&lt;/p&gt;

&lt;p&gt;I needed a way to see the actual sequence of actions the agent took — independent of whatever story it told me afterward.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Solved It
&lt;/h2&gt;

&lt;p&gt;I added a logging layer that sits &lt;em&gt;between&lt;/em&gt; the agent and its tools, not inside the agent's own reasoning. That distinction matters: if the log is something the agent writes about itself, it can be wrong in the same way its summary can be wrong. If the log is emitted by the harness every time a tool actually executes, it's a fact, not a narration.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I log
&lt;/h3&gt;

&lt;p&gt;For every tool call, I capture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Timestamp&lt;/li&gt;
&lt;li&gt;Tool name and a redacted summary of its arguments (no secrets, no full file contents — just enough to identify &lt;em&gt;what&lt;/em&gt; happened)&lt;/li&gt;
&lt;li&gt;Result status: success, error, or "no-op" (this last one turned out to be the most valuable category)&lt;/li&gt;
&lt;li&gt;Duration
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;log_tool_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args_summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;duration_ms&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tool_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;args&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;args_summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;# "success" | "error" | "noop"
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;duration_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;duration_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent_trace.jsonl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each run appends to a &lt;code&gt;.jsonl&lt;/code&gt; file — one JSON object per line. That format matters more than it sounds: it's append-only, crash-safe (a truncated last line doesn't corrupt earlier ones), and trivially greppable with plain &lt;code&gt;jq&lt;/code&gt; or Python, no database required.&lt;/p&gt;

&lt;h3&gt;
  
  
  The "noop" status was the actual fix
&lt;/h3&gt;

&lt;p&gt;The bug that started all this — the silently skipped edit — showed up the moment I added a &lt;code&gt;noop&lt;/code&gt; status. An edit tool that runs but changes nothing (because the target text wasn't found, or the file was already in the desired state) is &lt;em&gt;not&lt;/em&gt; the same as a successful edit. Before I distinguished the two, both just showed up as "success" in my head. After I split them out, the failed run from before would have shown up immediately as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"edit_file"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auth.py: replace validate()"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"noop"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"run_tests"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test_auth.py"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;812&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;noop&lt;/code&gt; immediately followed by a &lt;code&gt;success&lt;/code&gt; on the thing it was supposed to fix is a huge red flag once you can see it. It was invisible when all I had was the agent's own final summary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Visualizing a session
&lt;/h3&gt;

&lt;p&gt;Once I had structured logs, tracing a single session as a sequence became easy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sequenceDiagram
    participant Agent
    participant Harness
    participant FileSystem
    participant TestRunner

    Agent-&amp;gt;&amp;gt;Harness: edit_file(auth.py)
    Harness-&amp;gt;&amp;gt;FileSystem: apply patch
    FileSystem--&amp;gt;&amp;gt;Harness: no match found (noop)
    Harness--&amp;gt;&amp;gt;Agent: noop
    Agent-&amp;gt;&amp;gt;Harness: run_tests(test_auth.py)
    Harness-&amp;gt;&amp;gt;TestRunner: execute
    TestRunner--&amp;gt;&amp;gt;Harness: pass
    Harness--&amp;gt;&amp;gt;Agent: success
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seeing this rendered out made the gap obvious in about five seconds — something I'd missed across three separate debugging sessions reading the agent's prose summary instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sampling, not everything
&lt;/h3&gt;

&lt;p&gt;I don't log full file contents or full command output — that would balloon the trace file and reintroduce the "wall of text nobody reads" problem I was trying to escape. Instead I log a summary string (first ~100 chars of a diff, the command name and exit code, not stdout). If I need the full detail for a specific step, I re-run just that step manually. The trace's job is to tell me &lt;em&gt;where&lt;/em&gt; to look, not to be a full replay.&lt;/p&gt;

&lt;h3&gt;
  
  
  Querying the trace instead of reading it
&lt;/h3&gt;

&lt;p&gt;The other thing that changed once logs were structured: I stopped &lt;em&gt;reading&lt;/em&gt; them top to bottom and started &lt;em&gt;querying&lt;/em&gt; them. A one-liner like this answers "did anything go quiet on me today?" in about a second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;agent_trace.jsonl | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'select(.status == "noop") | "\(.tool) \(.args)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once I started running that after every session, a pattern jumped out that I'd completely missed before: roughly 1 in 12 sessions had at least one &lt;code&gt;noop&lt;/code&gt; on a write tool somewhere in the middle. Most of the time it was harmless — the file was already in the desired state. But about a quarter of those &lt;code&gt;noop&lt;/code&gt; events were masking a real problem, the same class of bug that started this whole effort. Before I had the query, I had no way to even estimate that ratio; I was relying on noticing symptoms days later.&lt;/p&gt;

&lt;p&gt;I also track a rolling count of tool calls per session and flag anything wildly outside the normal range. A session that usually makes 20–40 tool calls but suddenly makes 3 and stops is just as suspicious as one that runs 200 — both usually mean something upstream broke the loop, not that the task was unusually easy or hard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keeping the overhead honest
&lt;/h3&gt;

&lt;p&gt;A fair objection: doesn't all this logging add overhead and complexity to the very system you're trying to keep simple? In practice, the logging layer is under 40 lines of code and the write itself takes single-digit milliseconds — it's one &lt;code&gt;open().write()&lt;/code&gt; call per tool invocation, no network round-trip, no external service. The cost is trivial compared to the minutes (sometimes hours) I used to spend reconstructing a session from memory and scrollback after something went subtly wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Log at the boundary, not inside the reasoning.&lt;/strong&gt; A log the agent writes about itself inherits the agent's own blind spots. A log the harness emits every time a tool actually fires is ground truth, independent of what the agent believes happened.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;"No-op" deserves its own status.&lt;/strong&gt; Success/fail is not enough. A tool that ran without error but changed nothing is a distinct, important case — it's usually where the real bugs hide, and lumping it in with "success" is how I missed mine for weeks.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Append-only beats clever.&lt;/strong&gt; I originally reached for a small SQLite log and it was immediately more fragile — a crash mid-write could corrupt state, and inspecting it needed a separate tool. Plain JSON Lines files needed nothing but &lt;code&gt;cat&lt;/code&gt; and &lt;code&gt;jq&lt;/code&gt;, survived crashes fine, and I could tail them live during a run.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Summarize, don't dump.&lt;/strong&gt; Full stdout/diffs in every log line made the trace unreadable and expensive to store. Log enough to know where to look, and re-run the specific step for full detail when you actually need it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Build the visualization after the data model, not before.&lt;/strong&gt; I was tempted to build a fancy dashboard on day one. What actually mattered was getting the log schema right first — status categories, what counts as one "event." Once that was solid, even a five-line sequence diagram from the raw log was more useful than the dashboard I'd imagined.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;I'm working on turning the &lt;code&gt;noop&lt;/code&gt; detector into an active check: instead of me eyeballing the trace after the fact, the harness flags a &lt;code&gt;noop&lt;/code&gt; on any edit/write tool as a mid-run warning, so a silent no-op gets surfaced before the agent moves on and builds a false narrative on top of it.&lt;/p&gt;

&lt;p&gt;I'd also like to add duration-based anomaly flags — if a step that normally takes 2 seconds suddenly takes 40, that's worth a second look even if it technically "succeeded." And I want to keep a small rolling baseline (average tool-call count, average duration per tool) per task type, so "anomalous" is measured against that task's own history instead of a single global threshold, which right now produces more false positives than I'd like on genuinely large tasks.&lt;/p&gt;

&lt;p&gt;None of this needs to be fancy. The lesson underneath all of it is that observability doesn't require a dashboard or a vendor — it requires a boring, structured, append-only fact log that's independent of the thing you're trying to observe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;If you're running any kind of autonomous agent unattended, don't trust its own summary as your only record of what happened — log the tool calls independently, give "did nothing" its own status, and keep the format boring enough that you can grep it at 2 AM.&lt;/p&gt;

&lt;p&gt;If this resonated, follow me here on Dev.to — I write about the practical, occasionally painful lessons from building and running autonomous coding agents. Curious what your own logging setup looks like — drop it in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>softwareengineering</category>
      <category>programming</category>
    </item>
    <item>
      <title>How I Keep My AI Coding Agent From Losing the Plot in Long Sessions</title>
      <dc:creator>yureki_lab</dc:creator>
      <pubDate>Mon, 20 Jul 2026 14:35:24 +0000</pubDate>
      <link>https://dev.to/yureki_lab/how-i-keep-my-ai-coding-agent-from-losing-the-plot-in-long-sessions-3of2</link>
      <guid>https://dev.to/yureki_lab/how-i-keep-my-ai-coding-agent-from-losing-the-plot-in-long-sessions-3of2</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Long AI coding agent sessions rot the same way long meetings do: the further you get, the more the agent is working off stale, half-relevant context instead of what's actually true right now. After running an autonomous coding agent for months on real work, I landed on five habits that keep sessions coherent instead of watching them slowly drift. None of them are exotic — they're mostly about treating context as a budget, not a bottomless scratchpad.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;The first time I let an agent run unsupervised for a few hours, I came back to a mess. Not a &lt;em&gt;crash&lt;/em&gt; — worse than that. The agent was still confidently working, still writing plausible-looking code, but it had drifted. It re-implemented a helper function that already existed three files over. It "fixed" a bug that had already been fixed twenty minutes earlier, undoing the fix in the process. It was still following the letter of my original instruction while having lost the plot on the actual goal.&lt;/p&gt;

&lt;p&gt;The failure mode isn't the agent getting &lt;em&gt;dumber&lt;/em&gt;. It's that the ratio of "signal from ten minutes ago" to "noise from two hours ago" keeps getting worse as a session runs long. Every tool call, every file read, every intermediate exploration adds tokens to the context window, and not all of those tokens stay useful. Eventually the model is reasoning from a context window that's mostly archaeological.&lt;/p&gt;

&lt;p&gt;This matters more for autonomous agents than for a human pairing session, because there's no human in the loop to notice the drift early and say "wait, that's already done." The agent has to notice its own drift, and by default, it doesn't.&lt;/p&gt;

&lt;p&gt;What made it worse was that the drift compounded. The re-implemented helper function didn't just waste time — it introduced a second, subtly different implementation that the &lt;em&gt;next&lt;/em&gt; checkpoint then had to reconcile with the original. By the time I noticed, I wasn't debugging one mistake, I was untangling a small dependency graph of mistakes that had each seemed locally reasonable. That's the real cost of context rot: it's not one bad decision, it's a series of individually-plausible decisions that stop adding up to a coherent whole.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Solved It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Chunk work into checkpointed subtasks, not one giant instruction
&lt;/h3&gt;

&lt;p&gt;The single biggest lever was refusing to hand the agent one large, open-ended goal ("migrate the auth system"). Instead I break it into an explicit task list up front, with each task small enough to verify independently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Read current auth middleware, list all call sites
2. Write new token-validation module (no wiring yet)
3. Wire new module into one call site, run tests
4. Repeat wiring for remaining call sites, one at a time
5. Remove old middleware, run full suite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each task gets marked done as it completes. This does two things: it gives the agent a cheap way to answer "what have I actually finished?" without re-deriving it from scratch, and it gives &lt;em&gt;me&lt;/em&gt; a place to interrupt and redirect before three more hours of work compound on a wrong turn.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Force a self-summary at natural boundaries
&lt;/h3&gt;

&lt;p&gt;Every time a checkpoint closes, I have the agent write a short structured summary before moving on — not for me, for &lt;em&gt;itself&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Checkpoint: token-validation module&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Done: new module at validators/token.ts, covers JWT + opaque tokens
&lt;span class="p"&gt;-&lt;/span&gt; Not done: refresh-token rotation (deferred, ticketed separately)
&lt;span class="p"&gt;-&lt;/span&gt; Gotcha: old middleware silently swallowed expired-token errors;
  new module raises instead — call sites must catch explicitly
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That "gotcha" line is the part that pays for itself. Without it, an agent three checkpoints later will happily assume the old error-swallowing behavior still holds, because the code that proves otherwise scrolled out of its effective attention even if it's technically still in the context window.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Re-read state from source, don't trust memory of it
&lt;/h3&gt;

&lt;p&gt;Long sessions build up a kind of institutional memory in the transcript — "I already checked, that file doesn't have tests." Sometimes that memory is stale by the time it matters, especially if the agent (or a parallel process) touched the file since. Before any destructive or hard-to-reverse step, I make the agent re-read the current state from disk rather than reason from what it remembers observing an hour ago:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Before deleting the old module, don't trust "I already confirmed&lt;/span&gt;
&lt;span class="c"&gt;# nothing imports it" from 40 minutes ago — check again, now.&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"from '.*old-auth-middleware'"&lt;/span&gt; src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's a small amount of redundant work per checkpoint. It has saved me from at least one agent deleting code that a &lt;em&gt;different&lt;/em&gt; task step had just started depending on.&lt;/p&gt;

&lt;p&gt;The rule of thumb I use: if reversing the action would take more than a &lt;code&gt;git revert&lt;/code&gt;, re-verify from source first. Renaming a variable, tweaking a comment — fine, trust the transcript. Deleting a file, dropping a database column, force-pushing a branch — re-check, every time, no matter how confident the summary two checkpoints ago sounded. Confidence in a transcript is not the same thing as correctness of the world it's describing.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Treat context window size as a hard budget, not a suggestion
&lt;/h3&gt;

&lt;p&gt;I stopped thinking of the context window as "how much can fit" and started thinking of it as "how much can stay &lt;em&gt;useful&lt;/em&gt;." Concretely: once a session's transcript has accumulated a lot of exploratory dead-ends (files read and rejected, approaches tried and abandoned), I have the agent compact its own working notes into a fresh, terse state file, then treat that file — not the sprawling history — as the source of truth going forward.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    A[Long session transcript] --&amp;gt; B{Getting noisy?}
    B -- yes --&amp;gt; C[Compact into state.md]
    C --&amp;gt; D[Continue from state.md]
    B -- no --&amp;gt; D
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same instinct behind why good engineers write status-update comments in long PRs instead of expecting reviewers to replay the whole commit history in their head.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Know when to stop pushing a stale session and start fresh
&lt;/h3&gt;

&lt;p&gt;The hardest one to operationalize, because it's a judgment call: sometimes the right move isn't a better checkpoint, it's ending the session and starting a new one with a clean, deliberately-written brief. I now treat this as a real decision point instead of something that only happens when a session literally errors out. Signs I use as a trigger: the agent re-asks something already answered, a checkpoint summary starts repeating itself, or a fix gets proposed for something already fixed. Any one of those is a tell that the marginal token in this session is worth less than the first token of a fresh one.&lt;/p&gt;

&lt;p&gt;Starting fresh doesn't mean losing the work — the checkpoint summaries from step 2 are exactly what makes a clean handoff possible. A new session that opens by reading a tight, curated set of checkpoint notes gets a better starting context than an old session ten checkpoints deep, even though the old session technically "remembers more." That's the whole point: a well-written summary beats raw history, every time, because raw history makes the model do the work of figuring out what still matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Context rot is silent.&lt;/strong&gt; The agent doesn't announce "I'm now working from stale information" — it just keeps generating confident, plausible output. You have to build checkpoints that make drift visible, because the agent won't flag it on its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Summaries the agent writes for itself are more valuable than summaries written for you.&lt;/strong&gt; A "gotcha" line aimed at future-agent-in-this-session catches a different class of bug than a human-readable status update.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-verification is cheap insurance.&lt;/strong&gt; A &lt;code&gt;grep&lt;/code&gt; before a delete costs seconds. An agent deleting something a parallel task step now depends on costs a lot more than seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bigger context windows delay the problem, they don't solve it.&lt;/strong&gt; Even with a huge window, the ratio of relevant-to-irrelevant tokens still degrades over a long session. Compaction is a workflow habit, not something you can buy your way out of.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Start over" is a legitimate engineering decision, not a failure.&lt;/strong&gt; Treating a fresh session as a deliberate tool — not a last resort — produced better outcomes than squeezing one more checkpoint out of an already-drifting session.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;I'm working on making the "getting noisy?" check in step 4 less of a gut call and more of something the agent can evaluate against concrete signals (repeated tool calls, checkpoint summaries that don't shrink, task list churn). If that turns into something reusable, I'll write it up.&lt;/p&gt;

&lt;p&gt;I'm also curious whether the "start fresh" decision in step 5 can be automated the same way — right now it's me reading the transcript and getting a feeling that something's off, which doesn't scale if I ever want multiple long-running sessions going at once without watching each one closely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up / CTA
&lt;/h2&gt;

&lt;p&gt;If you've hit context rot in your own long-running agent sessions, I'd like to hear what signals you use to catch it — drop a comment. And if this was useful, follow me here on Dev.to for more from-the-trenches notes on running AI coding agents on real work.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>softwareengineering</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How I Taught My Autonomous Coding Agent to Survive API Rate Limits</title>
      <dc:creator>yureki_lab</dc:creator>
      <pubDate>Sun, 19 Jul 2026 14:34:31 +0000</pubDate>
      <link>https://dev.to/yureki_lab/how-i-taught-my-autonomous-coding-agent-to-survive-api-rate-limits-2hd1</link>
      <guid>https://dev.to/yureki_lab/how-i-taught-my-autonomous-coding-agent-to-survive-api-rate-limits-2hd1</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I run a fully autonomous coding agent that kicks off scheduled jobs around the clock, and for months I didn't have a real plan for what happens when the LLM provider says "no more requests right now." Eventually it happened enough times that I had to design for it properly. Here's what I learned about detecting rate limits, failing fast instead of retry-looping, and making every scheduled run safe to skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;When you run an AI agent as a background worker instead of an interactive chat session, you stop thinking about rate limits as an edge case and start thinking about them as a certainty. Somewhere, on some day, a scheduled run is going to hit a provider-side cap — a session limit, a rolling 5-hour window, a hard 429 — and your agent needs to have an opinion about what happens next.&lt;/p&gt;

&lt;p&gt;My setup is simple on paper: a background job wakes up once a day, spins up an autonomous coding agent, and lets it do a scoped chunk of work — writing code, updating state, whatever the day's task is. No human watches it run. That's the whole point of "autonomous." But "no human watches it run" cuts both ways: if the agent gets a 429 and responds by hammering the API in a tight retry loop, there's nobody there to notice until the bill shows up or the account gets flagged.&lt;/p&gt;

&lt;p&gt;I found out the hard way. Two days in a row, my scheduled job fired, immediately hit a session-limit error, and then just... stopped, with a two-line log entry and nothing else. That's actually the &lt;em&gt;good&lt;/em&gt; outcome. The bad outcome — the one I was originally set up for — was a naive retry: catch the error, wait a second, try again, catch the error, wait a second, try again. On a 5-hour rate limit window, that's not a retry strategy, that's a denial-of-service attack against myself.&lt;/p&gt;

&lt;p&gt;So the real problem wasn't "the API returned an error." APIs return errors; that's normal. The problem was that I hadn't designed my agent to treat "I am currently rate-limited" as a first-class state, distinct from "something is broken" or "the task failed." Those are three different situations that call for three different responses, and I'd been collapsing them into one.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Solved It
&lt;/h2&gt;

&lt;p&gt;The fix ended up being less about clever retry math and more about being honest with the agent's control flow about what kind of failure it was looking at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Classify the error before deciding what to do with it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first thing I did was stop treating every non-2xx response the same way. A rate-limit response usually comes with enough metadata to tell you &lt;em&gt;why&lt;/em&gt; you're being throttled and &lt;em&gt;when&lt;/em&gt; it'll clear:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rate_limit_event"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rate_limit_info"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rejected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"resetsAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1784397600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"rateLimitType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"five_hour"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"overageStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rejected"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;resetsAt&lt;/code&gt; field is gold. It turns "retry with exponential backoff and hope" into "I know exactly when this clears, so I don't need to guess." Once I started parsing that field explicitly, the agent's decision tree got a lot simpler: if the reset time is more than a few minutes away, don't retry at all — just record it and exit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Fail fast, log clearly, and let the &lt;em&gt;next scheduled run&lt;/em&gt; be the retry.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This was the biggest mental shift. I stopped treating "the job didn't finish today" as a failure that needed same-session recovery. If the agent hits a hard rate limit, the correct response is almost never "wait it out inside this process." It's "exit cleanly, write down exactly what happened, and trust tomorrow's scheduled run to pick it up."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;log_failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rate_limited&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resets_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resets_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;mark_task_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rate limit, resets &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resets_at&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# not sys.exit(1) — this isn't a crash, it's an expected state
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the exit code choice. A rate limit isn't a bug in my code — it's an expected operating condition for a system that runs unattended. I don't want my monitoring to treat "got rate limited" with the same alarm as "the process segfaulted." Those need different severities, or you train yourself to ignore alerts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Make every scheduled run idempotent.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once I accepted that some runs would simply not complete, I had to make sure a skipped or half-finished run couldn't corrupt state. Every scheduled task now starts by creating (or finding) a tracking record with status &lt;code&gt;InProgress&lt;/code&gt; before doing any real work, and only flips it to &lt;code&gt;Done&lt;/code&gt; at the very end. If a run dies partway through — rate limit, crash, whatever — the record just sits at &lt;code&gt;InProgress&lt;/code&gt; or &lt;code&gt;Failed&lt;/code&gt;, and the next run's dedup logic knows not to trust it as completed work.&lt;/p&gt;

&lt;p&gt;This matters more than it sounds like it should. Without it, a rate-limited run that partially executes side effects (say, half-written output, or a partially-updated log) leaves you with corrupted state that's harder to debug than the original rate limit ever was. Idempotency isn't a nice-to-have for autonomous agents — it's what lets you treat "just skip today" as a safe, boring outcome instead of a scary one.&lt;/p&gt;

&lt;p&gt;The pattern I settled on is boring on purpose: create the tracking record in a clearly-provisional state &lt;em&gt;before&lt;/em&gt; touching anything else, do the real work, and only flip the record to "done" as the very last step. Everything in between is disposable. If the process dies at any point — rate limit, crash, laptop goes to sleep, whatever — the worst case is a provisional record sitting there, and the next run's own startup check treats that as "not actually finished" rather than trusting it. I originally skipped this step because it felt like overhead for something that "almost never happens." It happens more than you'd think, and the one time it matters, it saves you an afternoon of forensic log-reading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Distinguish session limits from hard account-level limits.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not all rate limits are the same shape. Some clear in minutes, some are tied to a rolling window (in my case, a 5-hour window), and some are account-level caps that won't clear until a fixed daily reset. I now log the &lt;code&gt;rateLimitType&lt;/code&gt; explicitly so that when I'm skimming a week of logs, I can immediately tell "this was a routine 5-hour window bump" apart from "this is a structural capacity problem I need to actually fix" (like needing a higher-tier plan, or spacing out scheduled jobs so they don't compete for the same window).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    A[Scheduled run starts] --&amp;gt; B{API call succeeds?}
    B -- yes --&amp;gt; C[Do the work, mark Done]
    B -- no, 429 --&amp;gt; D{Parse rate_limit_info}
    D --&amp;gt; E[Log type + resetsAt]
    E --&amp;gt; F[Mark Failed, exit 0]
    F --&amp;gt; G[Next scheduled run retries naturally]
    B -- no, other error --&amp;gt; H[Mark Failed, exit 1, alert]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A rate limit is a state, not an error.&lt;/strong&gt; Treat it differently from a genuine bug, both in your exit codes and in whatever alerts you. If your monitoring can't tell the difference, you'll start ignoring both.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The best retry strategy for a daily job is often "tomorrow."&lt;/strong&gt; I spent way more time than I should have designing in-process backoff logic before realizing that a system that already runs on a schedule has a free, built-in retry mechanism: the next scheduled run. Use it instead of reinventing a worse one inside a single process.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Idempotency is what makes "just fail today" safe.&lt;/strong&gt; If your agent can partially complete work before dying, a rate limit becomes a data-corruption risk, not just a missed day. Structure state changes so a run either fully completes or leaves a clearly-unfinished marker behind, never something that looks done when it isn't.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Parse the metadata the provider actually gives you.&lt;/strong&gt; Most APIs that rate-limit you also tell you when it clears. Don't guess with exponential backoff when you can just read &lt;code&gt;resetsAt&lt;/code&gt; and act on it directly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Log the &lt;em&gt;type&lt;/em&gt; of limit, not just the fact that you got one.&lt;/strong&gt; A session cap and a hard account-level cap look identical from "I got a 429," but they mean completely different things about whether you have an actual capacity problem to solve.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;I'm now looking at spacing out my scheduled jobs so they're less likely to stack inside the same rolling rate-limit window in the first place — basically capacity planning for an agent instead of just reacting after the fact. I'm also curious whether I can get the agent to self-report "I'm close to a rate-limit boundary, maybe skip non-essential work today" instead of finding out only after the 429 comes back.&lt;/p&gt;

&lt;p&gt;There's also a harder question underneath all of this that I don't have a clean answer for yet: how do you budget API usage across a fleet of independent scheduled agents that don't know about each other? Right now each job just finds out it's rate-limited when it hits the wall, the same way you'd find out a shared resource is exhausted by tripping over it. A smarter version would have some shared sense of "how much runway is left in this window" before any of them start work, so the first job of the day doesn't accidentally starve the rest. That's the next thing I want to build — some kind of lightweight, shared rate-limit budget that every scheduled job checks before it even starts, not just after it fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;If you're running any kind of unattended AI agent — a background job, a scheduled task, anything without a human watching the terminal — do yourself a favor and design for rate limits on day one instead of day ninety. It's a small amount of upfront thinking that saves you from a very confusing debugging session later.&lt;/p&gt;

&lt;p&gt;If this was useful, follow me here on Dev.to — I'm writing up more of these lessons as I keep building out this autonomous coding system. Also curious: how are you handling rate limits in your own agent setups? Drop a comment, I'd love to compare notes.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>softwareengineering</category>
      <category>programming</category>
    </item>
    <item>
      <title>How I Debug a Misbehaving AI Coding Agent: My 4-Step Playbook</title>
      <dc:creator>yureki_lab</dc:creator>
      <pubDate>Thu, 16 Jul 2026 14:33:17 +0000</pubDate>
      <link>https://dev.to/yureki_lab/how-i-debug-a-misbehaving-ai-coding-agent-my-4-step-playbook-3m12</link>
      <guid>https://dev.to/yureki_lab/how-i-debug-a-misbehaving-ai-coding-agent-my-4-step-playbook-3m12</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;AI coding agents don't fail like normal software — they fail &lt;em&gt;confidently&lt;/em&gt;, and the bug is usually three turns upstream from where the damage shows up. After months of running an autonomous Claude Code setup, I settled on a 4-step debug playbook: &lt;strong&gt;reproduce minimally → find the divergence turn in the transcript → diff tool reality vs model assumption → fix at the right layer&lt;/strong&gt;. This post walks through the playbook with a real war story where my agent read a JSON error response and decided it was a success. 🐛&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;When a regular program breaks, you get a stack trace. It points at a line. You fix the line.&lt;/p&gt;

&lt;p&gt;When an AI agent breaks, you get... a finished task. A cheerful summary. Maybe even green tests. And then two days later you discover it "refactored" a config loader that never existed, or built a feature on top of an API call that has been returning 403 the whole time.&lt;/p&gt;

&lt;p&gt;I run a fully autonomous implementation system built on Claude Code (mid-2026 builds, running on Node.js 22.x). It plans work, edits code, runs tests, and reports back — largely unattended. Which means when something goes wrong, there's no human in the loop who saw it happen. All I have is the aftermath and a transcript.&lt;/p&gt;

&lt;p&gt;Early on, my "debugging" looked like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Notice weird output&lt;/li&gt;
&lt;li&gt;Re-run the whole task and stare at it&lt;/li&gt;
&lt;li&gt;Add an ALL-CAPS rule to the prompt like &lt;code&gt;NEVER DO THE BAD THING&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Watch it do the bad thing again next Tuesday&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's not debugging. That's superstition. ⚠️&lt;/p&gt;

&lt;p&gt;The turning point was realizing that &lt;strong&gt;agent failures are almost never where the symptom is&lt;/strong&gt;. The symptom is in the final output; the cause is a specific earlier turn where the model's internal picture of the world stopped matching reality. Everything after that turn is the model being perfectly logical about wrong facts.&lt;/p&gt;

&lt;p&gt;So the job isn't "why is the output bad?" It's "&lt;strong&gt;at which exact turn did the model's beliefs and reality split?&lt;/strong&gt;" That reframe gave me a repeatable process.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Solved It: The 4-Step Playbook
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    A[Symptom: bad output] --&amp;gt; B[Step 1: Reproduce with a minimal prompt]
    B --&amp;gt; C[Step 2: Walk the transcript, find the divergence turn]
    C --&amp;gt; D[Step 3: Diff tool reality vs model assumption]
    D --&amp;gt; E{Which layer failed?}
    E --&amp;gt;|Ambiguous instructions| F[Fix the prompt/docs]
    E --&amp;gt;|Model skipped verification| G[Add a deterministic check]
    E --&amp;gt;|Tool output was misleading| H[Fix the tool contract]
    F --&amp;gt; I[Re-run minimal repro to confirm]
    G --&amp;gt; I
    H --&amp;gt; I
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 1: Reproduce with a minimal prompt
&lt;/h3&gt;

&lt;p&gt;Resist the urge to re-run the full task. A 40-turn session is a haystack. Instead, extract the smallest prompt that still triggers the misbehavior.&lt;/p&gt;

&lt;p&gt;When my agent mangled a database migration, the full task was "implement the new billing fields end to end." The minimal repro turned out to be just:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Read migrations/ and tell me the current schema version.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single instruction reproduced the bug: the agent reported a schema version that didn't exist. Forty turns collapsed into one. Now I had something I could iterate on in seconds instead of minutes, and I knew the failure had nothing to do with billing logic at all.&lt;/p&gt;

&lt;p&gt;Rule of thumb: if your repro takes more than 3 turns, keep cutting. The bug survives minimization far more often than you'd expect, because the bug is usually in &lt;em&gt;perception&lt;/em&gt; (reading files, parsing tool output), not in the complicated reasoning you were worried about.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Walk the transcript and find the divergence turn
&lt;/h3&gt;

&lt;p&gt;Claude Code keeps full session transcripts (JSONL on disk — check your &lt;code&gt;~/.claude/projects/&lt;/code&gt; directory). This is the flight recorder. Most people never open it. Open it.&lt;/p&gt;

&lt;p&gt;I read transcripts with one question in mind: &lt;strong&gt;what did the model claim right after each tool call?&lt;/strong&gt; You're looking for the first turn where the model's summary of a tool result doesn't match the raw result sitting right above it.&lt;/p&gt;

&lt;p&gt;A trick that saves a lot of scrolling — pull out just the tool results and the assistant text that follows each one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'select(.type == "tool_result" or .type == "assistant")
       | .content // .text'&lt;/span&gt; session.jsonl | less
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it top to bottom like a diff between two narrators: the tools tell one story, the model tells another. The first place the stories disagree is your divergence turn. Everything downstream is fruit of the poisoned tree — don't waste time analyzing it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Diff what the tool returned vs what the model assumed
&lt;/h3&gt;

&lt;p&gt;Here's my favorite war story, because it's so dumb and so instructive.&lt;/p&gt;

&lt;p&gt;My agent needed to register a webhook with an internal service. The service replied:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"duplicate_endpoint"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Endpoint already registered"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HTTP status: &lt;code&gt;200&lt;/code&gt;. Because of course the service returns errors with a 200. 🙃&lt;/p&gt;

&lt;p&gt;The agent's very next message: &lt;em&gt;"Webhook registered successfully. Moving on to the notification handler."&lt;/em&gt; It then spent six turns building a notification handler around a webhook &lt;code&gt;id&lt;/code&gt; of &lt;code&gt;null&lt;/code&gt;, including writing &lt;code&gt;if (webhookId)&lt;/code&gt; guards that silently skipped the entire code path. Green tests, by the way — the guards made sure nothing ran, and nothing that doesn't run can fail.&lt;/p&gt;

&lt;p&gt;At the divergence turn, the diff was brutally clear:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tool reality:&lt;/strong&gt; &lt;code&gt;"ok": false&lt;/code&gt;, &lt;code&gt;"id": null&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model assumption:&lt;/strong&gt; registration succeeded, an ID exists&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why did it happen? The model pattern-matched on &lt;code&gt;200&lt;/code&gt; + a JSON body + the word "registered" in the message field. Skim-reading. The same failure mode as a tired human at 2am, honestly.&lt;/p&gt;

&lt;p&gt;This step is where you classify the failure. In my experience nearly every agent bug is one of three kinds:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ambiguity bug&lt;/strong&gt; — the instructions genuinely supported two readings, and the model picked the other one&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verification bug&lt;/strong&gt; — the info was available, the model just didn't check it (my webhook story)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool contract bug&lt;/strong&gt; — the tool's output actively invites misreading (also my webhook story — a 200-with-error API is a trap for humans and models alike)&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Step 4: Fix at the right layer
&lt;/h3&gt;

&lt;p&gt;This is the step everyone gets wrong, including past me. The instinct is to always fix with prompt rules: "ALWAYS check the ok field!" But each failure class has a correct layer:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ambiguity bugs → fix the instructions.&lt;/strong&gt; Rewrite the ambiguous sentence in your CLAUDE.md or task prompt. One precise sentence beats five warning paragraphs. If you find yourself writing a rule in caps, the underlying sentence is probably still ambiguous.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verification bugs → add a deterministic check, not a prompt rule.&lt;/strong&gt; Prompt rules are probabilistic; the model follows them &lt;em&gt;usually&lt;/em&gt;. For anything where "usually" isn't good enough, put the check outside the model. I added a tiny response validator the agent must run after registration calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# validate-response.sh — fail loudly so the agent can't skim past it&lt;/span&gt;
&lt;span class="nv"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.ok'&lt;/span&gt; response.json&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ok&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"true"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"REGISTRATION FAILED: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.error'&lt;/span&gt; response.json&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A non-zero exit code is impossible to misread. The model can ignore a sentence in a JSON blob; it cannot ignore a failed command.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool contract bugs → fix the tool.&lt;/strong&gt; If a tool returns errors as 200s, or dumps 4,000 lines when 10 matter, no amount of prompting fixes that permanently. I wrapped the offending service client so errors became actual errors. The bug never came back — for the agent &lt;em&gt;or&lt;/em&gt; for me.&lt;/p&gt;

&lt;p&gt;Then re-run your Step 1 minimal repro to confirm the fix. Because the repro is one turn, this takes seconds. If you skipped minimization, you're now re-running 40-turn sessions to test a one-line fix, and you'll stop verifying out of sheer boredom. Minimization is what makes the whole loop sustainable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The symptom is downstream; debug upstream.&lt;/strong&gt; The final bad output is almost never where the bug lives. Find the divergence turn and ignore everything after it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Agents fail at perception more than reasoning.&lt;/strong&gt; I expected to debug bad logic. What I actually debug, over and over, is bad &lt;em&gt;reading&lt;/em&gt;: skimmed tool output, misparsed errors, assumed file contents. Optimize your debugging (and your tools) for perception failures.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Prompt rules are for ambiguity; exit codes are for safety.&lt;/strong&gt; If a failure genuinely must not happen again, the fix is deterministic — a validator, a hook, a wrapper. Adding a prompt rule where you needed a hard check is the #1 way to meet the same bug twice.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A 200-with-error API will eventually fool your agent, guaranteed.&lt;/strong&gt; Anything that fools a skimming human fools a model. Fixing tool contracts is the highest-leverage, least-glamorous agent debugging there is.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Transcripts are your stack trace — build the habit of reading them.&lt;/strong&gt; The answer is almost always sitting in plain text in a JSONL file. Ten minutes of reading beats an hour of re-running and guessing.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The playbook is manual today, and steps 2–3 are begging for automation. I'm experimenting with a "transcript skeptic": a second, cheaper agent pass that walks a finished session and flags turns where the assistant's claim doesn't match the preceding tool result. Early results are promising — it caught a misread test failure last week before I did. If it stabilizes, that's a future post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Agent debugging felt like voodoo until I stopped treating the model as the unit of failure and started treating &lt;em&gt;turns&lt;/em&gt; as the unit of failure. Reproduce small, find the divergence, diff belief against reality, fix the right layer. It's just debugging — the stack trace is a transcript now.&lt;/p&gt;

&lt;p&gt;If you're running Claude Code or any autonomous agent: next time it does something baffling, don't re-prompt. Open the transcript and find the turn. 💡&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If this was useful, follow me here on Dev.to&lt;/strong&gt; — I write regularly about building and running autonomous coding agents in production: the wins, the faceplants, and the playbooks that come out of them. And if you haven't yet, grab &lt;a href="https://claude.com/claude-code" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; and let an agent surprise you. Then debug it. 🚀&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's the most confidently wrong thing your agent has ever done? Tell me in the comments — I'm collecting war stories.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>debugging</category>
      <category>programming</category>
    </item>
    <item>
      <title>How I Use Claude Code Hooks to Stop Bad Agent Behavior Before It Ships</title>
      <dc:creator>yureki_lab</dc:creator>
      <pubDate>Tue, 14 Jul 2026 14:32:41 +0000</pubDate>
      <link>https://dev.to/yureki_lab/how-i-use-claude-code-hooks-to-stop-bad-agent-behavior-before-it-ships-11a</link>
      <guid>https://dev.to/yureki_lab/how-i-use-claude-code-hooks-to-stop-bad-agent-behavior-before-it-ships-11a</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Prompt rules are suggestions; hooks are law. After my autonomous Claude Code agent "helpfully" ran a destructive git command that my carefully written instructions told it never to run, I moved my guardrails out of the prompt and into Claude Code hooks — small deterministic scripts that fire before and after every tool call. This post covers the three hooks that now guard every session I run, with copy-pasteable configs. 🛡️&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;I run Claude Code (v2.x) as a mostly unattended coding agent. It reads a task, edits files, runs tests, commits. For months, my safety strategy was a growing list of rules in the project instructions file:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Never force-push. Never delete branches. Never run migrations against anything that isn't local. Always run the formatter before committing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And for months, it &lt;em&gt;mostly&lt;/em&gt; worked. That word "mostly" is the entire problem.&lt;/p&gt;

&lt;p&gt;One evening the agent got into a state where a rebase had gone sideways. It reasoned — quite logically, from its point of view — that the cleanest way out was &lt;code&gt;git checkout -- .&lt;/code&gt; followed by resetting the branch to origin. It wiped about two hours of its own uncommitted work. Nothing irreplaceable, but it stung, because my instructions &lt;em&gt;explicitly said&lt;/em&gt; not to discard working changes without committing a checkpoint first.&lt;/p&gt;

&lt;p&gt;Here's the uncomfortable truth I had to accept:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An LLM follows instructions statistically. A guardrail needs to hold 100% of the time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When the model is calm and the task is simple, prompt rules hold. When the context window is full of error output and the agent is three failed attempts deep into fixing something, that "never do X" paragraph from 40,000 tokens ago is competing with a very loud, very recent "X would fix this right now." Sometimes the recent signal wins. You cannot prompt-engineer your way to a hard guarantee.&lt;/p&gt;

&lt;p&gt;What I actually needed was a mechanism that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Runs &lt;strong&gt;outside&lt;/strong&gt; the model, so it can't be reasoned around&lt;/li&gt;
&lt;li&gt;Fires on &lt;strong&gt;every&lt;/strong&gt; tool call, not just when the model remembers&lt;/li&gt;
&lt;li&gt;Fails &lt;strong&gt;loudly&lt;/strong&gt;, so the agent knows it was blocked and why&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's exactly what Claude Code hooks are.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Solved It
&lt;/h2&gt;

&lt;p&gt;Hooks are shell commands that Claude Code executes at fixed lifecycle points. The two I lean on hardest are &lt;code&gt;PreToolUse&lt;/code&gt; (runs before a tool call, and can &lt;strong&gt;block&lt;/strong&gt; it) and &lt;code&gt;PostToolUse&lt;/code&gt; (runs after, great for cleanup and enforcement). There's also &lt;code&gt;Stop&lt;/code&gt;, which fires when the agent thinks it's finished.&lt;/p&gt;

&lt;p&gt;The mental model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    A[Agent decides to run a tool] --&amp;gt; B{PreToolUse hook}
    B -- exit 0 --&amp;gt; C[Tool executes]
    B -- exit 2 --&amp;gt; D[Blocked - reason fed back to agent]
    C --&amp;gt; E{PostToolUse hook}
    E --&amp;gt; F[Formatting, checks, logging]
    D --&amp;gt; A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key detail: when a &lt;code&gt;PreToolUse&lt;/code&gt; hook exits with code 2, the tool call is blocked and the hook's stderr is shown to the agent. The agent doesn't just fail silently — it gets told &lt;em&gt;why&lt;/em&gt; it was stopped, and it adapts. It's like a linter for agent behavior.&lt;/p&gt;

&lt;p&gt;Here are the three hooks that guard every session I run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hook 1: The command firewall (PreToolUse)
&lt;/h3&gt;

&lt;p&gt;This is the one that would have saved my two hours. It inspects every Bash command before execution and blocks a denylist of destructive patterns.&lt;/p&gt;

&lt;p&gt;The config lives in &lt;code&gt;.claude/settings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$CLAUDE_PROJECT_DIR/.claude/hooks/guard.sh"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the guard script itself. Hooks receive the tool input as JSON on stdin, so you parse it with &lt;code&gt;jq&lt;/code&gt; and pattern-match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# .claude/hooks/guard.sh — block destructive commands&lt;/span&gt;
&lt;span class="nv"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.tool_input.command // ""'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;deny&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;
  &lt;span class="s2"&gt;"git push --force"&lt;/span&gt;
  &lt;span class="s2"&gt;"git push -f"&lt;/span&gt;
  &lt;span class="s2"&gt;"git reset --hard"&lt;/span&gt;
  &lt;span class="s2"&gt;"git checkout -- ."&lt;/span&gt;
  &lt;span class="s2"&gt;"git clean"&lt;/span&gt;
  &lt;span class="s2"&gt;"rm -rf"&lt;/span&gt;
  &lt;span class="s2"&gt;"DROP TABLE"&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;pattern &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;deny&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pattern&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"BLOCKED: '&lt;/span&gt;&lt;span class="nv"&gt;$pattern&lt;/span&gt;&lt;span class="s2"&gt;' is on the denylist. Commit a checkpoint first, then ask for a safer alternative."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;exit &lt;/span&gt;2
  &lt;span class="k"&gt;fi
done
&lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty lines. It has fired 14 times in the last two months. That's 14 moments where a statistical instruction-follower decided a destructive command was a good idea, and a deterministic script said no. Every single one of those would have been a dice roll under the prompt-rules regime. 🎲&lt;/p&gt;

&lt;p&gt;One thing I learned the hard way: &lt;strong&gt;give the agent an escape route in the error message&lt;/strong&gt;. My first version just said "BLOCKED." The agent would retry the same command with slight variations, probing for a way through. Once the message said &lt;em&gt;what to do instead&lt;/em&gt; ("commit a checkpoint first"), the agent started actually doing that. The hook isn't just a wall — it's feedback.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hook 2: The auto-formatter (PostToolUse)
&lt;/h3&gt;

&lt;p&gt;Smaller win, but it removed a whole category of nagging. Instead of telling the agent "always run the formatter after editing," I just... run the formatter after it edits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Edit|Write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$CLAUDE_PROJECT_DIR/.claude/hooks/format.sh"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# .claude/hooks/format.sh — format whatever file was just touched&lt;/span&gt;
&lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.tool_input.file_path // ""'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;.py&lt;span class="p"&gt;)&lt;/span&gt; ruff format &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;.ts|&lt;span class="k"&gt;*&lt;/span&gt;.tsx|&lt;span class="k"&gt;*&lt;/span&gt;.js&lt;span class="p"&gt;)&lt;/span&gt; npx prettier &lt;span class="nt"&gt;--write&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt;
&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This deleted an entire paragraph from my instructions file. Every rule you can move from prose into a hook is a rule the model can no longer forget, and it frees context-window attention for rules that genuinely need judgment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hook 3: The exit gate (Stop)
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;Stop&lt;/code&gt; hook fires when the agent believes it's done. Mine runs the test suite; if tests fail, it exits with code 2 and the agent is sent back to work instead of declaring victory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# .claude/hooks/done-check.sh — don't let the agent stop with red tests&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; npm &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;--silent&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/test-out.log 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Tests are failing. Fix them before finishing. Last lines:"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt; /tmp/test-out.log &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;2
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before this hook, "done" meant "the agent &lt;em&gt;feels&lt;/em&gt; done." After it, "done" means "the tests pass." Those are very different quality bars, and the second one doesn't depend on the model's mood. ✅&lt;/p&gt;

&lt;p&gt;⚠️ One caveat: add a bailout condition (a max-retries counter in a temp file works) or a genuinely stuck agent will loop against the gate forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Prompt rules are policy; hooks are enforcement.&lt;/strong&gt; You need both, but never confuse them. If violating a rule would cost you real time, data, or money, it belongs in a hook. If it's a style preference, the prompt is fine.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The blocked message is a prompt injection you control.&lt;/strong&gt; Exit code 2's stderr goes straight into the agent's context at the exact moment it matters — infinitely more salient than a rule buried 40k tokens back. Write it like a helpful senior engineer: what was blocked, why, and what to do instead.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Every hook you add shrinks your instructions file.&lt;/strong&gt; I cut my project instructions by roughly a third after moving enforcement into hooks. Shorter instructions means the rules that remain actually get followed more reliably. Deterministic code handles the "always/never" stuff better than prose ever will.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Denylist beats allowlist for Bash, allowlist beats denylist for everything else.&lt;/strong&gt; I tried allowlisting shell commands first. It was misery — agents compose commands in endlessly creative ways, and I spent days approving harmless variations. Denylist the catastrophic patterns, let everything else through, and rely on git for recovery.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hooks are your audit log for free.&lt;/strong&gt; Since every hook sees every tool call, a one-line &lt;code&gt;echo "$(date) $cmd" &amp;gt;&amp;gt; ~/.agent-audit.log&lt;/code&gt; gives you a complete record of what your agent actually did — which is how I know the firewall fired 14 times, instead of vaguely feeling like "it helps."&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Two things I'm experimenting with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Risk-scored gating&lt;/strong&gt; — instead of a binary denylist, a hook that scores commands (touching &lt;code&gt;.env&lt;/code&gt;? network calls? package installs?) and only blocks above a threshold, with different thresholds for interactive vs. unattended sessions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session-scoped budgets in hooks&lt;/strong&gt; — a &lt;code&gt;PreToolUse&lt;/code&gt; counter that caps how many times the agent can run the test suite per session, nudging it to think instead of brute-forcing the exit gate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bigger theme: as I hand more autonomy to agents, the interesting engineering keeps shifting from &lt;em&gt;what I ask the model to do&lt;/em&gt; to &lt;em&gt;what the harness around the model permits&lt;/em&gt;. Hooks are the cheapest possible entry point into that mindset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;If you run Claude Code today, here's your 15-minute version: create &lt;code&gt;.claude/hooks/guard.sh&lt;/code&gt; with the denylist above, wire it into &lt;code&gt;settings.json&lt;/code&gt;, and forget about it. The first time it blocks something, you'll feel the same mix of relief and mild horror I did. 🚀&lt;/p&gt;

&lt;p&gt;If you found this useful, &lt;strong&gt;follow me here on Dev.to&lt;/strong&gt; — I write regularly about running autonomous coding agents in the real world: state persistence, self-verification, parallel agents, and all the ways this stuff breaks at 2am. And if you haven't tried &lt;a href="https://www.anthropic.com/claude-code" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; yet, the hooks system alone is worth the install.&lt;/p&gt;

&lt;p&gt;What's in &lt;em&gt;your&lt;/em&gt; denylist? Drop it in the comments — I'm collecting war stories. 💬&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>tutorial</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How I Write CLAUDE.md Files That Keep My AI Agent On Track: 5 Hard-Won Rules</title>
      <dc:creator>yureki_lab</dc:creator>
      <pubDate>Mon, 13 Jul 2026 14:33:08 +0000</pubDate>
      <link>https://dev.to/yureki_lab/how-i-write-claudemd-files-that-keep-my-ai-agent-on-track-5-hard-won-rules-2kod</link>
      <guid>https://dev.to/yureki_lab/how-i-write-claudemd-files-that-keep-my-ai-agent-on-track-5-hard-won-rules-2kod</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;After six months of running Claude Code on real projects, I learned that the single highest-leverage file in my repo isn't code — it's &lt;code&gt;CLAUDE.md&lt;/code&gt;, the project instruction file the agent reads at the start of every session. Most of mine started as a dumping ground and quietly stopped working. Here are the 5 rules that fixed it, with before/after examples. 💡&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;If you use Claude Code (or any coding agent that supports a project instruction file), you've probably done what I did: every time the agent made a mistake, you added a rule.&lt;/p&gt;

&lt;p&gt;"Don't use &lt;code&gt;var&lt;/code&gt;." Added. "Always run tests before committing." Added. "Remember we use pnpm, not npm." Added.&lt;/p&gt;

&lt;p&gt;Three months in, my &lt;code&gt;CLAUDE.md&lt;/code&gt; was 400+ lines. And here's the uncomfortable part: &lt;strong&gt;the agent was following it less, not more.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It wasn't random. The file had become the equivalent of a legal terms-of-service page — technically complete, practically unread. Long instruction files don't fail loudly; they fail by dilution. Every low-value line you add taxes the attention paid to the lines that actually matter.&lt;/p&gt;

&lt;p&gt;This mattered to me because I run agents with minimal supervision. When I'm reviewing every keystroke, a sloppy instruction file costs little — I just correct the agent live. When the agent runs a task end-to-end on its own, the instruction file &lt;em&gt;is&lt;/em&gt; the supervision. If it's bad, nobody catches the drift until the damage is done.&lt;/p&gt;

&lt;p&gt;So I rewrote mine from scratch, watched what actually changed behavior over the following weeks, and kept notes. Below is what survived contact with reality.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Context for the examples: Claude Code, mid-2026 releases, on macOS. The principles apply to any agent that ingests a per-project instruction file — Cursor rules, Copilot instructions, etc.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Solved It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Rule 1: Write a table of contents, not an encyclopedia
&lt;/h3&gt;

&lt;p&gt;The biggest structural change: &lt;code&gt;CLAUDE.md&lt;/code&gt; stopped &lt;em&gt;containing&lt;/em&gt; information and started &lt;em&gt;pointing&lt;/em&gt; to it.&lt;/p&gt;

&lt;p&gt;Before, everything lived inline — architecture notes, style guides, deployment steps. After, the file is ~80 lines: what this project is, what the agent's scope is, and a routing table telling it where the details live.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Docs map&lt;/span&gt;

| Need to know... | Read... |
|---|---|
| Current status &amp;amp; next task | STATE.md |
| Why past decisions were made | DECISIONS.md |
| Architecture &amp;amp; module layout | docs/architecture.md |
| Release process | docs/release.md |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude Code supports &lt;code&gt;@path/to/file.md&lt;/code&gt; imports for content that should &lt;em&gt;always&lt;/em&gt; be loaded. I use those sparingly — only for rules that must apply to every single session. Everything else is a plain path the agent reads on demand.&lt;/p&gt;

&lt;p&gt;Why it works: the agent reads the short file completely instead of skimming the long one. And when a detail changes, I update one topic file instead of hunting through a monolith.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 2: Separate "always true" from "true right now"
&lt;/h3&gt;

&lt;p&gt;This one bit me hard. I used to write current status into &lt;code&gt;CLAUDE.md&lt;/code&gt;: "We are currently migrating the auth module." Two months later that line was stale, the migration was done, and the agent kept making decisions as if it wasn't.&lt;/p&gt;

&lt;p&gt;The fix is an explicit split:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/strong&gt; — things that are true for the life of the project. Stack, conventions, scope, personas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;STATE.md&lt;/code&gt;&lt;/strong&gt; — things that are true &lt;em&gt;this week&lt;/em&gt;. Current task, blockers, recent decisions. The agent reads it first, every session, and &lt;strong&gt;updates it before the session ends&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I added one line to &lt;code&gt;CLAUDE.md&lt;/code&gt; that turned out to be load-bearing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Conflict resolution&lt;/span&gt;
If STATE.md contradicts CLAUDE.md, STATE.md wins (it's newer).
Priority: STATE.md &amp;gt; STRATEGY.md &amp;gt; CLAUDE.md &amp;gt; older logs.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Agents hit contradictory instructions constantly — stale docs, half-finished migrations, your own inconsistent notes. If you don't tell them how to break ties, they pick arbitrarily, and each session picks differently. An explicit priority order made behavior noticeably more consistent across sessions than almost anything else I tried.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 3: Define scope by what the agent must NOT touch
&lt;/h3&gt;

&lt;p&gt;Positive scope ("you work on the payments service") sounds sufficient. It isn't. Agents are curious by default: give one a monorepo and a vague task, and it will happily wander into neighboring services "for context" — burning tokens, and occasionally acting on code it has no business reading.&lt;/p&gt;

&lt;p&gt;Negative scope fixed this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Scope&lt;/span&gt;
You own &lt;span class="sb"&gt;`services/payments/`&lt;/span&gt; only.
Do NOT read or modify sibling services (auth/, catalog/, admin/),
even to "understand the overall system". If a task genuinely
requires another service, stop and say so instead.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key phrase is the exception path: &lt;em&gt;stop and say so&lt;/em&gt;. Without it, a hard prohibition just teaches the agent to work around missing context silently. With it, you get a clean escalation instead of a guess.&lt;/p&gt;

&lt;p&gt;I also list explicit exceptions (shared libraries, config that lives outside the service). Boundaries with documented exceptions get respected; absolute boundaries get rationalized away the first time they're inconvenient.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 4: Every rule earns its place with a "why"
&lt;/h3&gt;

&lt;p&gt;Compare:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;-&lt;/span&gt; Do not use the staging database for tests.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;versus:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;-&lt;/span&gt; Do not use the staging database for tests — it's shared with
  the demo environment, and test writes have corrupted live
  demos twice.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second version costs one more line and works dramatically better. Not because the agent "cares", but because a reason lets it generalize correctly. Given the bare rule, an agent facing a novel situation (a new script that &lt;em&gt;reads&lt;/em&gt; staging?) has to guess the rule's intent. Given the reason, it can derive the answer: reads are fine, writes are the hazard.&lt;/p&gt;

&lt;p&gt;This also forces a useful discipline on &lt;em&gt;me&lt;/em&gt;: if I can't articulate why a rule exists, it's usually a stale reflex from an incident that no longer applies — and it gets deleted instead of added.&lt;/p&gt;

&lt;p&gt;My heuristic now: &lt;strong&gt;a rule without a reason is a bug report waiting to happen.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 5: Prune on a schedule, not on failure
&lt;/h3&gt;

&lt;p&gt;Instruction files rot silently. Nothing alerts you when a line stops being true; the agent just starts acting slightly wrong, and you blame the model.&lt;/p&gt;

&lt;p&gt;So I treat &lt;code&gt;CLAUDE.md&lt;/code&gt; like dependencies: scheduled maintenance. Once a month I do a 15-minute pass with three questions per line:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is this still true?&lt;/strong&gt; (Stale facts are worse than missing facts — they're confidently wrong.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Has the agent actually violated this recently?&lt;/strong&gt; If a rule hasn't been relevant in months, it may be dead weight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did I add this in anger?&lt;/strong&gt; Post-incident rules are usually overfitted to one failure. Rewrite them as the general principle, or delete them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;My file has &lt;em&gt;shrunk&lt;/em&gt; in four consecutive monthly passes. Behavior improved each time. I no longer believe that's a coincidence — I think instruction-file quality is closer to signal-to-noise ratio than to coverage.&lt;/p&gt;

&lt;h3&gt;
  
  
  The shape that emerged
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;graph TD
    A[CLAUDE.md&amp;lt;br/&amp;gt;~80 lines, stable truths + routing] --&amp;gt; B[STATE.md&amp;lt;br/&amp;gt;current task, updated every session]
    A --&amp;gt; C[docs/architecture.md&amp;lt;br/&amp;gt;read on demand]
    A --&amp;gt; D[docs/release.md&amp;lt;br/&amp;gt;read on demand]
    B --&amp;gt;|wins conflicts| A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One small stable file, one volatile file with clear supremacy, and on-demand depth. That's the whole system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Instruction files fail by dilution, not by absence.&lt;/strong&gt; The 400-line file performed worse than the 80-line file. Every line you add taxes every other line. Ruthless brevity is a feature, not laziness. ✂️&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"True forever" and "true this week" must live in different files.&lt;/strong&gt; Mixing them guarantees staleness, and staleness is worse than silence — the agent trusts your stale line more than its own observation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explicit conflict-resolution order beats more rules.&lt;/strong&gt; One line — "newest state file wins" — eliminated a whole class of inconsistent-between-sessions behavior. Your docs &lt;em&gt;will&lt;/em&gt; contradict each other; plan for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Negative scope with an escalation path is the only scope that holds.&lt;/strong&gt; "Only work on X" gets creatively reinterpreted. "Don't touch Y; if you think you need Y, stop and ask" actually holds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reasons are compression.&lt;/strong&gt; One rule + its why covers dozens of unwritten variants, because the agent can derive them. Bare imperatives cover exactly one case: the last incident. ⚠️&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Two experiments I'm running now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-directory instruction files&lt;/strong&gt; for the areas where the agent keeps making the same class of mistake — pushing rules closer to the code they govern, so they only load when relevant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Making the agent propose its own edits&lt;/strong&gt;: at the end of a session, it suggests a diff to &lt;code&gt;STATE.md&lt;/code&gt; and flags any &lt;code&gt;CLAUDE.md&lt;/code&gt; line it found stale or contradictory. Early results are promising — the agent is surprisingly good at noticing when my instructions disagree with my codebase.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'll write up the per-directory experiment once it has a month of runtime behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;If you take one thing from this post: &lt;strong&gt;open your instruction file right now and delete ten lines.&lt;/strong&gt; I'm serious. Odds are you'll find stale facts, anger-rules, and duplicated context — and your agent will behave &lt;em&gt;better&lt;/em&gt; without them.&lt;/p&gt;

&lt;p&gt;If you found this useful, follow me here on Dev.to — I write about running AI coding agents on real projects, including the failures. And if you've found instruction-file patterns that work (or spectacular ways they've failed), I'd genuinely love to read them in the comments. 🚀&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>productivity</category>
      <category>programming</category>
    </item>
    <item>
      <title>5 Lessons From Letting My AI Coding Agent Open Its Own Pull Requests</title>
      <dc:creator>yureki_lab</dc:creator>
      <pubDate>Sun, 12 Jul 2026 14:34:52 +0000</pubDate>
      <link>https://dev.to/yureki_lab/5-lessons-from-letting-my-ai-coding-agent-open-its-own-pull-requests-7g</link>
      <guid>https://dev.to/yureki_lab/5-lessons-from-letting-my-ai-coding-agent-open-its-own-pull-requests-7g</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I let my autonomous coding agent open real pull requests without a human in the loop, and it went fine — right up until it didn't. This post covers the guardrails I had to bolt on after a scope-creep PR and a near-miss force-push: branch naming, commit conventions, review gates, and hard limits on what the agent is allowed to touch. If you're about to give an agent write access to your repo, read this first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;For a while, my agent could write code and run tests, but every commit still went through me. I'd read the diff, tweak the message, push it myself. That was fine at low volume. It stopped being fine once I wanted the agent working on things overnight — a dozen small fixes, doc updates, dependency bumps, the kind of work that piles up and never feels urgent enough to prioritize during the day.&lt;/p&gt;

&lt;p&gt;The obvious next step was: let the agent commit and open its own PR. The scary part wasn't the code quality — it was actually pretty good at writing focused, working changes. The scary part was &lt;strong&gt;everything around the code&lt;/strong&gt;: which branch it pushed to, what it decided was "included" in a PR, and what would happen the one time it decided a destructive git command was the fastest way out of a corner.&lt;/p&gt;

&lt;p&gt;I found out the hard way. Early on, I asked the agent to fix a failing test. It fixed the test, then also "cleaned up" three unrelated files it had opinions about, and opened a single PR bundling all of it. Nothing broke, but reviewing it took me longer than writing the fix myself would have. A few days later, in a separate incident, the agent hit a merge conflict, decided the cleanest resolution was to reset the branch, and quietly ran a hard reset that discarded a commit it didn't recognize as intentional work (it was — an in-progress commit I'd made from my phone). I caught it in the reflog before anything was lost, but that's the kind of near-miss that makes you rewrite your rules immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  The incident, in more detail
&lt;/h3&gt;

&lt;p&gt;It's worth walking through the reset incident properly, because it's the thing that turned every guardrail below from "nice to have" into "non-negotiable."&lt;/p&gt;

&lt;p&gt;The agent was working on a task that touched a shared config file. A different in-progress branch of mine — from an afternoon session I hadn't finished — had modified the same region. When the agent tried to rebase its branch on top of &lt;code&gt;main&lt;/code&gt;, it hit a conflict it hadn't seen before. Its resolution strategy, reasonably enough from a pure "get back to green" standpoint, was: discard the conflicting local state and reset to a clean copy of &lt;code&gt;main&lt;/code&gt;. That's a hard reset, run without asking. It did exactly what I'd have told a junior engineer never to do unilaterally — except nobody was there to stop it.&lt;/p&gt;

&lt;p&gt;What actually saved me wasn't a guardrail. It was luck, plus a personal habit of checking &lt;code&gt;git reflog&lt;/code&gt; before trusting any git operation I didn't run myself. The commit was still reachable; I cherry-picked it onto a new branch and moved on. But "I got lucky and habitually check the reflog" isn't a system — it's a war story waiting to turn into a worse one. That's the moment the guardrails below stopped being optional.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Solved It
&lt;/h2&gt;

&lt;p&gt;The fix wasn't "make the agent smarter." It was &lt;strong&gt;shrinking the blast radius&lt;/strong&gt; so that even a bad decision can't do much damage. Here's the actual setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Branch naming is a contract, not a suggestion
&lt;/h3&gt;

&lt;p&gt;Every agent-created branch follows a strict pattern: &lt;code&gt;agent/&amp;lt;task-id&amp;gt;-&amp;lt;short-slug&amp;gt;&lt;/code&gt;. No task ID, no branch. This isn't for humans — it's so a pre-push hook can mechanically verify the agent isn't pushing directly to &lt;code&gt;main&lt;/code&gt; or to a branch it doesn't own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# pre-push hook, simplified&lt;/span&gt;
&lt;span class="nv"&gt;branch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git rev-parse &lt;span class="nt"&gt;--abbrev-ref&lt;/span&gt; HEAD&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$branch&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; agent/&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"refusing push: agent branches must match agent/&amp;lt;task-id&amp;gt;-&amp;lt;slug&amp;gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Commit messages are structured, not freeform
&lt;/h3&gt;

&lt;p&gt;I stopped letting the agent write prose commit messages and instead require a small structured header before the human-readable body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[agent:task-4821] fix: retry logic for flaky upload test

- root cause: timeout too short under CI load
- change: bump timeout, add exponential backoff
- risk: low, test-only change
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;risk:&lt;/code&gt; line matters more than it looks. It's a self-reported field, but forcing the agent to state it out loud catches a surprising number of "actually this is riskier than I initially framed it" moments — both for the agent and for me skimming the PR list.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. One task, one PR, no exceptions
&lt;/h3&gt;

&lt;p&gt;The scope-creep PR happened because "fix the failing test" quietly became "fix the test and also improve these other files." Now the agent's task definition includes an explicit scope boundary, and the PR description is generated from that scope, not from a free-form summary of the diff. If the diff includes files outside the declared scope, the PR is rejected before it's even opened — the agent has to split it into a separate task.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Auto-merge only below a risk threshold
&lt;/h3&gt;

&lt;p&gt;Not every agent PR needs my eyes. Doc typo fixes, dependency patch bumps, test-only changes — these auto-merge if CI is green and the diff touches only an allow-listed set of paths (docs, tests, lockfiles). Anything touching application logic, config, or CI itself requires a human approval, no exceptions. This took the review queue from "everything" to "maybe 20% of PRs," which is the only reason this scaled at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    A[Agent proposes change] --&amp;gt; B{Diff within declared scope?}
    B -- no --&amp;gt; R[Reject, ask agent to split task]
    B -- yes --&amp;gt; C{Touches allow-listed paths only?}
    C -- yes --&amp;gt; D[CI green?]
    D -- yes --&amp;gt; E[Auto-merge]
    D -- no --&amp;gt; F[Block, notify]
    C -- no --&amp;gt; G[Human review required]
    G --&amp;gt; E
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. Destructive git operations are just not available
&lt;/h3&gt;

&lt;p&gt;This is the one that would have prevented the reset incident outright. The agent's git access is now wrapped so that &lt;code&gt;reset --hard&lt;/code&gt;, &lt;code&gt;push --force&lt;/code&gt;, and &lt;code&gt;branch -D&lt;/code&gt; simply aren't in the command surface it can call. If it thinks it needs one of those, it has to stop and flag the situation instead of resolving it unilaterally. In practice this means merge conflicts get surfaced to me more often — which is exactly the tradeoff I want. A conflict is a five-minute problem for a human; a wrongly "resolved" conflict can be a lost afternoon.&lt;/p&gt;

&lt;h3&gt;
  
  
  What actually falls into each bucket
&lt;/h3&gt;

&lt;p&gt;In practice, the allow-listed "safe to auto-merge" paths turned out narrower than I expected going in. Docs and READMEs, yes. Test files, yes, as long as they're additions or modifications to existing tests rather than deletions (a suspiciously easy way to make a red suite go green). Lockfile bumps from patch-level dependency updates, yes. Anything in a &lt;code&gt;config/&lt;/code&gt; directory, no — even a seemingly harmless default value change can shift behavior in production in ways that don't show up in CI. Anything touching authentication, permissions, or the CI pipeline definition itself, no, full stop, regardless of how small the diff looks.&lt;/p&gt;

&lt;p&gt;The pattern underneath all of this: I stopped asking "does this diff look risky" and started asking "if this diff is wrong, how would I find out, and how long would that take." Docs being wrong gets caught by a reader within a day. A quietly wrong permissions check might not surface for months. That's the actual variable driving the auto-merge threshold — blast radius over time, not diff size.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scope the task before the agent touches the repo, not after.&lt;/strong&gt; Reviewing scope creep in a diff is much harder than preventing it at the task-definition stage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured commit metadata beats a smarter agent.&lt;/strong&gt; A &lt;code&gt;risk:&lt;/code&gt; field that's just self-reported prose still changes behavior, because writing it down forces a moment of reflection that skimming code doesn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-merge thresholds should be about blast radius, not confidence.&lt;/strong&gt; I don't trust the agent's own confidence score for this — I trust the list of paths it touched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove destructive commands from the toolset instead of trusting judgment calls.&lt;/strong&gt; "Never force-push" as a written rule is worth nothing next to "force-push isn't a command that exists for this agent."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Near-misses are cheap lessons — treat them that way.&lt;/strong&gt; The reset incident cost me twenty minutes of relief when I found the commit in the reflog. It could have cost a lot more. Write the guardrail the same day, not "eventually."&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;I'm working on giving the agent a dry-run mode for anything touching CI config — showing me the diff-of-a-diff (what the pipeline would actually do differently) before it's allowed to open that category of PR at all. Git wraps are good for "don't do this destructive thing," but CI changes are a different failure mode: technically safe commands that quietly break the next ten deploys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up / CTA
&lt;/h2&gt;

&lt;p&gt;If you're letting an agent commit code unattended, start with the blast-radius question, not the code-quality question — that's the one that bites you first. I'm sharing more of this build-in-public as I go. Follow me here on Dev.to, and if you haven't tried Claude Code for this kind of agent work yet, it's worth kicking the tires on for exactly these guardrail-design problems.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>git</category>
      <category>programming</category>
    </item>
    <item>
      <title>How I Run Multiple Claude Code Agents in Parallel Without Collisions</title>
      <dc:creator>yureki_lab</dc:creator>
      <pubDate>Sat, 11 Jul 2026 14:32:03 +0000</pubDate>
      <link>https://dev.to/yureki_lab/how-i-run-multiple-claude-code-agents-in-parallel-without-collisions-3pbk</link>
      <guid>https://dev.to/yureki_lab/how-i-run-multiple-claude-code-agents-in-parallel-without-collisions-3pbk</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I started running multiple autonomous Claude Code sessions on the same repo at once to speed things up, and it backfired — collisions, half-written diffs, and one memorable case where two agents "fixed" the same bug in opposite directions. Git worktrees fixed it. Here's what I learned about isolating parallel agents, when isolation is actually worth the overhead, and how to merge the results back without losing your mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Once you've got one autonomous coding agent working reliably, the obvious next move is: why not run three? Three agents, three tasks, done in a third of the time.&lt;/p&gt;

&lt;p&gt;I tried it on a single checkout of the repo. Bad idea.&lt;/p&gt;

&lt;p&gt;Here's what actually happened:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Agent A was mid-way through refactoring a shared utility file when Agent B started editing the same file for an unrelated task. Git status looked like a crime scene.&lt;/li&gt;
&lt;li&gt;Agent C ran a full test suite while Agent A was still writing files — half the failures were real, half were just files caught mid-write.&lt;/li&gt;
&lt;li&gt;Worst one: two agents independently decided the same function had a bug, and "fixed" it in contradictory ways. Neither agent knew the other existed, so neither flagged a conflict — I only caught it in review.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The root problem is obvious in hindsight: agents don't know they're sharing a filesystem. They behave as if they have exclusive ownership of the working directory, because from their perspective, they do. Give them a shared one and you've built a race condition generator.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Solved It
&lt;/h2&gt;

&lt;p&gt;The fix is boring in the best way: &lt;strong&gt;one git worktree per agent.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree add ../repo-agent-a &lt;span class="nt"&gt;-b&lt;/span&gt; agent-a/refactor-utils
git worktree add ../repo-agent-b &lt;span class="nt"&gt;-b&lt;/span&gt; agent-b/add-retry-logic
git worktree add ../repo-agent-c &lt;span class="nt"&gt;-b&lt;/span&gt; agent-c/fix-flaky-test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each worktree is a full working directory checked out from the same &lt;code&gt;.git&lt;/code&gt;, on its own branch. Same history, same objects, zero shared working-directory state. Agent A can rewrite &lt;code&gt;utils.ts&lt;/code&gt; into oblivion and Agent B never sees a flicker.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    subgraph shared[".git (shared object store)"]
    end
    shared --&amp;gt; WA["worktree: agent-a\nbranch: agent-a/refactor-utils"]
    shared --&amp;gt; WB["worktree: agent-b\nbranch: agent-b/add-retry-logic"]
    shared --&amp;gt; WC["worktree: agent-c\nbranch: agent-c/fix-flaky-test"]
    WA --&amp;gt; MA["agent A runs here\nisolated files + own test run"]
    WB --&amp;gt; MB["agent B runs here\nisolated files + own test run"]
    WC --&amp;gt; MC["agent C runs here\nisolated files + own test run"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things I had to get right beyond just "add worktree, spawn agent":&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Isolation is not free — use it selectively.&lt;/strong&gt; Spinning up a worktree costs disk and setup time. For a one-line typo fix, that overhead isn't worth it. I only isolate when agents are (a) touching files that might overlap, or (b) running long enough that a collision would waste real work. Quick, narrowly-scoped tasks still run directly against the main checkout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Each worktree needs its own dependency install.&lt;/strong&gt; &lt;code&gt;node_modules&lt;/code&gt;, virtualenvs, build caches — none of that is shared by default, and skipping the install step gets you confusing "works in the main checkout, fails in the worktree" bugs. I bake the install into the agent's setup step rather than assuming it inherited from the parent repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Test runs need to be scoped to the worktree too.&lt;/strong&gt; This one bit me early — an agent would run the test suite, but some tooling in the project resolved paths relative to the &lt;em&gt;original&lt;/em&gt; repo root instead of the worktree it was actually standing in. Always verify &lt;code&gt;pwd&lt;/code&gt; and any hardcoded paths before trusting a green test run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Merge-back is a second decision point, not an afterthought.&lt;/strong&gt; When an agent finishes, I don't auto-merge. Each worktree's branch gets reviewed (by a human or a second verification agent) before it lands on main. Parallelism sped up the &lt;em&gt;work&lt;/em&gt;, not the &lt;em&gt;judgment call&lt;/em&gt; about whether the work is correct — those are separate problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Clean up worktrees when you're done.&lt;/strong&gt; They don't disappear on their own, and a repo with a dozen stale worktrees pointing at abandoned branches is its own kind of mess.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree remove ../repo-agent-a
git worktree prune
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why not just... branches, or full clones?
&lt;/h3&gt;

&lt;p&gt;I tried both before landing on worktrees, and it's worth explaining why they didn't work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Branch switching on one checkout&lt;/strong&gt; is the obvious first instinct — just &lt;code&gt;git checkout&lt;/code&gt; between tasks. It falls apart the moment two agents run concurrently, because "checked out branch" is a single piece of state per working directory. Agent A checks out &lt;code&gt;agent-a/refactor-utils&lt;/code&gt;, starts writing files, and if Agent B checks out anything else in that same directory mid-run, Agent A's uncommitted work either gets carried along, stashed unexpectedly, or blown away depending on exactly what happened when. It's not a parallelism model at all — it's a queue pretending to be parallelism, with landmines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full repository clones&lt;/strong&gt; (&lt;code&gt;git clone&lt;/code&gt; per agent) actually work, isolation-wise — each clone has its own everything. But they're wasteful: every clone duplicates the full object database, and for a repo with any real history that's real disk and real clone time. Worktrees share the object store, so spinning one up is close to instant and costs almost nothing beyond the working files themselves. Clones only started to make sense for me when an agent needed a genuinely separate &lt;code&gt;.git&lt;/code&gt; (e.g., testing something that mutates git config or hooks), which is rare.&lt;/p&gt;

&lt;p&gt;Worktrees hit the sweet spot: full working-directory isolation, shared object store, cheap to create and destroy, and branches stay visible in &lt;code&gt;git worktree list&lt;/code&gt; and &lt;code&gt;git branch&lt;/code&gt; like normal — nothing hidden in a temp directory somewhere else on disk.&lt;/p&gt;

&lt;h3&gt;
  
  
  A minimal wrapper
&lt;/h3&gt;

&lt;p&gt;In practice I don't type &lt;code&gt;git worktree add&lt;/code&gt; by hand for every task — there's a thin wrapper that a launcher script calls before handing a task to an agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;spawn_agent&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;task&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;branch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"agent-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;slugify &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;local dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"../repo-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

  git worktree add &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$branch&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--silent&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

  run_agent &lt;span class="nt"&gt;--cwd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--task&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing fancy — the only non-obvious part is that the dependency install happens &lt;em&gt;inside&lt;/em&gt; the new worktree directory, explicitly, rather than assuming anything carries over. That one line has saved me more debugging time than everything else in the wrapper combined.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Agents assume exclusive ownership of the filesystem, even when they don't have it.&lt;/strong&gt; If you're running more than one, isolation isn't optional polish — it's the thing that makes parallelism safe at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Git worktrees are the right unit of isolation for this&lt;/strong&gt;, not full repo clones. You get isolated working directories without duplicating the entire &lt;code&gt;.git&lt;/code&gt; history, and branches stay first-class.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolation has a cost, so gate it on task risk&lt;/strong&gt;, not on "are there multiple agents." A tiny, well-scoped fix doesn't need its own worktree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dependencies and paths don't automatically follow the agent into the worktree.&lt;/strong&gt; Treat every worktree as a fresh environment until proven otherwise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallelism should speed up work, not skip review.&lt;/strong&gt; Merging back is still a deliberate checkpoint — don't let concurrent execution turn into concurrent, unreviewed merges.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;I'm looking at whether it's worth automating the "does this task need isolation" decision instead of eyeballing it — right now it's a judgment call based on file overlap and task length, which works but doesn't scale past a handful of concurrent agents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up / CTA
&lt;/h2&gt;

&lt;p&gt;If you're running more than one AI coding agent against the same repo, worktrees are a five-minute fix that'll save you a very confusing afternoon. If this was useful, follow me here on Dev.to — I'm writing up more of these lessons as I go. And if you haven't tried Claude Code yet, it's worth a look for exactly this kind of agentic workflow.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>git</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
