<?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: Marcin Dudek</title>
    <description>The latest articles on DEV Community by Marcin Dudek (@marcindudekdev).</description>
    <link>https://dev.to/marcindudekdev</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%2F3952428%2F7fa26691-9eac-410d-866a-036a2d4ecd6c.png</url>
      <title>DEV Community: Marcin Dudek</title>
      <link>https://dev.to/marcindudekdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/marcindudekdev"/>
    <language>en</language>
    <item>
      <title>Hooks That Won't Let the AI Shoot Me in the Foot</title>
      <dc:creator>Marcin Dudek</dc:creator>
      <pubDate>Mon, 06 Jul 2026 10:06:43 +0000</pubDate>
      <link>https://dev.to/marcindudekdev/hooks-that-wont-let-the-ai-shoot-me-in-the-foot-20ae</link>
      <guid>https://dev.to/marcindudekdev/hooks-that-wont-let-the-ai-shoot-me-in-the-foot-20ae</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://marcindudek.dev/blog/claude-code-guardrails-pretooluse/" rel="noopener noreferrer"&gt;https://marcindudek.dev/blog/claude-code-guardrails-pretooluse/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;TL;DR&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;bash&lt;/code&gt; tool is a pair of scissors. The agent will run &lt;code&gt;rm -rf&lt;/code&gt; in the wrong place, or pipe a script off the internet into your shell, if a plausible task points that way. It has no skin in the game.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PreToolUse&lt;/code&gt; fires after the agent picks a tool and before the tool runs. My script sees the command while it's still just a string.&lt;/li&gt;
&lt;li&gt;The guardrails are &lt;strong&gt;deny-by-pattern&lt;/strong&gt;: block the handful of shapes I never want unattended, wave everything else through. Default is allow.&lt;/li&gt;
&lt;li&gt;The veto is &lt;code&gt;exit 2&lt;/code&gt;. Whatever I print to stderr goes back to the agent as the reason, so it reroutes instead of getting stuck.&lt;/li&gt;
&lt;li&gt;Pattern-matching catches the known-bad shapes; real isolation needs a sandbox. Every rule here is a scar from something that already broke once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Giving an agent a &lt;code&gt;bash&lt;/code&gt; tool is handing it a pair of scissors and saying "go, run". Most of the time it runs fine. Then one day it doesn't, because the model has no idea that &lt;code&gt;rm -rf&lt;/code&gt; in the wrong directory ends your afternoon, or that &lt;a href="https://www.lukespademan.com/blog/the-dangers-of-curlbash/" rel="noopener noreferrer"&gt;piping a script straight off the internet into your shell&lt;/a&gt; is how you get owned. It'll do either one without blinking if a plausible task leads there. It isn't reckless. It just has nothing at stake and no memory of the last time something like this bit you.&lt;/p&gt;

&lt;p&gt;This is part 2 of my series on Claude Code hooks. &lt;a href="https://marcindudek.dev/blog/claude-code-context-rot-handoff/" rel="noopener noreferrer"&gt;Part 1 was about context rot&lt;/a&gt;, the slow kind of damage where a session quietly degrades over an hour. This one is the fast kind: a single command that wrecks something in a second. The layer that catches it is &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;PreToolUse&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What PreToolUse actually is
&lt;/h2&gt;

&lt;p&gt;PreToolUse runs in the gap between the agent deciding to call a tool and the tool actually running. For a bash command, that gap is where my script gets to read the exact command while it's still just text. Claude Code passes it the tool name and the tool input on stdin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"tool_name"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;"tool_input"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"rm -rf /tmp/build"&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;One job: decide whether this runs. The bluntest answer is the exit code. &lt;code&gt;exit 2&lt;/code&gt; kills the call, and whatever the hook writes to stderr is handed back to the agent as the reason it was blocked. That last part matters more than it sounds, and I'll come back to it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;# hook on PreToolUse&lt;/span&gt;
&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="mf"&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="mf"&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;cmd&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"tool_input"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"command"&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="no"&gt;RISKY_PATTERN&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&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="o"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Blocked: use the safe wrapper instead"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&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="mf"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="k"&gt;exit&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;# exit 2 = veto; stderr is fed back to the agent&lt;/span&gt;

&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# no objection; normal permission flow continues&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A command from the agent hits the PreToolUse hook. If it matches a risky pattern it is denied with exit 2 and the reason is fed back to the agent, which reroutes. If it matches nothing, it runs.&lt;/p&gt;

&lt;p&gt;Agent calls Bash&lt;br&gt;
rm -rf /tmp/build&lt;/p&gt;

&lt;p&gt;PreToolUse hook&lt;br&gt;
risky pattern?&lt;/p&gt;

&lt;p&gt;hit&lt;/p&gt;

&lt;p&gt;miss&lt;/p&gt;

&lt;p&gt;DENY · exit 2&lt;br&gt;
stderr reason returns to the agent&lt;/p&gt;

&lt;p&gt;ALLOW&lt;br&gt;
command runs&lt;/p&gt;

&lt;p&gt;agent reads the reason, reroutes&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One command through a deny-hook: a match is vetoed with exit 2 and the reason is fed back so the agent reroutes; everything else just runs.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There's a structured form too, for when you want more than a yes or no. Print a &lt;code&gt;permissionDecision&lt;/code&gt; of &lt;code&gt;deny&lt;/code&gt;, &lt;code&gt;allow&lt;/code&gt;, or &lt;code&gt;ask&lt;/code&gt;, with a reason attached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="s2"&gt;"hookSpecificOutput"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s2"&gt;"hookEventName"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;"permissionDecision"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;"permissionDecisionReason"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Use the password manager, not a plaintext key."&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;For a plain block, &lt;code&gt;exit 2&lt;/code&gt; and one honest stderr line is the whole hook. I only reach for the JSON form when I want &lt;code&gt;ask&lt;/code&gt;, so a borderline command bounces to me for a yes or no instead of getting a flat refusal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why deny-by-pattern
&lt;/h2&gt;

&lt;p&gt;Every guard I run is a denylist, never an allowlist. Trying to enumerate every safe command is a game you lose. The set is effectively infinite, it changes daily, and you spend your life unblocking yourself. So I do the inverse. I write down the few shapes I never want running unattended, match against those, and let everything else through untouched. The default is yes; the hook only ever opens its mouth to say no.&lt;/p&gt;

&lt;p&gt;What makes this work is an asymmetry in the cost of being wrong. When the hook blocks something it shouldn't have, the price is one retry and a mildly confused agent. When it misses something it should have caught, the price is a deleted directory, a leaked key, or a server I have to go hunting for. Those two mistakes are not the same size. A short denylist that errs in the cheap direction beats a long allowlist that errs in the expensive one, every time.&lt;/p&gt;

&lt;p&gt;An allowlist of safe commands is infinite and you fight it daily. A denylist of dangerous shapes is short, specific, and grows only when something bites you. Be wrong in the direction that costs a retry, not the one that costs an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guard 1: secrets in plaintext
&lt;/h2&gt;

&lt;p&gt;The first one watches for secrets heading into plaintext. The moment a command looks like it's about to bake an API key, a password, or a token straight into a file, the hook stops it and tells the agent to stash the value in a password manager and read it back at runtime instead.&lt;/p&gt;

&lt;p&gt;This guard is boring, which is exactly the point. A key pasted into a tracked file is harmless right up until it gets committed, and then it lives in &lt;a href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/removing-sensitive-data-from-a-repository" rel="noopener noreferrer"&gt;git history forever&lt;/a&gt;. You can rotate the key. You can't un-leak it. Blocking the write costs nothing. Scrubbing a secret out of history after the fact costs a bad afternoon and a quiet jolt of panic when you realise how long it was sitting there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guard 2: filesystem discipline
&lt;/h2&gt;

&lt;p&gt;The next guard has nothing to do with danger, just mess. Agents scatter temp files everywhere: a throwaway script here, a screenshot there, a downloaded PDF dropped in whatever directory they happened to be standing in. Give that a week and you can't tell the junk from anything that matters.&lt;/p&gt;

&lt;p&gt;So a hook quietly catches those temp writes and points them at one per-project scratch directory. The agent thinks it wrote to a temp path; it actually landed somewhere I can wipe in a single command. Small thing. It's the difference between tidying up and doing archaeology.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guard 3: the risky shapes (every one is a scar)
&lt;/h2&gt;

&lt;p&gt;The third group is the interesting one, because I didn't design it. Every rule in it is a scar, a command shape that once did something I never asked for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Raw SSH straight to a box&lt;/strong&gt;, when there's a safe wrapper that should be used instead, so a fat-fingered hostname can't fire a command at the wrong server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Processes that open a listening port&lt;/strong&gt; and never get cleaned up, quietly stacking into a little graveyard of orphaned servers humming in the background.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writes that look like they're about to clobber&lt;/strong&gt; something that matters, where the path or the redirect just smells wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each one started as "huh, that shouldn't have happened" and ended as a line in a deny-hook so it can't happen the same way twice. The list isn't finished and never will be. It covers the mistakes I've made so far, which is a smaller and more honest claim than calling it done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this stops working
&lt;/h2&gt;

&lt;p&gt;Worth being straight about the limits. Matching patterns on a command string only goes so far. An agent that genuinely wanted to get around a regex could reword its way past it, and a hook can't catch what it never sees. If I needed real isolation I'd run the whole thing in a container or a throwaway VM and stop trusting the host at all.&lt;/p&gt;

&lt;p&gt;But that was never the threat I had. The thing I'm defending against is a helpful agent, moving fast, with no memory of my past mistakes. For that, a cheap layer that catches the known-bad shapes covers most of the risk for almost none of the effort. And it buys me something I didn't expect when I started: the rails are what let me hand over more rope. I'll leave a session grinding on a long task unattended precisely because I know the few things that would actually hurt are already blocked. The guardrails don't slow the agent down. They're what make it safe to let it go faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Copy the pattern, not my list
&lt;/h2&gt;

&lt;p&gt;Don't copy my rules. Your disasters won't be mine. Copy the shape:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the JSON on stdin, pull out &lt;code&gt;tool_input.command&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Match it against the one risky pattern this hook cares about.&lt;/li&gt;
&lt;li&gt;On a hit, &lt;code&gt;exit 2&lt;/code&gt; with a stderr message that says &lt;em&gt;what to do instead&lt;/em&gt;, not just "no".&lt;/li&gt;
&lt;li&gt;Otherwise &lt;code&gt;exit 0&lt;/code&gt; and get out of the way.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two things decide whether you keep a hook or rip it out in a week. The stderr message has to reroute the agent, not just stop it. "Blocked, use the safe wrapper instead" gets read and acted on; a bare "denied" just makes the agent try the identical thing again and burn a turn. And keep each hook small and single-purpose, one concern per file. A mega-hook stuffed with every pattern you've ever written is its own hazard, because the day it has a bug it fails closed for everything at once.&lt;/p&gt;

&lt;p&gt;Series: Claude Code hooks (5 parts)&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://marcindudek.dev/blog/claude-code-context-rot-handoff/" rel="noopener noreferrer"&gt;Context rot + handoff - how I stop sessions from rotting&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Guardrails - hooks that won't let the AI shoot me in the foot &lt;em&gt;(you're here)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marcindudek.dev/blog/claude-code-quality-gates-stop-hook/" rel="noopener noreferrer"&gt;Quality gates - forcing the agent to verify before it says "done"&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marcindudek.dev/blog/claude-code-prompt-gate-local-llm/" rel="noopener noreferrer"&gt;The prompt layer - a local LLM that reads my prompt before it hits Claude&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marcindudek.dev/blog/claude-code-ambient-hooks-sessions/" rel="noopener noreferrer"&gt;The ambient layer - the hooks you stop noticing&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All five parts are now live.&lt;/p&gt;

&lt;h3&gt;
  
  
  Get what I write next in your inbox
&lt;/h3&gt;

&lt;p&gt;Five posts on Claude Code hooks, now complete. Drop your email and I'll send whatever I write next. Just new posts, nothing else.&lt;/p&gt;

&lt;p&gt;Email address&lt;/p&gt;

&lt;p&gt;Subscribe&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;None of this came from a threat model. It accreted, one scar at a time, each small disaster turned into a rule so it can't land twice. That's the real reason it lives in a hook instead of my head: the hook remembers across every session, so I only have to learn each lesson once. Hand the agent the scissors. Just put a guard on the blade first.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>aiengineering</category>
    </item>
    <item>
      <title>The Hook That Won't Let the Agent Say "Done"</title>
      <dc:creator>Marcin Dudek</dc:creator>
      <pubDate>Mon, 06 Jul 2026 10:06:42 +0000</pubDate>
      <link>https://dev.to/marcindudekdev/the-hook-that-wont-let-the-agent-say-done-bj5</link>
      <guid>https://dev.to/marcindudekdev/the-hook-that-wont-let-the-agent-say-done-bj5</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://marcindudek.dev/blog/claude-code-quality-gates-stop-hook/" rel="noopener noreferrer"&gt;https://marcindudek.dev/blog/claude-code-quality-gates-stop-hook/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;TL;DR&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An AI agent says "done" the way it says everything else: confident, fluent, and often without having checked. The word is cheap to produce and expensive to trust.&lt;/li&gt;
&lt;li&gt;A line in the prompt asking it to verify is a request. A hook on &lt;code&gt;Stop&lt;/code&gt; is enforcement. The hook runs in the harness and the model gets no vote.&lt;/li&gt;
&lt;li&gt;The Stop hook can't read the model's intent, so it reads evidence instead: it checks the session transcript, or a small proof file the agent has to write, before it's allowed to end its turn.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;stop_hook_active&lt;/code&gt; is the escape hatch. Without it, a gate that can never be satisfied loops forever. The hook stands down after forcing one continuation.&lt;/li&gt;
&lt;li&gt;A second gate on &lt;code&gt;PostToolUse&lt;/code&gt; grades the code the agent just wrote and feeds the verdict back. That one only injects an opinion; the edit already ran. Only the Stop hook guards the exit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Done." "It works." "Perfect, all tests pass." Those are the most expensive words an AI agent will ever say to you, because most of the time it hasn't checked. It pattern-matched its way to a confident sentence and stopped. The code might be fine. It might also be broken in a way that costs you an hour to find, precisely because you trusted the word "done" and moved on to the next thing.&lt;/p&gt;

&lt;p&gt;This is part 3 of my series on Claude Code hooks. &lt;a href="https://marcindudek.dev/blog/claude-code-context-rot-handoff/" rel="noopener noreferrer"&gt;Part 1 was about context rot&lt;/a&gt;, the slow decay of a session that's been running too long. &lt;a href="https://marcindudek.dev/blog/claude-code-guardrails-pretooluse/" rel="noopener noreferrer"&gt;Part 2 was guardrails&lt;/a&gt;, catching one dangerous command before it runs. This one is about the gap between an agent saying it finished and the work actually being finished. The hook that closes that gap fires on &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;Stop&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A prompt asks. A hook enforces.
&lt;/h2&gt;

&lt;p&gt;I used to handle this with a line in the system prompt. Something like "always run the tests before you report success, and if you can't test, say so." It helps. It's also the first thing to evaporate when the context gets long or the task gets hard. A prompt is a request. The model is free to forget it, reinterpret it, or quietly decide this particular case is the exception. Under pressure it usually does exactly that.&lt;/p&gt;

&lt;p&gt;A hook works at a different layer. It runs in the harness, not inside the model, so the model doesn't get a vote. When the agent tries to end its turn, my code runs first and decides whether that's allowed. That's the whole reason this lives in a hook instead of a hopeful paragraph of good intentions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Stop hook sees
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;Stop&lt;/code&gt; hook fires the moment the agent decides it's done talking. Claude Code hands it a small JSON blob on stdin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"hook_event_name"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Stop"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;"transcript_path"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"/path/to/session.jsonl"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;"stop_hook_active"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two fields carry the weight. &lt;code&gt;transcript_path&lt;/code&gt; points at the full session log, so the hook can look back at what actually happened in the turn. &lt;code&gt;stop_hook_active&lt;/code&gt; tells me whether I've already forced a continuation once. I'll come back to that one, because it's what keeps this whole thing from turning into an infinite loop.&lt;/p&gt;

&lt;p&gt;The decision is top-level. To keep the agent working, the hook prints a block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"decision"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"block"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;"reason"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"No test run found this session. Run the suite and report the result before you stop."&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is one of the spots where it's easy to invent a field that doesn't exist. The Stop decision sits at the &lt;em&gt;top level&lt;/em&gt; of the JSON. Other hooks tuck their output inside a &lt;code&gt;hookSpecificOutput&lt;/code&gt; object; this one does not. That &lt;code&gt;reason&lt;/code&gt; string goes straight back to the agent as its next instruction. If you'd rather not assemble JSON, &lt;code&gt;exit 2&lt;/code&gt; with a single line on stderr does the same job. Either way the session doesn't end. The agent reads the reason and keeps going.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the hook knows the work was verified
&lt;/h2&gt;

&lt;p&gt;The interesting question is how a shell script decides whether anything was actually checked. It can't see the model's intent. So it goes looking for evidence instead. Two mechanisms, and you only need one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scan the transcript.&lt;/strong&gt; &lt;code&gt;transcript_path&lt;/code&gt; is right there in the input. The hook reads back through the session for a tool call that ran the test suite, or the linter, or the build, and checks whether it passed. No such call anywhere in the turn, no exit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Demand a marker.&lt;/strong&gt; Simpler and more honest: the agent has to write a small proof file before it's allowed to stop. It writes down the command it ran and the result it got. The hook checks that the file exists and is fresh. If the agent never ran anything, there's nothing to write, and the gate stays shut.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I lean on the marker. Grepping a transcript for "did a test really pass here" gets fuzzy fast, and fuzzy is the enemy of a gate. A file that says "ran X, got Y" is a thing I can check in one line and trust.&lt;/p&gt;

&lt;p&gt;The agent tries to stop. The Stop hook checks for proof of a test run. With no proof it blocks with decision block and the reason returns to the agent, which runs the suite and tries again. With proof present, the session ends.&lt;/p&gt;

&lt;p&gt;Agent says done&lt;br&gt;
"tests pass"&lt;/p&gt;

&lt;p&gt;Stop hook&lt;br&gt;
proof of a test?&lt;/p&gt;

&lt;p&gt;none&lt;/p&gt;

&lt;p&gt;found&lt;/p&gt;

&lt;p&gt;BLOCK · keep going&lt;br&gt;
reason returns to the agent&lt;/p&gt;

&lt;p&gt;ALLOW&lt;br&gt;
session ends&lt;/p&gt;

&lt;p&gt;agent runs the suite, writes proof&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The exit gate: the agent only gets to stop once there's proof a test actually ran. Otherwise the Stop hook hands back a reason and the session keeps going.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The infinite loop problem
&lt;/h2&gt;

&lt;p&gt;Here's the failure mode that bites everyone who builds one of these. The agent says done. The hook blocks: no proof. The agent has no way to produce proof, because there are no tests, or the task genuinely had nothing to run. It says done again. The hook blocks again. You've built a machine that can never stop, burning tokens in a circle.&lt;/p&gt;

&lt;p&gt;That's what &lt;code&gt;stop_hook_active&lt;/code&gt; is for. The first time my hook blocks, the harness flips that flag on the next &lt;code&gt;Stop&lt;/code&gt;. When I see it's already true, I know I forced a continuation once and the agent still came back empty-handed. So the hook stands down and lets the session end, usually after logging a quiet note that the gate was bypassed this time. A gate that can never be satisfied protects nothing and just hangs the session. The escape hatch has to be in the design from the first line. You learn that the hard way once, when your terminal locks up grinding out the same refusal over and over.&lt;/p&gt;

&lt;p&gt;Any hook that blocks an exit needs an answer to "what if the block is impossible to clear?" Without one, the first un-runnable task turns your quality gate into an infinite loop. &lt;code&gt;stop_hook_active&lt;/code&gt; is the harness handing you that answer for free.&lt;/p&gt;
&lt;h2&gt;
  
  
  Grading the code it just wrote
&lt;/h2&gt;

&lt;p&gt;The Stop hook guards the exit. A second gate works further upstream, on &lt;code&gt;PostToolUse&lt;/code&gt;, firing right after the agent edits a file. This one can't block, because the edit already happened by the time the hook runs, but it can talk back. After a write, the hook runs the changed file through a handful of cheap, mechanical checks: how deeply &lt;a href="https://en.wikipedia.org/wiki/Cyclomatic_complexity" rel="noopener noreferrer"&gt;nested&lt;/a&gt; the functions are, whether there are bare &lt;a href="https://en.wikipedia.org/wiki/Magic_number_(programming)" rel="noopener noreferrer"&gt;magic numbers&lt;/a&gt; with no name, whether the type hints quietly disappeared, whether a 200-line function is doing six jobs while pretending to do one. Each principle gets a rough score.&lt;/p&gt;

&lt;p&gt;Then it does the one thing that makes this worth running. It feeds the verdict back to the agent as &lt;code&gt;additionalContext&lt;/code&gt;, parked right next to the tool result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="s2"&gt;"hookSpecificOutput"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s2"&gt;"hookEventName"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"PostToolUse"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;"additionalContext"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Quality check: nesting 4 deep in handle(); 2 unnamed constants. Tidy before moving on."&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;Note where &lt;code&gt;additionalContext&lt;/code&gt; lives here: &lt;em&gt;inside&lt;/em&gt; &lt;code&gt;hookSpecificOutput&lt;/code&gt;. That's the opposite of the Stop hook's top-level &lt;code&gt;decision&lt;/code&gt;, and mixing them up is the quickest way to write a hook that silently does nothing. The agent sees "nesting is four deep, the constant on line 12 has no name" while the change is still warm, and it tends to clean things up on the next pass without being asked. The same checks lean on old, boring ideas like &lt;a href="https://en.wikipedia.org/wiki/KISS_principle" rel="noopener noreferrer"&gt;keeping it simple&lt;/a&gt;, which a tired model forgets the second the task gets interesting.&lt;/p&gt;

&lt;p&gt;Worth being precise about the limit. This gate has no veto. The file is already on disk by the time the hook runs. All it can do is hand back an opinion. But an opinion delivered at the right moment, before the agent has moved on, lands far better than a code review three days later when nobody remembers why the function looks like that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Forcing the recon before the writing
&lt;/h2&gt;

&lt;p&gt;The gates so far catch trouble after it exists. The cheapest bug is the one the agent never writes, and that means getting it to look before it leaps. A &lt;code&gt;UserPromptSubmit&lt;/code&gt; hook fires when I send a message, before the model sees it, and can staple a short instruction onto the front of my prompt through &lt;code&gt;additionalContext&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For anything that smells like an implementation task, it injects a tiny checklist: read the files you're about to change first, find the pattern that's already there and match it, state your plan in one line before you touch anything. It's the same advice I'd give a junior in their first week, except it arrives every single time instead of whenever I remember to say it. An agent that reads the surrounding code before writing produces code that looks like it belongs. One that skips that step writes a lonely island of a function in its own private style, and you pay for it at review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing the loop on todos
&lt;/h2&gt;

&lt;p&gt;The last gate is small and a little dumb and I'd never give it up. When the agent opens a todo list, three steps or five, it has a habit of doing the first two, declaring victory, and quietly abandoning the rest. So a hook watches for started-but-unclosed work and, on &lt;code&gt;Stop&lt;/code&gt;, treats an open todo the same as a missing test. You said you'd do five things, four are done, you don't get to stop yet.&lt;/p&gt;

&lt;p&gt;Half-finished is the worst state to leave a task in, because it wears the costume of finished. The code runs, the obvious path works, and the thing you actually asked for, the edge case buried in step five, is silently missing. A todo-enforcer turns "I'll get to it" into "you'll get to it now, before this session ends."&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this stops working
&lt;/h2&gt;

&lt;p&gt;None of this verifies that the code is correct. It verifies that the agent did the checking it claimed to do. A test suite that's all green and tests nothing will sail straight through. A marker file that says "ran the tests" when the tests are hollow gets believed all the same. These gates raise the floor. They make "done" mean "I at least ran something and it didn't blow up" instead of "I have a good feeling about this." They don't raise the ceiling. That part is still on me and on how good the tests are in the first place.&lt;/p&gt;

&lt;p&gt;But the floor is where the cheap, embarrassing failures live. The ones where the agent confidently ships code that doesn't even import. Catching those automatically, on every single turn, is worth a small stack of hooks that each do one boring thing well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build your own gate
&lt;/h2&gt;

&lt;p&gt;Don't copy my checklist. Your idea of "proof" won't be mine. Maybe it's a passing test, maybe a clean type-check, maybe a screenshot of the thing actually rendering. Copy the shape instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the JSON on stdin and pull out &lt;code&gt;transcript_path&lt;/code&gt; and &lt;code&gt;stop_hook_active&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Decide what counts as proof, then go looking for it. Scan the transcript for a check that passed, or require a fresh marker file the agent had to write before stopping.&lt;/li&gt;
&lt;li&gt;No proof? Block. Print a top-level &lt;code&gt;{"decision":"block","reason":"..."}&lt;/code&gt;, or &lt;code&gt;exit 2&lt;/code&gt; with one line on stderr. Make the reason say exactly what to run, so the agent acts instead of just retrying the same dead end.&lt;/li&gt;
&lt;li&gt;Proof is there? &lt;code&gt;exit 0&lt;/code&gt; and let the turn end.&lt;/li&gt;
&lt;li&gt;Always check &lt;code&gt;stop_hook_active&lt;/code&gt; first. If it's already true, stand down and let the session close. That single line is what separates a quality gate from an infinite loop.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Five lines of logic, one boring job. The same skeleton, pointed at whatever "done" is supposed to mean on your project.&lt;/p&gt;

&lt;p&gt;Series: Claude Code hooks (5 parts)&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://marcindudek.dev/blog/claude-code-context-rot-handoff/" rel="noopener noreferrer"&gt;Context rot + handoff - how I stop sessions from rotting&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marcindudek.dev/blog/claude-code-guardrails-pretooluse/" rel="noopener noreferrer"&gt;Guardrails - hooks that won't let the AI shoot me in the foot&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Quality gates - forcing the agent to verify before it says "done" &lt;em&gt;(you're here)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marcindudek.dev/blog/claude-code-prompt-gate-local-llm/" rel="noopener noreferrer"&gt;The prompt layer - a local LLM that reads my prompt before it hits Claude&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marcindudek.dev/blog/claude-code-ambient-hooks-sessions/" rel="noopener noreferrer"&gt;The ambient layer - the hooks you stop noticing&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All five parts are now live.&lt;/p&gt;

&lt;h3&gt;
  
  
  Get what I write next in your inbox
&lt;/h3&gt;

&lt;p&gt;Five posts on Claude Code hooks, now complete. Drop your email and I'll send whatever I write next. Just new posts, nothing else.&lt;/p&gt;

&lt;p&gt;Email address&lt;/p&gt;

&lt;p&gt;Subscribe&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;"Done" is a feeling the model has. It says nothing about whether the code actually works. A prompt asking it to verify is a request it's free to drop the instant the task gets hard. A hook that won't let the session end without proof isn't asking anything. That's the whole difference, and under pressure it's the only difference that holds.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>aiengineering</category>
    </item>
    <item>
      <title>A Local LLM Reads My Prompt Before Claude Does</title>
      <dc:creator>Marcin Dudek</dc:creator>
      <pubDate>Thu, 02 Jul 2026 13:05:00 +0000</pubDate>
      <link>https://dev.to/marcindudekdev/a-local-llm-reads-my-prompt-before-claude-does-lbl</link>
      <guid>https://dev.to/marcindudekdev/a-local-llm-reads-my-prompt-before-claude-does-lbl</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://marcindudek.dev/blog/claude-code-prompt-gate-local-llm/" rel="noopener noreferrer"&gt;https://marcindudek.dev/blog/claude-code-prompt-gate-local-llm/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;TL;DR&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The weak link in AI coding is usually the half-formed prompt I type at the end of a long day. The model can only work with what I hand it.&lt;/li&gt;
&lt;li&gt;A hook on &lt;code&gt;UserPromptSubmit&lt;/code&gt; fires the instant I hit enter, before Claude reads a single word. It hands short prompts to a small model running on my own machine, which answers one question: is this clear enough to act on?&lt;/li&gt;
&lt;li&gt;It runs locally on purpose. &lt;code&gt;UserPromptSubmit&lt;/code&gt; blocks the session for up to 30 seconds while it works, so a cloud round-trip on every short message would be unbearable. A small on-device model answers in about a second, costs nothing per call, and the prompt never leaves the laptop.&lt;/li&gt;
&lt;li&gt;The gate flags, it does not block. A vague prompt gets a one-line note stapled alongside it through &lt;code&gt;additionalContext&lt;/code&gt;; my words still reach Claude untouched. I would never let a vagueness score erase something I typed.&lt;/li&gt;
&lt;li&gt;If the local model is slow or down, the hook exits with a non-blocking error and the prompt goes through ungated. I lose the check, never the message.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I sat down to write this post, the one about the hook that reads my prompts, and typed: &lt;em&gt;"ok, time to write post #4 of the series about"&lt;/em&gt;. Then I trailed off and hit enter anyway. Before Claude saw a single word, a small model running on my own laptop read that prompt and flagged it: vague, missing context, says nothing about which series or what the post should actually cover. It was right. I had run out of sentence at the end of a long day, and the cheapest model in my stack caught it before the expensive one burned a turn guessing what I meant.&lt;/p&gt;

&lt;p&gt;This is part 4 of my series on Claude Code hooks. &lt;a href="https://marcindudek.dev/blog/claude-code-context-rot-handoff/" rel="noopener noreferrer"&gt;Part 1 was about context rot&lt;/a&gt; and the handoff that resets a session before it decays. &lt;a href="https://marcindudek.dev/blog/claude-code-guardrails-pretooluse/" rel="noopener noreferrer"&gt;Part 2 was guardrails&lt;/a&gt; that catch a dangerous command before it runs. &lt;a href="https://marcindudek.dev/blog/claude-code-quality-gates-stop-hook/" rel="noopener noreferrer"&gt;Part 3 was quality gates&lt;/a&gt; that won't let the agent say "done" without proof. Those all sit downstream of the model. This one sits upstream of it, on the one part of the whole pipeline I am personally responsible for: the prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  The weak link is the thing I type
&lt;/h2&gt;

&lt;p&gt;It's tempting to blame the model when a session goes sideways. Most of the time the model did exactly what I asked. The problem is that I asked badly. At 11pm I write three terse words, leave out the file, the constraint, and the goal, and then expect the agent to read my mind. It can't, so it guesses, and a confident guess off a thin prompt is how you get forty minutes of work pointed at the wrong target.&lt;/p&gt;

&lt;p&gt;I tried fixing this with a line in the system prompt, the same way I fix everything first: "if a request is unclear, ask before you act." It helps a little. It's also the first instruction to evaporate the moment the model gets a whiff of a plausible interpretation. A prompt is a request the model is free to ignore. So I stopped asking the model to police my input and moved the check to a place the model doesn't control: the moment between hitting enter and the model reading anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  A hook that runs before the model reads a word
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;UserPromptSubmit&lt;/code&gt;&lt;/a&gt; hook fires when I submit a message and before the model sees it. Claude Code hands it a small JSON blob on stdin with the prompt right there in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"hook_event_name"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"UserPromptSubmit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;"prompt"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ok, time to write post #4 of the series about"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;"cwd"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"/path/to/project"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From here the hook has a choice. It can block the prompt outright, which erases it from context entirely, or it can let the prompt through and staple extra context onto it. For my own input I almost always want the second one. I don't want to stop myself from typing. I want the agent to know when my prompt is thin before it commits to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small local model reads the prompt first
&lt;/h2&gt;

&lt;p&gt;Here is the part people raise an eyebrow at. For short prompts, the ones under about a hundred characters, which are reliably the half-baked ones, the hook hands my text to a separate, much smaller language model. A quantized &lt;a href="https://github.com/QwenLM/Qwen3" rel="noopener noreferrer"&gt;Qwen&lt;/a&gt; running entirely on the laptop through &lt;a href="https://github.com/ml-explore/mlx" rel="noopener noreferrer"&gt;Apple's MLX&lt;/a&gt;. It gets one job: read the prompt and decide whether it's clear enough to act on. If it isn't, it writes a single line saying what's missing.&lt;/p&gt;

&lt;p&gt;It has to run locally, and the reason is right there in the docs. &lt;code&gt;UserPromptSubmit&lt;/code&gt; blocks model processing while the hook runs, so it ships with a 30-second timeout instead of the usual ten minutes. Now imagine putting a cloud API call in that path. Every short message I send would stall behind a network round-trip and a queue, and I would spend my day watching a spinner. A small model on-device answers in roughly a second, costs nothing per call, works on a plane, and the prompt never leaves my machine. The latency budget alone forces this layer to be local.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flag, don't block
&lt;/h2&gt;

&lt;p&gt;The mechanism is the same one I leaned on in &lt;a href="https://marcindudek.dev/blog/claude-code-quality-gates-stop-hook/" rel="noopener noreferrer"&gt;part 3&lt;/a&gt;: &lt;code&gt;additionalContext&lt;/code&gt;. On a clean exit, the hook prints a small JSON object and whatever sits in &lt;code&gt;additionalContext&lt;/code&gt; rides into the conversation right alongside my prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"hookSpecificOutput"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"hookEventName"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"UserPromptSubmit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"additionalContext"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Heads up: this prompt is short and light on specifics (no target, no goal). Confirm scope before you run."&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;There is a more aggressive option, and I deliberately don't use it. Exiting with code 2 blocks the prompt and erases it from context completely. That's the right call for a genuinely dangerous prompt. It's the wrong call for a vague one, because a small model's opinion that my prompt "seems unclear" isn't worth deleting something I just typed. A false positive there would be infuriating: you write a message, the gate disagrees with its phrasing, and your words vanish. So the gate never blocks my input. It flags. Claude receives my three sleepy words &lt;em&gt;and&lt;/em&gt; the note, reads both, and asks the clarifying question instead of charging off a cliff. Which, for the record, is exactly what happened with this post.&lt;/p&gt;

&lt;p&gt;You submit a short prompt. A local model reads it before Claude does. If the prompt is clear it passes straight to Claude. If it is vague, the hook staples a one-line clarity note onto it as additionalContext, and Claude receives the prompt together with that note.&lt;/p&gt;

&lt;p&gt;You hit enter&lt;br&gt;
short prompt&lt;/p&gt;

&lt;p&gt;Local model&lt;br&gt;
clear enough?&lt;/p&gt;

&lt;p&gt;vague&lt;/p&gt;

&lt;p&gt;clear&lt;/p&gt;

&lt;p&gt;Staple a clarity note&lt;br&gt;
additionalContext&lt;/p&gt;

&lt;p&gt;Claude reads it&lt;br&gt;
your prompt + any note&lt;/p&gt;

&lt;p&gt;joined to your prompt&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The clarity gate: a vague prompt picks up a one-line note on the way through, a clear one passes straight to Claude. Either way your words reach the model untouched.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What else this prompt layer can do
&lt;/h2&gt;

&lt;p&gt;Once you own that sliver of time between hitting enter and the model reading, flagging vague prompts is just the first thing you can do with it. A few others I run on the same input layer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keyword to skill.&lt;/strong&gt; The hook scans the prompt for intent and, when it spots a clear match, pre-loads the right tool or skill so the agent shows up already holding what it needs instead of fumbling for it three messages in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context that arrives with the prompt.&lt;/strong&gt; The relevant project facts and past decisions get pulled in and handed to the model along with my message, so the prompt reaches Claude already carrying the context I would otherwise have to retype for the hundredth time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trimming the empty stall.&lt;/strong&gt; A small nudge so the agent acts on clear, unambiguous instructions instead of reflexively asking "should I go ahead and do the thing you just asked for?" Real decisions, the ones with a genuine tradeoff, still get a question, because I want to be asked about those. It is only the hollow permission-seeking on an obvious yes that gets trimmed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A small local model is a cheap, private pre-processor for everything you type. It does not need to be smart. It needs to be fast, free, and right next to the keyboard. Most of its value is in the boring, repetitive judgment you would never bother a frontier model with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this stops working
&lt;/h2&gt;

&lt;p&gt;A four-billion-parameter model is no genius, and I don't pretend otherwise. Sometimes it flags a prompt that was perfectly clear, and sometimes it waves through one that was mud. That's survivable precisely because the note is advisory. Claude can read "this looks vague" and reasonably decide it isn't. Nothing was blocked, nothing was erased. A wrong call from the small model costs me a sentence of context. That's the whole price.&lt;/p&gt;

&lt;p&gt;The failure mode I care more about is the gate getting in the way of the work. That's what the fail-open behavior is for. If the local model hangs or isn't running, the hook exits with a non-zero code that isn't 2, which Claude Code treats as a &lt;em&gt;non-blocking&lt;/em&gt; error: it shows a small notice and processes my prompt normally. The gate going dark never costs me my message. It just quietly stops checking until I notice. For a tool that sits in front of every single thing I type, that asymmetry is the whole design: a missed flag is a shrug, a swallowed prompt would be a real problem.&lt;/p&gt;

&lt;p&gt;And the honest limit: it can only read what I wrote. It catches the prompt that is vague on its face. It can't catch the prompt that is perfectly clear and perfectly wrong, or the crucial constraint I just forgot to type. &lt;a href="https://en.wikipedia.org/wiki/Garbage_in,_garbage_out" rel="noopener noreferrer"&gt;Garbage in, garbage out&lt;/a&gt; still rules. This just catches the garbage I can see in my own sentence before I make the expensive model eat it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build your own
&lt;/h2&gt;

&lt;p&gt;The skeleton is small. Point it at whatever "a good prompt" means for your work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Hook on &lt;code&gt;UserPromptSubmit&lt;/code&gt;. Read the JSON on stdin and pull out the &lt;code&gt;prompt&lt;/code&gt; field.&lt;/li&gt;
&lt;li&gt;Gate cheaply. Only bother for short or terse prompts, where the payoff is biggest. Let the long, detailed ones sail straight through untouched.&lt;/li&gt;
&lt;li&gt;Ask a local model one question: is this clear enough to act on, and if not, what is missing? Keep it on-device so you stay inside the 30-second timeout and your text never leaves the machine.&lt;/li&gt;
&lt;li&gt;Flag, don't block. Exit 0 and return the verdict in &lt;code&gt;hookSpecificOutput.additionalContext&lt;/code&gt;. Never &lt;code&gt;exit 2&lt;/code&gt; on your own input, because a false positive that erases your prompt is far worse than a prompt that was a little vague.&lt;/li&gt;
&lt;li&gt;Fail open. If the model is slow or down, let the prompt through. The gate is a helper standing next to the door. It was never the lock.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Series: Claude Code hooks (5 parts)&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://marcindudek.dev/blog/claude-code-context-rot-handoff/" rel="noopener noreferrer"&gt;Context rot + handoff - how I stop sessions from rotting&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marcindudek.dev/blog/claude-code-guardrails-pretooluse/" rel="noopener noreferrer"&gt;Guardrails - hooks that won't let the AI shoot me in the foot&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marcindudek.dev/blog/claude-code-quality-gates-stop-hook/" rel="noopener noreferrer"&gt;Quality gates - forcing the agent to verify before it says "done"&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;The prompt layer - a local LLM that reads my prompt before it hits Claude &lt;em&gt;(you're here)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marcindudek.dev/blog/claude-code-ambient-hooks-sessions/" rel="noopener noreferrer"&gt;The ambient layer - the hooks you stop noticing&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All five parts are now live.&lt;/p&gt;

&lt;h3&gt;
  
  
  Get what I write next in your inbox
&lt;/h3&gt;

&lt;p&gt;Five posts on Claude Code hooks, now complete. Drop your email and I'll send whatever I write next. Just new posts, nothing else.&lt;/p&gt;

&lt;p&gt;Email address&lt;/p&gt;

&lt;p&gt;Subscribe&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The model gets all the attention. The prompt gets almost none, and the prompt is the part I actually control. Putting a small, free, local model in front of the expensive one, to read what I typed before Claude does, turned out to be one of the cheapest upgrades I've made to this setup, and most days it earns its keep before I've finished my coffee. The post you just read exists because that gate stopped me from firing three sleepy words into a fresh session and calling it a brief.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>aiengineering</category>
    </item>
    <item>
      <title>The Hooks You Stop Noticing: Claude Code's Ambient Layer</title>
      <dc:creator>Marcin Dudek</dc:creator>
      <pubDate>Thu, 02 Jul 2026 13:04:59 +0000</pubDate>
      <link>https://dev.to/marcindudekdev/the-hooks-you-stop-noticing-claude-codes-ambient-layer-4me8</link>
      <guid>https://dev.to/marcindudekdev/the-hooks-you-stop-noticing-claude-codes-ambient-layer-4me8</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://marcindudek.dev/blog/claude-code-ambient-hooks-sessions/" rel="noopener noreferrer"&gt;https://marcindudek.dev/blog/claude-code-ambient-hooks-sessions/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;TL;DR&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The first four posts in this series were about hooks that stop things: block a command, hold an exit, flag a vague prompt. This last one is about hooks that do none of that. They run quietly and change how it feels to work with the agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TTS narration.&lt;/strong&gt; A hook on &lt;code&gt;Stop&lt;/code&gt; speaks the result of a finished turn, and one on &lt;code&gt;Notification&lt;/code&gt; speaks up when a session needs me. I hear state changes instead of watching the terminal for them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A status file.&lt;/strong&gt; Each session writes its state on &lt;code&gt;SessionStart&lt;/code&gt; and refreshes it every time it ends a turn. Glance at one small file and see what every parallel session is doing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sessions leaving notes.&lt;/strong&gt; A shared queue file, checked at session start and between turns, lets parallel sessions hand each other messages without me playing courier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Showing up equipped.&lt;/strong&gt; &lt;code&gt;SessionStart&lt;/code&gt; can pre-load a tool, re-scan skills with &lt;code&gt;reloadSkills&lt;/code&gt;, and inject this project's conventions through &lt;code&gt;additionalContext&lt;/code&gt;, so the agent starts the session already knowing the local rules.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The hooks in the first four parts of this series all announce themselves. A blocked command throws an error in your face. A held exit makes the agent keep working when it wanted to stop. A flagged prompt staples a visible note onto your message. You feel each one the moment it fires. The hooks in this post are the opposite. The best one I run is a hook I forget exists, because it never interrupts anything. It just sits in the background and quietly changes the texture of working with the agent.&lt;/p&gt;

&lt;p&gt;This is part 5, the last one. &lt;a href="https://marcindudek.dev/blog/claude-code-context-rot-handoff/" rel="noopener noreferrer"&gt;Part 1 was context rot&lt;/a&gt; and the handoff that resets a session before it decays. &lt;a href="https://marcindudek.dev/blog/claude-code-guardrails-pretooluse/" rel="noopener noreferrer"&gt;Part 2 was guardrails&lt;/a&gt; that catch a dangerous command before it runs. &lt;a href="https://marcindudek.dev/blog/claude-code-quality-gates-stop-hook/" rel="noopener noreferrer"&gt;Part 3 was quality gates&lt;/a&gt; that won't let the agent say "done" without proof. &lt;a href="https://marcindudek.dev/blog/claude-code-prompt-gate-local-llm/" rel="noopener noreferrer"&gt;Part 4 was the prompt layer&lt;/a&gt;, a local model reading my input before Claude does. Those were all about control. This one is about comfort, and it lives on three events that never block a thing: &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;SessionStart&lt;/code&gt;, &lt;code&gt;Notification&lt;/code&gt;, and &lt;code&gt;Stop&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hooks that whisper instead of shout
&lt;/h2&gt;

&lt;p&gt;Every hook in the earlier parts had a verdict. Allow this, block that, here's a note you should read. The ambient ones have no verdict at all. They observe an event and produce a side effect somewhere off to the side: a sound, a line in a file, a message dropped in a queue. The agent's turn is completely unaffected. Nothing is held, nothing is denied, nothing is injected into the conversation unless I choose to.&lt;/p&gt;

&lt;p&gt;That sounds like a smaller thing than a guardrail, and on any single turn it is. The payoff shows up over a week. After living with these for a while, the hooks stopped feeling like rules I imposed on the agent and started feeling like the agent's interface to me. They are how I keep a sense of what's going on without staring at a wall of scrolling text. That shift, from policing the agent to quietly living alongside it, is the whole reason I wanted to end the series here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hearing the agent instead of watching it
&lt;/h2&gt;

&lt;p&gt;The first ambient hook I ever wrote reads the agent's turns out loud. There was nothing clever behind it. I was tired of babysitting a terminal. I'd kick off a long task, then sit there watching lines scroll, afraid to look away in case it finished or got stuck. &lt;a href="https://en.wikipedia.org/wiki/Speech_synthesis" rel="noopener noreferrer"&gt;Text-to-speech&lt;/a&gt; let me look away.&lt;/p&gt;

&lt;p&gt;Here's the part the marketing version of this gets wrong, and I want to be precise about it. There is no hook in Claude Code that fires on every little thing the agent does mid-turn. The events I have to work with are turn boundaries and notifications. So the narration isn't a live play-by-play of every file edit. It's narration of &lt;em&gt;state changes&lt;/em&gt;, which turns out to be exactly what I actually want.&lt;/p&gt;

&lt;p&gt;Two events carry it. &lt;code&gt;Stop&lt;/code&gt; fires when the agent finishes a turn, so a hook there can speak a short summary of what just happened: "finished, tests green" or "stopped to ask you something." And &lt;code&gt;Notification&lt;/code&gt; fires when Claude Code wants my attention, which is the real moment I need to hear about. The input is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"hook_event_name"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Notification"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;"message"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Claude needs your permission to run a command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;"session_id"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;"cwd"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"/path/to/project"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing to know before you build on this: the &lt;code&gt;Notification&lt;/code&gt; hook is &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;informational only&lt;/a&gt;. It cannot block, it cannot change anything, its exit code is ignored. That's perfect for narration, because narration should never be able to interfere with the work. The hook reads the &lt;code&gt;message&lt;/code&gt; field, pipes it to a text-to-speech voice, and gets out of the way. It also fires on a fixed set of moments rather than continuously, things like the agent going idle or asking for permission, so what I hear is "your turn" and "I'm done," never a firehose. I can go make coffee, hear the voice say the build passed, and wander back. The terminal stopped being something I have to guard.&lt;/p&gt;

&lt;p&gt;The trick with audio is to narrate state changes rather than activity. You don't want to hear every tool call. You want to hear the two moments that matter: it needs you, or it's done. &lt;code&gt;Notification&lt;/code&gt; and &lt;code&gt;Stop&lt;/code&gt; map onto exactly those two, which is why this works as well as it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small file that says what each session is doing
&lt;/h2&gt;

&lt;p&gt;The minute you run more than one Claude session at a time, you lose the plot. Three terminal tabs, each grinding on something, and no way to tell at a glance which one is waiting on you and which one is happily working. I fixed that with a hook and a single tiny file.&lt;/p&gt;

&lt;p&gt;On &lt;code&gt;SessionStart&lt;/code&gt;, each session writes a line into a shared status file: who I am, what I'm working on, started just now. The event hands the hook everything it needs to identify itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"hook_event_name"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"SessionStart"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;"source"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"startup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;"session_id"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;"cwd"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"/path/to/project"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;source&lt;/code&gt; field tells the hook how the session began. It's one of &lt;code&gt;startup&lt;/code&gt;, &lt;code&gt;resume&lt;/code&gt;, &lt;code&gt;clear&lt;/code&gt;, or &lt;code&gt;compact&lt;/code&gt;, so a hook can write "fresh start" differently from "resumed an old session" if it cares to. Then, on every &lt;code&gt;Stop&lt;/code&gt;, the same session updates its line: still working, or now idle and waiting. The status file becomes a live board. One small file each session keeps current, and a glance at it tells me the state of everything running. No dashboard server, no daemon, just a file that every session politely keeps honest as it goes.&lt;/p&gt;

&lt;p&gt;The reason this is a hook and not a feature I bolted onto my workflow is that I will absolutely forget to update a status by hand. The hook never forgets. It fires on &lt;code&gt;SessionStart&lt;/code&gt; and &lt;code&gt;Stop&lt;/code&gt; whether I'm paying attention or not, so the board is never stale. That's the recurring theme of this whole series: the things you mean to do every time are the things a hook should do for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sessions leaving notes for each other
&lt;/h2&gt;

&lt;p&gt;Once each session is writing where I can see it, the next thought is obvious: can they talk to each other? Not in real time, they're separate processes that don't share memory. But they can leave notes. A shared &lt;a href="https://en.wikipedia.org/wiki/Message_queue" rel="noopener noreferrer"&gt;queue file&lt;/a&gt;, one session appends a message addressed to another, and the recipient picks it up the next time one of its hooks runs.&lt;/p&gt;

&lt;p&gt;The honest mechanical detail here matters, because it's easy to imagine this as live chat and it isn't. Hooks are event-triggered rather than background daemons. A session can only check its inbox at the moments its hooks fire: at &lt;code&gt;SessionStart&lt;/code&gt;, it reads any messages left while it was away, and on &lt;code&gt;Stop&lt;/code&gt;, between turns, it peeks at the queue again. So the delivery is "the next time you come up for air" rather than "instantly." In practice that's plenty. One session finishes a piece of research and leaves a note for another that's been waiting on it; the waiting session sees it the next time it ends a turn and picks up the thread. I stopped being the courier carrying findings from one tab to another.&lt;/p&gt;

&lt;p&gt;Two parallel Claude sessions and an ambient layer between them. On SessionStart each session writes its state to a shared status file and reads any queued messages. On Stop, at each turn boundary, a session refreshes its status, leaves a note in the queue for another session, and speaks the result out loud through text to speech. You glance at the status file and hear the narration without watching the terminal.&lt;/p&gt;

&lt;p&gt;Session A&lt;br&gt;
SessionStart / Stop&lt;/p&gt;

&lt;p&gt;Session B&lt;br&gt;
SessionStart / Stop&lt;/p&gt;

&lt;p&gt;Status file&lt;br&gt;
who, what, idle?&lt;/p&gt;

&lt;p&gt;Message queue&lt;br&gt;
notes between sessions&lt;/p&gt;

&lt;p&gt;You glance&lt;br&gt;
one quick look&lt;/p&gt;

&lt;p&gt;You hear it&lt;br&gt;
TTS on Stop&lt;/p&gt;

&lt;p&gt;read state&lt;br&gt;
speak result&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The ambient layer: sessions write state and leave notes at their SessionStart and Stop boundaries. You take it in by glancing at one file and listening, instead of watching every terminal.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Showing up already equipped
&lt;/h2&gt;

&lt;p&gt;The other thing &lt;code&gt;SessionStart&lt;/code&gt; is good for is making the agent useful from its first message instead of its fifth. A fresh session is a blank slate. It doesn't know which project it's in, what the local rules are, or which tools this particular job needs. Normally I'd spend the first few exchanges telling it. A hook can hand it all of that before I type anything.&lt;/p&gt;

&lt;p&gt;There are three concrete moves here, and unlike the narration story, these come straight from documented fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Inject the local conventions.&lt;/strong&gt; A &lt;code&gt;SessionStart&lt;/code&gt; hook can return &lt;code&gt;additionalContext&lt;/code&gt;, the same field &lt;a href="https://marcindudek.dev/blog/claude-code-prompt-gate-local-llm/" rel="noopener noreferrer"&gt;the prompt gate from part 4&lt;/a&gt; uses, and whatever it returns lands in the agent's context for the session. I use it to feed in this project's git rules, the report format I expect, the conventions that would otherwise need repeating every single time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-scan skills.&lt;/strong&gt; The hook output supports a &lt;code&gt;reloadSkills&lt;/code&gt; boolean. Set it and Claude Code re-scans for skills after the hook runs, so a tool I just added or a project-specific skill is available immediately rather than after a restart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-load a tool.&lt;/strong&gt; If a job reliably needs a particular tool ready, a browser driver, say, the hook can warm it up at session start so the agent shows up already holding it instead of fumbling for it three messages in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The shape of the output is the familiar one. Print JSON, and the context rides into the session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"hookSpecificOutput"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"hookEventName"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"SessionStart"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"additionalContext"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Project conventions: branch off main, never commit to it directly. Reports go in the project report folder, not the repo root."&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;Note the casing, because it's a silent-failure trap. The input field is &lt;code&gt;hook_event_name&lt;/code&gt; in snake_case; the output key is &lt;code&gt;hookEventName&lt;/code&gt; in camelCase. Get one wrong and the hook runs, exits clean, and does precisely nothing, which is the most annoying kind of bug to chase. If you'd rather skip the JSON entirely, a &lt;code&gt;SessionStart&lt;/code&gt; hook can just print plain text to stdout on a clean exit and Claude Code folds that into the context too. The JSON form is for when you also want to flip &lt;code&gt;reloadSkills&lt;/code&gt; or set a session title.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this stops working
&lt;/h2&gt;

&lt;p&gt;Ambient hooks fail quietly, which is both their charm and their risk. A guardrail that breaks throws an error you can't miss. A status writer that breaks just stops updating, and you might not notice for an hour that the board has gone stale and you've been trusting a frozen snapshot. So the failure mode flips compared to the earlier posts: there, a broken hook was loud and safe; here, a broken hook is silent and quietly misleading. I keep these dead simple for exactly that reason. The more an ambient hook does, the more ways it has to lie to you without saying a word.&lt;/p&gt;

&lt;p&gt;The other limit is restraint. It is genuinely tempting to narrate everything, write five status files, wire up elaborate cross-session protocols. Don't. The value of the ambient layer is that it's quiet. The moment it starts demanding attention, narrating noise, filling files you have to parse, it's just another thing shouting at you, and you've lost the one property that made it worth building. The good version fades into the background. The bad version becomes a second job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build your own
&lt;/h2&gt;

&lt;p&gt;The pattern is the smallest in the whole series, because these hooks don't decide anything:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick an event with no verdict: &lt;code&gt;SessionStart&lt;/code&gt; for setup, &lt;code&gt;Stop&lt;/code&gt; for turn boundaries, &lt;code&gt;Notification&lt;/code&gt; for "needs you" moments.&lt;/li&gt;
&lt;li&gt;Read the JSON on stdin. Pull &lt;code&gt;source&lt;/code&gt; on &lt;code&gt;SessionStart&lt;/code&gt;, &lt;code&gt;message&lt;/code&gt; on &lt;code&gt;Notification&lt;/code&gt;, &lt;code&gt;session_id&lt;/code&gt; when you need to tell sessions apart.&lt;/li&gt;
&lt;li&gt;Produce a side effect off to the side: speak a line, append to a status file, drop a note in a shared queue. Never touch the agent's turn.&lt;/li&gt;
&lt;li&gt;To feed the session at startup, return &lt;code&gt;hookSpecificOutput.additionalContext&lt;/code&gt; (camelCase) or just print plain text. Add &lt;code&gt;reloadSkills&lt;/code&gt; if you need skills re-scanned.&lt;/li&gt;
&lt;li&gt;Keep it boring and keep it quiet. An ambient hook earns its place by being something you stop noticing, not something you have to manage.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Series: Claude Code hooks (5 parts)&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://marcindudek.dev/blog/claude-code-context-rot-handoff/" rel="noopener noreferrer"&gt;Context rot + handoff - how I stop sessions from rotting&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marcindudek.dev/blog/claude-code-guardrails-pretooluse/" rel="noopener noreferrer"&gt;Guardrails - hooks that won't let the AI shoot me in the foot&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marcindudek.dev/blog/claude-code-quality-gates-stop-hook/" rel="noopener noreferrer"&gt;Quality gates - forcing the agent to verify before it says "done"&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marcindudek.dev/blog/claude-code-prompt-gate-local-llm/" rel="noopener noreferrer"&gt;The prompt layer - a local LLM that reads my prompt before it hits Claude&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;The ambient layer - the hooks you stop noticing &lt;em&gt;(you're here)&lt;/em&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the series. Five posts, from the loud hooks to the quiet ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  That's the series
&lt;/h3&gt;

&lt;p&gt;Five posts on the hooks that keep Claude Code honest. Drop your email and I'll send whatever I write next. Just new posts, nothing else.&lt;/p&gt;

&lt;p&gt;Email address&lt;/p&gt;

&lt;p&gt;Subscribe&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole series, in one line each
&lt;/h2&gt;

&lt;p&gt;Five posts, and a shape underneath them. It started with &lt;a href="https://marcindudek.dev/blog/claude-code-context-rot-handoff/" rel="noopener noreferrer"&gt;context rot&lt;/a&gt;: a session that runs too long quietly gets worse, so a hook resets it before it decays. Then &lt;a href="https://marcindudek.dev/blog/claude-code-guardrails-pretooluse/" rel="noopener noreferrer"&gt;guardrails&lt;/a&gt;, catching the one dangerous command before it runs. Then &lt;a href="https://marcindudek.dev/blog/claude-code-quality-gates-stop-hook/" rel="noopener noreferrer"&gt;quality gates&lt;/a&gt;, refusing to let "done" mean anything less than proof. Then &lt;a href="https://marcindudek.dev/blog/claude-code-prompt-gate-local-llm/" rel="noopener noreferrer"&gt;the prompt layer&lt;/a&gt;, a local model reading my half-formed input before the expensive one ever sees it. And finally this, the ambient layer, where the hooks stop being walls I built around the agent and become the way I keep a calm sense of what it's doing. Each one is a small piece of judgment I got tired of supplying by hand, handed to the deterministic part of the system that never gets tired and never forgets. That, more than any single hook, is what the whole series was about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The loud hooks get the credit. They block the disaster, hold the exit, catch the bad prompt, and you feel grateful the moment they fire. The quiet ones get none of that, and they might be the ones I'd miss most if they vanished. A voice that tells me the build passed while I'm across the room. A file that shows me what four sessions are doing in one glance. Sessions that hand each other notes so I don't have to. None of it blocks anything. All of it changed how it feels to work with the agent, which is the part the loud hooks never touch.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>aiengineering</category>
    </item>
    <item>
      <title>The Podcast Invite That Was Actually Malware</title>
      <dc:creator>Marcin Dudek</dc:creator>
      <pubDate>Thu, 02 Jul 2026 13:04:12 +0000</pubDate>
      <link>https://dev.to/marcindudekdev/the-podcast-invite-that-was-actually-malware-23b1</link>
      <guid>https://dev.to/marcindudekdev/the-podcast-invite-that-was-actually-malware-23b1</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://marcindudek.dev/blog/fake-podcast-invite-phishing/" rel="noopener noreferrer"&gt;https://marcindudek.dev/blog/fake-podcast-invite-phishing/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;TL;DR&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A verified X account with thousands of followers invited me to a podcast. Flattering, personalized, professional.&lt;/li&gt;
&lt;li&gt;The "Google Meet link" in the follow-up email led to a fake Meet page on a free website builder - real Meet links are always on &lt;code&gt;meet.google.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The page used a browser protocol-handler trick to push a self-signed "meeting app" installer - an infostealer that goes after passwords, sessions, and crypto wallets.&lt;/li&gt;
&lt;li&gt;Rule of thumb: &lt;strong&gt;no real meeting ever requires you to install something or run a command to join.&lt;/strong&gt; Meet, Zoom, and Teams all work in the browser.&lt;/li&gt;
&lt;li&gt;This is a real attack I received and reported in June 2026 - I've left out all identifying details on purpose. What matters is the method, not who ran it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I got invited to a podcast this week. The host had done his homework: he knew how long I've been in the industry, name-dropped the tech I actually work with, and said my profile was "exactly what his audience wants to hear." Verified account, blue check, a few thousand followers, registered years ago.&lt;/p&gt;

&lt;p&gt;One problem. The podcast didn't exist, and the "Google Meet link" was a delivery mechanism for malware built to empty browser sessions and crypto wallets.&lt;/p&gt;

&lt;p&gt;I didn't get caught, but I want to walk you through exactly how it works, because this one is &lt;em&gt;good&lt;/em&gt;. This isn't the usual "DEAR SIR, URGENT BUSINESS PROPOSAL" spam. It's a targeted, researched, multi-step con, and there's been a whole wave of these fake-meeting attacks through 2025 and 2026. If you have any kind of public track record - a blog, a GitHub, a conference talk - you're on the target list.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Attack Unfolds
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: The flattering DM
&lt;/h3&gt;

&lt;p&gt;It starts with a direct message on X. A verified account - blue check, thousands of followers, registered years ago - says they run a podcast and would love to have you on as a guest. The message references your real work. This is the part that disarms people: it doesn't read like spam because it isn't mass-sent. Someone (or someone's AI) actually researched you.&lt;/p&gt;

&lt;p&gt;Tailored flattery is the whole trick. The more accurately a stranger describes your career, the more you should wonder what they want from you next.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Moving off the platform
&lt;/h3&gt;

&lt;p&gt;You agree to a date, they ask for your email "to send the calendar invite." The email that arrives looks like a normal meeting invitation with a "Join with Google Meet" button. Two details are off, though both are easy to miss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The sender is a &lt;strong&gt;throwaway free email address&lt;/strong&gt; with a name that doesn't match the X account. Different person entirely, no domain, no podcast branding.&lt;/li&gt;
&lt;li&gt;The button doesn't go to &lt;code&gt;meet.google.com&lt;/code&gt;. It goes to a page &lt;em&gt;hosted on a free website builder&lt;/em&gt;, dressed up to look like the Meet lobby.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That second point is worth repeating, because it's the single most reliable check in this whole story: &lt;strong&gt;a real Google Meet link is always on the &lt;code&gt;meet.google.com&lt;/code&gt; domain.&lt;/strong&gt; Not "almost", not "usually". Always. Anything else - a lookalike subdomain, a free-hosting page, a shortened URL that unwraps to something Google-ish - isn't Meet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: "Just be with your PC"
&lt;/h3&gt;

&lt;p&gt;Before the call, the "host" asks a strange question: will you be at your computer, on Windows? I asked why that mattered for a podcast. The full answer I got was: &lt;em&gt;"just be with your pc."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here's why they care. The whole attack depends on you sitting at a desktop machine during the "meeting", so that when the fake Meet page tells you to install something or run a "fix", you can actually do it. A phone won't execute their payload. A real podcast host couldn't care less what device you join from.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: The trap springs
&lt;/h3&gt;

&lt;p&gt;The fake Google Meet page is where the technical part kicks in. Instead of a meeting, you get one of two endings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The installer.&lt;/strong&gt; Through a browser protocol-handler trick, the page force-opens a URL in a specific browser and pushes a "meeting application" install. The package is self-signed - no trusted publisher, just a certificate the attacker generated themselves. The "app" is an infostealer: it grabs saved passwords, browser session cookies (which let attackers walk into your logged-in accounts without ever needing your password), and crypto wallet data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The login.&lt;/strong&gt; Some variants skip the malware and simply ask you to "sign in with Google to join the waiting room." That's a straight account-takeover phish.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either way, the moment you comply, it's over. Sessions, passwords, wallets - gone in seconds, quietly, while you sit there waiting for a podcast host who was never going to show up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 7 Red Flags, In Order of Appearance
&lt;/h2&gt;

&lt;p&gt;Looking back at the whole exchange, the warning signs were there from the first minute. Here's the checklist version:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Identity mismatch.&lt;/strong&gt; One name on X, a different name in the email, a random free email address as the sender. Real podcasts have domains, websites, and consistent branding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No public footprint.&lt;/strong&gt; A real show has episodes you can listen to, past guests you can find, a page that existed before last month. This one had nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A "Meet" link outside &lt;code&gt;meet.google.com&lt;/code&gt;.&lt;/strong&gt; The number one tell. Hover before you click and read the actual domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Be at your computer" pressure.&lt;/strong&gt; No legitimate meeting cares about your operating system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any request to install or run something to join.&lt;/strong&gt; Meet, Zoom, and Teams all work in a plain browser tab. "Install our meeting client to continue" is the attack, every single time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The blue check means nothing.&lt;/strong&gt; Verified accounts get bought, sold, and hijacked. Followers can be purchased by the thousand. Account age and verification prove nothing here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flattery tuned precisely to your CV.&lt;/strong&gt; That's research, and research costs effort. Ask yourself why a stranger invested it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How to Not Get Caught
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never install a "meeting client" sent by someone you've never met.&lt;/strong&gt; Every mainstream conferencing tool runs in the browser, and if you do use desktop apps, you already have them installed. There's no scenario where joining a podcast requires new software from a link in an email.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never run "fix" commands a meeting page gives you.&lt;/strong&gt; A page that tells you to paste a command into your terminal, PowerShell, or the Windows Run dialog "to fix your microphone" is doing something else entirely. This pattern - known as a ClickFix attack - is one of the most effective malware delivery tricks of the past two years precisely because it makes &lt;em&gt;you&lt;/em&gt; do the installation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the person through an independent channel.&lt;/strong&gt; Look up the podcast. Listen to thirty seconds of a past episode. Find the host's name on the show's website and contact them there. A real host will be happy you checked; a fake one will evaporate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read domains before you click.&lt;/strong&gt; Hover over the button, look at the status bar, read the actual hostname right-to-left. &lt;code&gt;meet.google.com&lt;/code&gt; is Meet. A free site-builder URL with "meet" somewhere in the path isn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;If you suspect you already ran something, do this now, in this order:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Disconnect the machine&lt;/strong&gt; from the network immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Change critical passwords from a different, clean device&lt;/strong&gt; - email first, then anything tied to money or code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat every browser session as compromised:&lt;/strong&gt; log out everything, everywhere, and enable two-factor authentication as you go.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you keep crypto on that machine, consider the wallet burned&lt;/strong&gt; - migrate funds from a clean device.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then scan or reinstall.&lt;/strong&gt; Modern infostealers finish their job in the first minute, so speed matters more than forensics.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Report it.&lt;/strong&gt; Report the account on X, report the phishing page to &lt;a href="https://safebrowsing.google.com/safebrowsing/report_phish/" rel="noopener noreferrer"&gt;Google Safe Browsing&lt;/a&gt; and to the abuse channel of whatever free host it sits on, forward the email as phishing in your mail client. It takes five minutes and it genuinely burns the attacker's infrastructure - these campaigns reuse the same pages and accounts against many targets, and takedowns hurt them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why You, Specifically
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable part. This scam targets people &lt;em&gt;because&lt;/em&gt; they have a visible body of work. Developers, founders, writers - anyone whose career is public enough to research and whose machine is likely to hold something worth stealing: GitHub sessions, cloud consoles, deploy keys, crypto wallets, client access.&lt;/p&gt;

&lt;p&gt;Fifteen-plus years in this industry, and I still felt the pull of "someone noticed my work." That's the hook. It works on experienced people precisely because it flatters the thing we're proudest of. You won't out-think the scammer in the moment, so don't try. What works is mechanical rules that don't bend to flattery: real Meet lives on &lt;code&gt;meet.google.com&lt;/code&gt;, real meetings never require installs, and real hosts survive a background check.&lt;/p&gt;

&lt;p&gt;Stay paranoid. It's cheaper than the alternative. It's the same instinct that once made me dig into a weird metric and catch an attack in progress - I wrote that one up in &lt;a href="https://marcindudek.dev/blog/xmlrpc-brute-force-cache-rate/" rel="noopener noreferrer"&gt;spotting an XML-RPC brute force through a dropping cache hit rate&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And if you know someone with a public profile - a blog, a GitHub, a few conference talks - send them this post. The next "podcast invite" in their DMs might be this exact play, and the cheapest defense is having seen it once before.&lt;/p&gt;

</description>
      <category>security</category>
      <category>socialengineering</category>
    </item>
    <item>
      <title>Claude Code Quietly Taught Itself to Stop Waiting for You</title>
      <dc:creator>Marcin Dudek</dc:creator>
      <pubDate>Thu, 02 Jul 2026 13:04:09 +0000</pubDate>
      <link>https://dev.to/marcindudekdev/claude-code-quietly-taught-itself-to-stop-waiting-for-you-4fg0</link>
      <guid>https://dev.to/marcindudekdev/claude-code-quietly-taught-itself-to-stop-waiting-for-you-4fg0</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://marcindudek.dev/blog/claude-code-afk-mode/" rel="noopener noreferrer"&gt;https://marcindudek.dev/blog/claude-code-afk-mode/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;TL;DR&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Claude Code shipped &lt;strong&gt;AFK mode&lt;/strong&gt; (away from keyboard) in &lt;strong&gt;v2.1.198&lt;/strong&gt;, built July 1, 2026. It is not in the changelog and there is no release note.&lt;/li&gt;
&lt;li&gt;An unanswered &lt;code&gt;AskUserQuestion&lt;/code&gt; dialog now auto-answers after &lt;strong&gt;60 seconds&lt;/strong&gt; (with a 20-second on-screen countdown) and tells the model to proceed on its best judgment.&lt;/li&gt;
&lt;li&gt;Two undocumented env vars control it: &lt;code&gt;CLAUDE_AFK_TIMEOUT_MS&lt;/code&gt; and &lt;code&gt;CLAUDE_AFK_COUNTDOWN_MS&lt;/code&gt;. Set the timeout to &lt;code&gt;2147483647&lt;/code&gt; and it effectively never fires.&lt;/li&gt;
&lt;li&gt;I found all of this by running &lt;code&gt;strings&lt;/code&gt; over the local binary and bisecting four versions. Every command in this post is reproducible on your own machine.&lt;/li&gt;
&lt;li&gt;The upside: unattended and remote sessions stop hanging forever. The catch: an away user hasn't agreed to anything, so the model can now proceed on a guess and act on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I run a lot of Claude Code sessions I'm not watching. Delegated tasks, overnight jobs, browser QA runs kicked off from another session. So when one of them auto-answered its own question and kept going, I noticed. The transcript had a line I'd never seen: it had &lt;code&gt;continued without an answer&lt;/code&gt;. Nothing had crashed. It had waited about a minute, given up on me, and moved on. That's Claude Code's &lt;strong&gt;AFK mode&lt;/strong&gt;, and it's brand new.&lt;/p&gt;

&lt;p&gt;Away from keyboard - that's the whole idea. AFK mode resolves an unanswered &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;AskUserQuestion&lt;/code&gt;&lt;/a&gt; dialog after a timeout and lets the model carry on instead of blocking forever. It landed in v2.1.198 on July 1, 2026, with no changelog entry, no release note, and no mention anywhere I could find. So I pulled the binary apart to see exactly what it does. Here's the whole thing, and the commands to check it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The message the model actually gets
&lt;/h2&gt;

&lt;p&gt;When the countdown runs out, the dialog resolves itself and the tool hands the model an injected instruction. This is the string, pulled verbatim out of the v2.1.198 binary:&lt;/p&gt;

&lt;p&gt;No response after ${Math.round(e/1000)}s — the user may be away from keyboard. Proceed using your best judgment based on the context so far; you can re-ask this question later if it's still relevant.&lt;br&gt;
Injected tool result, verbatim from claude v2.1.198&lt;/p&gt;

&lt;p&gt;Notice the &lt;code&gt;${Math.round(e/1000)}&lt;/code&gt;. The "60s" you see in the transcript isn't hardcoded. It's computed from whatever the timeout is set to, which is the first hint that the number is configurable. The transcript line you actually see depends on the state of the dialog when it expired:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nothing highlighted when the clock ran out, so the transcript shows &lt;code&gt;continued without an answer&lt;/code&gt;. That's the one that caught my eye.&lt;/li&gt;
&lt;li&gt;Options were pre-selected, so it reads &lt;code&gt;continued with the answers selected so far&lt;/code&gt;, and those selections are what get submitted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's also a telemetry event, &lt;code&gt;tengu_ask_user_question_afk_auto_advance&lt;/code&gt;, that fires on every auto-advance with the timeout, the number of questions, whether there were partial answers, and whether the session was in plan mode. So Anthropic is measuring this. It just isn't telling anyone about it yet.&lt;/p&gt;
&lt;h2&gt;
  
  
  How I found it (run this yourself)
&lt;/h2&gt;

&lt;p&gt;Nothing here needs a debugger or a disassembler. Claude Code ships as a big JavaScript bundle inside the binary, and &lt;code&gt;strings&lt;/code&gt; plus &lt;code&gt;grep&lt;/code&gt; gets you most of the way. On my Mac the versioned binaries live under &lt;code&gt;~/.local/share/claude/versions/&lt;/code&gt; - your installer may keep them elsewhere, but the idea is the same.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Look for the AFK strings in the current version&lt;/span&gt;
&lt;span class="n"&gt;cd&lt;/span&gt; &lt;span class="o"&gt;~/.&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;share&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;claude&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;versions&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;
&lt;span class="n"&gt;strings&lt;/span&gt; &lt;span class="mf"&gt;2.1.198&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;grep&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="s1"&gt;'CLAUDE_AFK_TIMEOUT_MS'&lt;/span&gt;
&lt;span class="n"&gt;strings&lt;/span&gt; &lt;span class="mf"&gt;2.1.198&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;grep&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="s1"&gt;'No response after[^"]*'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second command prints the full injected message, dynamic &lt;code&gt;${Math.round(e/1000)}s&lt;/code&gt; and all. Once I had the strings, the next question was: when did this show up? I kept a few old versions around, so I bisected them by counting the AFK tokens in each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="mf"&gt;2.1.195&lt;/span&gt; &lt;span class="mf"&gt;2.1.196&lt;/span&gt; &lt;span class="mf"&gt;2.1.197&lt;/span&gt; &lt;span class="mf"&gt;2.1.198&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== &lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="s2"&gt; ==="&lt;/span&gt;
  &lt;span class="n"&gt;strings&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;grep&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;E&lt;/span&gt; &lt;span class="s1"&gt;'CLAUDE_AFK_TIMEOUT_MS|afkTimeoutMs|No response after'&lt;/span&gt;
&lt;span class="n"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean cutover. The implementation is absent from every version before 2.1.198 and present in it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Built&lt;/th&gt;
&lt;th&gt;AFK auto-advance&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2.1.195&lt;/td&gt;
&lt;td&gt;Jun 26&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.1.196&lt;/td&gt;
&lt;td&gt;Jun 30&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.1.197&lt;/td&gt;
&lt;td&gt;Jun 30&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.1.198&lt;/td&gt;
&lt;td&gt;Jul 1&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One nuance worth being precise about. The feature gate id, &lt;code&gt;afk-mode-2026-01-31&lt;/code&gt;, shows up in &lt;em&gt;all four&lt;/em&gt; versions, not just the new one. That date is January 31, 2026. So the concept has been sitting behind a gate for about five months. What's new in 2.1.198 is the part that matters to you: the code that actually watches the clock in the terminal and answers the question when it runs out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The internals, de-minified
&lt;/h2&gt;

&lt;p&gt;The logic reads clean once you rename the minified variables. Two defaults, and an env var that wins over each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Reconstructed from the minified bundle in v2.1.198&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;AFK_TIMEOUT_DEFAULT&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// 60s before it answers for you&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;AFK_COUNTDOWN_DEFAULT&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// 20s visible countdown&lt;/span&gt;

&lt;span class="c1"&gt;// env var wins, else the default; countdown can't exceed the timeout&lt;/span&gt;
&lt;span class="n"&gt;timeout&lt;/span&gt;   &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="no"&gt;CLAUDE_AFK_TIMEOUT_MS&lt;/span&gt;   &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="no"&gt;AFK_TIMEOUT_DEFAULT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;countdown&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;countdown&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="no"&gt;CLAUDE_AFK_COUNTDOWN_MS&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="no"&gt;AFK_COUNTDOWN_DEFAULT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire knob set. A 60-second timeout, a 20-second countdown that appears near the end of it, and two environment variables that override both. Nothing in &lt;code&gt;settings.json&lt;/code&gt; schema, nothing in &lt;code&gt;--help&lt;/code&gt;, nothing in the docs. Just these two strings baked into the bundle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Users asked for this for months. Twice they were told no.
&lt;/h2&gt;

&lt;p&gt;Here's the part that makes this more than a patch note. This exact behavior was requested on GitHub, repeatedly, and closed as not planned - before it shipped anyway with no announcement.&lt;/p&gt;

&lt;p&gt;Timeline from January to July 2026: the AFK gate is created in January, users file issues in March and May asking for a timeout and both are closed as not planned, a related issue stays open in June, and in July v2.1.198 ships the feature silently.&lt;/p&gt;

&lt;p&gt;Jan 31&lt;br&gt;
gate created&lt;/p&gt;

&lt;p&gt;Mar 4&lt;/p&gt;
&lt;h1&gt;
  
  
  30740
&lt;/h1&gt;

&lt;p&gt;"add timeout" · not planned&lt;/p&gt;

&lt;p&gt;May 22&lt;/p&gt;
&lt;h1&gt;
  
  
  61337
&lt;/h1&gt;

&lt;p&gt;blocks /goal · not planned&lt;/p&gt;

&lt;p&gt;Jun 23&lt;/p&gt;
&lt;h1&gt;
  
  
  70294 open
&lt;/h1&gt;

&lt;p&gt;Jul 1&lt;br&gt;
v2.1.198&lt;br&gt;
ships, silent&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Five months from gate to ship. Two feature requests closed as "not planned" along the way, and no changelog line when it finally landed.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/anthropics/claude-code/issues/30740" rel="noopener noreferrer"&gt;#30740&lt;/a&gt;&lt;/strong&gt; (March 4, closed, not planned): "Add configurable timeout (or option to disable) for AskUserQuestion tool." The exact thing that just shipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/anthropics/claude-code/issues/33848" rel="noopener noreferrer"&gt;#33848&lt;/a&gt;&lt;/strong&gt; (March 13): the &lt;code&gt;afk-mode-2026-01-31&lt;/code&gt; beta header leaked into Auto Mode API calls and caused a 400 error. Proof the gate existed in dev long before the terminal ever used it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/anthropics/claude-code/issues/61337" rel="noopener noreferrer"&gt;#61337&lt;/a&gt;&lt;/strong&gt; (May 22, closed, not planned): "Claude can use AskUserQuestion to block /goal mode indefinitely." A session that made zero progress overnight because it was waiting on a question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/anthropics/claude-code/issues/70294" rel="noopener noreferrer"&gt;#70294&lt;/a&gt;&lt;/strong&gt; (June 23, still open): interactive prompts hang remote sessions on Telegram and Slack with no timeout, called the single biggest reliability cliff when running Claude Code remotely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I get why it shipped quietly. It's a behavior change that could surprise people, and a soft rollout behind a gate is the safe way to do that. But the people in those threads are the ones who'd most want to know it's fixed, and right now they have no way to find out except by tripping over the transcript line like I did.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to configure it
&lt;/h2&gt;

&lt;p&gt;The two env vars go in the &lt;code&gt;env&lt;/code&gt; block of your &lt;code&gt;~/.claude/settings.json&lt;/code&gt;, or in whatever launcher starts your delegated and headless sessions. All values are milliseconds and strings.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"env"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Wait 10 minutes before answering for me&lt;/span&gt;
    &lt;span class="s2"&gt;"CLAUDE_AFK_TIMEOUT_MS"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"600000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="c1"&gt;// Or effectively never auto-advance (max 32-bit int)&lt;/span&gt;
    &lt;span class="c1"&gt;// "CLAUDE_AFK_TIMEOUT_MS": "2147483647",&lt;/span&gt;

    &lt;span class="c1"&gt;// Grow or shrink the visible countdown&lt;/span&gt;
    &lt;span class="s2"&gt;"CLAUDE_AFK_COUNTDOWN_MS"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"30000"&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;One honest caveat, because I couldn't verify past the client. These vars are the defaults the binary carries, but the whole thing sits behind a Statsig gate called &lt;code&gt;afk_mode&lt;/code&gt;. A gate can be flipped per cohort from the server, so I can't promise the 60-second default is live for every user, or that it won't change. What I can promise is that the strings, the constants, and the env vars are exactly what's in the v2.1.198 binary on my disk today.&lt;/p&gt;

&lt;h2&gt;
  
  
  The new failure mode: proceeding on a guess
&lt;/h2&gt;

&lt;p&gt;For unattended work this is a real fix. My delegated sessions run non-interactive, and before this they could wedge forever the moment the model decided to ask me something. A hung session that burns nothing and produces nothing is the worst outcome, and AFK mode kills it. That alone makes it worth knowing about.&lt;/p&gt;

&lt;p&gt;But there's a cost, and it's the flip side of what issue #30740 warned about. An away user hasn't agreed to anything. "Best judgment" is the model's judgment, made with no answer to the question it thought was worth asking. If the question was "should I delete these or archive them?" and nobody's there, the model now picks one and acts on it. The clarifying question exists precisely because the answer wasn't obvious. Auto-answering it means the least-obvious calls now get made by a guess, and then executed.&lt;/p&gt;

&lt;p&gt;A blocked session wastes your time. An auto-advanced session can waste your data. Those are different risks, and AFK mode trades the first for the second. For read-only work that's a clear win. For anything that writes, deletes, or sends, it's a reason to keep a human in the loop or turn the timeout way up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this changes about how I write questions
&lt;/h2&gt;

&lt;p&gt;Here's the practical takeaway, and it's the part nobody's written down yet. If an unanswered question resolves on its own, then the option the model lands on when I'm not there is effectively the choice I made by walking away. So the design rule for &lt;code&gt;AskUserQuestion&lt;/code&gt; shifts.&lt;/p&gt;

&lt;p&gt;Put the safe option first. Make the default the one you'd be fine with an absent human "picking" - usually the conservative, reversible, do-less choice. If I'm scripting agents that run without me, and I do, then every question they might ask needs a safe fallback baked into its first option, because that's the one AFK mode will effectively select. This matters most in delegation setups like my own &lt;code&gt;op&lt;/code&gt; workflow, where a session runs another session's task with no human watching either end. A hung prompt there used to strand the whole chain. Now it proceeds, which is better, as long as "proceeds" means "does the safe thing."&lt;/p&gt;

&lt;p&gt;This is the same instinct behind the guardrails I've written about before. If you want the background on making Claude Code fail safe instead of fail loud, the &lt;a href="https://marcindudek.dev/blog/claude-code-guardrails-pretooluse/" rel="noopener noreferrer"&gt;PreToolUse guardrails post&lt;/a&gt; covers catching a dangerous command before it runs, and the &lt;a href="https://marcindudek.dev/blog/claude-code-quality-gates-stop-hook/" rel="noopener noreferrer"&gt;Stop-hook quality gates post&lt;/a&gt; covers not letting a session end until the work is actually verified. AFK mode is one more place where the default behavior decides what happens when you're not looking.&lt;/p&gt;

&lt;p&gt;One thing I want to be straight about, since I checked. There are already third-party tools with "AFK" in the name for Claude Code, and hook-based workarounds people built to escape exactly this hang. Those are real and they predate this. What's undocumented is the &lt;em&gt;native&lt;/em&gt; auto-advance that now ships in the binary and the two env vars that drive it. That's the new part, and that's what nobody's written up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Get the next teardown in your inbox
&lt;/h3&gt;

&lt;p&gt;I dig into Claude Code internals and ship notes when I find something worth sharing. Drop your email and I'll send the next one. Just new posts, nothing else.&lt;/p&gt;

&lt;p&gt;Email address&lt;/p&gt;

&lt;p&gt;Subscribe&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Claude Code stopped waiting for you, and it did it without saying so. For the overnight and remote sessions that used to hang, that's the fix a lot of people asked for and gave up on. For anything with consequences, it's a new default worth setting on purpose. Check whether it's live in your version with one &lt;code&gt;strings&lt;/code&gt; command, decide how long you actually want it to wait, and write your questions so the option it lands on when you're gone is one you can live with.&lt;/p&gt;

&lt;p&gt;I'll keep pulling on these threads as new versions ship. The interesting stuff is rarely in the changelog.&lt;/p&gt;

&lt;p&gt;More on Claude Code&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://marcindudek.dev/blog/claude-code-context-rot-handoff/" rel="noopener noreferrer"&gt;Context rot and the handoff pattern&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marcindudek.dev/blog/claude-code-guardrails-pretooluse/" rel="noopener noreferrer"&gt;Guardrails: hooks that won't let the agent shoot me in the foot&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marcindudek.dev/blog/claude-code-quality-gates-stop-hook/" rel="noopener noreferrer"&gt;Quality gates: forcing the agent to verify before it says "done"&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marcindudek.dev/blog/claude-code-prompt-gate-local-llm/" rel="noopener noreferrer"&gt;A local LLM that reads my prompt before Claude does&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marcindudek.dev/blog/claude-code-ambient-hooks-sessions/" rel="noopener noreferrer"&gt;The ambient layer: the hooks you stop noticing&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A five-part series on the hooks I run in Claude Code.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>aiengineering</category>
    </item>
    <item>
      <title>I Tested All 11 Free WooCommerce EU Withdrawal-Button Plugins</title>
      <dc:creator>Marcin Dudek</dc:creator>
      <pubDate>Thu, 18 Jun 2026 15:27:01 +0000</pubDate>
      <link>https://dev.to/marcindudekdev/i-tested-all-11-free-woocommerce-eu-withdrawal-button-plugins-4emd</link>
      <guid>https://dev.to/marcindudekdev/i-tested-all-11-free-woocommerce-eu-withdrawal-button-plugins-4emd</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://marcindudek.dev/blog/woocommerce-eu-withdrawal-button-plugins-tested/" rel="noopener noreferrer"&gt;https://marcindudek.dev/blog/woocommerce-eu-withdrawal-button-plugins-tested/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;From &lt;strong&gt;19 June 2026&lt;/strong&gt;, every shop selling to EU consumers has to put a clearly labelled, one-click &lt;strong&gt;"withdraw from contract"&lt;/strong&gt; function in the storefront. That's &lt;a href="https://eur-lex.europa.eu/eli/dir/2023/2673/oj/eng" rel="noopener noreferrer"&gt;Directive (EU) 2023/2673&lt;/a&gt;, which adds a new &lt;strong&gt;Article 11a&lt;/strong&gt; to the old Consumer Rights Directive. WooCommerce has no native support for it - there's just an &lt;a href="https://github.com/woocommerce/woocommerce/issues/65443" rel="noopener noreferrer"&gt;open request (#65443)&lt;/a&gt; sitting there with no roadmap.&lt;/p&gt;

&lt;p&gt;So you need a plugin. The wp.org repo has a pile of them now, all claiming compliance. I didn't trust the readme screenshots. I installed all 11 I could find, one at a time, on a real WooCommerce site and drove the actual withdrawal flow as a guest. Then I scored each one against what the directive literally asks for.&lt;/p&gt;

&lt;p&gt;This is the "I did the tedious testing so you don't have to" post. Here's what I found, including two I'd actively avoid for this job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the law actually wants
&lt;/h2&gt;

&lt;p&gt;First, the thing everyone gets wrong: this is not a new right. The 14-day cooling-off period already existed. Refund timelines, return-shipping rules - all unchanged. What changed is the &lt;em&gt;method&lt;/em&gt;. If a customer can buy in a few clicks, they have to be able to withdraw with comparable ease, through a clear electronic function. Not by hunting for a PDF or emailing your support inbox.&lt;/p&gt;

&lt;p&gt;In practice the directive boils down to a few hard requirements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A visible button/link, reachable from every page.&lt;/strong&gt; "withdraw from contract here" (PL: &lt;em&gt;"Odstąp od umowy tutaj"&lt;/em&gt;) or an unambiguous equivalent. A buried footer link that looks like your T&amp;amp;Cs is no longer enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guest access.&lt;/strong&gt; If you allow guest checkout, guests have to be able to withdraw without registering or logging in. This is the one that kills half the "return" plugins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A two-step flow.&lt;/strong&gt; Initiate, then a separate "confirm withdrawal" action so nobody submits by accident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whole order or part of it.&lt;/strong&gt; Partial withdrawal - per item, per quantity - is explicitly required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No extra mandatory fields.&lt;/strong&gt; You can ask for name, order ID and an email for the confirmation. You can't force IBAN or a reason-for-return as required fields.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A durable-medium acknowledgement.&lt;/strong&gt; A timestamped email (or account record) confirming receipt, without undue delay.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One easy-to-miss knock-on: your pre-contractual info under Art. 6 now has to mention the function exists and where it is. So your return-policy and terms pages need a small update too, not just the UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested (and scored)
&lt;/h2&gt;

&lt;p&gt;This wasn't a desk review. I spun up a real WooCommerce site on my local wp-test stack - store country Poland, currency PLN, guest checkout on - and installed each plugin on its own, one at a time. Then I placed a real guest order (#13, &lt;code&gt;jan.kowalski@example.com&lt;/code&gt;) and drove the withdrawal flow in a real browser as a logged-out guest.&lt;/p&gt;

&lt;p&gt;For the durable-medium step I hooked &lt;code&gt;wp_mail&lt;/code&gt; and captured what each plugin actually queued, because the test box can't send real mail. So "email verified" here means "the plugin generated a timestamped customer + admin email", not "it landed in an inbox". That's an honest limitation - confirm deliverability on your own staging with real SMTP.&lt;/p&gt;

&lt;p&gt;I scored nine criteria, 0-2 each, mapped straight to Art. 11a (max 18):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;C1&lt;/th&gt;
&lt;th&gt;Site-wide visible button, reachable from every page&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;C2&lt;/td&gt;
&lt;td&gt;Guest access - usable without login, by order # + email&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C3&lt;/td&gt;
&lt;td&gt;Two-step flow - initiate, then a separate confirm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C4&lt;/td&gt;
&lt;td&gt;Durable-medium acknowledgement - timestamped email/record&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C5&lt;/td&gt;
&lt;td&gt;Partial withdrawal - select specific items / quantities&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C6&lt;/td&gt;
&lt;td&gt;No extra mandatory fields - IBAN / reason not forced&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C7&lt;/td&gt;
&lt;td&gt;Admin management - request list / status flag / order-note trail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C8&lt;/td&gt;
&lt;td&gt;Polish i18n - translatable, label editable to "Odstąp od umowy"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C9&lt;/td&gt;
&lt;td&gt;Doesn't break the store - no fatal, HPOS-compatible&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;0 = no, 1 = partial or paywalled, 2 = yes, free.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scoreboard - all 11
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plugin&lt;/th&gt;
&lt;th&gt;C1&lt;/th&gt;
&lt;th&gt;C2&lt;/th&gt;
&lt;th&gt;C3&lt;/th&gt;
&lt;th&gt;C4&lt;/th&gt;
&lt;th&gt;C5&lt;/th&gt;
&lt;th&gt;C6&lt;/th&gt;
&lt;th&gt;C7&lt;/th&gt;
&lt;th&gt;C8&lt;/th&gt;
&lt;th&gt;C9&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;EU Order Withdrawal Button (Vendidero) ★&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EU Withdrawal Button (runner-up)&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WWU Withdrawal Button (OSS)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WPify Woo (module)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Germanized (bundles the winner)&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EU Withdrawal Compliance&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Easy Return&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebToffee EU Order Withdrawal&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BuddyPilot Withdrawal *&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bitron Right of Withdrawal&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WP Desk Flexible Refund&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;BuddyPilot: C4/C5 scored conservatively. The architecture (magic-link, partial, PDF declaration) is clearly there, but it doesn't use &lt;code&gt;wp_mail&lt;/code&gt; and the access-link step wouldn't complete in my headless harness, so I couldn't verify the end-to-end email live. It's likely higher on a real-mail staging site.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnvhmsuk7h11m25d06xur.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnvhmsuk7h11m25d06xur.webp" alt="Horizontal bar chart of all 11 WooCommerce withdrawal-button plugins scored out of 18 against Article 11a: five tie at 17, with the Vendidero winner highlighted, down to WP Desk Flexible Refund at 9" width="800" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The same scores as a picture. Five plugins tie at 17/18; the two at the bottom (Bitron, WP Desk) are the ones I'd avoid for this job.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Five plugins tie at 17/18. That's not as decisive as it looks - the gaps are in different places, and for a Poland-only store the practical winner is clear.&lt;/p&gt;

&lt;h2&gt;
  
  
  The winner: EU Order Withdrawal Button (Vendidero)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;EU Order Withdrawal Button for WooCommerce&lt;/strong&gt; - &lt;code&gt;wordpress.org/plugins/eu-order-withdrawal-button-for-woocommerce&lt;/code&gt;, v2.2.1, ~1,000 installs, ★5, HPOS-ready. The free core covers everything you need. Pro only adds per-product exclusions and CSV export.&lt;/p&gt;

&lt;p&gt;It's the closest fit to the literal Art. 11a checklist with the least fuss. Nice surprise from testing: this is &lt;strong&gt;Vendidero's&lt;/strong&gt; plugin - the same company behind &lt;a href="https://wordpress.org/plugins/woocommerce-germanized/" rel="noopener noreferrer"&gt;Germanized&lt;/a&gt;, and Germanized literally bundles this exact engine. So you get a serious legal-compliance vendor's code, standalone and lightweight.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F81fj5b4agah41h28o89s.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F81fj5b4agah41h28o89s.webp" alt="Guest withdrawal form with only Email required, plus the site-wide sticky Withdraw from contract button at the bottom" width="800" height="831"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Guest form - only Email is required - and the site-wide sticky "Withdraw from contract" button, bottom centre.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz8tjq5rcmep1ryfyvnzb.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz8tjq5rcmep1ryfyvnzb.webp" alt="WooCommerce Withdrawals admin queue with a Full badge, verified email, Requested status and approve/reject/delete actions" width="800" height="564"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;WooCommerce → Withdrawals: a real request queue with full/partial badge, verified email, status and per-row actions.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What I verified on the test site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Site-wide button (C1):&lt;/strong&gt; one toggle (&lt;code&gt;embed_everywhere&lt;/code&gt;) drops a sticky "Withdraw from contract" button on every page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guest (C2):&lt;/strong&gt; submitted as a logged-out guest against order #13, no account. Only the email is required - the &lt;code&gt;email_repeat&lt;/code&gt; field is a hidden anti-spam honeypot, not a real field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Durable medium (C4):&lt;/strong&gt; I captured two real &lt;code&gt;wp_mail&lt;/code&gt; calls - a customer "Your withdrawal request for order #13 has been received" and an admin notification, both timestamped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admin/audit (C7):&lt;/strong&gt; a dedicated &lt;strong&gt;Withdrawals&lt;/strong&gt; queue, a custom "Pending withdrawal" order status, and an order note logging the exact items. This is the best merchant side of the bunch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where it loses its one point: the confirm is a single-page "Confirm withdrawal" rather than a distinct review screen (C3=1), and the per-item picker for partials is delivered through the emailed link instead of inline. Two honest cons: site-wide visibility is off by default - flip the toggle - and there's no Polish bundled, though the page title and button label are freely editable to &lt;em&gt;"Odstąp od umowy tutaj"&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you just need to be compliant and move on, this is the one.&lt;/strong&gt; Most complete free feature set, a real admin queue with an audit trail, the install leader, and a vendor that does compliance for a living.&lt;/p&gt;

&lt;h2&gt;
  
  
  The runner-up: EU Withdrawal Button
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;EU Withdrawal Button for WooCommerce&lt;/strong&gt; - &lt;code&gt;wordpress.org/plugins/eu-withdrawal-button-for-woocommerce&lt;/code&gt;, v2.0.1, freemium. This is the one with the nicest customer-facing UX of the lot. A genuine two-step modal (Continue, review, Confirm) and the cleanest inline partial picker I saw - per-item checkboxes &lt;em&gt;and&lt;/em&gt; editable quantities with a live total. IBAN and reason are both explicitly optional, with helpful copy.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0h3wia8izgpne65837ek.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0h3wia8izgpne65837ek.webp" alt="Two-step withdrawal modal showing order #13 recovered as guest, per-item checkboxes with editable quantity and a live withdrawal total" width="800" height="714"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Step 2: order #13 recovered as a guest, per-item plus quantity partial selection, live total, then Confirm. The cleanest withdrawal UX I tested.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So why not #1? The admin side is weak. There's no dedicated request dashboard, and the selected partial items aren't stored on the order - they only go out in the email. For a merchant who has to act on each request, that's an operational gap the winner doesn't have. It also defaults to "My Account only" and needs a couple of toggles to go truly site-wide.&lt;/p&gt;

&lt;p&gt;If you care most about the customer experience and you're fine managing requests from the email, this is a great pick. For most stores, the better admin tooling on the winner wins out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three alternatives worth knowing
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Most legally rigorous: WWU Withdrawal Button (OSS) - 17/18
&lt;/h3&gt;

&lt;p&gt;If compliance evidence is your priority, nothing beats this open-source one. Every declaration gets a PDF plus an &lt;a href="https://opentimestamps.org/" rel="noopener noreferrer"&gt;OpenTimestamps&lt;/a&gt; cryptographic timestamp, precise legal wording, EU/EEA applicability rules, exclusions and consent-IP capture. The catch: it's off the wp.org repo (GitHub install, &lt;strong&gt;no automatic updates&lt;/strong&gt;), disabled by default, and has more knobs than the leaders. Great for the audit-minded, more upkeep.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3nsar84o2enhaj5hf0he.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3nsar84o2enhaj5hf0he.webp" alt="WWU withdrawal form step 2 with withdraw from contract here heading, optional reason and per-item partial selection" width="800" height="1182"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;WWU step 2 - "withdraw from contract here", reason optional, per-item partial.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdiueudy8n2bhvtc7c8pr.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdiueudy8n2bhvtc7c8pr.webp" alt="Easy Return step 2 wizard with per-item partial selection, shipping Polish out of the box" width="800" height="1071"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Easy Return step 2 - polished wizard, per-item partial, and it ships Polish out of the box.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The only one with Polish bundled: Easy Return - 16/18
&lt;/h3&gt;

&lt;p&gt;Gorgeous 3-step wizard, good guest order recovery, and the only plugin that ships &lt;code&gt;pl_PL&lt;/code&gt; out of the box. But it's SaaS-coupled (easyreturn.eu). In free/unconnected mode the confirmation email is literally labelled "This is a test withdrawal confirmation email", and it doesn't declare HPOS. Lovely UX, but I wouldn't bet compliance-grade email delivery on the free tier.&lt;/p&gt;

&lt;h3&gt;
  
  
  Most reliable vendor, but no partial: WebToffee - 16/18
&lt;/h3&gt;

&lt;p&gt;Established vendor, clean email verification, and a site-wide footer link on by default. The dealbreaker for Art. 11a: no partial withdrawal, whole order only. The directive explicitly says "whole order or part of it", so this misses a hard requirement.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd avoid for this job
&lt;/h2&gt;

&lt;p&gt;Two plugins came up when I searched but are the wrong tool here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WP Desk Flexible Refund (9/18).&lt;/strong&gt; It's a capable refund/RMA tool, but the free form is login-gated behind My Account with no guest path. It fails the two non-negotiable tests - site-wide and guest - so for the withdrawal-button obligation it's the wrong category, even though the plugin itself is fine at what it does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bitron Right of Withdrawal (11/18).&lt;/strong&gt; Single-step (no separate confirm), no partial, and the declaration isn't even tied to the real order - the order ID is optional and unvalidated. Too loose for this.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And a note on the other two 17/18 plugins: &lt;strong&gt;WPify Woo&lt;/strong&gt; and &lt;strong&gt;Germanized&lt;/strong&gt; both do the feature well - Germanized actually bundles the winner's engine. But both are heavy suites (Czech and DACH respectively). Only pick them if you also need the rest of the suite. For a Poland-only store that just wants the button, the standalone winner is a fraction of the weight.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Poland gotcha
&lt;/h2&gt;

&lt;p&gt;Here's where Poland is different from "just install it before the 19th". Poland missed the 19 December 2025 transposition deadline. The withdrawal-button rules were meant to ride into the &lt;em&gt;ustawa o prawach konsumenta&lt;/em&gt; via a consumer-credit vehicle (UC82), steered by UOKiK. In May 2026 the government pulled that and said it would start new work. So there's likely no Polish statute in force on 19 June. Commentators are calling the date &lt;em&gt;"nierealne"&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Does that mean you can wait? I wouldn't. A few reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The directive is binding EU law. Polish transposition is a when, not an if - and it may land with a short runway.&lt;/li&gt;
&lt;li&gt;If you ship to other EU consumers, those countries' rules already apply to those sales from 19 June. Germany (§356a BGB) and France (Art. 54-bis) have transposed.&lt;/li&gt;
&lt;li&gt;Big platforms are rolling it out now, so it's becoming a baseline trust signal. Doing it calmly this week is cheap. Retrofitting under enforcement pressure later is not.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  So which plugin for a Polish store?
&lt;/h3&gt;

&lt;p&gt;Same answer as overall - &lt;strong&gt;EU Order Withdrawal Button (Vendidero)&lt;/strong&gt;. There's one Poland-specific catch worth knowing: it doesn't ship a Polish translation. But everything is editable, so you just type the labels yourself - the page title and button become &lt;em&gt;"Odstąp od umowy tutaj"&lt;/em&gt; and the confirm becomes &lt;em&gt;"Potwierdź odstąpienie od umowy"&lt;/em&gt;. Two minutes of work, and you get the best admin tooling of the bunch.&lt;/p&gt;

&lt;p&gt;The only plugin that ships &lt;code&gt;pl_PL&lt;/code&gt; out of the box is &lt;strong&gt;Easy Return&lt;/strong&gt;. If you want zero translation work and a polished Polish wizard, it's tempting. I'd still hesitate on the free tier - its confirmation email is labelled "test", and the durable-medium email is the one part of this you can't get wrong. For a Polish store I'd rather spend two minutes translating the winner than rely on Easy Return's free-tier email. If you're already paying for Easy Return's connected service, that calculus changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is a practitioner write-up, not legal advice.&lt;/strong&gt; For final shop-policy wording, confirm against the Polish transposition once it's published, and for high-value exposure talk to a consumer-law lawyer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs if you skip it
&lt;/h2&gt;

&lt;p&gt;The penalties vary by member state, but one consequence is EU-wide and it's the one that actually bites day to day: if a compliant withdrawal function is missing, &lt;strong&gt;the 14-day cooling-off window can extend to 12 months and 14 days&lt;/strong&gt;. Customers can send things back the better part of a year after delivery. That's the real commercial risk, well before any regulator gets involved.&lt;/p&gt;

&lt;p&gt;On top of that, fines exist where the rule is enforceable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Poland (UOKiK):&lt;/strong&gt; collective-consumer-interest fines up to 10% of annual turnover, once it's transposed and enforceable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Germany:&lt;/strong&gt; administrative fines up to €75,000 for legal persons, per current commentary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-border (CPC / Omnibus):&lt;/strong&gt; coordinated actions can reach up to 4% of annual turnover in the member states concerned.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd actually do
&lt;/h2&gt;

&lt;p&gt;If this were a client store on Poland/PLN, here's the path. It's a 1-2 hour job on staging, not a fire:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install &lt;strong&gt;EU Order Withdrawal Button for WooCommerce&lt;/strong&gt; on a clone first.&lt;/li&gt;
&lt;li&gt;Turn on site-wide embed. Rename the page and button to &lt;em&gt;"Odstąp od umowy tutaj"&lt;/em&gt; and the confirm to &lt;em&gt;"Potwierdź odstąpienie od umowy"&lt;/em&gt;. Labels are editable, no PL pack ships.&lt;/li&gt;
&lt;li&gt;Check only name + order ID + email are required. Leave IBAN and reason off.&lt;/li&gt;
&lt;li&gt;Wire real SMTP and confirm the timestamped acknowledgement email actually fires. The durable-medium email is the legal bit, so don't skip it.&lt;/li&gt;
&lt;li&gt;Update your return-policy / terms pages to disclose the function and where it lives (Art. 6).&lt;/li&gt;
&lt;li&gt;Cache-exclude the withdrawal page so the no-login form isn't served stale. If you're on a full-page cache, this matters - I wrote about the cache-bypass cookie logic in my &lt;a href="https://marcindudek.dev/blog/woocommerce-blazing-fast-stack/" rel="noopener noreferrer"&gt;WooCommerce performance stack post&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Keep refunds manual through your existing gateway. The form only registers the legal declaration.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On step 2: the winner lets you edit the button text in its settings and rename the page, which is the simplest route. If you'd rather pin the labels in code - handy when you manage several stores - a small &lt;code&gt;gettext&lt;/code&gt; filter does it without a translation file. This works for any plugin that runs its strings through WordPress translation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Force Polish labels without shipping a .po file.&lt;/span&gt;
&lt;span class="c1"&gt;// Drop this in a tiny mu-plugin or your theme's functions.php.&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'gettext'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$translated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$pl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'Withdraw from contract'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Odstąp od umowy tutaj'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'Confirm withdrawal'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Potwierdź odstąpienie od umowy'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$pl&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$translated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;20&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. The good news is the hard part - figuring out which plugin to trust - is already done. WooCommerce will probably ship something native eventually, but #65443 has no roadmap, so I wouldn't wait for core on a deadline.&lt;/p&gt;

&lt;p&gt;If you want more WooCommerce config wins while you're in there, my &lt;a href="https://marcindudek.dev/blog/30-wordpress-woocommerce-performance-tips-2026/" rel="noopener noreferrer"&gt;30 WordPress &amp;amp; WooCommerce performance tips&lt;/a&gt; is a decent companion read.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do I need the withdrawal button if I'm a small Polish store?
&lt;/h3&gt;

&lt;p&gt;Yes, in practice. The obligation applies to any business selling to EU consumers from 19 June 2026, regardless of size. Poland's own statute is delayed (see above), but the directive is binding and your cross-border EU sales are already covered. There's no small-shop exemption.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I just label the button "Cancel" or "Return"?
&lt;/h3&gt;

&lt;p&gt;No. The directive points to clear wording like "withdraw from contract here" to open the form and "confirm withdrawal" to submit. Ambiguous labels like "Cancel" are likely treated as insufficient. In Polish, use &lt;em&gt;"Odstąp od umowy tutaj"&lt;/em&gt; and &lt;em&gt;"Potwierdź odstąpienie od umowy"&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a footer link to my returns policy enough?
&lt;/h3&gt;

&lt;p&gt;Not anymore. A policy page you have to read, or a "email us for the form" line, no longer satisfies the rule. You need a working digital function - reachable from every page, usable by guests without logging in, with a two-step confirm and a timestamped acknowledgement email.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the plugin handle the actual refund?
&lt;/h3&gt;

&lt;p&gt;No, and that's fine. The withdrawal function only registers the legal declaration and timestamps it. The refund still goes through your normal WooCommerce flow and payment gateway, and the physical return and inspection rules are unchanged. Keep refunds manual.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will WooCommerce add this to core?
&lt;/h3&gt;

&lt;p&gt;Maybe eventually. There's an open enhancement request (#65443) but no roadmap or shipped support as of June 2026. I wouldn't wait for core on a deadline - install a plugin.&lt;/p&gt;

</description>
      <category>woocommerce</category>
      <category>eucompliance</category>
    </item>
    <item>
      <title>WordPress Manifesto - 15 Years In, Here's What's Actually Broken</title>
      <dc:creator>Marcin Dudek</dc:creator>
      <pubDate>Tue, 26 May 2026 13:54:11 +0000</pubDate>
      <link>https://dev.to/marcindudekdev/wordpress-manifesto-15-years-in-heres-whats-actually-broken-36oo</link>
      <guid>https://dev.to/marcindudekdev/wordpress-manifesto-15-years-in-heres-whats-actually-broken-36oo</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://marcindudek.dev/blog/wordpress-manifesto/" rel="noopener noreferrer"&gt;marcindudek.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I've been doing WordPress for 15 years. I love it and I'm tired of it at the same time. Not tired in the "I'm moving to Webflow" way. Tired of the lies - the small ones and the big ones. The "free CMS", the "open source community", the "40% of the web" brag. All of it. Here's what's actually going on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "40% of the web" line is a cope
&lt;/h2&gt;

&lt;p&gt;Yeah, it's probably true. But most of those sites are throwaway blogs and directory junk. WordPress makes it trivial to spin up a basic content site, so of course the number is huge. Quantity isn't quality. Stop quoting it like it proves something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Core is broken
&lt;/h2&gt;

&lt;p&gt;Vanilla WordPress, fresh install, no plugins - and XML-RPC is wide open. The moment your site goes live, bots start brute-forcing wp-login. You shouldn't need a plugin to not get owned on day one. That's not a minor gap, that's a design failure.&lt;/p&gt;

&lt;p&gt;Then there's SEO. You need a plugin for basic meta titles. You need a plugin for descriptions. Meta tags. In 2026. There's no excuse for that. SEO should be native by now.&lt;/p&gt;

&lt;p&gt;You can't just flip one switch to kill comments site-wide. The settings sprawl across multiple screens and are absurdly complex for a feature most sites don't even want anymore. A single "Comments: off" checkbox would save millions of hours of googling and plugin installs.&lt;/p&gt;

&lt;p&gt;There's a full admin page at &lt;code&gt;/wp-admin/options.php&lt;/code&gt; that lists every option in the database and lets you edit it. It's been there forever. It's actually useful. And there's literally no link to it anywhere in the admin UI. You either know the URL or you don't. That's just weird.&lt;/p&gt;

&lt;p&gt;WordPress has Recovery Mode for when a plugin fatals your site. Nice feature - except the recovery link only goes to one admin email. A site can have ten admins, any of them can break something, but only the "main" admin gets the recovery email. If you're admin number two and you crash the site, you're locked out. Your only way back is FTP or SSH. In 2026. Bonkers.&lt;/p&gt;

&lt;p&gt;And instead of fixing any of this, Automattic keeps bolting more React and builder tooling onto the editor. The wrong direction. What core needs is hardening, slimming, cutting dependencies. Maybe even a real fork into something modern. It's long overdue and nobody wants to say it out loud.&lt;/p&gt;

&lt;p&gt;I tried contributing. There's a "good first bugs" list, so I picked one - an underperforming function, open for 8 years, with a couple of stale PRs sitting on it. I posted mine. Silence. If that's the on-ramp for new contributors, no wonder core moves like it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free isn't free
&lt;/h2&gt;

&lt;p&gt;Core WordPress is bare-minimum. To get a real, usable site you need a stack of plugins. Running a store? You need WooCommerce, a pile of Woo add-ons (because Woo doesn't ship with invoicing or most payment gateways), an SMTP plugin, a security plugin, 2FA, SEO, and something like ACF or Pods for custom content types.&lt;/p&gt;

&lt;p&gt;Most of those plugins have a "free" tier that's a gateway drug. You hit a wall, then pay. Forty bucks here, sixty there. Individually cheap, but it compounds. And it's almost always a yearly subscription. So before your site earns a single euro, you're already locked into hosting, domain, and a stack of renewals.&lt;/p&gt;

&lt;p&gt;"Just cancel the subscriptions" isn't a real answer. The moment a plugin stops updating and a vulnerability drops, your site is a sitting duck. Pay or get hacked. Those are the options.&lt;/p&gt;

&lt;p&gt;Most site owners also end up paying for maintenance - because knowing how to configure all these plugins and fix the gaps between them is its own full-time skill. So "free CMS" quietly becomes hosting + domain + 6–10 subscriptions + a retainer. Let's stop pretending it's free.&lt;/p&gt;

&lt;p&gt;And the market knows it. Core is free. Hosting is cheap because WordPress runs on ancient PHP and weak shared boxes. Themes are free. Plugins are free. So everyone expects everything around WordPress to cost nothing too - designing, implementing, cleaning up hacked installs, performance, SEO. All of it. Treated like it should cost nothing. That's a bad deal for everyone doing the actual work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The GPL racket
&lt;/h2&gt;

&lt;p&gt;Plugins are what makes WordPress different from every other CMS. In theory, a non-developer can buy hosting, click together a few plugins, and turn a blog into a shop, a directory, a booking system, whatever. That's incredible.&lt;/p&gt;

&lt;p&gt;But try to actually sell a plugin and you hit a wall. People don't want to pay. And the GPL crowd will tell you that you can't sell the code anyway - you can only sell "support" or "updates". That's bullshit. If your plugin is good and stable, you shouldn't be forced to sell babysitting on top of it. You're selling a license to use software, like every other commercial software on earth.&lt;/p&gt;

&lt;p&gt;Meanwhile entire scammer networks buy one license, strip it, and resell the same plugin on hundreds of "GPL sites" for a few bucks. Basically nothing you can do about it, because it's PHP and anyone with a text editor can edit a plugin.&lt;/p&gt;

&lt;p&gt;Automattic also gatekeeps wordpress.org. Plugins can get banned if the directory team doesn't like how they're sold. A loud chunk of the community - moderators, GPL purists, people who personally benefit from the status quo - will fight any attempt to charge for code by waving the license at you. The people benefiting most from that setup aren't the small developers. They're the resellers and the platform owners.&lt;/p&gt;

&lt;h2&gt;
  
  
  The community fiction
&lt;/h2&gt;

&lt;p&gt;Site owners, freelancers, agencies, theme shops, plugin shops, core contributors, hosts - all lumped together as "the WordPress community". WordCamps, WordUps, meetups everywhere. On the surface, one big family.&lt;/p&gt;

&lt;p&gt;Underneath, it's tense. Plugin developers ally with other plugin developers because they share enemies (the GPL resellers), but they're also competing with each other, so the alliances are shallow. Plugin devs and theme devs are in permanent conflict because there's no clean line between what a theme should do and what a plugin should do. I've opened sites with custom CSS in five different places - core, theme, three plugins - all fighting each other. That's not a community. That's a turf war with a friendly logo on top.&lt;/p&gt;

&lt;p&gt;And the bar to call yourself a "WordPress developer" is on the floor. If you can install WordPress and pick a theme, you can call yourself one. There's no real distinction between that and someone who can build a custom theme from scratch, write a real plugin, or debug core. After 15 years, it's genuinely hard to differentiate yourself in a market where the word means nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automattic's two faces
&lt;/h2&gt;

&lt;p&gt;The mission is "free and open source, for everyone". Meanwhile Automattic runs WordPress.com for profit, takes huge sponsor money, and quietly buys up plugin companies. They're a for-profit business extracting enormous value from an ecosystem they frame as community-owned. You can't preach open source on stage and run a hosting empire backstage and pretend there's no tension.&lt;/p&gt;

&lt;p&gt;Here's the part that gets me. WordPress looks open source, but in practice it's basically in Automattic's hands. If you're a small plugin developer, you're on your own. But the second your work gets popular and solves a real pain point, Automattic can just take it. They can fold it into core. They can copy it and ship their own version. They can buy you. The best case for you is they throw some money your way - and they don't even have to do that.&lt;/p&gt;

&lt;p&gt;Beside Automattic, the big winners are scammers, GPL resellers, and a handful of hosting giants. Everyone else is small fries. Freelancers can still pull in a few hundred bucks building sites - but that tier is being eaten by AI fast. The money is consolidating: Automattic, the Elementor owners, a couple of big hosts. Hundreds of thousands of people contribute to the ecosystem. A handful of companies capture almost all the value. That's not a community. That's a supply chain.&lt;/p&gt;

&lt;p&gt;The WP Engine drama showed exactly how this works. Grow big enough inside the WordPress world and eventually one of Automattic's tentacles comes out, grabs you, and either devours you or kills you. It wasn't a one-off. It was the mask slipping. It's not a family. It's a pond, and there's one big fish in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Users lose too
&lt;/h2&gt;

&lt;p&gt;Most of this is from a developer's seat, but users are paying for this mess just as much. Because core is so bare-minimum and because "anyone can build a WordPress site", the experience is wildly inconsistent. One WordPress site is a polished, fast, accessible portal. The next one - same CMS - is a slow, broken disaster hosted on the cheapest shared box by someone building their second site ever. Users have no way to tell the difference until they're on the page.&lt;/p&gt;

&lt;p&gt;If core had real quality baked in - speed, SEO, security, sane defaults - the floor would be much higher and every site in the world would benefit. Right now "it's WordPress" tells a user almost nothing about what they're about to experience. And that's on core.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually want
&lt;/h2&gt;

&lt;p&gt;Two things. Either serious competition - a truly open CMS, genuinely community-owned, where the only thing you pay for is hosting. That would break this whole weird economy overnight. Or WordPress core finally grows up: lean, fast, secure, SEO-aware by default, with a clean line between core, plugins (features), and themes (layout). Right now everything is tangled and it feels toxic to work in.&lt;/p&gt;

&lt;p&gt;The uncomfortable truth is that nobody in power actually wants core to be good. If core shipped proper SEO, proper performance, proper security - most small plugin businesses lose their moat and disappear. So maybe WordPress is partly a facade. A smoke screen that lures small fish in, lets them grow a bit, then eats them when they get big enough to matter.&lt;/p&gt;

&lt;p&gt;I'm not leaving. I've spent 15 years on this thing. It's a huge part of my life and I'm actually good at it. But I'm done pretending it's something it isn't. WordPress is still the best tool for a huge chunk of the web. I just want it to stop lying about what it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Revolution or collapse
&lt;/h2&gt;

&lt;p&gt;Here's the part nobody in the WordPress world wants to hear. The era where WordPress had no real alternative is ending. AI is already writing whole sites, whole themes, whole plugins. Every month the tooling gets better. The moat that protected the WordPress empire - "it's the easiest way to build a site" - is evaporating. Fast.&lt;/p&gt;

&lt;p&gt;Long term, I see exactly two paths:&lt;/p&gt;

&lt;p&gt;Revolution. The community wakes up, pushes back on the Automattic-owned "open source" theater, forces core to become genuinely lean, fast, secure and SEO-aware, draws a clean line between core/plugins/themes, and opens the contributor pipeline to people who actually want to fix things. WordPress earns its 40% by being good, not by being entrenched.&lt;/p&gt;

&lt;p&gt;Collapse. Nothing changes. Core keeps bloating. The contributor pipeline keeps ghosting people. Automattic keeps extracting. Small devs and small agencies get picked off one by one by AI and by the big players. And WordPress slowly turns into the Roman Empire of the web - still huge on a map, still quoting old stats, rotting from the inside while the world moves on.&lt;/p&gt;

&lt;p&gt;This is a wake-up call. Not a goodbye. I'd rather see the revolution. But if nothing changes, the collapse is already on the calendar - we're just arguing about the date.&lt;/p&gt;

&lt;p&gt;Tags: wordpress · opinion · manifesto · open-source · automattic · gpl&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>opinion</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
