<?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: Todd Doane</title>
    <description>The latest articles on DEV Community by Todd Doane (@skibuff).</description>
    <link>https://dev.to/skibuff</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%2F2438797%2F390e0092-7538-4977-9390-fcb53dd32e9b.png</url>
      <title>DEV Community: Todd Doane</title>
      <link>https://dev.to/skibuff</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/skibuff"/>
    <language>en</language>
    <item>
      <title>A Claude Code hook that blocks pip install requests and tells the agent why</title>
      <dc:creator>Todd Doane</dc:creator>
      <pubDate>Sat, 07 Nov 2026 13:19:56 +0000</pubDate>
      <link>https://dev.to/skibuff/a-claude-code-hook-that-blocks-pip-install-requests-and-tells-the-agent-why-a1j</link>
      <guid>https://dev.to/skibuff/a-claude-code-hook-that-blocks-pip-install-requests-and-tells-the-agent-why-a1j</guid>
      <description>&lt;p&gt;A coding agent that hits &lt;code&gt;ModuleNotFoundError&lt;/code&gt; can run &lt;code&gt;pip install requests&lt;/code&gt; and carry on. The script works on that machine. The package never reaches requirements.txt, so CI or the next server breaks on the same import.&lt;/p&gt;

&lt;p&gt;A rule in CLAUDE.md asking the agent not to do that is a request. A PreToolUse hook is a check that runs before the command does. Below is a 35-line one, the contract it depends on, and the case where it fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contract
&lt;/h2&gt;

&lt;p&gt;Claude Code runs a PreToolUse hook before a tool call and sends it the call as JSON on stdin, including &lt;code&gt;tool_name&lt;/code&gt; and, for Bash, &lt;code&gt;tool_input.command&lt;/code&gt;. Exit code 2 blocks the call, and the hook's stderr becomes the message Claude sees. Exit 0 with nothing on stdout approves nothing; the call goes on to the normal permission flow. A hook can also print JSON with a &lt;code&gt;permissionDecision&lt;/code&gt; of &lt;code&gt;allow&lt;/code&gt;, which skips the prompt. [src: &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;https://code.claude.com/docs/en/hooks&lt;/a&gt;]&lt;/p&gt;

&lt;p&gt;The hook below only exits 0 or 2 and prints nothing to stdout, so it cannot widen what the agent may do; your permission settings stay in charge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hook
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;.claude/hooks/block_pip.py&lt;/code&gt;, standard library only:&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;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;PreToolUse hook: block `pip install &amp;lt;pkg&amp;gt;` that skips the requirements file.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&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;shlex&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;ALLOWED&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;-r&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;--requirement&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;-e&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;--editable&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;-c&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;--constraint&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;reason&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&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="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shlex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&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;tokens&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&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;python&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;-m&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;pip&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;python3&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;-m&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;pip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
        &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&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;tokens&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&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;pip&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;install&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;pip3&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;install&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="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ALLOWED&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;pkgs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&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="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;pkgs&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;all&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;startswith&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&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;/&lt;/span&gt;&lt;span class="sh"&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;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pkgs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="nf"&gt;return &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;`pip install &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&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;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pkgs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;` blocked: add it to requirements.txt &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;with a version pin, then run `pip install -r requirements.txt`.&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;main&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;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;event&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;load&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="n"&gt;command&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&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_input&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="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;command&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="n"&gt;why&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&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;why&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;why&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&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;stderr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Claude Code shows stderr to the model
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;                     &lt;span class="c1"&gt;# exit 2 = block this tool call
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;                         &lt;span class="c1"&gt;# exit 0 = normal permission flow
&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&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="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The message is written for the agent, not for you. It says what was blocked and what to do instead, so the agent has a next step that is not another way around the block.&lt;/p&gt;

&lt;p&gt;Wire it 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;"python3 &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$CLAUDE_PROJECT_DIR&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;/.claude/hooks/block_pip.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;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;h2&gt;
  
  
  Test it without Claude Code
&lt;/h2&gt;

&lt;p&gt;The hook is a function from a string to a reason, so most of it is a plain unit test:&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;block_pip&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_blocks_adhoc&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pip install requests&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;python3 -m pip install &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;httpx==0.27.0&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;test_allows_lockfile_and_local&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;cmd&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;pip install -r requirements.txt&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;pip install -e .&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;pip install .&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;pip list&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;pytest -q&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;reason&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="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_misses_chained_command&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="c1"&gt;# one-line split only: the second command is not seen
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cd app &amp;amp;&amp;amp; pip install requests&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the whole hook from the shell, with a fake payload:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"tool_name":"Bash","tool_input":{"command":"pip install requests"}}'&lt;/span&gt; | python3 .claude/hooks/block_pip.py
&lt;span class="sb"&gt;`&lt;/span&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;requests&lt;span class="sb"&gt;`&lt;/span&gt; blocked: add it to requirements.txt with a version pin, &lt;span class="k"&gt;then &lt;/span&gt;run &lt;span class="sb"&gt;`&lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt&lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then once in Claude Code itself. We ran it headless (&lt;code&gt;claude -p&lt;/code&gt;, Claude Code 2.1.312), told to run &lt;code&gt;pip install requests&lt;/code&gt; and not to try another install method. It was blocked, and the agent's reply quoted the hook's message word for word.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case it misses
&lt;/h2&gt;

&lt;p&gt;The third test is there on purpose. &lt;code&gt;cd app &amp;amp;&amp;amp; pip install requests&lt;/code&gt; is one string to this hook: the first token is &lt;code&gt;cd&lt;/code&gt;, so it passes.&lt;/p&gt;

&lt;p&gt;The fix is to split the line on &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;, &lt;code&gt;||&lt;/code&gt;, &lt;code&gt;;&lt;/code&gt;, &lt;code&gt;|&lt;/code&gt; and newlines before checking each part, and to skip leading &lt;code&gt;FOO=1&lt;/code&gt; assignments and &lt;code&gt;sudo&lt;/code&gt;. Our paid kit's &lt;code&gt;guard_bash.py&lt;/code&gt; does that. Neither version sees &lt;code&gt;uv pip install&lt;/code&gt;, &lt;code&gt;pipx&lt;/code&gt;, or a command hidden in a script file or &lt;code&gt;eval&lt;/code&gt;. A hook that reads command text is a guard against the common mistake, not a sandbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this came from
&lt;/h2&gt;

&lt;p&gt;The same pattern, extended to five command types (commit while a secrets linter finds a key, &lt;code&gt;--no-verify&lt;/code&gt;, force-push with &lt;code&gt;--force-with-lease&lt;/code&gt; allowed, &lt;code&gt;rm -r&lt;/code&gt; on &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;~&lt;/code&gt; or the project root, and ad-hoc &lt;code&gt;pip install&lt;/code&gt;), is the guard hook in our &lt;a href="https://calyx-ai.net/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=pretooluse-hook#inside" rel="noopener noreferrer"&gt;Agent Ops Kit&lt;/a&gt; ($29 one-time). The storefront shows a capture of it blocking real commands, unedited except for line wrapping. The free, MIT-licensed &lt;a href="https://github.com/skibuff22/agent-ops-kit-lite" rel="noopener noreferrer"&gt;agent-ops-kit-lite&lt;/a&gt; plugin has the CLAUDE.md scaffold, the secrets linter and a &lt;code&gt;/verify-claims&lt;/code&gt; skill.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written by Calyx AI, a product line of Quality Tester Solutions LLC. Drafted with AI assistance; the code and outputs were run on Python 3.14.8 and Claude Code 2.1.312.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>python</category>
      <category>ai</category>
      <category>devops</category>
    </item>
    <item>
      <title>Math.round(x * 100) / 100 rounds 1.005 to 1: the rounding bug in our unit tables</title>
      <dc:creator>Todd Doane</dc:creator>
      <pubDate>Thu, 05 Nov 2026 13:24:59 +0000</pubDate>
      <link>https://dev.to/skibuff/mathroundx-100-100-rounds-1005-to-1-the-rounding-bug-in-our-unit-tables-44ke</link>
      <guid>https://dev.to/skibuff/mathroundx-100-100-rounds-1005-to-1-the-rounding-bug-in-our-unit-tables-44ke</guid>
      <description>&lt;p&gt;We build free calculators for people who pull-test anchors and fasteners. One of them converts displacement between mm and inches and prints two reference tables. On 2026-11-04 our publish gate failed the page: the 1/16 in row said 1.587 mm.&lt;/p&gt;

&lt;p&gt;1/16 in is 0.0625 in, and 1 in is exactly 25.4 mm, so the exact value is 1.5875 mm. Rounded half up to three decimals that is 1.588. The table was off by one in the last digit, on a page whose whole job is to be right about the last digit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where 1.587 came from
&lt;/h2&gt;

&lt;p&gt;The static tables were generated in Python with an f-string:&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="o"&gt;&amp;gt;&amp;gt;&amp;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="mf"&gt;0.0625&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;25.4&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1.587&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.5875&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1.587499999999999911182158029987476766109466552734375&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;1.5875 has no exact binary form. The nearest double is a little below it, and formatting rounds that stored value correctly: it is below the halfway point, so it goes down. Python's own docs give the same example with &lt;code&gt;round(2.675, 2)&lt;/code&gt;, which returns 2.67. [src: &lt;a href="https://docs.python.org/3/library/functions.html#round" rel="noopener noreferrer"&gt;https://docs.python.org/3/library/functions.html#round&lt;/a&gt;]&lt;/p&gt;

&lt;p&gt;The fix for the tables was to stop using floats for them: build each value as a &lt;code&gt;Decimal&lt;/code&gt; from strings and round with &lt;code&gt;ROUND_HALF_UP&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;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;D&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ROUND_HALF_UP&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="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;D&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0.0625&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="nc"&gt;D&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;25.4&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;quantize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;D&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0.001&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ROUND_HALF_UP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# '1.588'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The same class in the browser
&lt;/h2&gt;

&lt;p&gt;The live calculators round in JavaScript, so we checked those next. They used the two common idioms, and both have the same problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.005&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;   &lt;span class="c1"&gt;// 1, not 1.01&lt;/span&gt;
&lt;span class="mf"&gt;1.005&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;                     &lt;span class="c1"&gt;// 100.49999999999999&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.005&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;// "1.00"&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;2.55&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="c1"&gt;// "2.5"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;1.005 * 100&lt;/code&gt; lands just below 100.5, so &lt;code&gt;Math.round&lt;/code&gt; goes down. &lt;code&gt;toFixed&lt;/code&gt; rounds the stored binary value, which for 1.005 and 2.55 is just below the halfway point; MDN documents the 2.55 case. [src: &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toFixed" rel="noopener noreferrer"&gt;https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toFixed&lt;/a&gt;]&lt;/p&gt;

&lt;p&gt;There is a second, smaller trap: &lt;code&gt;Math.round&lt;/code&gt; rounds halves toward positive infinity, so &lt;code&gt;Math.round(-2.5)&lt;/code&gt; is -2, not -3, and &lt;code&gt;Math.round(-0.4)&lt;/code&gt; returns -0. [src: &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round" rel="noopener noreferrer"&gt;https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round&lt;/a&gt;]&lt;/p&gt;

&lt;p&gt;All 19 calculator pages we had at the time rounded with &lt;code&gt;Math.round(x * 10^d) / 10^d&lt;/code&gt; or &lt;code&gt;toFixed&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: round the decimal string, not the binary value
&lt;/h2&gt;

&lt;p&gt;JavaScript prints a number using the shortest decimal string that maps back to the same double. For the double nearest 1.005 that string is &lt;code&gt;"1.005"&lt;/code&gt;. If you shift the decimal point by editing the exponent in that string, instead of multiplying, the number you round is the one you see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;e&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&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="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;e&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;e&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&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="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;e&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;rd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.005&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;// 1.01&lt;/span&gt;
&lt;span class="nf"&gt;rd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;1.005&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// -1.01   (half away from zero, like ROUND_HALF_UP)&lt;/span&gt;
&lt;span class="nf"&gt;rd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;2.5&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;// -3&lt;/span&gt;
&lt;span class="nf"&gt;rd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;0.004&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// 0, not -0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Number("1.005e2")&lt;/code&gt; is exactly 100.5, so &lt;code&gt;Math.round&lt;/code&gt; sees the true halfway value. The sign is handled separately, which gives half away from zero for negatives and matches Python's &lt;code&gt;ROUND_HALF_UP&lt;/code&gt; on the table side. Exponents in the string (&lt;code&gt;1e-7&lt;/code&gt;) are handled by adding to the existing exponent. The result goes to &lt;code&gt;toLocaleString&lt;/code&gt; for display; the calculation itself keeps full precision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it stay fixed
&lt;/h2&gt;

&lt;p&gt;A fix that depends on everyone remembering it does not last, so the repo now has two tests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A lint test that reads every calculator page and fails on &lt;code&gt;Math.round(x * 10...)&lt;/code&gt; / &lt;code&gt;Math.round(x * Math.pow(10, d))&lt;/code&gt; or a bare &lt;code&gt;.toFixed(&lt;/code&gt; (one on a value &lt;code&gt;rd&lt;/code&gt; already rounded is allowed). It flagged all 19 old pages before the change and none after.&lt;/li&gt;
&lt;li&gt;A headless-browser check that loads each live page, pulls out the &lt;code&gt;rd&lt;/code&gt; that page actually ships, and runs 14 fixed cases through it (1.005, 1.5875, 0.0625 * 25.4, 2.675, -1.005, 1e-7, 0.1 + 0.2 and others), plus one check in the displacement converter's UI. It ran 267 checks when we made the change and 295 now, after two more pages, all passing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We also rechecked the 245 static table cells on six pages against exact &lt;code&gt;Decimal&lt;/code&gt; values after the 1/16 in fix: none were wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not fix
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;rd&lt;/code&gt; rounds the number it is given. If the arithmetic before it already drifted, &lt;code&gt;rd&lt;/code&gt; rounds the drifted value: in our stress converter, 0.00100005 ksi to psi computes 1.0000499999999999, not 1.00005, so &lt;code&gt;rd&lt;/code&gt; shows 1. We left that case out of the test on purpose and wrote down why. It fixes halfway rounding, not every floating-point error. For values that must be exact (prices, published tables), compute in decimal from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part we did not expect
&lt;/h2&gt;

&lt;p&gt;The bug was not found by a unit test. It was found because the page went through our publish gate, which checks each claim and number in a draft against a facts file before it goes live, and the gate's claim-by-claim check flagged the row. A generalized version of that gate is the &lt;a href="https://calyx-ai.net/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=rounding#publish-gate" rel="noopener noreferrer"&gt;Publish Gate&lt;/a&gt; we sell ($19 one-time). A free claim-check skill for Claude Code, &lt;code&gt;/verify-claims&lt;/code&gt;, is in our MIT-licensed &lt;a href="https://github.com/skibuff22/agent-ops-kit-lite" rel="noopener noreferrer"&gt;agent-ops-kit-lite&lt;/a&gt; repo. The calculators, including the fixed &lt;a href="https://calyx-ai.net/qa/displacement-unit-converter/" rel="noopener noreferrer"&gt;displacement unit converter&lt;/a&gt;, are free at &lt;a href="https://calyx-ai.net/qa/" rel="noopener noreferrer"&gt;calyx-ai.net/qa&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written by Calyx AI, a product line of Quality Tester Solutions LLC. Drafted with AI assistance and checked by the gate it describes; the code and the numbers are from our own repo and were run on Node v26.7.0 and Python 3.14.8.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>python</category>
      <category>webdev</category>
      <category>testing</category>
    </item>
    <item>
      <title>A zero-dependency MCP server in one file: what the protocol actually needs</title>
      <dc:creator>Todd Doane</dc:creator>
      <pubDate>Sat, 31 Oct 2026 12:23:14 +0000</pubDate>
      <link>https://dev.to/skibuff/a-zero-dependency-mcp-server-in-one-file-what-the-protocol-actually-needs-khj</link>
      <guid>https://dev.to/skibuff/a-zero-dependency-mcp-server-in-one-file-what-the-protocol-actually-needs-khj</guid>
      <description>&lt;p&gt;We wanted two checks from our publish gate available inside any MCP client: "is every number in this draft in my facts file" and "does every link in it load". The official SDKs are fine, but for two tools we did not want a dependency tree, so we wrote the server against the protocol directly. It is one file, 125 lines, and it imports only &lt;code&gt;node:fs&lt;/code&gt; and &lt;code&gt;node:readline&lt;/code&gt;. We also got the error split wrong, twice, which makes it the most useful section below.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a stdio MCP server has to do
&lt;/h2&gt;

&lt;p&gt;For a tools-only server on the stdio transport, the job is small:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read JSON-RPC 2.0 messages from stdin, one per line.&lt;/li&gt;
&lt;li&gt;Write one JSON reply per line to stdout. The spec says the server must not write anything to stdout that is not a valid MCP message; logs go to stderr. [src: &lt;a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/transports" rel="noopener noreferrer"&gt;https://modelcontextprotocol.io/specification/2025-06-18/basic/transports&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;Answer four methods: &lt;code&gt;initialize&lt;/code&gt;, &lt;code&gt;tools/list&lt;/code&gt;, &lt;code&gt;tools/call&lt;/code&gt; and &lt;code&gt;ping&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Stay silent on notifications (messages without an &lt;code&gt;id&lt;/code&gt;), such as &lt;code&gt;notifications/initialized&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The read loop is &lt;code&gt;readline&lt;/code&gt; over stdin. This is the same logic as &lt;code&gt;server.js&lt;/code&gt;, restructured for reading:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createInterface&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdin&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;rl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;line&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&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="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// blank or bad line: ignore&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&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="c1"&gt;// notification: never reply&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdout&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="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;jsonrpc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&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="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;32603&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;handle&lt;/code&gt; is a switch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;initialize&lt;/span&gt;&lt;span class="dl"&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="na"&gt;protocolVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PROTOCOL_VERSIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;protocolVersion&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
               &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protocolVersion&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PROTOCOL_VERSIONS&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;// ["2025-11-25", "2025-06-18"]&lt;/span&gt;
             &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tools&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="na"&gt;serverInfo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;publish-gate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VERSION&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tools/list&lt;/span&gt;&lt;span class="dl"&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="na"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TOOLS&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tools/call&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="cm"&gt;/* run the tool, see below */&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ping&lt;/span&gt;&lt;span class="dl"&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="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// notification: no reply&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`method not found: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;method&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;32601&lt;/span&gt; &lt;span class="p"&gt;});&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;initialize&lt;/code&gt; answers with the client's protocol version only if it is one of the revisions we checked the server against; otherwise it offers the newest of those. The lifecycle spec asks for exactly that: same version if supported, otherwise another version the server supports. [src: &lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle" rel="noopener noreferrer"&gt;https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle&lt;/a&gt;] The first version echoed whatever the client sent, which told a client asking for a newer revision that the server followed rules it had never been checked against.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two kinds of error, and the one we got wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Update 2026-11-01:&lt;/strong&gt; a reader in the comments pointed out that this section followed the 2025-06-18 revision, and the 2025-11-25 revision moved input validation errors to the other bucket (SEP-1303). [src: &lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/changelog" rel="noopener noreferrer"&gt;https://modelcontextprotocol.io/specification/2025-11-25/changelog&lt;/a&gt;] They were right; the code and this section are corrected below.&lt;/p&gt;

&lt;p&gt;The MCP spec splits tool errors in two. Protocol errors are ordinary JSON-RPC errors, for an unknown tool or a request that does not fit the call schema. Tool execution errors are a normal result with &lt;code&gt;isError: true&lt;/code&gt;, for input validation errors and failures while the tool runs, so the model sees the message and can retry. Clients should pass tool execution errors to the model and may pass protocol errors. [src: &lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/server/tools" rel="noopener noreferrer"&gt;https://modelcontextprotocol.io/specification/2025-11-25/server/tools&lt;/a&gt;]&lt;/p&gt;

&lt;p&gt;Our first version put everything in the second bucket. On 2026-10-31 we moved both an unknown tool and a call with no draft to protocol errors, reading the older revision. The second is an input validation error, so a model might never have learned which argument was missing. Now only an unknown tool gets a coded error, rethrown so the read loop turns it into a JSON-RPC error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invalidParams&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&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;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;32602&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// in callTool: reject bad calls before doing any work&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TOOLS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nf"&gt;invalidParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`unknown tool: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;draft_text&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;draft_path&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;give draft_text or draft_path&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// in handle, case "tools/call":&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;callTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;32602&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                       &lt;span class="c1"&gt;// protocol error&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`error: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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="na"&gt;isError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now an unknown tool gets &lt;code&gt;-32602&lt;/code&gt;, an unknown method gets &lt;code&gt;-32601&lt;/code&gt;, a call without a draft and an unreadable &lt;code&gt;draft_path&lt;/code&gt; file both come back as an &lt;code&gt;isError&lt;/code&gt; result starting with &lt;code&gt;error:&lt;/code&gt;, and anything else thrown becomes &lt;code&gt;-32603&lt;/code&gt;. A stdio test sends an unknown tool, a call without a draft, an unreadable &lt;code&gt;draft_path&lt;/code&gt; and an unknown method, and checks each reply; another checks the version answer for a known, an unknown and a missing &lt;code&gt;protocolVersion&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Each tool returns plain text: &lt;code&gt;PASS&lt;/code&gt;, or &lt;code&gt;FAIL (n)&lt;/code&gt; followed by one line per finding. That is a design choice for a model reading the result, not something we measured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replies can arrive out of order
&lt;/h2&gt;

&lt;p&gt;Handlers are &lt;code&gt;async&lt;/code&gt;, so a slow call can finish after a fast one. In a test session on 2026-10-31, request 4 (an unknown method) was answered before request 3 (a &lt;code&gt;tools/call&lt;/code&gt; to &lt;code&gt;check_numbers&lt;/code&gt;). That is allowed: clients match replies by &lt;code&gt;id&lt;/code&gt;, never by order. Do not "fix" it with a queue unless a tool really needs ordering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test it without a client
&lt;/h2&gt;

&lt;p&gt;You can drive the server from a shell, which is a quick way to see what a client sees:&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;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'{"jsonrpc":"2.0","method":"notifications/initialized"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":2,"method":"tools/list"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | node server.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For unit tests, the server exports its check functions (&lt;code&gt;checkNumbers&lt;/code&gt;, &lt;code&gt;checkLinks&lt;/code&gt;) and injects what touches the outside world: &lt;code&gt;checkLinks&lt;/code&gt; takes a &lt;code&gt;fetchFn&lt;/code&gt;, so &lt;code&gt;node --test&lt;/code&gt; runs without the network. The server has 6 tests and they pass on Node v26.7.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install it
&lt;/h2&gt;

&lt;p&gt;The server is not on npm yet. From a clone, in Claude Code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/skibuff22/agent-ops-kit-lite
claude mcp add publish-gate &lt;span class="nt"&gt;--&lt;/span&gt; node &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;/agent-ops-kit-lite/mcp/publish-gate/server.js"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We have tested it in Claude Code (&lt;code&gt;claude mcp list&lt;/code&gt; shows Connected); other stdio MCP clients should take the same setup (command &lt;code&gt;node&lt;/code&gt;, the path to &lt;code&gt;server.js&lt;/code&gt; as the argument), but we have not tested them. &lt;code&gt;check_numbers&lt;/code&gt; takes the draft and your &lt;code&gt;facts.md&lt;/code&gt; as text or paths; &lt;code&gt;check_links&lt;/code&gt; takes the draft. The source is in &lt;a href="https://github.com/skibuff22/agent-ops-kit-lite" rel="noopener noreferrer"&gt;agent-ops-kit-lite&lt;/a&gt; (MIT), next to the free Claude Code plugin it belongs to.&lt;/p&gt;

&lt;p&gt;These are two of the deterministic checks in our publish gate. The paid &lt;a href="https://calyx-ai.net/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=mcp-server#publish-gate" rel="noopener noreferrer"&gt;Publish Gate&lt;/a&gt; adds the rest: style and voice checks, an LLM claim-by-claim check that fails closed, a SHA-256 lock, and publishers that refuse a draft that changed after it passed. $19 one-time.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written by Calyx AI, a product line of Quality Tester Solutions LLC. Drafted with AI assistance and checked by the gate it describes; the code and the test session are from our own repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>node</category>
      <category>ai</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>A publish gate for AI-assisted posts: check every claim before it goes out</title>
      <dc:creator>Todd Doane</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:23:11 +0000</pubDate>
      <link>https://dev.to/skibuff/a-publish-gate-for-ai-assisted-posts-check-every-claim-before-it-goes-out-5b2a</link>
      <guid>https://dev.to/skibuff/a-publish-gate-for-ai-assisted-posts-check-every-claim-before-it-goes-out-5b2a</guid>
      <description>&lt;p&gt;If an AI agent drafts what you publish, the risk is not bad grammar. It is a confident number nobody measured, a feature that does not exist, or a paragraph of stock phrases with your name on it. We let our store's daily agent post under a real person's name, so we built a gate it cannot skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes wrong without one
&lt;/h2&gt;

&lt;p&gt;A language model writes what is plausible. Plausible is fine for a first draft and wrong for a public claim. The failures we wrote the gate against are ordinary ones: a round number that sounds right, a feature described the way it will work next month, a link to a checkout page with no tracking, and the familiar vocabulary that tells a reader nobody read the draft.&lt;/p&gt;

&lt;p&gt;Asking the agent to "be accurate" does not fix this. The agent that wrote the claim is the one judging it, in the same context, with the same blind spots. The check has to sit outside the writing step and it has to be able to say no.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate, in three layers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A facts file is the only source of truth
&lt;/h3&gt;

&lt;p&gt;Every claim and every number a post may use lives in &lt;code&gt;facts.md&lt;/code&gt;, each with its evidence: a file path, a command and its output, or a URL. If it is not in the file, it is not claimable. The file also has a "not claimable" section: customer counts, benchmarks, anything nobody has measured. Writing that section down is what keeps later posts honest.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deterministic checks are the floor
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Numbers:&lt;/strong&gt; every number in the body must appear in &lt;code&gt;facts.md&lt;/code&gt;, or the line carries a source link. Years, times, versions and list numbering are exempt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phrases:&lt;/strong&gt; a built-in list of stock AI phrases plus your own "Banned phrases" list from &lt;code&gt;voice.md&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structure:&lt;/strong&gt; no emoji, at most one exclamation mark, no rhetorical-question openers, a cap on em-dash density.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Links:&lt;/strong&gt; every link must resolve, and checkout links must carry &lt;code&gt;utm_source&lt;/code&gt; so you can see which post sent the buyer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These cost nothing to run and they never get tired. They catch the cheap mistakes before a model is involved.&lt;/p&gt;

&lt;h3&gt;
  
  
  An LLM checks each claim, and fails closed
&lt;/h3&gt;

&lt;p&gt;The last check sends the draft and the facts file to a model and asks for a verdict per claim: SUPPORTED, UNSUPPORTED or CONTRADICTED. Anything other than SUPPORTED fails the post. If the model cannot be reached, the gate fails too. A check that passes when it cannot run is not a check.&lt;/p&gt;

&lt;p&gt;A PASS writes a small sidecar file with the SHA-256 of the post body. Every publisher script calls one function before it posts:&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;side&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require_verified&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="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# refuses if the sidecar is missing,
&lt;/span&gt;                                      &lt;span class="c1"&gt;# the hash differs, or the LLM step was skipped
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Edit one word after the PASS and the publisher refuses until the gate runs again. That rule stops an agent from tweaking a post after it passed. It does not stop an agent with write access from changing the publisher itself; that part is still code review.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it caught, and what it did not
&lt;/h2&gt;

&lt;p&gt;The gate checks content. Today it showed us the edge of that. Our Show HN post passed the gate, the publisher submitted it and printed PUBLISHED, and the account's submissions page was empty. Hacker News had answered with a notice that it is temporarily restricting Show HNs from new accounts. The publisher had trusted the redirect after submitting and, when it could not find the item, reported a generic URL as success.&lt;/p&gt;

&lt;p&gt;That is a publisher bug, not a content problem, and we handled it the way we handle any failure: find the cause, fix it, add the test, then recover. The publisher now reports success only when the item appears under the account's own submissions, saves HN's response page otherwise, and reports the restriction as declined. We did not retitle the post to get around the rule; the draft is parked and the rule is logged. A gate tells you a post matches your facts file and your voice guide. It cannot tell you a community will take it, and your publishers need their own proof that the post actually exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the pattern without buying anything
&lt;/h2&gt;

&lt;p&gt;Start with the cheapest part: a &lt;code&gt;facts.md&lt;/code&gt; with evidence per line and a "not claimable" section, plus a rule in your &lt;code&gt;CLAUDE.md&lt;/code&gt; or &lt;code&gt;AGENTS.md&lt;/code&gt; that nothing is published unless every number in it is in that file. Then add the hash lock: write the body's hash on approval and refuse to publish if it changed. Those two steps are the core of the pattern.&lt;/p&gt;

&lt;p&gt;The free &lt;a href="https://github.com/skibuff22/agent-ops-kit-lite" rel="noopener noreferrer"&gt;Agent Ops Kit Lite&lt;/a&gt; (MIT) has the &lt;code&gt;CLAUDE.md&lt;/code&gt; structure this runs inside.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I sell the finished version. Publish Gate v1 is &lt;code&gt;verify_post.py&lt;/code&gt; with configurable facts and voice files, any model CLI through &lt;code&gt;PUBLISH_GATE_LLM&lt;/code&gt; (default &lt;code&gt;claude -p&lt;/code&gt;), the &lt;code&gt;require_verified&lt;/code&gt; helper, an example dev.to publisher, templates, a directive for your agent and 8 tests: $19, or $29 bundled with the Agent Ops Kit, at &lt;a href="https://calyx-ai.net/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=publish-gate#publish-gate" rel="noopener noreferrer"&gt;calyx-ai.net&lt;/a&gt;. This article was drafted with AI assistance and passed the gate it describes. Originally published at &lt;a href="https://calyx-ai.net/blog/publish-gate-ai-posts/" rel="noopener noreferrer"&gt;calyx-ai.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>writing</category>
      <category>python</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The self-annealing loop: make every AI agent failure leave a test behind</title>
      <dc:creator>Todd Doane</dc:creator>
      <pubDate>Wed, 21 Oct 2026 12:26:37 +0000</pubDate>
      <link>https://dev.to/skibuff/the-self-annealing-loop-make-every-ai-agent-failure-leave-a-test-behind-4joh</link>
      <guid>https://dev.to/skibuff/the-self-annealing-loop-make-every-ai-agent-failure-leave-a-test-behind-4joh</guid>
      <description>&lt;p&gt;When an AI coding agent hits an error, the easiest move is to get past it: retry, reset, reprocess. That makes today's run green and guarantees the same failure next week. The self-annealing loop is a short rule that changes what the agent does after something breaks, so the system gets harder to break each time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why agents default to recovery
&lt;/h2&gt;

&lt;p&gt;Ask a coding agent to finish a job and you have told it, implicitly, that a finished job is the goal. When a step fails, the shortest path back to "finished" is recovery: run it again, clear the stuck queue, restart the service, add a retry. Each of those is reasonable in isolation. Together they hide the cause, and because the next session starts with a fresh context, nothing about the failure survives except the workaround.&lt;/p&gt;

&lt;p&gt;A human engineer does the same thing under pressure. The difference is that the human usually remembers. The agent does not, unless you give it somewhere to write the lesson down and a rule that says it must.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop, in order
&lt;/h2&gt;

&lt;p&gt;Annealing is the metallurgy term for heating and cooling a metal so its internal stresses relax and it ends up more ductile and less likely to crack. In an agent-run system the "heat" is a failure. The loop is five steps, and the order is the whole point:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Diagnose the root cause.&lt;/strong&gt; Keep asking "why" until you reach something that, once changed, prevents the failure from recurring. "The request timed out" is a symptom. "The health check treats any timeout as a store outage" is a cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix it&lt;/strong&gt; in code, config or infrastructure, not in the data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the test&lt;/strong&gt; that would have caught it, so the fix cannot silently regress.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the standard down.&lt;/strong&gt; Add a line to the directive's &lt;em&gt;Edge cases &amp;amp; learnings&lt;/em&gt; section, and a lint if the pattern can be detected mechanically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then recover:&lt;/strong&gt; reprocess the stuck items, restart, re-run.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Recovery still happens. It just happens last, after the cause can no longer produce the same failure. A rule worth adding: if the agent cannot finish the first four steps in the session, it says so and leaves the item open instead of quietly recovering.&lt;/p&gt;

&lt;h3&gt;
  
  
  Put it where the agent reads it first
&lt;/h3&gt;

&lt;p&gt;The loop only works if it is part of the agent's standing instructions, not a prompt you remember to type. In a D.O.E. repo (Directive, Orchestration, Execution, described in &lt;a href="https://calyx-ai.net/blog/why-ai-coding-agent-breaks-prod/" rel="noopener noreferrer"&gt;the first article&lt;/a&gt;) it lives in &lt;code&gt;CLAUDE.md&lt;/code&gt;. A condensed example:&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;## When something fails (self-annealing)&lt;/span&gt;
&lt;span class="p"&gt;1.&lt;/span&gt; Diagnose the root cause. A symptom is not a cause.
&lt;span class="p"&gt;2.&lt;/span&gt; Fix it in code/config/infra.
&lt;span class="p"&gt;3.&lt;/span&gt; Add the test that would have caught it.
&lt;span class="p"&gt;4.&lt;/span&gt; Add the standard to the directive's "Edge cases &amp;amp; learnings"
   (and a lint if it is mechanically detectable).
&lt;span class="p"&gt;5.&lt;/span&gt; Only then recover. Never recover first and call it fixed.
Record each pass in the anneal log: date, symptom, cause, fix, test.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The anneal log matters more than it looks. It is how you see that the same area keeps failing, which is usually the signal that the first fix treated a symptom.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real example: three anneal passes in four days
&lt;/h2&gt;

&lt;p&gt;We run our store with a daily agent job on a small Linux box. Each day a script checks the storefront and the checkout endpoint, and if either returns nothing (HTTP status &lt;code&gt;000&lt;/code&gt;), it raises an ATTENTION note: the agent must diagnose and fix the cause before doing anything else.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pass 1.&lt;/strong&gt; ATTENTION: storefront unreachable. Root cause: the box had lost its own uplink (gateway and DNS failing); the store was fine. Left behind: a "host offline" check before raising ATTENTION, curl timeouts and retries, and a fix for a &lt;code&gt;git pull&lt;/code&gt; that failed on a dirty log file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pass 2.&lt;/strong&gt; The metrics job failed with a name-resolution error. Root cause: flaky DNS on the same box. Left behind: retries with backoff on transport errors only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pass 3.&lt;/strong&gt; ATTENTION again, storefront unreachable. Root cause: the LAN was clean but the WAN was dropping most packets, and the single offline probe happened to succeed, so the pass-1 check did not fire. Left behind: six probes instead of one; two or more failures is classified as "host network degraded", not a store outage. Plus a standard in the store-operations directive, and a note that this is a hardware problem for a human.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pass 1 was a real fix and still not the whole answer, which is normal. What the log gives you is the history: by pass 3 it showed the same alarm with the same kind of cause, which pointed at the check itself rather than at the store.&lt;/p&gt;

&lt;p&gt;The last pass also shows the loop's honest exit: some root causes are not code. When the cause is a flaky network link, the right standard is "classify it correctly and tell a human", not another retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check before you call a pass done
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Could you state the cause in one sentence that does not contain the words "sometimes" or "flaky"? If not, keep asking why.&lt;/li&gt;
&lt;li&gt;Does a test fail on the old code and pass on the new? If you cannot write one, write the lint or the directive line and say why.&lt;/li&gt;
&lt;li&gt;Is the learning in the file the next session reads first, not in a commit message or a chat transcript?&lt;/li&gt;
&lt;li&gt;Did recovery happen after the fix, and was it verified (an HTTP OK, a matching hash, a row count)?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Start with one directive
&lt;/h2&gt;

&lt;p&gt;You do not need a framework to try this. Add the block above to your &lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;.cursorrules&lt;/code&gt; or &lt;code&gt;AGENTS.md&lt;/code&gt;, and give your most failure-prone job a directive with an &lt;em&gt;Edge cases &amp;amp; learnings&lt;/em&gt; section. After a week, read that section. It is a list of failures that now have a test, a rule or a lint against them.&lt;/p&gt;

&lt;p&gt;The free &lt;a href="https://github.com/skibuff22/agent-ops-kit-lite" rel="noopener noreferrer"&gt;Agent Ops Kit Lite&lt;/a&gt; (MIT) has the &lt;code&gt;CLAUDE.md&lt;/code&gt; with this loop, the directive template and a secrets linter. It also installs as a Claude Code plugin: &lt;code&gt;/plugin marketplace add skibuff22/agent-ops-kit-lite&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://calyx-ai.net/blog/self-annealing-loop/" rel="noopener noreferrer"&gt;calyx-ai.net&lt;/a&gt;. Disclosure: I sell the full &lt;a href="https://calyx-ai.net/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=self-annealing-loop" rel="noopener noreferrer"&gt;Agent Ops Kit&lt;/a&gt; ($29), which adds the self-annealing, bug-fix and agent-governance directives, three example directives, seven execution scripts and multi-agent templates. The free lite kit above stands on its own. This article was drafted with AI assistance; the incident list is from our own run log.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>devops</category>
      <category>programming</category>
    </item>
    <item>
      <title>Why your AI coding agent keeps breaking prod (and the 3-layer fix)</title>
      <dc:creator>Todd Doane</dc:creator>
      <pubDate>Sun, 18 Oct 2026 12:08:23 +0000</pubDate>
      <link>https://dev.to/skibuff/why-your-ai-coding-agent-keeps-breaking-prod-and-the-3-layer-fix-fgj</link>
      <guid>https://dev.to/skibuff/why-your-ai-coding-agent-keeps-breaking-prod-and-the-3-layer-fix-fgj</guid>
      <description>&lt;p&gt;Claude Code, Cursor and Codex are excellent at single steps. Production work is not a single step. Here is the arithmetic of why agents fall apart on real tasks, and the structure we use to run a small company's platform with agents without babysitting them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom: brilliant in the demo, flaky on Tuesday
&lt;/h2&gt;

&lt;p&gt;You ask the agent to deploy a fix. It edits the right file, writes a plausible commit message, runs the deploy, and then something small goes wrong: a CRLF line ending breaks a shell script on the server, an environment variable is read from the wrong file, a webhook key rotates on redeploy. The agent notices the failure, "fixes" it by retrying or resetting state, and reports success. Next week, in a fresh session, it does exactly the same thing.&lt;/p&gt;

&lt;p&gt;None of this is the model being dumb. It is the shape of the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The arithmetic: errors compound
&lt;/h2&gt;

&lt;p&gt;Suppose your agent gets each step right 90% of the time, which is generous for a step like "parse this API response and write the right rows". Chain steps together and the probabilities multiply:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Steps the model executes&lt;/th&gt;
&lt;th&gt;Success rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3 decisions, scripts do the rest&lt;/td&gt;
&lt;td&gt;0.9³ = &lt;strong&gt;73%&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 steps&lt;/td&gt;
&lt;td&gt;0.9⁵ = &lt;strong&gt;59%&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 steps&lt;/td&gt;
&lt;td&gt;0.9¹⁰ = &lt;strong&gt;35%&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Real tasks (fetch, parse, transform, validate, write, deploy, verify) are ten to fifty steps. If the model &lt;em&gt;executes&lt;/em&gt; every one of them by improvising code or commands in the moment, the success rate of the whole job collapses, and you end up watching it.&lt;/p&gt;

&lt;p&gt;The fix is not a better prompt. It is moving the steps that have a right answer out of the model entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 3-layer fix: Directive, Orchestration, Execution
&lt;/h2&gt;

&lt;p&gt;We call it D.O.E. Each layer does one kind of work, and the boundaries between them are the point.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Nature&lt;/th&gt;
&lt;th&gt;Lives in&lt;/th&gt;
&lt;th&gt;Changed by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Directive&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Intent, in prose: goal, inputs, tools, outputs, edge cases, how to verify&lt;/td&gt;
&lt;td&gt;&lt;code&gt;directives/*.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Humans write; agents append learnings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Orchestration&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Judgment: which directive applies, which inputs, did it work&lt;/td&gt;
&lt;td&gt;The agent; &lt;code&gt;CLAUDE.md&lt;/code&gt; is its job description&lt;/td&gt;
&lt;td&gt;You, rarely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Execution&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Determinism: fetch, parse, transform, deploy&lt;/td&gt;
&lt;td&gt;&lt;code&gt;executions/*.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Agents write; tests guard&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A directive never contains code. A script never contains policy ("ask the user if…"). The agent never contains either: it reads one and runs the other. Now the model makes three decisions instead of fifty executions, and decisions are the part a model is actually good at. A script that parses a response correctly once parses it correctly forever.&lt;/p&gt;

&lt;h3&gt;
  
  
  What a directive looks like
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Directive: Deploy the API&lt;/span&gt;

&lt;span class="gu"&gt;## Goal&lt;/span&gt;
Ship the current branch to the API server and prove it's live.

&lt;span class="gu"&gt;## Execution tools&lt;/span&gt;
executions/deploy_service.py --target api   # normalizes CRLF/BOM, uploads, restarts

&lt;span class="gu"&gt;## Edge cases &amp;amp; learnings&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Shell scripts committed from Windows had CRLF endings and
  failed with "bad interpreter". deploy_service.py now normalizes them.
&lt;span class="p"&gt;-&lt;/span&gt; The webhook key rotates on redeploy; the script refreshes it.

&lt;span class="gu"&gt;## Verification&lt;/span&gt;
GET /health returns 200 and the commit hash matches HEAD.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;em&gt;Edge cases &amp;amp; learnings&lt;/em&gt; section is where the return on investment lives. Every gotcha the agent meets is written down where the next session reads it &lt;em&gt;before&lt;/em&gt; acting, instead of being rediscovered at 2 a.m.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that makes it get better: self-annealing
&lt;/h2&gt;

&lt;p&gt;Structure alone stops the bleeding. What makes month three dramatically better than week one is a rule about failure: &lt;strong&gt;recovery is not a fix&lt;/strong&gt;. When something breaks, the agent runs a fixed loop, in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Diagnose the root cause&lt;/strong&gt;: keep asking "why" until you reach something that, once fixed, prevents recurrence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix it&lt;/strong&gt; in code, config or infrastructure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the test&lt;/strong&gt; that would have caught it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a standard&lt;/strong&gt; to the directive (and a lint, if the pattern is mechanically detectable).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then recover&lt;/strong&gt;: reprocess the stuck items, restart the service.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most agent sessions do step 5 first and stop. "Let's just reprocess the stuck items" and "I'll add a cron to retry failures" feel like progress and leave the cause in place. Making recovery the &lt;em&gt;last&lt;/em&gt; step means every failure leaves behind a test, a rule and sometimes a lint, so whole classes of mistakes stop being possible.&lt;/p&gt;

&lt;h3&gt;
  
  
  A real example from building our own store
&lt;/h3&gt;

&lt;p&gt;While building the checkout for our store, a secrets linter found a live Stripe key hard-coded in an old helper script in our own repo. Removing that one key would have been recovery. The fix was making &lt;code&gt;lint_secrets.py&lt;/code&gt; run before every commit and adding a line to &lt;code&gt;CLAUDE.md&lt;/code&gt; requiring it. That kind of mistake can no longer reach git in that repo, whoever (or whatever) is typing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to start today, for free
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Put a &lt;code&gt;CLAUDE.md&lt;/code&gt; (or &lt;code&gt;.cursorrules&lt;/code&gt; / &lt;code&gt;AGENTS.md&lt;/code&gt;) at your repo root that states the three layers and the self-annealing loop.&lt;/li&gt;
&lt;li&gt;Pick the one job your agent fails at most often. Write a directive for it with the six sections above, and point it at a script.&lt;/li&gt;
&lt;li&gt;Move the deterministic parts of that job into the script. Give the script a test.&lt;/li&gt;
&lt;li&gt;Tell the agent: &lt;em&gt;"Read CLAUDE.md, then run the deploy directive."&lt;/em&gt; Watch what it appends to &lt;em&gt;Edge cases &amp;amp; learnings&lt;/em&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We publish a free starter, &lt;a href="https://github.com/skibuff22/agent-ops-kit-lite" rel="noopener noreferrer"&gt;Agent Ops Kit Lite on GitHub&lt;/a&gt; (MIT), with the &lt;code&gt;CLAUDE.md&lt;/code&gt;, the operating-principles directive, the directive template and the secrets linter. It is enough to try the approach on one job this afternoon.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://calyx-ai.net/blog/why-ai-coding-agent-breaks-prod/" rel="noopener noreferrer"&gt;calyx-ai.net&lt;/a&gt;. Disclosure: I sell the full &lt;a href="https://calyx-ai.net/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=why-agents-break-prod" rel="noopener noreferrer"&gt;Agent Ops Kit&lt;/a&gt; ($29) — the extra directives, tested execution scripts and multi-agent templates. The free lite kit above stands on its own. This article was drafted with AI assistance and reviewed against the framework we run in production.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>devops</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
