<?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: Chikoku_NEKO</title>
    <description>The latest articles on DEV Community by Chikoku_NEKO (@chikoku_neko).</description>
    <link>https://dev.to/chikoku_neko</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%2F4073554%2Fcf9a60a7-d850-4ae0-b517-2ef21875829f.png</url>
      <title>DEV Community: Chikoku_NEKO</title>
      <link>https://dev.to/chikoku_neko</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chikoku_neko"/>
    <language>en</language>
    <item>
      <title>A Batch-File Shim Was Truncating My Agent's Prompts on Windows (3/24 to 21/24)</title>
      <dc:creator>Chikoku_NEKO</dc:creator>
      <pubDate>Sun, 16 Aug 2026 16:05:33 +0000</pubDate>
      <link>https://dev.to/chikoku_neko/a-batch-file-shim-was-truncating-my-agents-prompts-on-windows-324-to-2124-512k</link>
      <guid>https://dev.to/chikoku_neko/a-batch-file-shim-was-truncating-my-agents-prompts-on-windows-324-to-2124-512k</guid>
      <description>&lt;p&gt;Environment: Windows 11 Home, Python 3.x, Claude Code installed via &lt;code&gt;npm install -g @anthropic-ai/claude-code&lt;/code&gt;. Measured 2026-08-14 to 2026-08-16.&lt;/p&gt;

&lt;p&gt;I was comparing two agent harnesses on the same set of web tasks. The first run of harness B scored 3 out of 24. I didn't touch the model and I didn't touch the prompt wording. I changed only how the prompt reached the process, and the score went to 21/24. Here is what was actually broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;I was running an eval to see how well current agents handle routine web work, the kind a triage process would flag as "candidate for RPA." Two harnesses, same 8 tasks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Harness A: the computer use API directly (claude-sonnet-5, &lt;code&gt;max_turns=40&lt;/code&gt;), 8 tasks x 1 trial.&lt;/li&gt;
&lt;li&gt;Harness B: Claude Code headless (&lt;code&gt;claude -p&lt;/code&gt;) plus Playwright, same model, 8 tasks x 3 trials = 24 runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Success in both harnesses is decided by machine verification only: submitted form payloads compared against expected JSONL, downloaded files checked for existence and size, answers matched by regex. The agent's own claim of "done" is never trusted as a success signal.&lt;/p&gt;

&lt;p&gt;First run of harness B: 3/24. Only the first task passed. I assumed the model was the weak link and started digging into what harness B was actually sending it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core finding: a multi-line prompt loses everything after the first newline
&lt;/h2&gt;

&lt;p&gt;On Windows, when you drive &lt;code&gt;claude -p&lt;/code&gt; from Python via &lt;code&gt;subprocess&lt;/code&gt; and let &lt;code&gt;shutil.which&lt;/code&gt; resolve the executable, a multi-line prompt gets truncated at the first newline before the process ever sees it. Everything after line 1 is silently dropped.&lt;/p&gt;

&lt;p&gt;Root cause: the npm global install of Claude Code puts a file named &lt;code&gt;claude.CMD&lt;/code&gt; on PATH. That's the file &lt;code&gt;shutil.which("claude")&lt;/code&gt; resolves to — not the real executable. It's a batch wrapper. Its body is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight batchfile"&gt;&lt;code&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="vm"&gt;%dp0&lt;/span&gt;&lt;span class="err"&gt;%&lt;/span&gt;&lt;span class="s2"&gt;\node_modules\@anthropic-ai\claude-code\bin\claude.exe"&lt;/span&gt;   &lt;span class="err"&gt;%&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let me separate what I measured from what I'm inferring.&lt;/p&gt;

&lt;p&gt;What I measured: swapping only &lt;code&gt;argv[0]&lt;/code&gt; between the two paths changes the outcome. Point it at &lt;code&gt;claude.CMD&lt;/code&gt; and the tail is gone; point it at &lt;code&gt;claude.exe&lt;/code&gt; and it arrives. Prompt, flags, and environment are identical, so the difference is the shim.&lt;/p&gt;

&lt;p&gt;What I'm inferring: &lt;code&gt;%*&lt;/code&gt; is cmd.exe's mechanism for forwarding arguments to the wrapped command, and the newline appears to be lost in that forwarding. I have not isolated which layer drops it (cmd.exe's argument expansion, the shell Python interposes when executing a &lt;code&gt;.CMD&lt;/code&gt;, or both). Either workaround below is sufficient without knowing, so I stopped there.&lt;/p&gt;

&lt;p&gt;Minimal reproduction:&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;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;

&lt;span class="n"&gt;EXE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APPDATA&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;npm&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;node_modules&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;@anthropic-ai&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;claude-code&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;bin&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;claude.exe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;CMD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APPDATA&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;npm&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;claude.CMD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Follow the instruction below exactly.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;Output the string MARKER_TAIL_9137 and nothing else.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;argv0&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude.CMD (shim)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CMD&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;claude.exe (direct)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EXE&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;argv0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PROMPT&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                       &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tail_received =&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;MARKER_TAIL_9137&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;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Measured result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;claude.CMD (shim)&lt;/code&gt;: &lt;code&gt;tail_received = False&lt;/code&gt;. The model's reply asks for the missing instruction, having seen only line 1: "Follow the instruction below exactly."&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;claude.exe (direct)&lt;/code&gt;: &lt;code&gt;tail_received = True&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are two fixes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Resolve and call &lt;code&gt;claude.exe&lt;/code&gt; directly instead of trusting whatever &lt;code&gt;shutil.which("claude")&lt;/code&gt; returns.&lt;/li&gt;
&lt;li&gt;Normalize newlines to spaces in the prompt before passing it. This is the more portable option, since it sidesteps the shim regardless of platform.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is hard to catch because there's no exception, no non-zero exit code, and no warning anywhere in the pipeline. cmd.exe forwards a truncated string and everything downstream treats it as a normal, well-formed prompt. The only symptom is a low success rate, which looks identical to "the model isn't capable enough for this task." I spent time suspecting the model before I suspected the wrapper.&lt;/p&gt;

&lt;h2&gt;
  
  
  How harness B's score moved (measured)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;3/24: first run. Only the first task passed. The prompt never actually included the target URL — I'd assumed the model could infer it from task context, which was wrong for a browser-driving harness. Harness A never surfaced this, because in harness A I navigate the browser to the URL programmatically before the agent's turn starts, so the missing URL in the prompt didn't matter there.&lt;/li&gt;
&lt;li&gt;6/24: after adding the URL to the prompt. Only tasks whose instruction was a single line started passing; multi-line task instructions still failed. That asymmetry is what led to the newline finding — the agent's own reply, when it failed, said the instruction seemed cut off, and the cutoff point matched exactly where the newline sat in the task definition.&lt;/li&gt;
&lt;li&gt;23/24: after normalizing newlines. I discarded this number. Harness B had no &lt;code&gt;--model&lt;/code&gt; flag set and was running on whatever the CLI's default model was, so this result wasn't comparable to harness A, which was pinned to sonnet-5. Confounded comparison, not a usable data point.&lt;/li&gt;
&lt;li&gt;21/24: after pinning both harnesses to claude-sonnet-5 explicitly and re-running. This is the number I'm keeping.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The measurement itself
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Harness A: 6/8 successes, 993.9 JPY total. Both failures were turn-limit cutoffs, not wrong answers: a 10-row transcription task finished 1 row before hitting the cap (251.7 JPY spent), and a 3-item conditional submission task finished 1 item before the cap (283.4 JPY spent).&lt;/li&gt;
&lt;li&gt;Harness B: 21/24 successes, 0 JPY (covered under a flat-rate plan, so no marginal API cost). All three failures were over-submission — the agent submitted 11 entries where 10 were expected, and separately 4 where 3 were expected.&lt;/li&gt;
&lt;li&gt;On harness A, what separates cheap runs from expensive ones is submission count, not task difficulty. Tasks that were read-once-answer-once finished in 5 to 7 turns at 12 to 24 JPY. Tasks requiring repeated submissions burned through all 40 turns and only completed 10 to 33 percent of the required work.&lt;/li&gt;
&lt;li&gt;993.9 JPY is an estimate, not an invoiced amount. It's derived from list pricing ($3 input / $15 output per Mtok) converted at 150 JPY per USD.&lt;/li&gt;
&lt;li&gt;Caveat: the stop conditions are asymmetric. Harness A had &lt;code&gt;max_turns=40&lt;/code&gt; plus a 600-second timeout; harness B had only the timeout, no turn cap. Part of the 6/8 vs 21/24 gap comes from that difference, so don't read those two numbers as a head-to-head ranking. The claim of this post is the within-harness-B change, 3/24 to 21/24.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Success rate is the product of model capability and harness quality, and the two are easy to conflate when the harness fails silently. Before concluding a model can't do a task, I now check four things: is the target (URL, path) actually present in what gets sent; is the full instruction arriving intact (print the exact prompt the process received, and read it — don't assume); can the agent actually reach whatever files it's supposed to produce or read; are model and stop conditions pinned explicitly on both sides being compared.&lt;/p&gt;

&lt;p&gt;If I'd taken that first run at face value, I would have reported that Claude Code plus Playwright can't handle routine web work. The cause had nothing to do with the model: a batch file was dropping everything after the first newline.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>automation</category>
    </item>
    <item>
      <title>Claude Code hooks looked like they stopped firing on MCP tools — it was my own hook crashing"</title>
      <dc:creator>Chikoku_NEKO</dc:creator>
      <pubDate>Wed, 12 Aug 2026 17:59:41 +0000</pubDate>
      <link>https://dev.to/chikoku_neko/claude-code-hooks-stop-firing-on-mcp-tools-after-the-first-deny-found-it-while-trying-to-cut-48hd</link>
      <guid>https://dev.to/chikoku_neko/claude-code-hooks-stop-firing-on-mcp-tools-after-the-first-deny-found-it-while-trying-to-cut-48hd</guid>
      <description>&lt;p&gt;&lt;strong&gt;Correction (2026-08-14): the main claim in this post is wrong.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I wrote that PreToolUse hooks stop firing for MCP tool calls after the hook returns a deny.&lt;br&gt;
They don't. &lt;strong&gt;The hook fired every time.&lt;/strong&gt; What failed was my own hook script, which crashed&lt;br&gt;
on MCP payloads. This is not a Claude Code bug.&lt;/p&gt;

&lt;p&gt;I re-ran the same setup (Claude Code 2.1.227, same machine) with a hook that writes one&lt;br&gt;
unconditional line &lt;em&gt;before&lt;/em&gt; it parses stdin:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;stage&lt;/th&gt;
&lt;th&gt;count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;hook invoked&lt;/td&gt;
&lt;td&gt;10 (including all 3 MCP calls)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;payload parsed&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;row written&lt;/td&gt;
&lt;td&gt;7 (non-MCP only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;exception&lt;/td&gt;
&lt;td&gt;3 (all &lt;code&gt;mcp__ollama__summarize_file&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The exception:&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UnicodeEncodeError: 'utf-8' codec can't encode character '\udc86'
  in position 353: surrogates not allowed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;The cause has nothing to do with Claude Code or MCP. Python on Japanese Windows decodes&lt;br&gt;
piped stdin as cp932, which mangles UTF-8 Japanese text into lone surrogates. Here is the&lt;br&gt;
whole thing without Claude Code involved:&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ echo '{"x":"順"}' | python -c "import sys; print(repr(sys.stdin.read()))"
'{"x":"�\udc86"}\n'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;My hook logged &lt;code&gt;tool_input&lt;/code&gt;. For &lt;code&gt;Read&lt;/code&gt; that is just an ASCII file path. For the MCP call the&lt;br&gt;
model passed a Japanese instruction as an argument, so only MCP payloads contained non-ASCII.&lt;br&gt;
This is not MCP-specific at all — any tool whose arguments contain non-ASCII would do it.&lt;/p&gt;

&lt;p&gt;Changing &lt;code&gt;sys.stdin.read()&lt;/code&gt; to &lt;code&gt;sys.stdin.buffer.read().decode("utf-8")&lt;/code&gt; — one line — makes&lt;br&gt;
the MCP rows appear, 3 sessions out of 3.&lt;/p&gt;

&lt;p&gt;Why it looked deny-dependent: without a deny the model just reads the file and stops, so it&lt;br&gt;
never calls the MCP tool at all. MCP calls only happened in sessions that had a deny, so those&lt;br&gt;
were the only sessions missing rows. The deny wasn't the cause; it was what made the model&lt;br&gt;
reach for the MCP tool.&lt;/p&gt;

&lt;p&gt;I checked whether Claude Code was sending malformed bytes. It wasn't — I captured the raw&lt;br&gt;
stdin with &lt;code&gt;sys.stdin.buffer&lt;/code&gt; and all six payloads are strictly valid UTF-8. The &lt;code&gt;0x86&lt;/code&gt; byte&lt;br&gt;
is the third byte of &lt;code&gt;\xe9\xa0\x86&lt;/code&gt;, U+9806 「順」.&lt;/p&gt;

&lt;p&gt;No issue was filed against anthropics/claude-code, and none should be. The body below is&lt;br&gt;
preserved as published, with each wrong claim struck through and corrected inline.&lt;/p&gt;


&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I tried to cut Claude Code's token use by having a &lt;code&gt;PreToolUse&lt;/code&gt; hook &lt;code&gt;deny&lt;/code&gt; Read and redirect the model to a local LLM summary tool.&lt;/li&gt;
&lt;li&gt;The hook log contained not a single row for the local summary tool. But the tool was being called. &lt;del&gt;&lt;strong&gt;Once a &lt;code&gt;PreToolUse&lt;/code&gt; hook returns a &lt;code&gt;deny&lt;/code&gt;, hooks stop firing for the rest of that session's MCP tool calls&lt;/strong&gt; (built-in tools are unaffected). Confirmed on Claude Code 2.1.227 / Windows 11 Home, 2026-08-12.&lt;/del&gt; &lt;strong&gt;Correction:&lt;/strong&gt; the hook fired every time. The missing rows were my own hook crashing on cp932-mangled stdin (see the correction above).&lt;/li&gt;
&lt;li&gt;There's a five-minute repro below. &lt;del&gt;Every claim in this article can be checked by running it, without taking my word for anything.&lt;/del&gt; &lt;strong&gt;Correction:&lt;/strong&gt; what the repro reproduces is not a hook failure — it's the &lt;code&gt;sys.stdin.read()&lt;/code&gt; bug inside the repro's own &lt;code&gt;hook.py&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The feature itself is a no-go. Local summaries invent facts, so the model either re-reads the original to verify (which cancels the savings) or skips verification and ships a wrong answer. That's structural, not a matter of model size.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Minimal repro
&lt;/h2&gt;

&lt;p&gt;Here first. Any stdio MCP server will do.&lt;/p&gt;

&lt;p&gt;Project &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;"*"&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;"python /abs/path/hook.py"&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;&lt;code&gt;hook.py&lt;/code&gt; — logs every call unconditionally, before any branching, and denies only &lt;code&gt;Read&lt;/code&gt;:&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;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="n"&gt;p&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;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;())&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;log.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="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_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="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;span class="c1"&gt;# unconditional
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_name&lt;/span&gt;&lt;span class="sh"&gt;"&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;Read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hookSpecificOutput&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;hookEventName&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;PreToolUse&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;permissionDecision&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;deny&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;permissionDecisionReason&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;use mcp__&amp;lt;your&amp;gt;__&amp;lt;tool&amp;gt; instead&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;In one session, get Read denied, then call the MCP tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"First try reading foo.md with Read. If it's blocked, use mcp__&amp;lt;your&amp;gt;__&amp;lt;tool&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allowedTools&lt;/span&gt; &lt;span class="s2"&gt;"Read,Grep,Glob,Bash,mcp__&amp;lt;your&amp;gt;__&amp;lt;tool&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;log.jsonl&lt;/code&gt; will show Read and the built-ins, but &lt;strong&gt;no row for the MCP tool&lt;/strong&gt;. The MCP server's own request log shows the call went through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Correction:&lt;/strong&gt; the missing row is caused by line 2 of this very &lt;code&gt;hook.py&lt;/code&gt;. On Japanese Windows, Python decodes piped stdin as cp932, so an MCP argument containing Japanese arrives as lone surrogates and the write dies with &lt;code&gt;UnicodeEncodeError&lt;/code&gt;. Replace &lt;code&gt;sys.stdin.read()&lt;/code&gt; with &lt;code&gt;sys.stdin.buffer.read().decode("utf-8")&lt;/code&gt; and every row appears.&lt;/p&gt;

&lt;p&gt;What would falsify this: finding a case where the MCP tool is called with no prior deny and the hook row is still missing. Then the cause is something other than the deny.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Correction:&lt;/strong&gt; this falsification condition was in fact met. Any non-ASCII argument drops the row, deny or no deny. My no-deny trials all logged fine only because their arguments happened to be pure ASCII.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I wanted
&lt;/h2&gt;

&lt;p&gt;I wanted to cut Claude Code's token consumption. On a flat-rate plan the bill doesn't change, but the rate limit does, and my nightly cron jobs eat into it.&lt;/p&gt;

&lt;p&gt;The idea: hand file reads off to a local model. When Read pulls in a big file, the content stays in context and gets billed on every following turn. Summarize it with Ollama at the entrance and that recurring cost disappears.&lt;/p&gt;

&lt;p&gt;I decided to do it with hooks. A hook is an external script that runs right before a tool executes; the &lt;code&gt;PreToolUse&lt;/code&gt; kind can return a &lt;code&gt;deny&lt;/code&gt; that stops the tool and hands a reason string back to the model. I used that to block Read and put "use &lt;code&gt;mcp__ollama__summarize_file&lt;/code&gt; instead" in the reason. That Ollama summary tool is wired in over MCP, so it's an external tool, a separate class from built-ins like Grep. That distinction matters later.&lt;/p&gt;

&lt;p&gt;I didn't know whether the model would call the local tool on its own, so the first thing to measure was whether a denied Read actually led to the local summary being used.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement was broken
&lt;/h2&gt;

&lt;p&gt;I wrote five fixtures (deploy notes, a 120-line changelog, a config reference, an incident report, an API spec) and ran trials headless with &lt;code&gt;claude -p&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The hook log had no rows for the local summary tool. Read denials were there. Grep was there. ToolSearch was there. Only the summary tool was missing. It looked like the redirect had failed completely.&lt;/p&gt;

&lt;p&gt;Except the model's answers said otherwise:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Processed locally with Ollama.&lt;/p&gt;

&lt;p&gt;The Ollama summary misreported the range as "Jan–Sep"; checking the tail showed it actually ends in March.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It says it used the summary. It comments on the content of what it used. The log says it never called the tool. One of them is wrong.&lt;/p&gt;

&lt;p&gt;I looked at a third record: the Ollama server log. Throughout the trial window, &lt;code&gt;/api/generate&lt;/code&gt; was being hit repeatedly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[GIN] 2026/08/12 - 03:32:17 | 200 | 22.44s | 127.0.0.1 | POST "/api/generate"
[GIN] 2026/08/12 - 03:33:20 | 200 | 21.20s | 127.0.0.1 | POST "/api/generate"
&lt;/span&gt;&lt;span class="c"&gt;...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model called the tool, the server answered, and the hook passed it through without logging it.&lt;/p&gt;

&lt;p&gt;An empty hook log has three possible causes: the hook never ran, it ran but threw after the deny branch and failed to write, or it wrote and the write got lost. The logging line sits before any branching, and the same sessions do contain the Read denial and the ToolSearch call — so "ran but failed to write" would still have left a row. That leaves "never ran."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the hook was blind
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Correction: this section's conclusion and the control matrix below are retracted.&lt;/strong&gt; The deny was a confounder, not the cause. Without a deny the model just reads the file and never calls the MCP tool, so MCP calls (= Japanese arguments = hook crash) only existed in deny sessions. The correct observation is the table in the correction above: 10/10 hook invocations, 3 exceptions, all of them MCP calls.&lt;/p&gt;

&lt;p&gt;I changed one condition at a time and reproduced each. The conclusion first. &lt;del&gt;&lt;strong&gt;Once a &lt;code&gt;PreToolUse&lt;/code&gt; hook returns a deny anywhere in the session, hooks stop firing for subsequent MCP tool calls.&lt;/strong&gt; Built-in tools (Grep, Glob) keep firing after the deny. Only MCP tools are affected, and the trigger is a prior deny.&lt;/del&gt;&lt;/p&gt;

&lt;p&gt;"matcher" is the hook-config field that says which tools the hook applies to: &lt;code&gt;*&lt;/code&gt; means all tools, a literal name means one specific tool. The last column is the observation — whether the hook managed to log that MCP call.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;matcher&lt;/th&gt;
&lt;th&gt;deny earlier in session&lt;/th&gt;
&lt;th&gt;hook fires on MCP call&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;fires&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;does not fire&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes (tool named explicitly)&lt;/td&gt;
&lt;td&gt;does not fire&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no (only emitted allow JSON)&lt;/td&gt;
&lt;td&gt;fires&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;literal name&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;fires&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The three no-deny rows fire. Only the two deny rows don't. Everything else is held constant, so the prior deny is what's doing the work.&lt;/p&gt;

&lt;p&gt;Two more checks. First, I attached the same logging hook to &lt;code&gt;PostToolUse&lt;/code&gt; and re-ran it. After a deny, the MCP call shows up in the &lt;code&gt;PostToolUse&lt;/code&gt; log as &lt;code&gt;mcp__ollama__summarize_file&lt;/code&gt; — and still not in &lt;code&gt;PreToolUse&lt;/code&gt;. The hook system is alive; &lt;code&gt;PreToolUse&lt;/code&gt; just isn't seeing MCP.&lt;/p&gt;

&lt;p&gt;Second, killing a rival explanation. In this setup MCP tools are lazily loaded: the model calls &lt;code&gt;ToolSearch&lt;/code&gt; to pull the tool in before using it. So "hooks don't fire for tools loaded mid-session via ToolSearch" would explain the same observations, and it correlates perfectly with the deny. The table alone can't separate them. So I swapped in a log-only hook that never denies, and ran a control where the model loads the tool through ToolSearch and then calls it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;16:12:47 ToolSearch
16:12:51 mcp__ollama__summarize_file   ← logged
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A ToolSearch-loaded MCP tool does fire the hook, as long as nothing denied earlier. The load path isn't the cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope of what I checked
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Claude Code 2.1.227, Windows 11 Home, on 2026-08-12&lt;/li&gt;
&lt;li&gt;No other OS or version tested; I can't say whether this is Windows-specific&lt;/li&gt;
&lt;li&gt;One stdio MCP server, one tool&lt;/li&gt;
&lt;li&gt;n=1 per cell in the table&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;del&gt;The docs say "MCP server tools appear as regular tools in tool events," so at minimum this isn't the documented behavior.&lt;/del&gt; &lt;strong&gt;Correction:&lt;/strong&gt; the behavior matched the docs. Hooks fired on every MCP call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implications
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Correction:&lt;/strong&gt; this section's premise (hooks not firing) is wrong, so both implications below are retracted. What survives, for a different reason: when a hook script crashes, auditing and enforcement both silently disappear — exit status ignored, no row, no warning.&lt;/p&gt;

&lt;p&gt;&lt;del&gt;One is observability: a hook-based audit log records nothing for MCP in any session containing a deny. You can no longer distinguish "not used" from "not logged."&lt;/del&gt;&lt;/p&gt;

&lt;p&gt;&lt;del&gt;The other: since &lt;code&gt;PreToolUse&lt;/code&gt; deny is the documented way to control whether a tool may run, every MCP call after the first deny slips past that control.&lt;/del&gt;&lt;/p&gt;

&lt;p&gt;Don't over-read it, though. What's bypassed is hook-level control and auditing; permission rules and confirmation prompts are a separate layer and still apply. There's no remote attack surface either — exploiting this needs local settings and a registered MCP server on the target machine. The people it hurts are the ones enforcing policy through hooks and the ones counting usage from hook logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The local summary itself
&lt;/h2&gt;

&lt;p&gt;With the measurement fixed, here's what the summaries actually looked like. The local model was qwen2.5-coder:7b.&lt;/p&gt;

&lt;p&gt;They invented content that wasn't in the file. The detection shows up in the model's own answers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The Ollama summary output "error-format JSON," "pagination (next token)," and "Authorization header format," but none of these exist in the actual file. The real content is one line per endpoint.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Checking the fixture confirms it: each endpoint is a single line, &lt;code&gt;Returns resource N. Auth: bearer. Rate limit: 100/min.&lt;/code&gt;, with nothing about error formats or pagination.&lt;/p&gt;

&lt;p&gt;In most runs the model caught this, threw the summary out, and re-read the original with Grep. But not every time. One changelog summary came back as "final value 756ms, continuing to around 2026-09." The last line of the file reads &lt;code&gt;2026-03-12 fix: adjust retry backoff to 833ms&lt;/code&gt;. Wrong value, wrong date. The answer carried a hedge ("ask for another method if you need the original verified exactly"), but a hedged wrong answer still went out.&lt;/p&gt;

&lt;p&gt;That's where the idea died. Verify the summary and the savings go into verification; skip verification and wrong answers get through.&lt;/p&gt;

&lt;p&gt;I only tested 7B against five fixtures, and a 14B or 32B would fabricate less. But the reason this fails isn't accuracy, it's structure. &lt;strong&gt;A model that doesn't trust the summary reads the original to check, and that verification cost cancels the savings. A model that trusts it answers from degraded input and takes on the error risk instead.&lt;/strong&gt; Better accuracy shrinks the fabrication, but it just turns into a judgment call about whether the summary is trustworthy enough to skip checking — which is not the same as the inserted layer paying for itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;I dropped the feature. Three reasons.&lt;/p&gt;

&lt;p&gt;I wouldn't use it myself. Trading answer quality against the reason I use a frontier model in the first place is backwards.&lt;/p&gt;

&lt;p&gt;There's a substitute inside the product. If you're about to hit the rate limit, switch to Sonnet or Haiku with &lt;code&gt;/model&lt;/code&gt;. Quality stays frontier-grade and no extra tooling is needed.&lt;/p&gt;

&lt;p&gt;It doesn't save anything. Verify and the cost comes back; skip verifying and you ship errors.&lt;/p&gt;

&lt;p&gt;&lt;del&gt;Some questions I didn't chase: whether a fresh session restores the hook, whether slipping one allow in after the deny brings it back, whether any of this holds outside Windows.&lt;/del&gt; &lt;strong&gt;Correction:&lt;/strong&gt; these questions dissolved along with their premise.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on how this article was written
&lt;/h2&gt;

&lt;p&gt;I rewrote the numbers three times before publishing.&lt;/p&gt;

&lt;p&gt;The first version reported a redirect success rate of 0% — a figure computed from the very log I had just shown to be broken, and I used it without hesitating. The second time I corrected that figure for one model and considered the job done. The third time, I trusted a log file's name and quoted one model's words as another's: I'd launched the trials without &lt;code&gt;--model&lt;/code&gt;, so switching the session's model mid-run mixed two models into one file. I noticed the mixing while it was happening, and then trusted the filename anyway when it came time to tally things up.&lt;/p&gt;

&lt;p&gt;All three were caught by someone else pointing at them. The common thread is that I stopped checking the moment a coherent explanation was available. The number matched the quotes, the filename matched my expectation, and that was enough to stop.&lt;/p&gt;

&lt;p&gt;So I removed my own aggregate numbers from this article. What's left is whether a row exists in a log, plus specific cases checked against the original file. The repro sits at the top for the same reason: &lt;strong&gt;run it, and you can verify this article's claim without trusting my arithmetic.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A broken instrument returns zero. Zero reads as "it didn't happen," which draws far less suspicion than a strange value would. The moment the numbers agree with what you expected is probably the most dangerous one.&lt;/p&gt;

</description>
      <category>claude</category>
      <category>llm</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Silent Failures in Unattended AI Agents — How I Built a Postmortem Tool to Find Them</title>
      <dc:creator>Chikoku_NEKO</dc:creator>
      <pubDate>Tue, 11 Aug 2026 17:43:46 +0000</pubDate>
      <link>https://dev.to/chikoku_neko/silent-failures-in-unattended-ai-agents-how-i-built-a-postmortem-tool-to-find-them-29ci</link>
      <guid>https://dev.to/chikoku_neko/silent-failures-in-unattended-ai-agents-how-i-built-a-postmortem-tool-to-find-them-29ci</guid>
      <description>&lt;p&gt;A cron-scheduled agent of mine ran fine: exit code 0, no stderr. But the weekly report it was supposed to write never appeared, and nothing told me. I only noticed days later, checking the directory by hand.&lt;/p&gt;

&lt;p&gt;I call this a silent failure, and I think it's the nastiest failure mode of unattended agents: the job reports success, so no alerting ever fires, and the only detection mechanism left is a human happening to look. I built agent-coroner to catch these automatically. v0.1.0 is now on GitHub, PyPI, and the Claude Code plugin marketplace.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;For five weeks straight, my weekly-report job ran without a problem. It pulled data, generated markdown, wrote a file every Friday morning. I didn't monitor it actively, because it was boring infrastructure that appeared to be working.&lt;/p&gt;

&lt;p&gt;Week six: no report. The logs showed nothing obviously wrong, and the job had no alerting of its own. By week seven it had recovered on its own and everything looked normal again. To this day I haven't root-caused that one missing week. That's the part that bothered me most: without an artifact check, I wouldn't even have known it happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design
&lt;/h2&gt;

&lt;p&gt;Instead of trying to guess when things go wrong, I decided to be explicit about what each job should produce, then check those promises deterministically. There are three layers.&lt;/p&gt;

&lt;p&gt;The first layer is a contracts file, where you declare what each job promises:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;weekly-report&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FRI&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;08:30"&lt;/span&gt;
    &lt;span class="na"&gt;grace_minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
    &lt;span class="na"&gt;artifacts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reports/weekly-*.md"&lt;/span&gt;
        &lt;span class="na"&gt;max_age_hours&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;192&lt;/span&gt;
        &lt;span class="na"&gt;min_bytes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;500&lt;/span&gt;
    &lt;span class="na"&gt;logs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run.log"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second layer is the checker, a small CLI you run from cron or Task Scheduler. It asks four questions: does the file exist, is it fresh (&lt;code&gt;max_age_hours&lt;/code&gt;), is it big enough (&lt;code&gt;min_bytes&lt;/code&gt;), and did your optional verify command pass. No LLM is involved, so a run takes seconds and ongoing monitoring costs nothing. It exits non-zero if a contract is violated and zero otherwise, so you can wire it into any alerting you already have.&lt;/p&gt;

&lt;p&gt;The third layer is the autopsy. Only when a contract breaks, Claude is dispatched read-only (&lt;code&gt;--allowedTools "Read,Grep,Glob"&lt;/code&gt;) to read the logs and write a four-section postmortem: facts, hypotheses ranked by confidence with log quotes, verification steps for a human to run, and prevention suggestions. It diagnoses; it never modifies anything.&lt;/p&gt;

&lt;p&gt;One design decision worth spelling out: detection never depends on the LLM. The checker always runs first, and if the autopsy crashes, the violation notification still goes out. There's also no self-healing on purpose. Once you let an LLM patch things in an unattended system, failures start getting hidden instead of reported, and I trust a monitor that only reports.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dogfooding it on a new project
&lt;/h2&gt;

&lt;p&gt;The day after publishing, I started a new project (a paper-trading eval that forecasts day-ahead prices on JEPX, Japan's electricity exchange) and registered its weekly accuracy report as a coroner contract. Just building and registering that pipeline surfaced two traps of exactly the kind the tool exists for.&lt;/p&gt;

&lt;p&gt;The first trap: the JEPX CSV endpoint returns HTTP 200 with a zero-byte body when the &lt;code&gt;Referer&lt;/code&gt; header is missing. Success code, no data. I caught it with curl while writing the fetch layer. A status-code-only check would have "succeeded", saved an empty file, and quietly poisoned everything downstream. The defense is now two layers deep: the fetcher rejects empty bodies outright, and the contract's &lt;code&gt;min_bytes&lt;/code&gt; rule guards the artifact. If one goes quiet, the other still fires.&lt;/p&gt;

&lt;p&gt;The second trap: my monitored artifacts live in a different repository on a different drive, so I wrote the contract with an absolute-path glob, and the checker crashed. Glob resolution is delegated to pathlib's relative globbing, so absolute paths don't work. The fix is to set &lt;code&gt;workdir&lt;/code&gt; (which defaults to the directory containing &lt;code&gt;contracts.yaml&lt;/code&gt;) and use a relative glob. After rewriting it, the checker passed: &lt;code&gt;power-eval-weekly: ok&lt;/code&gt;, exit 0.&lt;/p&gt;

&lt;p&gt;I found the second one funny in hindsight. Putting the verification tool into real service exposed a gap in the verification tool's own docs, so the verifier got verified. The README now documents &lt;code&gt;workdir&lt;/code&gt; and the relative-glob rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick start
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv tool &lt;span class="nb"&gt;install &lt;/span&gt;agent-coroner   &lt;span class="c"&gt;# or: pip install agent-coroner&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;claude&lt;/code&gt; CLI on PATH is required only for autopsies; the checker works standalone.&lt;/p&gt;

&lt;p&gt;Write &lt;code&gt;contracts.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;weekly-report&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FRI&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;08:30"&lt;/span&gt;
    &lt;span class="na"&gt;grace_minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
    &lt;span class="na"&gt;artifacts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reports/weekly-*.md"&lt;/span&gt;
        &lt;span class="na"&gt;max_age_hours&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;192&lt;/span&gt;
        &lt;span class="na"&gt;min_bytes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;500&lt;/span&gt;
    &lt;span class="na"&gt;logs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run.log"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the checker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;coroner check &lt;span class="nt"&gt;--config&lt;/span&gt; contracts.yaml &lt;span class="nt"&gt;--no-autopsy&lt;/span&gt;
&lt;span class="go"&gt;[coroner] 1 violation detected
&lt;/span&gt;&lt;span class="gp"&gt;- weekly-report / reports/weekly-*.md: missing (no file matches 'reports/weekly-*.md' under &amp;lt;contracts-dir&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="go"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once &lt;code&gt;reports/weekly-*.md&lt;/code&gt; exists, is fresh, and meets &lt;code&gt;min_bytes&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;coroner check &lt;span class="nt"&gt;--config&lt;/span&gt; contracts.yaml &lt;span class="nt"&gt;--no-autopsy&lt;/span&gt;
&lt;span class="go"&gt;weekly-report: ok
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="go"&gt;0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register it on a schedule. On Linux/macOS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*/15 * * * * coroner check --config /path/to/contracts.yaml &amp;gt;&amp;gt; /var/log/coroner.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Windows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;schtasks&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/create&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/tn&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"coroner-check"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/tr&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"coroner check --config C:\path\to\contracts.yaml"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/sc&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;daily&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/st&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;09:00&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notifications are delegated to an external command of your choice (email, Slack, whatever), with &lt;code&gt;{message_file}&lt;/code&gt; holding the path to the full message text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude Code integration
&lt;/h2&gt;

&lt;p&gt;If you use Claude Code, there's also a plugin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/plugin marketplace add Chikoku-NEKO/agent-coroner
/plugin install agent-coroner@agent-coroner-marketplace
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It adds &lt;code&gt;/coroner-status&lt;/code&gt; (which jobs are ok, which have violations), &lt;code&gt;/autopsy &amp;lt;job-name&amp;gt;&lt;/code&gt; (run a postmortem on demand), and a SessionStart hook that surfaces unread postmortems when you open a session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;If you run anything unattended that's supposed to produce an artifact — weekly reports, data pipelines, scheduled Claude jobs — an existence-and-freshness check on the artifact is cheap insurance, whether or not you use this tool. agent-coroner just makes it declarative: one YAML file, a deterministic checker, and a read-only postmortem when something breaks.&lt;/p&gt;

&lt;p&gt;Links:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/Chikoku-NEKO/agent-coroner" rel="noopener noreferrer"&gt;https://github.com/Chikoku-NEKO/agent-coroner&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyPI: &lt;a href="https://pypi.org/project/agent-coroner/" rel="noopener noreferrer"&gt;https://pypi.org/project/agent-coroner/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Claude Code marketplace: &lt;code&gt;agent-coroner&lt;/code&gt; plugin&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you try it and something breaks during registration, file an issue. That's how the two traps above got documented.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>opensource</category>
      <category>automation</category>
    </item>
  </channel>
</rss>
