<?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: Aoba Works</title>
    <description>The latest articles on DEV Community by Aoba Works (@aobaworks).</description>
    <link>https://dev.to/aobaworks</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%2F4190387%2F73db9167-55f9-43bd-b136-eb2f99919c55.png</url>
      <title>DEV Community: Aoba Works</title>
      <link>https://dev.to/aobaworks</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aobaworks"/>
    <language>en</language>
    <item>
      <title>Claude Code Hooks Not Working: 8 Fixes and a Debug Script</title>
      <dc:creator>Aoba Works</dc:creator>
      <pubDate>Sat, 07 Nov 2026 17:34:28 +0000</pubDate>
      <link>https://dev.to/aobaworks/claude-code-hooks-not-working-8-fixes-and-a-debug-script-58jc</link>
      <guid>https://dev.to/aobaworks/claude-code-hooks-not-working-8-fixes-and-a-debug-script-58jc</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://en.aobaworks.com/claude-code-hooks-not-working-8-fixes-and-a-debug-script/?utm_source=devto&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unattended&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Claude Code hooks not working? Run &lt;code&gt;/hooks&lt;/code&gt; first: if the hook isn't listed, Claude Code never loaded it, so check the file location and JSON. If it is listed, the official docs are blunt: &lt;a href="https://code.claude.com/docs/en/debug-your-config" rel="noopener noreferrer"&gt;"If the hook appears but doesn't fire, the matcher is the usual cause."&lt;/a&gt; If the matcher is right, the problem is usually the exit code, the script's environment, or the way you start &lt;code&gt;claude -p&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which option is right for you?
&lt;/h2&gt;

&lt;p&gt;For most Claude Code users, the best start is &lt;code&gt;/hooks&lt;/code&gt; plus the debug script below. On a tight budget, stick to command hooks and that script, because only &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;prompt and agent hooks call a Claude model&lt;/a&gt;. If nothing makes sense, run &lt;code&gt;claude --safe-mode&lt;/code&gt;, then try a clean config, to &lt;a href="https://code.claude.com/docs/en/debug-your-config" rel="noopener noreferrer"&gt;isolate your customizations&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why are my Claude Code hooks not running or firing?
&lt;/h2&gt;

&lt;p&gt;Claude Code hooks usually fail to fire because the hook never loaded, the matcher misses, or you are watching the wrong event.&lt;/p&gt;

&lt;p&gt;Work through the eight fixes in this order. The earlier ones are quicker to rule out.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Not listed in &lt;code&gt;/hooks&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Wrong file, bad JSON or untrusted folder&lt;/td&gt;
&lt;td&gt;Move it to a settings file and validate it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Listed, never fires&lt;/td&gt;
&lt;td&gt;Matcher typo or wrong case&lt;/td&gt;
&lt;td&gt;Use the exact tool name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Fires, doesn't block&lt;/td&gt;
&lt;td&gt;Exit 1 or misplaced JSON&lt;/td&gt;
&lt;td&gt;Use exit 2 or valid &lt;code&gt;hookSpecificOutput&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Stop hook keeps Claude going&lt;/td&gt;
&lt;td&gt;No &lt;code&gt;stop_hook_active&lt;/code&gt; check&lt;/td&gt;
&lt;td&gt;Exit 0 when it is &lt;code&gt;true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Notification stays silent&lt;/td&gt;
&lt;td&gt;Expecting an alert on every response&lt;/td&gt;
&lt;td&gt;Match a real notification type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;hook error&lt;/code&gt; in the transcript&lt;/td&gt;
&lt;td&gt;Permissions, path or missing &lt;code&gt;jq&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Run the script by hand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Works in the terminal, not in cron&lt;/td&gt;
&lt;td&gt;Working directory&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cd&lt;/code&gt; into the repo first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Works interactively, not in a script&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--bare&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Remove it for runs that need hooks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Also confirm the event itself. &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;&lt;code&gt;PreToolUse&lt;/code&gt; fires before a tool runs and &lt;code&gt;PostToolUse&lt;/code&gt; after it&lt;/a&gt;, and a successful hook shows nothing in the transcript. A working hook and a dead one can therefore look the same, which is why the logging script later in this guide helps. If you're unsure which event you need, see our &lt;a href="https://en.aobaworks.com/claude-code-hooks-lifecycle-every-event-explained/" rel="noopener noreferrer"&gt;Claude Code hooks lifecycle guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;Edit|Write&lt;/code&gt; hook can also look broken when Claude changes a file through a shell command. The guide notes that &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;Claude can modify files with shell commands&lt;/a&gt;, so audit hooks should also match &lt;code&gt;Bash|PowerShell&lt;/code&gt; or scan the working tree from a &lt;code&gt;Stop&lt;/code&gt; hook.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where should hooks go in settings.json (and why is my JSON ignored)?
&lt;/h2&gt;

&lt;p&gt;Put hooks under the &lt;code&gt;"hooks"&lt;/code&gt; key in a Claude Code settings file; broken JSON makes Claude Code skip that file.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Location&lt;/th&gt;
&lt;th&gt;Applies to&lt;/th&gt;
&lt;th&gt;Common trap&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~/.claude/settings.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All your projects&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://code.claude.com/docs/en/debug-your-config" rel="noopener noreferrer"&gt;&lt;code&gt;~/.claude.json&lt;/code&gt; is a different file&lt;/a&gt;, and hooks don't belong there&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.claude/settings.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One project&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;Loads from the current directory with no parent-directory fallback&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.claude/settings.local.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One project, you only&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;Loads from the git root even when you start in a subfolder&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plugin &lt;code&gt;hooks/hooks.json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;While the plugin is enabled&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/debug-your-config" rel="noopener noreferrer"&gt;Only plugins get a standalone hooks file&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second and third rows produce a confusing result. If you start Claude Code in &lt;code&gt;repo/src&lt;/code&gt;, your local file can still load while the committed one doesn't.&lt;/p&gt;

&lt;p&gt;The nesting must be event, then matcher group, then handler:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://code.claude.com/docs/en/settings" rel="noopener noreferrer"&gt;Settings files are strict JSON&lt;/a&gt;: a &lt;code&gt;//&lt;/code&gt; comment or trailing comma is a syntax error. Interactive sessions show a &lt;strong&gt;Settings Error&lt;/strong&gt; dialog for that. An individually invalid entry, such as an unknown event name, gets a &lt;strong&gt;Settings Warning&lt;/strong&gt; instead, and the rest of the file keeps working. To check files without opening a session, run &lt;code&gt;claude doctor&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Three other things can hide a valid hook:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Workspace trust.&lt;/strong&gt; An interactive session &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;holds back settings-file hooks&lt;/a&gt; until you accept the trust dialog for the folder, including hooks in your user file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;disableAllHooks&lt;/code&gt;.&lt;/strong&gt; &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Precedence applies&lt;/a&gt;, so a project value can override your user value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed policy.&lt;/strong&gt; If &lt;code&gt;/hooks&lt;/code&gt; shows &lt;code&gt;Only hooks from managed settings run here&lt;/code&gt;, &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;&lt;code&gt;allowManagedHooksOnly&lt;/code&gt; is on&lt;/a&gt; and your own hooks won't run or appear.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Is the hook matcher case-sensitive? Why doesn't my matcher match?
&lt;/h2&gt;

&lt;p&gt;Yes, Claude Code hook matchers are case-sensitive, so &lt;code&gt;bash&lt;/code&gt; never matches the &lt;code&gt;Bash&lt;/code&gt; tool.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://code.claude.com/docs/en/debug-your-config" rel="noopener noreferrer"&gt;Tool names are capitalized&lt;/a&gt;: &lt;code&gt;Bash&lt;/code&gt;, &lt;code&gt;Edit&lt;/code&gt;, &lt;code&gt;Write&lt;/code&gt;, &lt;code&gt;Read&lt;/code&gt;. The rest depends on &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;how the matcher string is evaluated&lt;/a&gt;. &lt;code&gt;*&lt;/code&gt;, an empty string or no matcher means match all. Strings containing only letters, digits, &lt;code&gt;_&lt;/code&gt;, &lt;code&gt;-&lt;/code&gt;, spaces, &lt;code&gt;,&lt;/code&gt; and &lt;code&gt;|&lt;/code&gt; are exact names or lists. Anything else is an unanchored JavaScript regular expression.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Matcher&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Use instead&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;"bash"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Matches nothing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"Bash"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;["Edit","Write"]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/debug-your-config" rel="noopener noreferrer"&gt;Invalid entry; under &lt;code&gt;PreToolUse&lt;/code&gt; or &lt;code&gt;PermissionRequest&lt;/code&gt;, the file's other hooks don't load either&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;`"Edit\&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;{% raw %}&lt;code&gt;"Edit,Write"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/debug-your-config" rel="noopener noreferrer"&gt;Matches both on v2.1.191 or later, nothing on older versions&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;`"Edit\&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;{% raw %}&lt;code&gt;"Edit.*"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Also matches &lt;code&gt;NotebookEdit&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"^Edit$"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;"mcp__memory"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;An exact string that matches no tool&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"mcp__memory__.*"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Not every event matches on a tool name. &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;&lt;code&gt;SessionStart&lt;/code&gt; matches how the session started, &lt;code&gt;Notification&lt;/code&gt; matches its type, and &lt;code&gt;Stop&lt;/code&gt; and &lt;code&gt;UserPromptSubmit&lt;/code&gt; have no matcher support&lt;/a&gt;, so they fire every time.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;if&lt;/code&gt; field has its own trap. It &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;only works on tool events, and adding it to any other event prevents the hook from running&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exit code vs. stdout JSON: why is my hook output ignored or not blocking?
&lt;/h2&gt;

&lt;p&gt;Claude Code treats exit 2 as a block and exit 0 as success; exit 1 is normally non-blocking, so the action still proceeds.&lt;/p&gt;

&lt;p&gt;This table summarizes the &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt; and &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Exit code&lt;/th&gt;
&lt;th&gt;Stdout&lt;/th&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;One JSON object and nothing else&lt;/td&gt;
&lt;td&gt;Fields are applied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Anything not starting with &lt;code&gt;{&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Treated as plain text, with no error shown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Anything&lt;/td&gt;
&lt;td&gt;Blocks on events that can block; stderr is the reason unless JSON supplies one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other&lt;/td&gt;
&lt;td&gt;Valid JSON that passes validation&lt;/td&gt;
&lt;td&gt;The JSON alone decides; the exit code is ignored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other&lt;/td&gt;
&lt;td&gt;Empty, plain text or invalid JSON&lt;/td&gt;
&lt;td&gt;Non-blocking error; the action goes ahead&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The current docs say a hook that prints JSON and exits 2 still blocks, and Claude Code still reads the JSON fields. They also advise choosing one approach per hook, and that keeps scripts easier to reason about. Use exit 2 with a stderr message for a simple block, or exit 0 with JSON for structured control:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hookSpecificOutput"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"hookEventName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"permissionDecision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"permissionDecisionReason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Use rg instead of grep"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The silent failures usually come from one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shell profile output.&lt;/strong&gt; An unconditional &lt;code&gt;echo&lt;/code&gt; in &lt;code&gt;~/.zshrc&lt;/code&gt; can land ahead of your JSON. Search the debug log for &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;&lt;code&gt;Hook output does not start with {&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong level.&lt;/strong&gt; &lt;code&gt;permissionDecision&lt;/code&gt; at the top level parses but is ignored. Search for &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;&lt;code&gt;Hook JSON output had unrecognized keys&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Too late to block.&lt;/strong&gt; &lt;code&gt;PostToolUse&lt;/code&gt; &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;can't undo an action&lt;/a&gt; because the tool already ran.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treating exit 0 as approval.&lt;/strong&gt; On &lt;code&gt;PreToolUse&lt;/code&gt;, exit 0 &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;leaves the normal permission flow in place&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timeouts.&lt;/strong&gt; A timed-out &lt;code&gt;PreToolUse&lt;/code&gt; hook &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;doesn't block the call unless &lt;code&gt;onFailure: "block"&lt;/code&gt; is set&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For copy-paste patterns, see our &lt;a href="https://en.aobaworks.com/claude-code-pretooluse-hook-copy-paste-block-scripts/" rel="noopener noreferrer"&gt;PreToolUse block scripts&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is my Claude Code Stop hook not working (and what is stop_hook_active)?
&lt;/h2&gt;

&lt;p&gt;A Claude Code Stop hook fires when Claude finishes responding, not at task completion, and should exit 0 when &lt;code&gt;stop_hook_active&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;stop_hook_active&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;&lt;/a&gt; when Claude is already continuing because of a stop hook. Without that check, your hook can keep sending Claude back to work until the safety cap applies: &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;"Claude Code overrides a Stop hook after it blocks eight times in a row with no tool call from Claude in between."&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Add this check to the top of the script. It's the guard from the official guide:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nv"&gt;INPUT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INPUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.stop_hook_active'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"true"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0  &lt;span class="c"&gt;# Allow Claude to stop&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="c"&gt;# ... rest of your hook logic&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then check these in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Remove any &lt;code&gt;matcher&lt;/code&gt;. &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Stop ignores it&lt;/a&gt;, so it can't narrow the hook.&lt;/li&gt;
&lt;li&gt;Block with exit 2 and a stderr message, or with &lt;code&gt;decision: "block"&lt;/code&gt; plus a &lt;code&gt;reason&lt;/code&gt;. The &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;reason&lt;/code&gt; is required&lt;/a&gt; with that decision.&lt;/li&gt;
&lt;li&gt;Read &lt;code&gt;last_assistant_message&lt;/code&gt; from the input rather than parsing the transcript, because the &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;transcript is written asynchronously and may lag&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Raise the cap with &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;&lt;code&gt;CLAUDE_CODE_STOP_HOOK_BLOCK_CAP&lt;/code&gt;&lt;/a&gt; only if your hook truly needs more than eight rounds.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Scheduled jobs have one more gap. Stop hooks &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;don't fire on user interrupts, and API errors fire &lt;code&gt;StopFailure&lt;/code&gt; instead&lt;/a&gt;. A cleanup script attached only to &lt;code&gt;Stop&lt;/code&gt; will therefore miss a rate-limited run. Our &lt;a href="https://en.aobaworks.com/claude-code-stop-hook-block-early-exit-without-loops/" rel="noopener noreferrer"&gt;Stop hook guide&lt;/a&gt; covers loop-safe patterns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is my Claude Code Notification hook not working?
&lt;/h2&gt;

&lt;p&gt;A Claude Code Notification hook fires only for specific notification types, such as &lt;code&gt;permission_prompt&lt;/code&gt; and &lt;code&gt;idle_prompt&lt;/code&gt;, not after every response.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No alert when Claude finishes a reply&lt;/td&gt;
&lt;td&gt;Notification isn't a reply-finished event&lt;/td&gt;
&lt;td&gt;Use a &lt;code&gt;Stop&lt;/code&gt; hook&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quick approvals never alert&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;permission_prompt&lt;/code&gt; needs the &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;prompt to wait about six seconds&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Leave the prompt open while testing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exit 2 doesn't stop anything&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Notification can't block; its exit code and stderr are ignored&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Use it for side effects only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Matcher &lt;code&gt;"permission"&lt;/code&gt; never hits&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;Values are exact types&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;permission_prompt&lt;/code&gt;, or omit the matcher&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OS alert never appears&lt;/td&gt;
&lt;td&gt;The command itself fails&lt;/td&gt;
&lt;td&gt;Run it outside Claude Code first&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Turning off desktop alerts isn't the cause. &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;preferredNotifChannel&lt;/code&gt; changes how you're alerted, not whether your hook runs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Test the notifier on its own before blaming the hook. On macOS, for example, run &lt;code&gt;osascript -e 'display notification "test"'&lt;/code&gt;. On Linux, try &lt;code&gt;notify-send test&lt;/code&gt;. If neither shows anything in your terminal, the hook won't fix that.&lt;/p&gt;

&lt;p&gt;Unattended runs need a different plan. The docs don't list Notification behavior for each mode, but &lt;code&gt;claude -p&lt;/code&gt; &lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;denies calls that need approval rather than prompting&lt;/a&gt;. Our inference is that you shouldn't rely on &lt;code&gt;permission_prompt&lt;/code&gt; for cron alerts. Use &lt;code&gt;Stop&lt;/code&gt;, &lt;code&gt;StopFailure&lt;/code&gt; or a &lt;code&gt;PermissionRequest&lt;/code&gt; hook instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I fix hook script permissions, paths and environment problems?
&lt;/h2&gt;

&lt;p&gt;Claude Code can only run a hook script that is executable, findable and fast enough; check &lt;code&gt;chmod +x&lt;/code&gt;, paths, &lt;code&gt;jq&lt;/code&gt; and timeout.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Make the script executable with &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;&lt;code&gt;chmod +x ./my-hook.sh&lt;/code&gt;&lt;/a&gt;, and start it with a shebang such as &lt;code&gt;#!/bin/bash&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Run it by hand with the official test, then read the exit code:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"tool_name":"Bash","tool_input":{"command":"ls"}}'&lt;/span&gt; | ./my-hook.sh
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Reference the script with &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;an absolute path or &lt;code&gt;${CLAUDE_PROJECT_DIR}&lt;/code&gt;&lt;/a&gt;. Add &lt;code&gt;"args": []&lt;/code&gt; to use exec form, which starts it without a shell and needs no quoting. In shell form, &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;wrap the path in double quotes&lt;/a&gt;:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$CLAUDE_PROJECT_DIR&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;/.claude/hooks/check.sh"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;If you see &lt;code&gt;jq: command not found&lt;/code&gt;, &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;install &lt;code&gt;jq&lt;/code&gt; or parse with Python or Node.js&lt;/a&gt;. Under cron, use absolute tool paths inside the script, because cron's &lt;code&gt;PATH&lt;/code&gt; is usually far smaller than your login shell's.&lt;/li&gt;
&lt;li&gt;Compare your runtime with the &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;default timeouts&lt;/a&gt; (as of November 2026). Set a &lt;code&gt;timeout&lt;/code&gt; in seconds to override them.&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hook type or event&lt;/th&gt;
&lt;th&gt;Default timeout&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;command&lt;/code&gt;, &lt;code&gt;http&lt;/code&gt;, &lt;code&gt;mcp_tool&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;10 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;UserPromptSubmit&lt;/code&gt;, &lt;code&gt;PreModelSwitch&lt;/code&gt;, &lt;code&gt;PostModelSwitch&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;30 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MessageDisplay&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prompt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;30 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;agent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;60 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SessionEnd&lt;/code&gt;, any type&lt;/td&gt;
&lt;td&gt;Shared 1.5-second budget, raised up to 60 seconds by a longer per-hook &lt;code&gt;timeout&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;SessionEnd&lt;/code&gt; budget catches many cleanup scripts. On Windows, a &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;shell-form hook with no &lt;code&gt;shell&lt;/code&gt; set runs in PowerShell when Git Bash is absent&lt;/a&gt;, so a Bash script may fail there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do Claude Code hooks work in headless mode (claude -p)?
&lt;/h2&gt;

&lt;p&gt;Yes, Claude Code hooks run in &lt;code&gt;claude -p&lt;/code&gt;, unless you add &lt;code&gt;--bare&lt;/code&gt;, which skips hook discovery and ignores hooks passed through &lt;code&gt;--settings&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The official wording is direct: &lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;"Hooks still don't run in bare mode when you pass them with &lt;code&gt;--settings&lt;/code&gt;."&lt;/a&gt; The same page calls &lt;code&gt;--bare&lt;/code&gt; the recommended mode for scripted calls and says it &lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;"will become the default for &lt;code&gt;-p&lt;/code&gt; in a future release."&lt;/a&gt; So a job that depends on hooks could break after an upgrade. Check the release notes before updating that machine, and keep the test run below handy.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Headless behavior&lt;/th&gt;
&lt;th&gt;What it breaks&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.claude/settings.json&lt;/code&gt; &lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;loads from the current directory&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Cron starts elsewhere and project hooks never load&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cd /path/to/repo &amp;amp;&amp;amp; claude -p ...&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/settings" rel="noopener noreferrer"&gt;No dialog for broken settings&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;The file or value is skipped and the run continues&lt;/td&gt;
&lt;td&gt;Run &lt;code&gt;claude doctor&lt;/code&gt; after an odd run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;No trust dialog; the folder counts as trusted&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A cloned repo's hooks run on your machine&lt;/td&gt;
&lt;td&gt;Review &lt;code&gt;.claude/&lt;/code&gt;, or pass &lt;code&gt;--settings '{"disableAllHooks": true}'&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;&lt;code&gt;PermissionRequest&lt;/code&gt; still runs outside &lt;code&gt;dontAsk&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A call no hook decides is denied&lt;/td&gt;
&lt;td&gt;Return a decision or add allow rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Async hooks are killed at teardown&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Background work ends with &lt;code&gt;cancelled&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Start a fully detached process&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To see what a headless run actually does, use this example. Replace the path with your repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /path/to/repo &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"List this folder using Bash"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allowedTools&lt;/span&gt; &lt;span class="s2"&gt;"Bash(ls *)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output-format&lt;/span&gt; stream-json &lt;span class="nt"&gt;--verbose&lt;/span&gt; &lt;span class="nt"&gt;--include-hook-events&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--debug-file&lt;/span&gt; /tmp/claude-hooks.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't treat a missing event as proof of failure. &lt;a href="https://code.claude.com/docs/en/cli-reference" rel="noopener noreferrer"&gt;&lt;code&gt;Notification&lt;/code&gt;, &lt;code&gt;SessionEnd&lt;/code&gt;, &lt;code&gt;PreCompact&lt;/code&gt; and &lt;code&gt;PostCompact&lt;/code&gt; never produce a &lt;code&gt;hook_started&lt;/code&gt; event&lt;/a&gt;, even with that flag. To turn this into a scheduled job, follow our &lt;a href="https://en.aobaworks.com/claude-code-cron-job-setup-run-automations-overnight/" rel="noopener noreferrer"&gt;cron setup guide&lt;/a&gt;. For the flag and auth details, see the &lt;a href="https://en.aobaworks.com/claude-code-headless-mode-flags-auth-and-cost-2026/" rel="noopener noreferrer"&gt;headless mode guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I reload hooks: do I need /hooks or a restart?
&lt;/h2&gt;

&lt;p&gt;Usually neither: Claude Code normally reloads hook edits automatically, and &lt;code&gt;/hooks&lt;/code&gt; is a read-only view rather than a reload button.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://code.claude.com/docs/en/settings" rel="noopener noreferrer"&gt;settings docs&lt;/a&gt; say Claude Code watches your settings files and applies &lt;code&gt;hooks&lt;/code&gt; edits to the running session. Advice that hooks only load at startup doesn't match the current documentation.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Save the file and wait a few seconds.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;/hooks&lt;/code&gt; again. &lt;a href="https://code.claude.com/docs/en/debug-your-config" rel="noopener noreferrer"&gt;Rerunning it refreshes the view&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Select the hook to see &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;its full command and where it's defined&lt;/a&gt;. Choose &lt;code&gt;All events&lt;/code&gt; to see events with no hooks.&lt;/li&gt;
&lt;li&gt;If nothing changed, &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;restart the session&lt;/a&gt;, because the watcher may have missed the edit.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two exceptions are worth knowing. &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;Skill frontmatter hooks don't appear until the skill is invoked&lt;/a&gt;. And &lt;a href="https://code.claude.com/docs/en/debug-your-config" rel="noopener noreferrer"&gt;before v2.1.257, edits in a &lt;code&gt;.claude/&lt;/code&gt; folder created mid-session weren't detected&lt;/a&gt;, so run &lt;code&gt;claude -v&lt;/code&gt; if you're on an old install.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I debug Claude Code hooks with a payload-logging script?
&lt;/h2&gt;

&lt;p&gt;Log the JSON each command hook receives on stdin to see exactly what fired and what your script gets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Save this as &lt;code&gt;.claude/hooks/debug-log.sh&lt;/code&gt;.&lt;/strong&gt; It writes nothing to stdout and always exits 0, so it won't make a decision for Claude Code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# Logs every hook payload. For temporary debugging only.&lt;/span&gt;
&lt;span class="nb"&gt;umask &lt;/span&gt;077
&lt;span class="nv"&gt;LOG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.claude/hook-debug.log"&lt;/span&gt;
&lt;span class="nv"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'--- %s pwd=%s project=%s\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%FT%TZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLAUDE_PROJECT_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'PATH=%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Make it executable&lt;/strong&gt; with &lt;code&gt;chmod +x .claude/hooks/debug-log.sh&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Register it in &lt;code&gt;.claude/settings.local.json&lt;/code&gt;.&lt;/strong&gt; With no matcher, it &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;fires on every occurrence&lt;/a&gt;. Merge this into any existing &lt;code&gt;"hooks"&lt;/code&gt; key, then add the same entry under &lt;code&gt;SessionStart&lt;/code&gt;, &lt;code&gt;PostToolUse&lt;/code&gt;, &lt;code&gt;Notification&lt;/code&gt;, &lt;code&gt;Stop&lt;/code&gt; and the event you're debugging if it's missing:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Trigger the behavior&lt;/strong&gt;, then run &lt;code&gt;tail -f ~/.claude/hook-debug.log&lt;/code&gt; in another terminal. Matching hooks &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;run in parallel&lt;/a&gt;, so this logger sits alongside your real hook. Each payload includes &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;common fields such as &lt;code&gt;session_id&lt;/code&gt;, &lt;code&gt;transcript_path&lt;/code&gt;, &lt;code&gt;cwd&lt;/code&gt;, &lt;code&gt;permission_mode&lt;/code&gt; and &lt;code&gt;hook_event_name&lt;/code&gt;&lt;/a&gt;, and tool events add &lt;code&gt;tool_name&lt;/code&gt; and &lt;code&gt;tool_input&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the log shows&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Next check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No file, no entries&lt;/td&gt;
&lt;td&gt;Hooks aren't loading&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/hooks&lt;/code&gt;, the file location, &lt;code&gt;--bare&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SessionStart&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;Your event didn't occur&lt;/td&gt;
&lt;td&gt;Wrong event, or no tool call happened&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tool_name&lt;/code&gt; differs from your matcher&lt;/td&gt;
&lt;td&gt;Matcher miss&lt;/td&gt;
&lt;td&gt;Copy the exact name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event logged, your script has no effect&lt;/td&gt;
&lt;td&gt;Script failure or wrong output&lt;/td&gt;
&lt;td&gt;Run it by hand, then check the exit code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Different &lt;code&gt;pwd&lt;/code&gt; or &lt;code&gt;PATH&lt;/code&gt; under cron&lt;/td&gt;
&lt;td&gt;Environment mismatch&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cd&lt;/code&gt; first, use absolute paths&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;5. Read Claude Code's own log.&lt;/strong&gt; Start with &lt;code&gt;claude --debug-file /tmp/claude.log&lt;/code&gt;, or run &lt;code&gt;/debug&lt;/code&gt; mid-session to &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;enable logging and find its path&lt;/a&gt;. Plain &lt;code&gt;claude --debug&lt;/code&gt; can look broken, since &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;"The &lt;code&gt;--debug&lt;/code&gt; flag doesn't print to the terminal."&lt;/a&gt; Its log goes to &lt;code&gt;~/.claude/debug/&amp;lt;session-id&amp;gt;.txt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On v2.1.296 or later, search for &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;Hooks:&lt;/code&gt; lines&lt;/a&gt;, such as &lt;code&gt;Hooks: PostToolUse:Write [.claude/hooks/log-write.sh] finished with status 0 (31ms)&lt;/code&gt;. Other endings include &lt;code&gt;timed out after &amp;lt;N&amp;gt;ms&lt;/code&gt; and &lt;code&gt;failed to start&lt;/code&gt;. For matcher counts, set &lt;code&gt;CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In a live session, &lt;code&gt;Ctrl+O&lt;/code&gt; &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;opens the transcript view&lt;/a&gt;, where a non-blocking failure appears as a &lt;code&gt;&amp;lt;hook name&amp;gt; hook error&lt;/code&gt; notice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Clean up.&lt;/strong&gt; Remove the entries and delete the log when you're done, since payloads can include prompts, commands and file contents. &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Command hooks run with your full user permissions&lt;/a&gt;, so read any hook you copy before installing it.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Where is the Claude Code hooks documentation?
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt; covers event schemas, output fields and debug logs. The &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt; covers setup and troubleshooting. For settings problems, use &lt;a href="https://code.claude.com/docs/en/debug-your-config" rel="noopener noreferrer"&gt;Debug your configuration&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do Claude Code hooks run in parallel and what is the default timeout?
&lt;/h3&gt;

&lt;p&gt;Yes, &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;matching hooks run in parallel&lt;/a&gt;, and an identical handler defined in several settings files runs once. Command hooks default to 10 minutes as of November 2026, with &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;shorter defaults on some events&lt;/a&gt;. Override them per hook with &lt;code&gt;timeout&lt;/code&gt;, in seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can a hook block a tool call before it runs?
&lt;/h3&gt;

&lt;p&gt;Yes. Use &lt;code&gt;PreToolUse&lt;/code&gt; with exit 2 or &lt;code&gt;permissionDecision: "deny"&lt;/code&gt;. A deny &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;holds even in &lt;code&gt;bypassPermissions&lt;/code&gt; mode&lt;/a&gt;, with one documented exception: on machines without managed settings or a Team or Enterprise &lt;code&gt;/login&lt;/code&gt;, an installed mod can approve a call your hook blocked. An &lt;code&gt;"allow"&lt;/code&gt; from a hook doesn't override deny rules.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my hook run but Claude ignores its output?
&lt;/h3&gt;

&lt;p&gt;Claude never &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;sees stderr from a hook that exits 0&lt;/a&gt;, and most events don't add plain stdout to Claude's context. Return &lt;code&gt;hookSpecificOutput.additionalContext&lt;/code&gt;, nested correctly, because &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;a top-level &lt;code&gt;additionalContext&lt;/code&gt; is silently ignored&lt;/a&gt;. Output over 10,000 characters is &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;saved to a file, and Claude gets the path plus a 2,000-character preview&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If you'd rather not dig through logs yourself:&lt;/strong&gt; run &lt;code&gt;/debug&lt;/code&gt; followed by your issue in a session. It &lt;a href="https://code.claude.com/docs/en/debug-your-config" rel="noopener noreferrer"&gt;turns on debug logging and prompts Claude to diagnose the problem&lt;/a&gt;, while &lt;code&gt;/doctor&lt;/code&gt; &lt;a href="https://code.claude.com/docs/en/debug-your-config" rel="noopener noreferrer"&gt;proposes fixes and asks before applying them&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Claude Code is included in paid plans: &lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;Pro is $20 billed monthly or $17 a month billed annually, Max is $100 a month, and a Team standard seat is $25 billed monthly or $20 billed annually&lt;/a&gt; (as of November 2026; check the official page). Pro suits a solo builder with one or two automations, while Max makes more sense only if you already hit Pro's usage limits. Neither suits someone who wants a person to take responsibility for the fix, and Claude's diagnosis can be wrong, so test the result with the manual command above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related guides:&lt;/strong&gt; &lt;a href="https://en.aobaworks.com/claude-code-hooks-lifecycle-every-event-explained/" rel="noopener noreferrer"&gt;Hooks lifecycle&lt;/a&gt;, &lt;a href="https://en.aobaworks.com/claude-code-stop-hook-block-early-exit-without-loops/" rel="noopener noreferrer"&gt;Stop hook without loops&lt;/a&gt;, &lt;a href="https://en.aobaworks.com/claude-code-cron-job-setup-run-automations-overnight/" rel="noopener noreferrer"&gt;Cron job setup&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;p&gt;The linked claims above come from these official pages, read on November 8, 2026.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Hooks reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;Automate actions with hooks (hooks guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/debug-your-config" rel="noopener noreferrer"&gt;Debug your configuration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/settings" rel="noopener noreferrer"&gt;Settings files and precedence&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Run Claude Code programmatically (headless)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;Configure permissions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/cli-reference" rel="noopener noreferrer"&gt;CLI reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;Claude plans and pricing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was created with the help of AI. Sources are linked inline; check the official documentation for the latest details.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>hooks</category>
      <category>debugging</category>
      <category>headless</category>
    </item>
    <item>
      <title>AI Agent vs Automation: A 5-Question Test for Claude Code</title>
      <dc:creator>Aoba Works</dc:creator>
      <pubDate>Fri, 06 Nov 2026 17:15:53 +0000</pubDate>
      <link>https://dev.to/aobaworks/ai-agent-vs-automation-a-5-question-test-for-claude-code-3mih</link>
      <guid>https://dev.to/aobaworks/ai-agent-vs-automation-a-5-question-test-for-claude-code-3mih</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://en.aobaworks.com/ai-agent-vs-automation-a-5-question-test-for-claude-code/?utm_source=devto&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unattended&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The difference between an AI agent and automation comes down to who picks the next step. With automation, you write every step ahead of time and the same input always takes the same path. With an AI agent such as Claude Code, the model looks at the situation and chooses its own next action. Anthropic's engineering guide puts it this way: workflows are systems "orchestrated through predefined code paths," and agents are systems "where LLMs dynamically direct their own processes and tool usage" (&lt;a href="https://www.anthropic.com/research/building-effective-agents" rel="noopener noreferrer"&gt;Anthropic, Building Effective Agents&lt;/a&gt;). Here is the practical test. If a task needs judgment at points where the right path depends on messy input, use an agent at those points. If the task is just steps on predictable input, write a script.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which option is right for you?
&lt;/h2&gt;

&lt;p&gt;Most solo builders should start with a script. Add a Claude Code agent step only where the script keeps failing on inputs it can't predict.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your situation&lt;/th&gt;
&lt;th&gt;What to pick first&lt;/th&gt;
&lt;th&gt;First page to open&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Beginner, never automated anything&lt;/td&gt;
&lt;td&gt;A plain shell script or cron job. You can read every line, and debugging is easy&lt;/td&gt;
&lt;td&gt;&lt;a href="https://en.aobaworks.com/claude-code-cron-job-setup-run-automations-overnight/" rel="noopener noreferrer"&gt;Claude Code Cron Job Setup&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Daily dev work (PR reviews, failing tests, triage)&lt;/td&gt;
&lt;td&gt;A Claude Code agent in headless mode (&lt;code&gt;claude -p&lt;/code&gt;) with a short tool allowlist&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Run Claude Code programmatically&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Work that lives on GitHub (issues, PRs)&lt;/td&gt;
&lt;td&gt;The Claude Code GitHub Action, triggered by &lt;code&gt;@claude&lt;/code&gt; or a schedule&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code GitHub Actions&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tight budget, fixed and structured inputs&lt;/td&gt;
&lt;td&gt;A script, with no model calls. Each run costs nothing beyond compute&lt;/td&gt;
&lt;td&gt;&lt;a href="https://www.make.com/en/blog/when-to-use-ai-agents" rel="noopener noreferrer"&gt;Make: when to use AI agents&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mostly fixed steps plus one fuzzy decision&lt;/td&gt;
&lt;td&gt;A hybrid: script around a single &lt;code&gt;claude -p --json-schema&lt;/code&gt; call&lt;/td&gt;
&lt;td&gt;The "How do you combine both?" section below&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unattended runs overnight&lt;/td&gt;
&lt;td&gt;A headless agent with strict permissions and retry handling&lt;/td&gt;
&lt;td&gt;&lt;a href="https://en.aobaworks.com/claude-code-permissions-best-practices-headless-template/" rel="noopener noreferrer"&gt;Permissions headless template&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One reason for each pick. Scripts fail loudly and in the same place every time, so they suit beginners. Agents are worth their cost when inputs vary, as with diffs, logs and tickets. The GitHub Action saves you from writing your own server. A hybrid keeps the model's freedom limited to one step.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is the Difference Between AI Agent and Automation?
&lt;/h2&gt;

&lt;p&gt;Automation runs steps you defined in advance. An AI agent like Claude Code decides which steps to run, based on what it observes as it works.&lt;/p&gt;

&lt;p&gt;Under the hood, Anthropic describes the building block of agentic systems as an LLM enhanced with retrieval, tools and memory (&lt;a href="https://www.anthropic.com/research/building-effective-agents" rel="noopener noreferrer"&gt;Building Effective Agents&lt;/a&gt;). Claude Code is that building block packaged as a tool. It reads files, runs shell commands, edits code, and loops until it decides the job is finished. A cron job that runs &lt;code&gt;rsync&lt;/code&gt; never decides anything. It does exactly what the line says.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Scripted automation&lt;/th&gt;
&lt;th&gt;AI agent (e.g. Claude Code)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Who chooses the next step&lt;/td&gt;
&lt;td&gt;You, in code&lt;/td&gt;
&lt;td&gt;The model, at runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same input → same output?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Not guaranteed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Handles inputs you didn't anticipate&lt;/td&gt;
&lt;td&gt;No, it errors or does the wrong thing silently&lt;/td&gt;
&lt;td&gt;Often, by reasoning about them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per run&lt;/td&gt;
&lt;td&gt;Compute only&lt;/td&gt;
&lt;td&gt;Model tokens plus compute (&lt;code&gt;--output-format json&lt;/code&gt; reports &lt;code&gt;total_cost_usd&lt;/code&gt;, per the &lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed&lt;/td&gt;
&lt;td&gt;Milliseconds to seconds&lt;/td&gt;
&lt;td&gt;Seconds to minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure mode&lt;/td&gt;
&lt;td&gt;Crash at a known line&lt;/td&gt;
&lt;td&gt;Plausible but wrong action, or errors that compound&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging&lt;/td&gt;
&lt;td&gt;Read the code&lt;/td&gt;
&lt;td&gt;Read the transcript&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For example, a script that moves invoices from an inbox to a folder works perfectly until a vendor sends a PDF named &lt;code&gt;scan001.pdf&lt;/code&gt; with no invoice number in the subject line. An agent can open the PDF, find the number, and file it. It can also misread it. Both outcomes are possible, and that is the trade.&lt;/p&gt;

&lt;p&gt;Anthropic is direct about the cost: "agentic systems often trade latency and cost for better task performance" (&lt;a href="https://www.anthropic.com/research/building-effective-agents" rel="noopener noreferrer"&gt;Building Effective Agents&lt;/a&gt;). The same article warns about the "potential for compounding errors" in autonomous runs, which is why agents need guardrails that scripts don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI Agent vs Automation Workflow: Which One Fits Your Task?
&lt;/h2&gt;

&lt;p&gt;A task needs a Claude Code agent when it requires branching judgment on varied or unstructured input. If it only needs repeatable steps on structured input, a script fits better.&lt;/p&gt;

&lt;p&gt;Here are the five questions. Answer them for one specific recurring task, not for "my work" in general.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Can you write the steps down completely today?&lt;/strong&gt; If yes, and they cover at least nine out of ten real cases, write a script. If you keep writing "it depends", that is where judgment lives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the input structured?&lt;/strong&gt; JSON from an API, a CSV with fixed columns, and a database row are structured. A bug report, a stack trace, a git diff or a customer email is not. Unstructured input points toward an agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the number of steps vary?&lt;/strong&gt; Anthropic recommends agents for "open-ended problems where it's difficult or impossible to predict the required number of steps" (&lt;a href="https://www.anthropic.com/research/building-effective-agents" rel="noopener noreferrer"&gt;Building Effective Agents&lt;/a&gt;). Fixing a failing test might take one edit or twelve. Syncing two APIs always takes the same calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What does a wrong answer cost?&lt;/strong&gt; If a mistake is cheap and reversible, such as a draft comment or a label, an agent is fine. If it is expensive or permanent, such as a payment, a deleted record or a production deploy, keep that step scripted, or let the agent only propose and have a human approve.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does it run often enough that the cost per run matters?&lt;/strong&gt; A job that runs every 5 minutes pays model costs 288 times a day. If a script can do it, the script wins on budget.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;How to score it: two or more answers that point to an agent (no to Q1, unstructured for Q2, variable for Q3) means an agent step is justified. A high-cost answer on Q4 means that step stays scripted or gated, whatever the other answers say.&lt;/p&gt;

&lt;p&gt;Example, labeled as an illustration. "Every morning, check the CI failures from last night and open a fix PR." Q1: no, because every failure is different. Q2: unstructured logs. Q3: variable. Q4: medium, since a PR is reviewable before merge. Q5: once a day. The result is an agent, with the PR as the human checkpoint. Compare "Every morning, back up the Postgres database to S3." Q1: yes. Q2: structured. Q3: fixed. Q4: high. The result is a script.&lt;/p&gt;

&lt;p&gt;Common mistake: running the test on the whole pipeline instead of on each step. "Triage support tickets" fails the test as one unit. Split it into fetch, classify, route and reply, and usually only "classify" needs an agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI Agent vs Agentic AI vs Automation: How Do the Terms Relate?
&lt;/h2&gt;

&lt;p&gt;Automation is the umbrella. An AI agent such as Claude Code is one kind of automation that makes decisions. "Agentic AI" describes systems built from agent-like parts.&lt;/p&gt;

&lt;p&gt;Anthropic's own vocabulary is the clearest anchor. Its guide says "we categorize all these variations as agentic systems," then splits them into workflows and agents (&lt;a href="https://www.anthropic.com/research/building-effective-agents" rel="noopener noreferrer"&gt;Building Effective Agents&lt;/a&gt;). So in Anthropic's usage, "agentic" covers both LLM workflows (fixed code paths that call a model) and true agents (the model directs the path).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;What it means in practice&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Automation&lt;/td&gt;
&lt;td&gt;Any process that replaces manual work, AI or not&lt;/td&gt;
&lt;td&gt;Cron + bash backup, a Zapier zap, an RPA bot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM workflow&lt;/td&gt;
&lt;td&gt;Fixed code path with model calls at defined steps&lt;/td&gt;
&lt;td&gt;Script that sends each ticket to Claude for a label, then routes by label&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI agent&lt;/td&gt;
&lt;td&gt;Model decides its own steps and tool use in a loop&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claude -p "Run the test suite and fix any failures"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agentic AI / agentic system&lt;/td&gt;
&lt;td&gt;Umbrella for the two above, and for multi-agent setups such as an orchestrator with workers&lt;/td&gt;
&lt;td&gt;Claude Code spawning &lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;subagents&lt;/a&gt; for parallel research&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Vendors stretch these words. Some product pages call any chatbot with a webhook "agentic AI". When you see the term, ask one question: who chooses the next step, the code or the model? That answer tells you more than the label does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is AI Part of Automation, or Separate From It?
&lt;/h2&gt;

&lt;p&gt;AI is one way to automate, and Claude Code is one AI tool among several. Automation is the goal, and AI, scripting and RPA are methods for reaching it.&lt;/p&gt;

&lt;p&gt;The methods sit side by side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rule-based scripting&lt;/strong&gt;: bash, Python, cron, CI pipelines. You write every rule yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RPA (robotic process automation)&lt;/strong&gt;: UiPath defines it as software robots that "handle repetitive, rule-based tasks like entering data, moving files, or processing transactions" (&lt;a href="https://www.uipath.com/rpa/robotic-process-automation" rel="noopener noreferrer"&gt;UiPath&lt;/a&gt;). It is still rules, applied through user interfaces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No-code workflow tools&lt;/strong&gt;: Zapier, Make and n8n. These are rules you build visually, often with an optional AI step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI agents&lt;/strong&gt;: Claude Code, the Agent SDK. The model makes the decisions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;UiPath now describes RPA as "the execution layer" under AI agents (&lt;a href="https://www.uipath.com/rpa/robotic-process-automation" rel="noopener noreferrer"&gt;UiPath&lt;/a&gt;). That reflects the shape most teams end up with: deterministic tools do the actions, and AI decides which action to take.&lt;/p&gt;

&lt;p&gt;For example, think of a solo SaaS founder's weekly churn report. A scheduled script pulls cancellations from Stripe's API, which is plain automation. A Claude call reads the free-text cancellation reasons and groups them into themes, which is AI used inside automation. The script then emails the summary, which is automation again. Calling this either "AI" or "automation" would be only half right.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI Agent Automation Examples: Which Tasks Need an Agent?
&lt;/h2&gt;

&lt;p&gt;Tasks with varied input need an agent. Claude Code agents beat scripts when the fix depends on what the agent finds, as in debugging, code review, triage and research.&lt;/p&gt;

&lt;p&gt;These come straight from the patterns in Anthropic's docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fixing failing tests.&lt;/strong&gt; The headless docs show &lt;code&gt;claude -p "Run the test suite and fix any failures" --allowedTools "Bash,Read,Edit"&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;). No script can know ahead of time which file to edit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explaining build errors.&lt;/strong&gt; &lt;code&gt;cat build-error.txt | claude -p 'concisely explain the root cause of this build error' &amp;gt; output.txt&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;). Error logs are unstructured by nature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pull request review.&lt;/strong&gt; The GitHub Action's review workflow posts inline comments on each issue it finds, and it skips drafts and PRs it judges not to need review (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;GitHub Actions docs&lt;/a&gt;). "Judges" is the key word there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Issue-to-PR.&lt;/strong&gt; Comment &lt;code&gt;@claude fix the TypeError in the user dashboard component&lt;/code&gt; on an issue and the Action works on it (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;GitHub Actions docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commit messages from staged changes.&lt;/strong&gt; The docs scope this with &lt;code&gt;--allowedTools "Bash(git diff *),Bash(git log *),Bash(git status *),Bash(git commit *)"&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pitfalls and fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The agent stops partway through an unattended run.&lt;/strong&gt; In &lt;code&gt;-p&lt;/code&gt; mode with Claude Code v2.1.302 or later, runs start in Manual mode when nothing sets a permission mode, and a call that needs approval is denied (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;). Fix: allowlist exactly the tools the job needs. &lt;a href="https://en.aobaworks.com/why-claude-code-stops-mid-automation-and-how-to-fix-it/" rel="noopener noreferrer"&gt;Why Claude Code stops mid-automation&lt;/a&gt; walks through the causes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The agent waits for an approval nobody will give.&lt;/strong&gt; Add &lt;code&gt;--permission-prompts none&lt;/code&gt; (requires v2.1.259 or later). Prompts are then denied and Claude is told not to retry them (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The agent does too much.&lt;/strong&gt; Put limits on it: &lt;code&gt;--max-turns&lt;/code&gt;, a tool allowlist, and written rules. See the &lt;a href="https://en.aobaworks.com/ai-agent-guardrails-example-copy-paste-claude-code-rules/" rel="noopener noreferrer"&gt;guardrails example&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The agent behaves differently on another machine.&lt;/strong&gt; Use &lt;code&gt;--bare&lt;/code&gt;. It skips the hooks, plugins, MCP servers and CLAUDE.md files found on the host. Note that bare mode needs &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; and doesn't use your subscription login (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Who agents are not for: anyone who needs byte-identical output on every run, such as compliance exports or invoice numbering. Agents also cost money on every run, and that cost varies with codebase size and task complexity (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;GitHub Actions docs&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  AI Automation Examples: Which Tasks Should Stay Scripted?
&lt;/h2&gt;

&lt;p&gt;Backups, syncs between two APIs, templated emails and scheduled reports from fixed queries should stay scripted. They need no judgment, and a script is cheaper, faster and repeatable.&lt;/p&gt;

&lt;p&gt;Make's own guidance says it plainly: for jobs like notifications and record updates, "classic automation is faster, cheaper, and easier to maintain" (&lt;a href="https://www.make.com/en/blog/when-to-use-ai-agents" rel="noopener noreferrer"&gt;Make&lt;/a&gt;). Make lists the conditions where agents don't fit: rules that are fixed and stable, inputs that are clean and structured, and speed and consistency that matter more than judgment.&lt;/p&gt;

&lt;p&gt;Examples where a script is the better tool:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Why a script wins&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nightly &lt;code&gt;pg_dump&lt;/code&gt; to S3&lt;/td&gt;
&lt;td&gt;Same command every night, and a wrong action is costly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Copy new Stripe customers into your CRM&lt;/td&gt;
&lt;td&gt;Structured JSON on both ends, with a fixed field mapping&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Send a welcome email from a template&lt;/td&gt;
&lt;td&gt;No decision at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rotate logs older than 14 days&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;find -mtime +14 -delete&lt;/code&gt; is one line and has no edge cases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bump a version and tag a release&lt;/td&gt;
&lt;td&gt;Deterministic, and reproducibility matters&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pitfalls and fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Putting a model call into a task that has no decision in it.&lt;/strong&gt; This adds cost and latency and makes the result less consistent. Fix: delete the model call and see whether anything gets worse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using a session-scoped &lt;code&gt;/loop&lt;/code&gt; for something durable.&lt;/strong&gt; Recurring &lt;code&gt;/loop&lt;/code&gt; tasks expire 7 days after creation and only fire while the session runs (&lt;a href="https://code.claude.com/docs/en/scheduled-tasks" rel="noopener noreferrer"&gt;scheduled tasks docs&lt;/a&gt;). Fix: use system cron, a &lt;a href="https://code.claude.com/docs/en/scheduled-tasks" rel="noopener noreferrer"&gt;Desktop scheduled task, a cloud Routine&lt;/a&gt;, or GitHub Actions. Cloud Routines have a 1-hour minimum interval, while Desktop tasks and &lt;code&gt;/loop&lt;/code&gt; go down to 1 minute (as of October 2026, per the same page).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming GitHub scheduled workflows run forever.&lt;/strong&gt; In public repositories, GitHub disables a schedule after 60 days without repository activity (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;GitHub Actions docs&lt;/a&gt;). Fix: add a check that alerts you when a run is missing, or schedule the job on a machine you control.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Who scripts are not for: tasks whose inputs change faster than you can update the rules. If you keep patching regexes for new edge cases every week, that is the sign to move that one step to an agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using AI for Automation: How Do You Combine Both?
&lt;/h2&gt;

&lt;p&gt;Use a hybrid. Scripted automation does the deterministic steps, and a single Claude Code call sits at the one decision point that needs judgment.&lt;/p&gt;

&lt;p&gt;Anthropic's guide describes this shape. "Prompt chaining" splits a task into a sequence of steps, and "routing classifies an input and directs it to a specialized followup task" (&lt;a href="https://www.anthropic.com/research/building-effective-agents" rel="noopener noreferrer"&gt;Building Effective Agents&lt;/a&gt;). It lists five workflow patterns in total: prompt chaining, routing, parallelization, orchestrator-workers and evaluator-optimizer. For solo builders, routing is the most useful one: the model labels the input, and your code acts on the label.&lt;/p&gt;

&lt;p&gt;Steps to build one (the example below is an illustration of support-ticket triage):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write the deterministic parts first.&lt;/strong&gt; Fetch the input and save it to a file. Piped stdin to &lt;code&gt;claude -p&lt;/code&gt; is capped at 10MB, and for anything bigger the docs say to write it to a file and reference the path (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the model return a fixed shape.&lt;/strong&gt; Use &lt;code&gt;--output-format json&lt;/code&gt; with &lt;code&gt;--json-schema&lt;/code&gt;. The result arrives in the &lt;code&gt;structured_output&lt;/code&gt; field (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;). An &lt;code&gt;enum&lt;/code&gt; in the schema keeps labels to a closed set your script can branch on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give the agent step the fewest tools possible.&lt;/strong&gt; A classifier needs &lt;code&gt;Read&lt;/code&gt; and nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Branch in code, not in the prompt.&lt;/strong&gt; &lt;code&gt;jq&lt;/code&gt; reads the label, and &lt;code&gt;case&lt;/code&gt; decides what happens next.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the exit code.&lt;/strong&gt; Claude Code "exits with code 0 on success and a non-zero code when the run fails" (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schedule it&lt;/strong&gt; with cron. See &lt;a href="https://en.aobaworks.com/claude-code-cron-job-setup-run-automations-overnight/" rel="noopener noreferrer"&gt;Claude Code Cron Job Setup&lt;/a&gt;.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# Example: hybrid triage. Script steps around one agent step.&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

./fetch_new_tickets.sh &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/tickets.json   &lt;span class="c"&gt;# deterministic (your own script)&lt;/span&gt;

&lt;span class="nv"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string","enum":["refund","bug","other"]}},"required":["id","label"]}}},"required":["items"]}'&lt;/span&gt;

&lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;claude &lt;span class="nt"&gt;--bare&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Read /tmp/tickets.json. Label each ticket refund, bug, or other."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allowedTools&lt;/span&gt; &lt;span class="s2"&gt;"Read"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output-format&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--json-schema&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"agent step failed"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.total_cost_usd'&lt;/span&gt;          &lt;span class="c"&gt;# log spend per run&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'.structured_output.items[]'&lt;/span&gt; | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; t&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; .label&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in
    &lt;/span&gt;refund&lt;span class="p"&gt;)&lt;/span&gt; ./route_to_billing.sh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;      &lt;span class="c"&gt;# deterministic&lt;/span&gt;
    bug&lt;span class="p"&gt;)&lt;/span&gt;    ./open_issue.sh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;            &lt;span class="c"&gt;# deterministic&lt;/span&gt;
    &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      ./send_template_reply.sh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;   &lt;span class="c"&gt;# deterministic&lt;/span&gt;
  &lt;span class="k"&gt;esac&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Common mistakes with hybrids:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Letting the agent take the actions too.&lt;/strong&gt; If Claude both classifies and issues the refund, you have handed a high-cost Q4 step to the model. Keep actions in your own scripts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ignoring rate limits.&lt;/strong&gt; A cron job that hits a limit at 3 a.m. fails silently unless you handle it. The stream emits &lt;code&gt;system/api_retry&lt;/code&gt; events with fields like &lt;code&gt;attempt&lt;/code&gt; and &lt;code&gt;retry_delay_ms&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;). See the &lt;a href="https://en.aobaworks.com/claude-code-rate-limit-reached-retry-setup-for-cron-jobs/" rel="noopener noreferrer"&gt;rate limit retry setup&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not tracking token spend.&lt;/strong&gt; &lt;code&gt;total_cost_usd&lt;/code&gt; is a client-side estimate and can differ from your bill (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;). Log it anyway, and read &lt;a href="https://en.aobaworks.com/claude-code-how-to-see-token-usage-and-reduce-it/" rel="noopener noreferrer"&gt;how to see token usage and reduce it&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI Agent and Automation Course: Is It Worth Taking One?
&lt;/h2&gt;

&lt;p&gt;You usually don't need a paid course. Anthropic's Claude Code documentation, the Building Effective Agents article and the cookbook cover the core material at no cost.&lt;/p&gt;

&lt;p&gt;What the free material covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Concepts and patterns:&lt;/strong&gt; &lt;a href="https://www.anthropic.com/research/building-effective-agents" rel="noopener noreferrer"&gt;Building Effective Agents&lt;/a&gt;, published December 19, 2024. It defines workflows and agents and the five patterns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Running agents headless:&lt;/strong&gt; the &lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt; and the &lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;GitHub Actions docs&lt;/a&gt;, with copy-paste commands and YAML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code recipes:&lt;/strong&gt; the &lt;a href="https://github.com/anthropics/claude-cookbooks" rel="noopener noreferrer"&gt;anthropics/claude-cookbooks&lt;/a&gt; repo, "a collection of notebooks/recipes" that includes tool use and agent examples.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured lessons:&lt;/strong&gt; the &lt;a href="https://anthropic.skilljar.com/" rel="noopener noreferrer"&gt;Anthropic Academy course catalog&lt;/a&gt; lists more than 20 courses, including Claude Code 101, Claude Code in Action, Introduction to subagents and Introduction to agent skills. The catalog page doesn't show prices. Third-party roundups describe the courses as free with a certificate (&lt;a href="https://www.analyticsvidhya.com/blog/2026/03/free-anthropic-ai-courses-with-certificates/" rel="noopener noreferrer"&gt;Analytics Vidhya&lt;/a&gt;), so confirm on the course page before you enroll.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a course helps: you learn better with a sequenced curriculum and exercises than by reading reference docs. When it doesn't: you already know the problem you want to solve. Building that one hybrid script teaches you more than a general course will.&lt;/p&gt;

&lt;p&gt;Pitfalls when choosing a third-party course: tools change quickly. The headless docs alone mention behavior changes at several 2.1.x versions. A course recorded a year ago may teach flags that have since been renamed. Check the recording date. Be wary of any course that promises income from "AI automation agencies". That promise is about marketing, not about the skill you are trying to learn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do it yourself today:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick one recurring task and run the five-question test on each of its steps. Write the answers down.&lt;/li&gt;
&lt;li&gt;If every step comes out "script", write the script and schedule it with cron. You're done, and no model is involved.&lt;/li&gt;
&lt;li&gt;If one step comes out "agent", try that step alone first: &lt;code&gt;claude -p "&amp;lt;your instruction&amp;gt;" --allowedTools "Read" --output-format json&lt;/code&gt;. Then wrap it with the hybrid script above.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;If you'd rather not build it yourself:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Claude Pro&lt;/strong&gt;: $20/month billed monthly, or $17/month with annual billing ($200 up front). Claude Code is included (&lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;Claude pricing&lt;/a&gt;, as of October 2026; check the official page). It suits a solo builder running a few agent jobs a day. It doesn't suit &lt;code&gt;--bare&lt;/code&gt; scripts, which need an API key instead of your subscription login (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Max&lt;/strong&gt;: from $100/month, billed monthly (&lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;Claude pricing&lt;/a&gt;, as of October 2026; check the official page). It suits heavy daily agent use. It is overkill if your agent step runs once a day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code GitHub Action&lt;/strong&gt;: no server to run, and setup is &lt;code&gt;/install-github-app&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;GitHub Actions docs&lt;/a&gt;). It suits repo-centric work. It doesn't suit local-only files, and it uses GitHub Actions minutes as well as API tokens or your subscription.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anthropic Academy courses&lt;/strong&gt;: structured lessons at &lt;a href="https://anthropic.skilljar.com/" rel="noopener noreferrer"&gt;anthropic.skilljar.com&lt;/a&gt;. They suit people who like guided exercises. They won't build your specific pipeline for you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Related guides on this site:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/claude-code-permissions-best-practices-headless-template/" rel="noopener noreferrer"&gt;Claude Code Permissions Best Practices: Headless Template&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/claude-code-checkpoints-how-rollback-works-in-automations/" rel="noopener noreferrer"&gt;Claude Code Checkpoints: How Rollback Works in Automations&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/claude-code-hooks-examples-7-copy-paste-scripts/" rel="noopener noreferrer"&gt;Claude Code Hooks Examples: 7 Copy-Paste Scripts&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  What is AI agent and automation?
&lt;/h3&gt;

&lt;p&gt;Automation is any setup that does manual work for you by following rules you wrote. An AI agent is a kind of automation where a model such as Claude decides its own next steps and which tools to use, in a loop. You can combine them: scripts for the fixed steps, an agent for the judgment calls.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is an AI agent vs automation?
&lt;/h3&gt;

&lt;p&gt;A scripted automation takes the same path every time for the same input. An AI agent may take a different path each run, depending on what it finds. Anthropic describes agents as systems where the model maintains "control over how they accomplish tasks" (&lt;a href="https://www.anthropic.com/research/building-effective-agents" rel="noopener noreferrer"&gt;Building Effective Agents&lt;/a&gt;). Choose based on whether the task needs judgment.&lt;/p&gt;

&lt;h3&gt;
  
  
  AI agents vs automation: what are people saying on Reddit?
&lt;/h3&gt;

&lt;p&gt;This guide doesn't summarize Reddit threads, because forum opinions can't be sourced the way documentation can. When you read them, judge each claim with the five-question test. Ask whether the commenter is describing structured or unstructured input, and whether a wrong action in their case could be undone. Claims about limits or pricing should be checked against the vendor's official page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Automation vs AI: which term should I use?
&lt;/h3&gt;

&lt;p&gt;Use "automation" for the goal and "AI" for the method. A cron job is automation without AI. A script that calls Claude to classify text is AI-assisted automation. Say "agent" only when the model chooses the steps, which avoids the vague way "agentic" often gets used.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.anthropic.com/research/building-effective-agents" rel="noopener noreferrer"&gt;Anthropic: Building Effective AI Agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Claude Code docs: Run Claude Code programmatically (headless)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs: GitHub Actions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/scheduled-tasks" rel="noopener noreferrer"&gt;Claude Code docs: Run prompts on a schedule&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;Claude Code docs: Subagents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;Claude pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://anthropic.skilljar.com/" rel="noopener noreferrer"&gt;Anthropic Academy course catalog&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/anthropics/claude-cookbooks" rel="noopener noreferrer"&gt;anthropics/claude-cookbooks on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.make.com/en/blog/when-to-use-ai-agents" rel="noopener noreferrer"&gt;Make: When to use AI agents versus automation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.uipath.com/rpa/robotic-process-automation" rel="noopener noreferrer"&gt;UiPath: Robotic process automation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.analyticsvidhya.com/blog/2026/03/free-anthropic-ai-courses-with-certificates/" rel="noopener noreferrer"&gt;Analytics Vidhya: Free Anthropic AI Academy courses&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.straive.com/blogs/ai-agents-vs-traditional-automation-which-is-better-for-businesses/" rel="noopener noreferrer"&gt;Straive: AI agents vs traditional automation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.enkryptai.com/blog/ai-automation-vs-ai-agents" rel="noopener noreferrer"&gt;Enkrypt AI: AI automation vs AI agents&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was created with the help of AI. Sources are linked inline; check the official documentation for the latest details.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>automation</category>
      <category>claudecode</category>
      <category>workflows</category>
    </item>
    <item>
      <title>Claude Code PreToolUse Hook: Copy-Paste Block Scripts</title>
      <dc:creator>Aoba Works</dc:creator>
      <pubDate>Thu, 05 Nov 2026 23:29:51 +0000</pubDate>
      <link>https://dev.to/aobaworks/claude-code-pretooluse-hook-copy-paste-block-scripts-3g47</link>
      <guid>https://dev.to/aobaworks/claude-code-pretooluse-hook-copy-paste-block-scripts-3g47</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://en.aobaworks.com/claude-code-pretooluse-hook-copy-paste-block-scripts/?utm_source=devto&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unattended&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A Claude Code PreToolUse hook is a script that inspects each tool call and can allow, deny, or escalate it. The docs say it &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;"Runs after Claude creates tool parameters and before processing the tool call."&lt;/a&gt; To block a risky command safely, register a &lt;code&gt;Bash&lt;/code&gt; matcher, read &lt;code&gt;tool_input.command&lt;/code&gt; from stdin, and either print a JSON &lt;code&gt;deny&lt;/code&gt; with exit 0 or exit 2.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which option is right for you?
&lt;/h2&gt;

&lt;p&gt;Claude Code PreToolUse setups differ by how much failure you can tolerate, so start from your situation.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;What to pick first&lt;/th&gt;
&lt;th&gt;First page to open&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Beginner, first hook&lt;/td&gt;
&lt;td&gt;Short &lt;code&gt;exit 2&lt;/code&gt; script&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;Hooks guide&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Fewest moving parts, and the block message is plain stderr&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Daily work, recurring headless runs&lt;/td&gt;
&lt;td&gt;JSON &lt;code&gt;deny&lt;/code&gt;, &lt;code&gt;onFailure: "block"&lt;/code&gt;, and permission deny rules&lt;/td&gt;
&lt;td&gt;&lt;a href="https://en.aobaworks.com/claude-code-permissions-best-practices-headless-template/" rel="noopener noreferrer"&gt;Headless permissions template&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A crashed hook blocks the call instead of letting it through&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Budget first&lt;/td&gt;
&lt;td&gt;Local &lt;code&gt;command&lt;/code&gt; hook, not a &lt;code&gt;prompt&lt;/code&gt; hook&lt;/td&gt;
&lt;td&gt;&lt;a href="https://en.aobaworks.com/claude-code-how-to-see-token-usage-and-reduce-it/" rel="noopener noreferrer"&gt;Token usage guide&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Prompt hooks send input to a Claude model&lt;/a&gt;; a shell script doesn't&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You need a hard filesystem or network boundary&lt;/td&gt;
&lt;td&gt;Sandbox plus hook&lt;/td&gt;
&lt;td&gt;&lt;a href="https://en.aobaworks.com/claude-code-sandbox-mode-exact-config-and-what-it-blocks/" rel="noopener noreferrer"&gt;Sandbox guide&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Pattern matching can miss a command; OS-level limits still apply&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Running on a repo you didn't write&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--bare&lt;/code&gt;, or hooks off for that run&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;Trust table&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;-p&lt;/code&gt; runs that repo's hooks without a trust dialog&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What is the Claude Code PreToolUse hook and when does it run?
&lt;/h2&gt;

&lt;p&gt;The Claude Code PreToolUse hook runs after Claude builds a tool call and before that call executes, whatever the permission mode.&lt;/p&gt;

&lt;p&gt;Docs say it &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;fires before any permission-mode check, in every mode&lt;/a&gt;. The one exception is &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;EndConversation&lt;/code&gt;, which skips PreToolUse and PostToolUse&lt;/a&gt;. The nearby tool events do different jobs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;When it fires&lt;/th&gt;
&lt;th&gt;Can it stop the call?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PreToolUse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Before a tool call executes&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Yes, on every matched call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PermissionRequest&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;When a call needs a permission decision&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Yes, but &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;through its &lt;code&gt;decision&lt;/code&gt; object; exit 2 isn't honored&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PostToolUse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;After a call succeeds&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;No, &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;the tool already ran&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Use PreToolUse for a block list, because it sees calls that an allow rule would otherwise wave through. A &lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;hook that exits 2 stops the call before permission rules are evaluated&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What JSON does a PreToolUse hook receive on stdin?
&lt;/h2&gt;

&lt;p&gt;A Claude Code PreToolUse hook receives one JSON object on stdin, containing the common fields plus &lt;code&gt;tool_name&lt;/code&gt;, &lt;code&gt;tool_input&lt;/code&gt;, and &lt;code&gt;tool_use_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This sample Bash call is assembled from the &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt;; the values are examples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"session_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"transcript_path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/home/you/.claude/projects/demo/session.jsonl"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cwd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/home/you/project"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permission_mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hook_event_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tool_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tool_input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm test"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Run test suite"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;120000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"run_in_background"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tool_use_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"toolu_01ABC123..."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tool_input&lt;/code&gt; changes with the tool, so read the field that belongs to it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Fields in &lt;code&gt;tool_input&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Field to check for blocking&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Bash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;command&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;timeout&lt;/code&gt;, &lt;code&gt;run_in_background&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;command&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;file_path&lt;/code&gt;, &lt;code&gt;offset&lt;/code&gt;, &lt;code&gt;limit&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;file_path&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Edit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;file_path&lt;/code&gt;, &lt;code&gt;old_string&lt;/code&gt;, &lt;code&gt;new_string&lt;/code&gt;, &lt;code&gt;replace_all&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;file_path&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Write&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;file_path&lt;/code&gt;, &lt;code&gt;content&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;file_path&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two details trip up scripts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;permission_mode&lt;/code&gt; can be &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;default&lt;/code&gt;, &lt;code&gt;plan&lt;/code&gt;, &lt;code&gt;acceptEdits&lt;/code&gt;, &lt;code&gt;auto&lt;/code&gt;, &lt;code&gt;dontAsk&lt;/code&gt;, or &lt;code&gt;bypassPermissions&lt;/code&gt;&lt;/a&gt;. Manual mode arrives as &lt;code&gt;default&lt;/code&gt;, so never compare against &lt;code&gt;"manual"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;description&lt;/code&gt; and &lt;code&gt;content&lt;/code&gt; are free text written by the model. Match on &lt;code&gt;command&lt;/code&gt; or &lt;code&gt;file_path&lt;/code&gt;, and &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;always quote shell variables&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do you allow, deny, or ask from a PreToolUse hook?
&lt;/h2&gt;

&lt;p&gt;A Claude Code PreToolUse hook decides by exiting 0 with &lt;code&gt;permissionDecision&lt;/code&gt; JSON on stdout, or by exiting 2 to block with stderr as the message.&lt;/p&gt;

&lt;p&gt;The deny form looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hookSpecificOutput"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"hookEventName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"permissionDecision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"permissionDecisionReason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Force pushes are blocked. Do not retry; note it in your report."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the full contract, drawn from the &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt; unless linked otherwise:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Exit code&lt;/th&gt;
&lt;th&gt;Stdout&lt;/th&gt;
&lt;th&gt;What happens on PreToolUse&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Empty&lt;/td&gt;
&lt;td&gt;The hook makes no decision, so the normal permission flow applies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;JSON &lt;code&gt;allow&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Skips the prompt, but &lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;deny and ask rules are still evaluated&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;JSON &lt;code&gt;deny&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Blocks the call and shows the reason to Claude&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;JSON &lt;code&gt;ask&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Prompts the user, or denies in a &lt;code&gt;-p&lt;/code&gt; run where nobody can answer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;JSON &lt;code&gt;defer&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Ends the turn for later resume, in &lt;code&gt;-p&lt;/code&gt; only, and only with a single tool call in the turn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Anything&lt;/td&gt;
&lt;td&gt;Blocks; the message is stderr, and even a JSON &lt;code&gt;allow&lt;/code&gt; can't override it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 or other&lt;/td&gt;
&lt;td&gt;Anything&lt;/td&gt;
&lt;td&gt;Non-blocking error; the call goes ahead with a &lt;code&gt;hook error&lt;/code&gt; notice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Invalid JSON&lt;/td&gt;
&lt;td&gt;Non-blocking parse or validation failure; the call goes ahead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Timed out&lt;/td&gt;
&lt;td&gt;Doesn't block, unless the hook sets &lt;code&gt;onFailure: "block"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The docs are blunt about exit 1: &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;"If your hook is meant to enforce a policy, use &lt;code&gt;exit 2&lt;/code&gt;."&lt;/a&gt; A script that calls &lt;code&gt;exit 1&lt;/code&gt; on a dangerous command logs a complaint and lets the command run.&lt;/p&gt;

&lt;p&gt;Other rules worth keeping nearby:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pick one style per hook.&lt;/strong&gt; The docs say to &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;use exit codes alone, or exit 0 and print JSON&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conflicting hooks.&lt;/strong&gt; Precedence is &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;deny&lt;/code&gt; &amp;gt; &lt;code&gt;defer&lt;/code&gt; &amp;gt; &lt;code&gt;ask&lt;/code&gt; &amp;gt; &lt;code&gt;allow&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Old field names.&lt;/strong&gt; Top-level &lt;code&gt;decision&lt;/code&gt; and &lt;code&gt;reason&lt;/code&gt; are &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;deprecated for this event&lt;/a&gt;, with &lt;code&gt;approve&lt;/code&gt; and &lt;code&gt;block&lt;/code&gt; mapped to &lt;code&gt;allow&lt;/code&gt; and &lt;code&gt;deny&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stopping the whole run.&lt;/strong&gt; The universal field &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;continue: false&lt;/code&gt; stops Claude entirely&lt;/a&gt; and takes precedence over event-specific decisions. Reserve it for commands where carrying on would make things worse.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a first experiment, this exit-code version is enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nv"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.tool_input.command // empty'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="s2"&gt;"rm -rf"&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Blocked: rm -rf is not allowed"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;2 &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt;
&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It has a weakness: if &lt;code&gt;jq&lt;/code&gt; is missing, &lt;code&gt;cmd&lt;/code&gt; is empty and every command passes. The next script closes that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you block risky Bash commands with a copy-paste script?
&lt;/h2&gt;

&lt;p&gt;Register a Claude Code PreToolUse hook on &lt;code&gt;Bash&lt;/code&gt; that matches &lt;code&gt;tool_input.command&lt;/code&gt; against risky patterns and returns a JSON &lt;code&gt;deny&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Create the folder.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; .claude/hooks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Save this as &lt;code&gt;.claude/hooks/block-risky-bash.sh&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# Example block list. Edit the rules to fit your project.&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;

&lt;span class="nv"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.tool_input.command // empty'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Blocked: hook could not parse its input (is jq installed?)"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;2
&lt;span class="o"&gt;}&lt;/span&gt;

deny&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\t%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%FT%TZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_PROJECT_DIR&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/.claude/hooks/denied.log"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
  &lt;/span&gt;jq &lt;span class="nt"&gt;-nc&lt;/span&gt; &lt;span class="nt"&gt;--arg&lt;/span&gt; reason &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'{hookSpecificOutput: {
    hookEventName: "PreToolUse",
    permissionDecision: "deny",
    permissionDecisionReason: $reason}}'&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="o"&gt;}&lt;/span&gt;

check&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-Eq&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;deny &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'(^|[;&amp;amp;|(`[:space:]/])'&lt;/span&gt;

check &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;start&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s1"&gt;'rm[[:space:]]+([^;&amp;amp;|]*[[:space:]])?(-[[:alnum:]]*[rR][[:alnum:]]*|--recursive)([[:space:]]|$)'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"Recursive delete is blocked. Remove specific files, or skip this step and note it in your report."&lt;/span&gt;
check &lt;span class="s1"&gt;'git[[:space:]]+([^;&amp;amp;|]*[[:space:]])?push([[:space:]]+[^;&amp;amp;|]*)?[[:space:]](--force[^[:space:]]*|-[[:alnum:]]*f[[:alnum:]]*)([[:space:]]|$)'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"Force pushes are blocked. Do not retry; note it in your report."&lt;/span&gt;
check &lt;span class="s1"&gt;'git[[:space:]]+([^;&amp;amp;|]*[[:space:]])?reset[[:space:]]+([^;&amp;amp;|]*[[:space:]])?--hard'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"git reset --hard is blocked. Commit or stash instead."&lt;/span&gt;
check &lt;span class="s1"&gt;'(curl|wget)[[:space:]].*\|[[:space:]]*(sudo[[:space:]]+)?(ba|z|da)?sh([[:space:]]|$)'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"Piping a download into a shell is blocked. Download and review the file instead."&lt;/span&gt;
check &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;start&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s1"&gt;'sudo[[:space:]]'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"sudo is blocked in unattended runs."&lt;/span&gt;

&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final &lt;code&gt;exit 0&lt;/code&gt; with no output means the hook has no opinion, so the normal permission flow still decides. Add &lt;code&gt;denied.log&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt;, because commands can contain secrets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Make it executable.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x .claude/hooks/block-risky-bash.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Register it in &lt;code&gt;.claude/settings.json&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Bash(git push --force *)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash(sudo *)"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${CLAUDE_PROJECT_DIR}/.claude/hooks/block-risky-bash.sh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"onFailure"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"block"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each addition earns its place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;"args": []&lt;/code&gt; selects &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;exec form, which spawns the script directly with no shell quoting to get wrong&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"timeout": 10&lt;/code&gt; replaces the &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;600-second default for command hooks&lt;/a&gt;. A hung check shouldn't stall a run for ten minutes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"onFailure": "block"&lt;/code&gt; makes &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;a missing script, a stray exit code, invalid output, or a timeout block the call&lt;/a&gt;. It requires v2.1.295 or later, so check with &lt;code&gt;claude --version&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The deny rules are a second layer. &lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;Deny rules are evaluated regardless of what a hook returns&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;5. Test it without starting Claude.&lt;/strong&gt; The expected results below describe what the script is written to do, so confirm them on your own machine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash &lt;span class="nt"&gt;-n&lt;/span&gt; .claude/hooks/block-risky-bash.sh
&lt;span class="nv"&gt;H&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.claude/hooks/block-risky-bash.sh

&lt;span class="c"&gt;# Should print a deny JSON, then exit=0&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}'&lt;/span&gt; | &lt;span class="nv"&gt;$H&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"exit=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"tool_name":"Bash","tool_input":{"command":"curl -s https://example.com/i.sh | sh"}}'&lt;/span&gt; | &lt;span class="nv"&gt;$H&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"exit=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Should print nothing, then exit=0&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"tool_name":"Bash","tool_input":{"command":"npm test"}}'&lt;/span&gt; | &lt;span class="nv"&gt;$H&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"exit=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;6. Confirm Claude Code sees it.&lt;/strong&gt; Run &lt;code&gt;/hooks&lt;/code&gt; and check that the entry appears &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;under PreToolUse&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Know the limits before you rely on this. A regex reads text and doesn't understand a shell, so &lt;code&gt;git commit -m "explain rm -rf"&lt;/code&gt; gets a false block, and a command wrapped in &lt;code&gt;eval&lt;/code&gt; or a script file can slip past. The docs recommend the &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;permission system for hard allow or deny enforcement when using the best-effort &lt;code&gt;if&lt;/code&gt; filter&lt;/a&gt;, and they point to &lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;sandboxing to limit where shell commands write&lt;/a&gt;. The sandbox &lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;runs on macOS, Linux, and WSL2, but not native Windows&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do PreToolUse hooks behave in headless runs (claude -p)?
&lt;/h2&gt;

&lt;p&gt;Claude Code PreToolUse hooks work the same under &lt;code&gt;claude -p&lt;/code&gt;, except &lt;code&gt;--bare&lt;/code&gt; skips them and an &lt;code&gt;ask&lt;/code&gt; with nobody to answer becomes a denial.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision or setting&lt;/th&gt;
&lt;th&gt;In a &lt;code&gt;claude -p&lt;/code&gt; run&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;deny&lt;/code&gt; or exit 2&lt;/td&gt;
&lt;td&gt;Blocks the call, and &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;&lt;code&gt;deny&lt;/code&gt; holds in every permission mode&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ask&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Denied when no one can answer, and Claude reads your reason in the tool result&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;allow&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://code.claude.com/docs/en/permission-modes" rel="noopener noreferrer"&gt;Approves calls that would otherwise be denied&lt;/a&gt;, but not past deny rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No decision&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://code.claude.com/docs/en/permission-modes" rel="noopener noreferrer"&gt;Manual mode denies calls needing approval unless something approves them&lt;/a&gt; (v2.1.302 or later)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--bare&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Hooks don't run, even if you pass them with &lt;code&gt;--settings&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;--bare&lt;/code&gt; row deserves the most attention. Docs say the flag &lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;"will become the default for &lt;code&gt;-p&lt;/code&gt; in a future release."&lt;/a&gt; If that happens, a scheduled job with a block list could lose its protection after an update, with no change on your side.&lt;/p&gt;

&lt;p&gt;Protect yourself with a smoke test in the same job, before the real work. The log line it searches for &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;requires v2.1.296 or later&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Run this exact command: echo hook-check"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allowedTools&lt;/span&gt; &lt;span class="s2"&gt;"Bash(echo *)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--debug-file&lt;/span&gt; /tmp/hook-check.log

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'Hooks: PreToolUse:Bash'&lt;/span&gt; /tmp/hook-check.log&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"PreToolUse hook did not run; aborting"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then start the real run. This example lets only named tools through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Run the nightly dependency check and write reports/deps.md"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--permission-mode&lt;/span&gt; dontAsk &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allowedTools&lt;/span&gt; &lt;span class="s2"&gt;"Read,Edit,Bash(npm test *),Bash(npm audit *)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--permission-prompts&lt;/span&gt; none
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under &lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;&lt;code&gt;dontAsk&lt;/code&gt;, every call that would prompt is denied&lt;/a&gt;. &lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;&lt;code&gt;--permission-prompts none&lt;/code&gt; also tells Claude not to retry those requests&lt;/a&gt;, and it needs v2.1.259 or later.&lt;/p&gt;

&lt;p&gt;Four traps apply specifically to unattended runs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Using &lt;code&gt;ask&lt;/code&gt; as your block.&lt;/strong&gt; The result is the same denial in a run with no permission host. With &lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;&lt;code&gt;--permission-prompt-tool&lt;/code&gt;, the run waits on that host&lt;/a&gt; unless you turn prompts off. Write &lt;code&gt;deny&lt;/code&gt; so the behavior is plain. The docs I read don't spell out a hook's &lt;code&gt;ask&lt;/code&gt; under &lt;code&gt;bypassPermissions&lt;/code&gt;, which is one more reason to avoid it there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vague deny reasons.&lt;/strong&gt; Claude reads the reason and may try a different route. Say what to do next, such as "skip this step and note it in your report."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allow rules in an untrusted folder.&lt;/strong&gt; In a never-trusted folder, &lt;code&gt;claude -p&lt;/code&gt; &lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;ignores &lt;code&gt;permissions.allow&lt;/code&gt; from project settings and prints a warning to stderr&lt;/a&gt;. Pass them with &lt;code&gt;--allowedTools&lt;/code&gt;, or trust the folder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Installed mods.&lt;/strong&gt; With no managed settings and no Team or Enterprise &lt;code&gt;/login&lt;/code&gt;, &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;a mod can approve a call your hook blocked&lt;/a&gt;. A run using an API key or &lt;code&gt;CLAUDE_CODE_OAUTH_TOKEN&lt;/code&gt; counts as not using that login. Review what's installed with &lt;code&gt;/plugin&lt;/code&gt; before relying on a block.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With &lt;code&gt;stream-json&lt;/code&gt;, &lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;permission denials appear as &lt;code&gt;permission_denied&lt;/code&gt; messages and in the result's &lt;code&gt;permission_denials&lt;/code&gt;&lt;/a&gt;. The page doesn't say whether hook denials are included, so the script keeps its own &lt;code&gt;denied.log&lt;/code&gt;. Check it after each run, and see &lt;a href="https://en.aobaworks.com/ai-agent-monitoring-in-claude-code-catch-silent-failures/" rel="noopener noreferrer"&gt;catching silent failures&lt;/a&gt; for how to alert on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does Claude Code show a PreToolUse hook error (Bash, Read, Edit)?
&lt;/h2&gt;

&lt;p&gt;Claude Code shows a PreToolUse hook error when your hook fails without a deliberate block. Common causes are a wrong exit code, invalid JSON, a missing script, or a missing dependency.&lt;/p&gt;

&lt;p&gt;The important part is that on a &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;non-blocking error, the action goes ahead&lt;/a&gt;. &lt;code&gt;PreToolUse:Bash hook error&lt;/code&gt; usually means your guard didn't run properly and the command ran anyway. The same applies to Read and Edit, which differ only in the matched tool.&lt;/p&gt;

&lt;p&gt;Follow these steps in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run the script by hand, as &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;the troubleshooting guide suggests&lt;/a&gt;:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"tool_name":"Bash","tool_input":{"command":"ls"}}'&lt;/span&gt; | ./my-hook.sh
   &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Press &lt;code&gt;Ctrl+O&lt;/code&gt; to &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;open the transcript view and read the notice&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Start with &lt;code&gt;claude --debug-file /tmp/claude.log&lt;/code&gt;, or &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;run &lt;code&gt;/debug&lt;/code&gt; mid-session&lt;/a&gt; to enable logging. Then search for your hook's &lt;code&gt;Hooks:&lt;/code&gt; line, which &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;reports status and duration&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;command not found&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;Relative or unresolved script path&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;${CLAUDE_PROJECT_DIR}&lt;/code&gt; and &lt;code&gt;"args": []&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jq: command not found&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;&lt;code&gt;jq&lt;/code&gt; isn't installed&lt;/a&gt; on this host&lt;/td&gt;
&lt;td&gt;Install it, which is easy to forget on a new VPS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Script never runs&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;Not executable&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;chmod +x&lt;/code&gt; the script&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Failed with non-blocking status code:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;Exit code other than 0 or 2&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Exit 0 with JSON, or exit 2 to block&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON parse message&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;Stdout looked like JSON but wasn't valid&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Build output with &lt;code&gt;jq&lt;/code&gt; rather than string concatenation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON validation message&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;Valid JSON that fails the schema&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Check field names and nesting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No error, but the decision is ignored&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;Profile &lt;code&gt;echo&lt;/code&gt; before the JSON, or &lt;code&gt;permissionDecision&lt;/code&gt; at the top level&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Search the debug log for &lt;code&gt;Hook output does not start with {&lt;/code&gt; or &lt;code&gt;Hook JSON output had unrecognized keys&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;blocking because Git Bash wasn't found&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;Windows without Git Bash&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Follow the linked Windows fix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every tool blocked&lt;/td&gt;
&lt;td&gt;Syntax error in the script&lt;/td&gt;
&lt;td&gt;See below&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is the reverse failure. Bash can exit 2 on a syntax error, which Claude Code reads as "block." One developer &lt;a href="https://dev.to/yurukusa/my-safety-tool-locked-me-out-of-my-own-ai-session-for-2-hours-34gj"&gt;reported being locked out of Read, Edit, and Bash for about two hours&lt;/a&gt; when a broken hook had an empty matcher. Run &lt;code&gt;bash -n&lt;/code&gt; before every deploy. To recover, edit the file outside Claude or start with &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;--settings '{"disableAllHooks": true}'&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Two public bug reports may match what you see, and neither ended with a confirmed fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/anthropics/claude-code/issues/34859" rel="noopener noreferrer"&gt;#34859&lt;/a&gt; reports the &lt;code&gt;hook error&lt;/code&gt; label on every call despite exit 0, on v2.1.76. A bot closed it as not planned.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/anthropics/claude-code/issues/3514" rel="noopener noreferrer"&gt;#3514&lt;/a&gt; reports exit 2 with &lt;code&gt;continue: false&lt;/code&gt; failing to stop a Bash call, on v1.0.51. Commenters said a &lt;code&gt;permissionDecision: "deny"&lt;/code&gt; response did block the call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both reports are old compared with current docs. Test the block on your installed version rather than assuming either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you write matchers for Bash, Read, and Edit PreToolUse hooks?
&lt;/h2&gt;

&lt;p&gt;A Claude Code PreToolUse matcher filters by tool name and is &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;case-sensitive&lt;/a&gt;, so &lt;code&gt;Bash&lt;/code&gt; matches and &lt;code&gt;bash&lt;/code&gt; never fires.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Matcher&lt;/th&gt;
&lt;th&gt;How it's evaluated&lt;/th&gt;
&lt;th&gt;What it matches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Bash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Exact string&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Only the Bash tool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;`Read\&lt;/td&gt;
&lt;td&gt;Edit\&lt;/td&gt;
&lt;td&gt;Write`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;*&lt;/code&gt;, empty, or omitted&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Match all&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Every tool, so a broken script can block everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Edit.*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Unanchored regex&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Edit&lt;/code&gt; and also &lt;code&gt;NotebookEdit&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;^Edit$&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Anchored regex&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Only &lt;code&gt;Edit&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mcp__memory__.*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Regex&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;All tools on the &lt;code&gt;memory&lt;/code&gt; MCP server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mcp__memory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Exact string&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Nothing, because the &lt;code&gt;.*&lt;/code&gt; is required&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To protect files, use a separate script, since these tools carry &lt;code&gt;file_path&lt;/code&gt; and no &lt;code&gt;command&lt;/code&gt;. Save this example as &lt;code&gt;.claude/hooks/protect-files.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
&lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.tool_input.file_path // empty'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Blocked: hook could not parse its input"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;2
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;/../&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.env|&lt;span class="k"&gt;*&lt;/span&gt;.env.&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;/.git/&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;/.ssh/&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.pem&lt;span class="p"&gt;)&lt;/span&gt;
    jq &lt;span class="nt"&gt;-nc&lt;/span&gt; &lt;span class="nt"&gt;--arg&lt;/span&gt; p &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'{hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: ("Protected path: " + $p + ". Do not retry.")}}'&lt;/span&gt;
    &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt;
&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register it as a second entry in the same &lt;code&gt;PreToolUse&lt;/code&gt; array, with &lt;code&gt;"matcher": "Read|Edit|Write"&lt;/code&gt;. Note that &lt;code&gt;*.env.*&lt;/code&gt; also catches &lt;code&gt;.env.example&lt;/code&gt;, so narrow it if your agent needs that file.&lt;/p&gt;

&lt;p&gt;This hook can't see &lt;code&gt;cat .env&lt;/code&gt;, because that's a Bash call. Add a Bash rule for such reads, or rely on the sandbox for shell commands.&lt;/p&gt;

&lt;p&gt;The optional &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;&lt;code&gt;if&lt;/code&gt; field&lt;/a&gt; can avoid spawning a process, for example &lt;code&gt;"if": "Bash(git *)"&lt;/code&gt;. It checks subcommands too, so &lt;code&gt;npm test &amp;amp;&amp;amp; git push&lt;/code&gt; matches. Treat it as a performance filter and keep your block logic in the script, since the guide calls the filter best-effort.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is stop_hook_active and when do you need it?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;stop_hook_active&lt;/code&gt; is a Claude Code Stop hook input field that is &lt;code&gt;true&lt;/code&gt; when Claude is &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;already continuing because of a stop hook&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;PreToolUse doesn't use it. You need it only when a Stop hook returns &lt;code&gt;decision: "block"&lt;/code&gt; to keep Claude working, for example until tests pass. Check it first and let Claude stop if it's set, using &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;the pattern from the guide&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nv"&gt;INPUT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INPUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.stop_hook_active'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"true"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0  &lt;span class="c"&gt;# Allow Claude to stop&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="c"&gt;# ... rest of your hook logic&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without that check, the loop isn't literally endless. Claude Code &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;overrides the block after eight consecutive continuations&lt;/a&gt;, and the count resets each time Claude calls a tool. You can change the cap with &lt;code&gt;CLAUDE_CODE_STOP_HOOK_BLOCK_CAP&lt;/code&gt;. In a paid run, those eight rounds still consume tokens. The &lt;a href="https://en.aobaworks.com/claude-code-stop-hook-block-early-exit-without-loops/" rel="noopener noreferrer"&gt;Stop hook guide&lt;/a&gt; covers the design in detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you check Claude Code usage when agents run unattended?
&lt;/h2&gt;

&lt;p&gt;Claude Code usage appears through &lt;code&gt;/usage&lt;/code&gt; in a session and through &lt;code&gt;total_cost_usd&lt;/code&gt; in &lt;code&gt;claude -p --output-format json&lt;/code&gt; output for scripted runs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where you run&lt;/th&gt;
&lt;th&gt;What to use&lt;/th&gt;
&lt;th&gt;What it tells you&lt;/th&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Interactive session&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/commands" rel="noopener noreferrer"&gt;&lt;code&gt;/usage&lt;/code&gt; (&lt;code&gt;/cost&lt;/code&gt; and &lt;code&gt;/stats&lt;/code&gt; are aliases)&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Session cost, plan limits, activity stats&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;Plan breakdown covers this machine only&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;claude -p&lt;/code&gt; script&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;&lt;code&gt;--output-format json&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;total_cost_usd&lt;/code&gt; and per-model cost&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;A client-side estimate that can differ from your bill&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Console (API)&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;Cost page and workspace spend limits&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Spend reporting and a cap&lt;/td&gt;
&lt;td&gt;API organizations only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Your own stack&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;OpenTelemetry export&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Per-user token and cost metrics in near real time&lt;/td&gt;
&lt;td&gt;You have to run the observability stack&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To record each run's estimate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Run the nightly dependency check"&lt;/span&gt; &lt;span class="nt"&gt;--output-format&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'[now, .total_cost_usd] | @tsv'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; usage.tsv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a subscription, ignore the dollar figure in the Session block, because &lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;it isn't relevant for billing&lt;/a&gt;. Read the plan usage bars instead, and note that the &lt;code&gt;/usage&lt;/code&gt; breakdown &lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;reports the share of recent usage from loops, skills, and subagents&lt;/a&gt;. Hard caps are also available: &lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;&lt;code&gt;--max-budget-usd&lt;/code&gt;&lt;/a&gt; stops a run's remaining background work when it reaches the cap.&lt;/p&gt;

&lt;p&gt;For scale, Anthropic reports &lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;an average around $13 per developer per active day across enterprise deployments&lt;/a&gt; (as of November 2026; check the official page). That's an enterprise figure, so a small cron job may look very different. Measure your own runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;Claude Code PreToolUse protection can be running today with three small actions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do it yourself:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Save &lt;code&gt;block-risky-bash.sh&lt;/code&gt;, add the settings entry with &lt;code&gt;onFailure: "block"&lt;/code&gt;, and run the &lt;code&gt;bash -n&lt;/code&gt; and piped tests.&lt;/li&gt;
&lt;li&gt;Add the smoke test to the top of your scheduled job, so a run aborts if the hook stops loading.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;denied.log&lt;/code&gt; after your next few runs, then adjust false blocks and add any missing patterns.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;If you'd rather not maintain scripts:&lt;/strong&gt; try &lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;auto mode&lt;/a&gt;, where a classifier reviews most actions, using &lt;code&gt;--permission-mode auto --permission-prompts none&lt;/code&gt;. It suits people who want fewer prompts without keeping a block list. It doesn't suit anyone who needs a fixed, auditable rule that gives the same answer every time, and you'll need to &lt;a href="https://code.claude.com/docs/en/permission-modes" rel="noopener noreferrer"&gt;confirm it's available for your account&lt;/a&gt;. Claude Code comes with &lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;Pro at $17/month billed annually or $20 monthly, and Max from $100/month&lt;/a&gt; (as of November 2026; check the official page). You can also keep your hooks alongside it, since &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;a hook's &lt;code&gt;deny&lt;/code&gt; holds in every permission mode&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related guides on this site:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/claude-code-permissions-best-practices-headless-template/" rel="noopener noreferrer"&gt;Claude Code Permissions Best Practices: Headless Template&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/claude-code-sandbox-mode-exact-config-and-what-it-blocks/" rel="noopener noreferrer"&gt;Claude Code Sandbox Mode: Exact Config and What It Blocks&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/ai-agent-monitoring-in-claude-code-catch-silent-failures/" rel="noopener noreferrer"&gt;AI Agent Monitoring in Claude Code: Catch Silent Failures&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/claude-code-stop-hook-block-early-exit-without-loops/" rel="noopener noreferrer"&gt;Claude Code Stop Hook: Block Early Exit Without Loops&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/claude-code-how-to-see-token-usage-and-reduce-it/" rel="noopener noreferrer"&gt;Claude Code: How to See Token Usage (and Reduce It)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Exit 1 doesn't block a Claude Code hook, &lt;code&gt;updatedInput&lt;/code&gt; can rewrite a tool call, and &lt;code&gt;deny&lt;/code&gt; survives bypass mode.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does exit code 1 block a tool call in a Claude Code hook?
&lt;/h3&gt;

&lt;p&gt;No. &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Exit 1 without valid JSON is a non-blocking error&lt;/a&gt;, so the tool call goes ahead and you see a &lt;code&gt;hook error&lt;/code&gt; notice. The exception is a hook with &lt;code&gt;onFailure: "block"&lt;/code&gt;, where any exit code other than 0 or 2 counts as a failure and blocks. Use exit 2 or a JSON &lt;code&gt;deny&lt;/code&gt; for an intentional block.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where is the Claude Code hooks documentation?
&lt;/h3&gt;

&lt;p&gt;Anthropic keeps it in two places. The &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Hooks reference&lt;/a&gt; holds event schemas and JSON formats, and the &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;Hooks guide&lt;/a&gt; holds setup steps and troubleshooting. Inside a session, &lt;code&gt;/hooks&lt;/code&gt; shows what's configured.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can a PreToolUse hook change the tool input before it runs?
&lt;/h3&gt;

&lt;p&gt;Yes, through &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;updatedInput&lt;/code&gt; in &lt;code&gt;hookSpecificOutput&lt;/code&gt;&lt;/a&gt;. It replaces the entire input object, so include the fields you aren't changing. Permission rules are evaluated against the input your hook returns. Avoid having two hooks rewrite the same tool's input, because &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;Claude Code doesn't merge them or guarantee which one is used&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do PreToolUse hooks still run with --dangerously-skip-permissions?
&lt;/h3&gt;

&lt;p&gt;Yes. A hook returning &lt;code&gt;deny&lt;/code&gt; &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;"blocks the tool even in &lt;code&gt;bypassPermissions&lt;/code&gt; mode or with &lt;code&gt;--dangerously-skip-permissions&lt;/code&gt;."&lt;/a&gt; That doesn't make bypass mode safe, since docs advise using it &lt;a href="https://code.claude.com/docs/en/permission-modes" rel="noopener noreferrer"&gt;only in isolated environments such as containers or VMs&lt;/a&gt;. A block list only stops the patterns you thought to write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;p&gt;These official docs and public reports were read on 2026-11-06 and support the claims above.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Claude Code docs: Hooks reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;Claude Code docs: Automate actions with hooks (guide and troubleshooting)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;Claude Code docs: Configure permissions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/permission-modes" rel="noopener noreferrer"&gt;Claude Code docs: Permission modes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Claude Code docs: Run Claude Code programmatically&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;Claude Code docs: Manage costs effectively&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/commands" rel="noopener noreferrer"&gt;Claude Code docs: Commands&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;Claude Code docs: Sandboxing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;Claude pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/anthropics/claude-code/issues/34859" rel="noopener noreferrer"&gt;GitHub issue #34859: hook error messages shown even when hooks exit 0&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/anthropics/claude-code/issues/3514" rel="noopener noreferrer"&gt;GitHub issue #3514: PreToolUse hooks with preventContinuation not blocking&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/yurukusa/my-safety-tool-locked-me-out-of-my-own-ai-session-for-2-hours-34gj"&gt;DEV Community: a broken hook that locked a session (community report)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was created with the help of AI. Sources are linked inline; check the official documentation for the latest details.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>hooks</category>
      <category>pretooluse</category>
      <category>headless</category>
    </item>
    <item>
      <title>Claude Code GitHub Actions Permissions: Least-Privilege YAML</title>
      <dc:creator>Aoba Works</dc:creator>
      <pubDate>Wed, 04 Nov 2026 16:15:45 +0000</pubDate>
      <link>https://dev.to/aobaworks/claude-code-github-actions-permissions-least-privilege-yaml-257p</link>
      <guid>https://dev.to/aobaworks/claude-code-github-actions-permissions-least-privilege-yaml-257p</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://en.aobaworks.com/claude-code-github-actions-permissions-least-privilege-yaml/?utm_source=devto&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unattended&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Claude Code GitHub Actions permissions come in two layers. The first is the Claude GitHub App, which needs Contents, Issues and Pull requests set to read and write. The second is the &lt;code&gt;permissions:&lt;/code&gt; block in your workflow, which scopes the &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; and must include &lt;code&gt;id-token: write&lt;/code&gt; for the action's default authentication. On top of those, &lt;code&gt;claude_args: --allowedTools&lt;/code&gt; decides which tools Claude can actually run, and your Anthropic credential sits in a GitHub secret. Below you'll find the exact YAML for review, scheduled and code-writing jobs, plus a least-privilege table built from the &lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;official Claude Code GitHub Actions docs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which option is right for you?
&lt;/h2&gt;

&lt;p&gt;Pick the job you actually want automated and start from the matching official example. Don't start from a broad template and trim it down.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your situation&lt;/th&gt;
&lt;th&gt;What to pick first&lt;/th&gt;
&lt;th&gt;Limitation / not for&lt;/th&gt;
&lt;th&gt;First page to open&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Beginner, one repo, already use Claude Code locally&lt;/td&gt;
&lt;td&gt;Run &lt;code&gt;/install-github-app&lt;/code&gt; (quick setup)&lt;/td&gt;
&lt;td&gt;Installs the official app with its full permission set, including Actions, Workflows and Repository hooks write, and you can't accept a subset. Not for you if you want only the three scopes the action uses (build a custom app instead)&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code GitHub Actions: Quick setup&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You want automatic PR review and nothing else&lt;/td&gt;
&lt;td&gt;Code-review skill workflow with read-only token scopes&lt;/td&gt;
&lt;td&gt;Comments only and never pushes fixes. On public repos it doesn't run on fork PRs because secrets are withheld. Not for you if you want Claude to fix what it finds&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Run a skill example&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Daily work: &lt;code&gt;@claude&lt;/code&gt; fixes issues and pushes commits&lt;/td&gt;
&lt;td&gt;Interactive mode workflow (&lt;code&gt;examples/claude.yml&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Needs write scopes on the workflow token, only users with write access can trigger it, and it can't edit &lt;code&gt;.github/workflows/&lt;/code&gt;. Not for repos where untrusted users comment&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/anthropics/claude-code-action/blob/main/examples/claude.yml" rel="noopener noreferrer"&gt;examples/claude.yml&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recurring report or triage on a cron&lt;/td&gt;
&lt;td&gt;Automation mode with a &lt;code&gt;prompt&lt;/code&gt; and named MCP tools&lt;/td&gt;
&lt;td&gt;Runs only from the default branch. In public repos it is disabled after 60 days of inactivity. Output stays in the run log unless you grant a posting tool. Not for one-off requests&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Run on a schedule&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tight budget, already pay for Pro/Max&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CLAUDE_CODE_OAUTH_TOKEN&lt;/code&gt; from &lt;code&gt;claude setup-token&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Tied to the subscription of whoever generated it and subject to plan usage limits. Not for org-wide shared secrets&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/authentication.md#generate-a-long-lived-token" rel="noopener noreferrer"&gt;Authentication docs&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Company with an AWS/GCP/Azure account and a no-static-keys rule&lt;/td&gt;
&lt;td&gt;Bedrock, Vertex or Foundry via OIDC&lt;/td&gt;
&lt;td&gt;Needs a cloud role plus a custom GitHub App. Not for quick experiments or anyone without cloud admin access&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/github-actions-cloud-providers.md" rel="noopener noreferrer"&gt;Cloud providers page&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What permissions does Claude Code GitHub Actions need?
&lt;/h2&gt;

&lt;p&gt;It needs two things: the Claude GitHub App and a workflow &lt;code&gt;permissions:&lt;/code&gt; block that includes &lt;code&gt;id-token: write&lt;/code&gt;. The app needs Contents, Issues and Pull requests set to read and write. The app and the workflow token are two separate systems, and most permission confusion comes from mixing them up.&lt;/p&gt;

&lt;p&gt;The first layer is the Claude GitHub App. The docs state the action "relies on three of the app's permissions": Contents, Issues and Pull requests, all read and write (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;). When you install the app you actually grant a bigger set, because the app is shared with Code Review and web auto-fix. It includes Actions (read and write), Checks (read and write), Workflows (read and write), Repository hooks (read and write) and several read-only scopes. You can't trim that set: "GitHub doesn't let you accept a subset" (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;). If that's a problem for you, the docs point to building a custom GitHub App with only Contents, Issues and Pull requests. That app works for the action only, though. Code Review and web auto-fix still need the official app.&lt;/p&gt;

&lt;p&gt;The second layer is the workflow's &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; scopes. These come from the &lt;code&gt;permissions:&lt;/code&gt; key at workflow or job level. GitHub's rule is strict: "If you specify the access for any of these permissions, all of those that are not specified are set to &lt;code&gt;none&lt;/code&gt;" (&lt;a href="https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax" rel="noopener noreferrer"&gt;GitHub workflow syntax&lt;/a&gt;). So once you write a block, list every scope you need.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Where you set it&lt;/th&gt;
&lt;th&gt;What it controls&lt;/th&gt;
&lt;th&gt;Minimum for this action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude GitHub App&lt;/td&gt;
&lt;td&gt;github.com/apps/claude install screen&lt;/td&gt;
&lt;td&gt;What the app token can do in the repo&lt;/td&gt;
&lt;td&gt;Contents, Issues, Pull requests: read and write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workflow &lt;code&gt;permissions:&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.github/workflows/*.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GITHUB_TOKEN&lt;/code&gt; scopes and the OIDC token&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;id-token: write&lt;/code&gt; plus the read scopes your job needs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;claude_args --allowedTools&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;with:&lt;/code&gt; input of the action step&lt;/td&gt;
&lt;td&gt;Which tools Claude may call&lt;/td&gt;
&lt;td&gt;Only the tools the task uses&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;id-token: write&lt;/code&gt; scope is easy to miss. The official example labels it "required for the Claude Code GitHub Action's default GitHub App authentication" (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;). Without it you get OIDC errors, as the &lt;a href="https://github.com/anthropics/claude-code-action/blob/main/docs/faq.md" rel="noopener noreferrer"&gt;action FAQ&lt;/a&gt; explains.&lt;/p&gt;

&lt;p&gt;Two limits apply whatever you grant. Per the FAQ, the app has no workflow write access through the action, so Claude won't edit &lt;code&gt;.github/workflows/&lt;/code&gt;. It also won't rebase, merge or force-push, even if you allow &lt;code&gt;Bash(git rebase:*)&lt;/code&gt;, because that rule lives in its system prompt (&lt;a href="https://github.com/anthropics/claude-code-action/blob/main/docs/faq.md" rel="noopener noreferrer"&gt;action FAQ&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you set a least-privilege permissions block for Claude Code?
&lt;/h2&gt;

&lt;p&gt;Start with read-only scopes plus &lt;code&gt;id-token: write&lt;/code&gt;. Add &lt;code&gt;contents: write&lt;/code&gt;, &lt;code&gt;pull-requests: write&lt;/code&gt; or &lt;code&gt;issues: write&lt;/code&gt; only for jobs that change code or post with the &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;. The &lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt; and the &lt;a href="https://github.com/anthropics/claude-code-action/blob/main/docs/security.md" rel="noopener noreferrer"&gt;security doc&lt;/a&gt; give 4 example blocks, and they show how far apart the needs are.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job type&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;permissions:&lt;/code&gt; block (from official examples)&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PR review via code-review skill&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;contents: read&lt;/code&gt;, &lt;code&gt;pull-requests: read&lt;/code&gt;, &lt;code&gt;issues: read&lt;/code&gt;, &lt;code&gt;id-token: write&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The app token posts the inline comments, so the workflow token only reads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scheduled report&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;contents: read&lt;/code&gt;, &lt;code&gt;issues: read&lt;/code&gt;, &lt;code&gt;id-token: write&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Reads commits and issues through GitHub MCP tools; output goes to the run log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@claude&lt;/code&gt; interactive (writes code)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;contents: write&lt;/code&gt;, &lt;code&gt;pull-requests: write&lt;/code&gt;, &lt;code&gt;issues: write&lt;/code&gt;, &lt;code&gt;id-token: write&lt;/code&gt;, &lt;code&gt;actions: read&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Pushes commits and updates comments; &lt;code&gt;actions: read&lt;/code&gt; lets Claude see CI results&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Issue labeling for non-write users&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;issues: write&lt;/code&gt; only, with &lt;code&gt;github_token&lt;/code&gt; passed&lt;/td&gt;
&lt;td&gt;The security doc's example of an "extremely limited" workflow&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sources: &lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code GitHub Actions docs&lt;/a&gt; for the first three rows and &lt;a href="https://github.com/anthropics/claude-code-action/blob/main/docs/security.md" rel="noopener noreferrer"&gt;action security doc&lt;/a&gt; for the last.&lt;/p&gt;

&lt;p&gt;Here's the interactive block as the docs publish it, set at job level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;claude&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
      &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
      &lt;span class="na"&gt;issues&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
      &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
      &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things trip people up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Job-level vs workflow-level.&lt;/strong&gt; GitHub accepts &lt;code&gt;permissions&lt;/code&gt; "for an entire workflow or for individual jobs" (&lt;a href="https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/controlling-permissions-for-github_token" rel="noopener noreferrer"&gt;GitHub Docs&lt;/a&gt;). Put it on the Claude job so that other jobs in the same file don't inherit write access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing &lt;code&gt;actions: read&lt;/code&gt;.&lt;/strong&gt; If you want Claude to read CI failures, you need the &lt;code&gt;actions: read&lt;/code&gt; scope and the action input &lt;code&gt;additional_permissions: "actions: read"&lt;/code&gt;, which is "for viewing workflow results" (&lt;a href="https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md" rel="noopener noreferrer"&gt;usage.md&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using &lt;code&gt;write-all&lt;/code&gt; for convenience.&lt;/strong&gt; That gives the token every scope. The docs' own advice is to "grant the &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; the least required access" (&lt;a href="https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/controlling-permissions-for-github_token" rel="noopener noreferrer"&gt;GitHub Docs&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you also run Claude headless on your own machine, the same mindset applies to local settings. See our &lt;a href="https://en.aobaworks.com/claude-code-permissions-best-practices-headless-template/" rel="noopener noreferrer"&gt;headless permissions template&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you use allowed_tools and claude_args in Claude Code GitHub Actions?
&lt;/h2&gt;

&lt;p&gt;In v1 of Claude Code GitHub Actions, tool limits and every other CLI flag go through &lt;code&gt;claude_args&lt;/code&gt; (&lt;a href="https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md" rel="noopener noreferrer"&gt;usage.md&lt;/a&gt;). The old &lt;code&gt;allowed_tools&lt;/code&gt; input is deprecated. Pass &lt;code&gt;--allowedTools&lt;/code&gt; with the specific tools and Bash patterns the job needs.&lt;/p&gt;

&lt;p&gt;The old input is marked "DEPRECATED: Use &lt;code&gt;claude_args&lt;/code&gt; with &lt;code&gt;--allowedTools&lt;/code&gt; instead" (&lt;a href="https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md" rel="noopener noreferrer"&gt;usage.md&lt;/a&gt;). The &lt;code&gt;claude_args&lt;/code&gt; input "accepts any Claude Code CLI argument" (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;), including &lt;code&gt;--max-turns&lt;/code&gt;, &lt;code&gt;--model&lt;/code&gt;, &lt;code&gt;--mcp-config&lt;/code&gt;, &lt;code&gt;--allowedTools&lt;/code&gt; (alias &lt;code&gt;--allowed-tools&lt;/code&gt;) and &lt;code&gt;--debug&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Defaults matter here. "The Bash tool is disabled by default for security" (&lt;a href="https://github.com/anthropics/claude-code-action/blob/main/docs/faq.md" rel="noopener noreferrer"&gt;action FAQ&lt;/a&gt;). For a plain-text automation prompt, the docs say Claude "has no shell or GitHub API access until you grant the tools the prompt needs" (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;). So you're starting from a closed position and opening what each job needs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Example &lt;code&gt;--allowedTools&lt;/code&gt; value&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Post inline review comments&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mcp__github_inline_comment__create_inline_comment&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Required even if the skill already lists it; the MCP server only starts when this flag names it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read commits and issues for a report&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mcp__github__list_commits,mcp__github__list_issues&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;From the official daily-report example&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run tests and lint&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Bash(npm test:*),Bash(npm run lint:*)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Example; narrower than the FAQ's &lt;code&gt;Bash(npm:*)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read an issue for triage&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Bash(gh issue view:*)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The security doc's recommended scope for non-write users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edit files&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Edit,Read,Write&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;From the migration guide example in usage.md&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here is a multi-line &lt;code&gt;claude_args&lt;/code&gt; example. Replace &lt;code&gt;&amp;lt;model-id&amp;gt;&lt;/code&gt; with a model ID your account can use. This article doesn't recommend a specific model ID.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;claude_args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
  &lt;span class="s"&gt;--model &amp;lt;model-id&amp;gt;&lt;/span&gt;
  &lt;span class="s"&gt;--max-turns 8&lt;/span&gt;
  &lt;span class="s"&gt;--allowedTools "Read,Edit,Bash(npm test:*),Bash(npm run lint:*)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Common mistakes and fixes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Bash(npm:*)&lt;/code&gt; is wider than it looks.&lt;/strong&gt; It covers &lt;code&gt;npm install&lt;/code&gt; and &lt;code&gt;npm publish&lt;/code&gt; as well as &lt;code&gt;npm test&lt;/code&gt;. Name subcommands when you can. Our &lt;a href="https://en.aobaworks.com/claude-code-subagent-allowed-tools-syntax-examples-fixes/" rel="noopener noreferrer"&gt;allowed-tools syntax guide&lt;/a&gt; covers how the pattern matching works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review posts nothing.&lt;/strong&gt; Either the &lt;code&gt;--comment&lt;/code&gt; argument is missing from the review prompt, or &lt;code&gt;claude_args&lt;/code&gt; doesn't name the inline-comment MCP tool. Without &lt;code&gt;--comment&lt;/code&gt;, "Claude posts nothing" and findings stay in the run log (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Still using &lt;code&gt;@beta&lt;/code&gt; keys.&lt;/strong&gt; &lt;code&gt;direct_prompt&lt;/code&gt;, &lt;code&gt;mode&lt;/code&gt;, &lt;code&gt;max_turns&lt;/code&gt; and &lt;code&gt;model&lt;/code&gt; are beta-era inputs. Move them to &lt;code&gt;prompt&lt;/code&gt; and &lt;code&gt;claude_args&lt;/code&gt;. The &lt;code&gt;custom_instructions&lt;/code&gt; input becomes &lt;code&gt;--append-system-prompt&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;upgrade section&lt;/a&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You can also pass tool rules as JSON through the &lt;code&gt;settings&lt;/code&gt; input with a &lt;code&gt;permissions.allow&lt;/code&gt; rule, if you'd rather keep them in a settings file (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you store the Anthropic API key safely as a GitHub secret?
&lt;/h2&gt;

&lt;p&gt;Save the credential as a GitHub Actions secret and reference it only as &lt;code&gt;${{ secrets.NAME }}&lt;/code&gt;. Name it &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; (or &lt;code&gt;CLAUDE_CODE_OAUTH_TOKEN&lt;/code&gt; for a subscription token). The action's security doc puts it bluntly: "Never hardcode your Anthropic API key or OAuth token in workflow files!" (&lt;a href="https://github.com/anthropics/claude-code-action/blob/main/docs/security.md" rel="noopener noreferrer"&gt;security.md&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;These are the steps for a repository secret, using GitHub's UI labels (&lt;a href="https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions" rel="noopener noreferrer"&gt;GitHub Docs&lt;/a&gt;):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the repo and click &lt;strong&gt;Settings&lt;/strong&gt; (or the &lt;strong&gt;More&lt;/strong&gt; dropdown if Settings isn't shown).&lt;/li&gt;
&lt;li&gt;In the sidebar's Security section, click &lt;strong&gt;Secrets and variables&lt;/strong&gt;, then &lt;strong&gt;Actions&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;On the &lt;strong&gt;Secrets&lt;/strong&gt; tab, click &lt;strong&gt;New repository secret&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Enter &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; as &lt;strong&gt;Name&lt;/strong&gt;, paste the key from the &lt;a href="https://platform.claude.com" rel="noopener noreferrer"&gt;Claude Console&lt;/a&gt; as &lt;strong&gt;Secret&lt;/strong&gt;, and click &lt;strong&gt;Add secret&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;In the workflow, add &lt;code&gt;anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}&lt;/code&gt; under &lt;code&gt;with:&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a subscription token, run &lt;code&gt;claude setup-token&lt;/code&gt; locally, save the output as &lt;code&gt;CLAUDE_CODE_OAUTH_TOKEN&lt;/code&gt;, and use the input &lt;code&gt;claude_code_oauth_token&lt;/code&gt; instead. The docs list it for Pro, Max, Ultra, Team and Enterprise plans (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;If you want a human gate on the riskier workflows, use an environment secret. Go to &lt;strong&gt;Settings → Environments&lt;/strong&gt;, pick the environment, and under &lt;strong&gt;Environment secrets&lt;/strong&gt; click &lt;strong&gt;Add secret&lt;/strong&gt; (&lt;a href="https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions" rel="noopener noreferrer"&gt;GitHub Docs&lt;/a&gt;). Then add &lt;code&gt;environment: &amp;lt;name&amp;gt;&lt;/code&gt; to the job and set protection rules for that environment, described in GitHub's "Managing environments for deployment" page.&lt;/p&gt;

&lt;p&gt;Pitfalls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sharing an OAuth token across an org.&lt;/strong&gt; The docs recommend a Console API key for shared secrets, since an OAuth token is tied to the subscription of whoever ran &lt;code&gt;claude setup-token&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thinking deleted means revoked.&lt;/strong&gt; Deleting the GitHub secret leaves the credential valid. To retire it, also delete the key in the Console (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaking via logs.&lt;/strong&gt; &lt;code&gt;show_full_output&lt;/code&gt; is off by default, and the security doc warns that logs "are publicly visible in GitHub Actions for public repositories!" (&lt;a href="https://github.com/anthropics/claude-code-action/blob/main/docs/security.md" rel="noopener noreferrer"&gt;security.md&lt;/a&gt;). Leave it off unless you're debugging a private repo.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do you set up an unattended PR review workflow with Claude Code?
&lt;/h2&gt;

&lt;p&gt;Run the official code-review plugin on a &lt;code&gt;pull_request&lt;/code&gt; trigger with read-only scopes. Add &lt;code&gt;id-token: write&lt;/code&gt; to the workflow scopes and a &lt;code&gt;claude_args&lt;/code&gt; line that allows only the inline-comment tool. This is the official example, with two optional additions marked in comments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Code Review&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;opened&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;synchronize&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;ready_for_review&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;reopened&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;            &lt;span class="c1"&gt;# example addition: only review source changes&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src/**"&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;review&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;   &lt;span class="c1"&gt;# example addition: cap runaway runs&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;issues&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;anthropics/claude-code-action@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;anthropic_api_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ANTHROPIC_API_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;plugin_marketplaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://github.com/anthropics/claude-plugins-official.git"&lt;/span&gt;
          &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code-review@claude-plugins-official"&lt;/span&gt;
          &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/code-review:code-review&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--comment&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;github.repository&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/pull/${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;github.event.pull_request.number&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
          &lt;span class="na"&gt;claude_args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--allowedTools&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"mcp__github_inline_comment__create_inline_comment"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The base file, including &lt;code&gt;actions/checkout@v6&lt;/code&gt;, &lt;code&gt;fetch-depth: 1&lt;/code&gt; and &lt;code&gt;claude-code-action@v1&lt;/code&gt;, comes from the &lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs, "Run a skill"&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install the Claude GitHub App on the repo.&lt;/li&gt;
&lt;li&gt;Add the &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; secret (previous section).&lt;/li&gt;
&lt;li&gt;Save the file as &lt;code&gt;.github/workflows/claude-code-review.yml&lt;/code&gt; on a branch and open a PR. That PR should trigger the first review.&lt;/li&gt;
&lt;li&gt;Check the run under the &lt;strong&gt;Actions&lt;/strong&gt; tab, then look for inline comments on the PR.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Some behavior is built in. Claude skips draft and closed PRs, PRs it judges trivial or automated, and PRs that already have a Claude comment. On public repos, the docs note GitHub withholds secrets from fork PRs, "so the review runs only on pull requests from branches in the same repository" (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;To limit by branch, add &lt;code&gt;branches: [main]&lt;/code&gt; under &lt;code&gt;pull_request&lt;/code&gt;. That matches the PR's base branch. Put your review criteria in &lt;code&gt;CLAUDE.md&lt;/code&gt; and keep the file short, because Claude reads it on every run. Our &lt;a href="https://en.aobaworks.com/claude-md-best-practices-a-template-for-unattended-agents/" rel="noopener noreferrer"&gt;CLAUDE.md template for unattended agents&lt;/a&gt; has a structure you can copy.&lt;/p&gt;

&lt;p&gt;If you'd rather not maintain a workflow at all, Anthropic's separate &lt;a href="https://code.claude.com/docs/en/code-review.md" rel="noopener noreferrer"&gt;Code Review&lt;/a&gt; product runs reviews without a workflow file.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you run Claude Code on a schedule or on issues without babysitting?
&lt;/h2&gt;

&lt;p&gt;Give Claude Code a &lt;code&gt;prompt&lt;/code&gt; (automation mode) on &lt;code&gt;schedule&lt;/code&gt; or &lt;code&gt;issues&lt;/code&gt; triggers. Pair it with narrow scopes, &lt;code&gt;--max-turns&lt;/code&gt; and &lt;code&gt;timeout-minutes&lt;/code&gt; so each run is bounded. Use &lt;code&gt;@claude&lt;/code&gt; mentions (interactive mode) only when a human starts each request.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Interactive mode&lt;/th&gt;
&lt;th&gt;Automation mode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;How it starts&lt;/td&gt;
&lt;td&gt;No &lt;code&gt;prompt&lt;/code&gt; input; waits for &lt;code&gt;@claude&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;prompt&lt;/code&gt; input set; runs on the event&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where results go&lt;/td&gt;
&lt;td&gt;Comment on the issue or PR&lt;/td&gt;
&lt;td&gt;Workflow run log, unless the prompt and a tool let it post&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Good for&lt;/td&gt;
&lt;td&gt;Ad-hoc fixes you request&lt;/td&gt;
&lt;td&gt;Cron reports, triage, recurring checks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who can trigger&lt;/td&gt;
&lt;td&gt;Users with write access&lt;/td&gt;
&lt;td&gt;Same check on issue/PR events; &lt;code&gt;schedule&lt;/code&gt; skips it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Source: &lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs, "Interactive and automation modes"&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Here's a bounded nightly example, adapted from the official daily report. The concurrency, timeout and turn cap are example additions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Daily Report&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;9&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;claude-daily-report&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;report&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;issues&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;anthropics/claude-code-action@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;anthropic_api_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ANTHROPIC_API_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Generate&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;summary&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;of&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;yesterday's&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;commits&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;open&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;issues"&lt;/span&gt;
          &lt;span class="na"&gt;claude_args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;--max-turns 6&lt;/span&gt;
            &lt;span class="s"&gt;--allowedTools "mcp__github__list_commits,mcp__github__list_issues"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scheduling has some catches. GitHub runs scheduled workflows "only from the default branch," and in public repos it disables the schedule after 60 days without repository activity (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;). Scheduled runs are attributed to whoever last changed the &lt;code&gt;cron&lt;/code&gt; line. If that user is a bot, the human-actor check rejects the run unless the bot is in &lt;code&gt;allowed_bots&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Loops are the other trap. If your workflow posts a comment containing &lt;code&gt;@claude&lt;/code&gt; using &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;, nothing triggers, because "The &lt;code&gt;github-actions&lt;/code&gt; user cannot trigger subsequent GitHub Actions workflows" (&lt;a href="https://github.com/anthropics/claude-code-action/blob/main/docs/faq.md" rel="noopener noreferrer"&gt;action FAQ&lt;/a&gt;). That's GitHub preventing infinite loops on purpose, so keep it. If you need chaining, use your own app token rather than widening &lt;code&gt;allowed_bots&lt;/code&gt; to &lt;code&gt;'*'&lt;/code&gt;. For machine-side scheduling outside GitHub, see our &lt;a href="https://en.aobaworks.com/claude-code-cron-job-setup-run-automations-overnight/" rel="noopener noreferrer"&gt;cron job setup guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is it safe to run Claude Code on pull requests from forks or untrusted users?
&lt;/h2&gt;

&lt;p&gt;Only with tight limits, because PR bodies, issues and comments can carry prompt injection. The defaults help: fork runs get no secrets, and non-write users are blocked.&lt;/p&gt;

&lt;p&gt;GitHub's rule: "With the exception of &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;, secrets are not passed to the runner when a workflow is triggered from a forked repository" (&lt;a href="https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions" rel="noopener noreferrer"&gt;GitHub Docs&lt;/a&gt;). That's why a plain &lt;code&gt;pull_request&lt;/code&gt; review simply doesn't run on forks. Switching to &lt;code&gt;pull_request_target&lt;/code&gt; to get fork coverage changes the risk. The security doc warns that &lt;code&gt;pull_request_target&lt;/code&gt; and &lt;code&gt;workflow_run&lt;/code&gt; "execute with the base repository's secrets" (&lt;a href="https://github.com/anthropics/claude-code-action/blob/main/docs/security.md" rel="noopener noreferrer"&gt;security.md&lt;/a&gt;).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;th&gt;Safer choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pull_request&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;Low; no secrets on forks&lt;/td&gt;
&lt;td&gt;Keep for review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pull_request_target&lt;/code&gt; + checkout PR head into root&lt;/td&gt;
&lt;td&gt;High; untrusted code runs with your secrets&lt;/td&gt;
&lt;td&gt;Check out the head into a subfolder (&lt;code&gt;path: pr-head&lt;/code&gt;) and pass &lt;code&gt;--add-dir pr-head&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;allowed_non_write_users: "*"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;High; bypasses the main access check&lt;/td&gt;
&lt;td&gt;Avoid, or pair with &lt;code&gt;issues: write&lt;/code&gt; only and &lt;code&gt;Bash(gh issue view:*)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;allowed_bots: "*"&lt;/code&gt; on a public repo&lt;/td&gt;
&lt;td&gt;External apps may trigger runs&lt;/td&gt;
&lt;td&gt;List bot names explicitly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Personal access token as &lt;code&gt;github_token&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Static token can leak via injection&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;${{ secrets.GITHUB_TOKEN }}&lt;/code&gt;, which rotates per run&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/anthropics/claude-code-action/blob/main/docs/security.md" rel="noopener noreferrer"&gt;security.md&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The core rule is short: "Do not check out an untrusted ref into the workspace root before this action" (&lt;a href="https://github.com/anthropics/claude-code-action/blob/main/docs/security.md" rel="noopener noreferrer"&gt;security.md&lt;/a&gt;). On PRs, the action restores Claude config paths like &lt;code&gt;.claude/&lt;/code&gt;, &lt;code&gt;.mcp.json&lt;/code&gt; and &lt;code&gt;CLAUDE.md&lt;/code&gt; from the base branch. The security doc notes that files such as &lt;code&gt;package.json&lt;/code&gt;, lockfiles and &lt;code&gt;Makefile&lt;/code&gt; stay at the PR head. Our inference from that is that an allowed command like &lt;code&gt;npm test&lt;/code&gt;, which runs scripts defined in &lt;code&gt;package.json&lt;/code&gt;, would execute whatever the PR author wrote there.&lt;/p&gt;

&lt;p&gt;The action strips HTML comments, invisible characters and hidden attributes before Claude sees them, though the doc admits "new bypass techniques may emerge." On public repos, &lt;code&gt;include_comments_by_actor&lt;/code&gt; lets you allowlist whose comments reach Claude. For more rule ideas, see our &lt;a href="https://en.aobaworks.com/ai-agent-guardrails-example-copy-paste-claude-code-rules/" rel="noopener noreferrer"&gt;guardrails examples&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you use an Anthropic API key or OIDC (Bedrock/Vertex) for Claude Code GitHub Actions?
&lt;/h2&gt;

&lt;p&gt;An Anthropic API key secret is the simplest choice for solo builders. OIDC through Bedrock, Vertex, Foundry or Anthropic workload identity federation avoids long-lived keys but needs cloud-side setup.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Secret stored&lt;/th&gt;
&lt;th&gt;Extra setup&lt;/th&gt;
&lt;th&gt;Suits&lt;/th&gt;
&lt;th&gt;Doesn't suit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Long-lived key&lt;/td&gt;
&lt;td&gt;None beyond the secret&lt;/td&gt;
&lt;td&gt;Solo builders, small teams&lt;/td&gt;
&lt;td&gt;Orgs that ban static keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CLAUDE_CODE_OAUTH_TOKEN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Subscription token&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claude setup-token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pro/Max users who want plan billing&lt;/td&gt;
&lt;td&gt;Org-wide shared secrets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic workload identity federation&lt;/td&gt;
&lt;td&gt;None (IDs only)&lt;/td&gt;
&lt;td&gt;Console federation rule and service account; &lt;code&gt;id-token: write&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Teams on the Claude API wanting no keys&lt;/td&gt;
&lt;td&gt;Quick experiments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bedrock / Agent Platform / Foundry&lt;/td&gt;
&lt;td&gt;None static; cloud OIDC role&lt;/td&gt;
&lt;td&gt;Cloud role, custom GitHub App with &lt;code&gt;APP_ID&lt;/code&gt;, &lt;code&gt;APP_PRIVATE_KEY&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Companies billing through a cloud account&lt;/td&gt;
&lt;td&gt;Anyone without cloud admin access&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sources: &lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt; and &lt;a href="https://code.claude.com/docs/en/github-actions-cloud-providers.md" rel="noopener noreferrer"&gt;cloud providers page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For federation, set &lt;code&gt;anthropic_federation_rule_id&lt;/code&gt; (&lt;code&gt;fdrl_...&lt;/code&gt;) and &lt;code&gt;anthropic_organization_id&lt;/code&gt;. You can optionally add &lt;code&gt;anthropic_service_account_id&lt;/code&gt; (&lt;code&gt;svac_...&lt;/code&gt;) and &lt;code&gt;anthropic_workspace_id&lt;/code&gt;. The docs say the action needs &lt;code&gt;id-token: write&lt;/code&gt; for that exchange "even when you pass your own &lt;code&gt;github_token&lt;/code&gt;" (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;). For cloud providers, set &lt;code&gt;use_bedrock: "true"&lt;/code&gt;, &lt;code&gt;use_vertex: "true"&lt;/code&gt; or &lt;code&gt;use_foundry: "true"&lt;/code&gt; and follow the provider page. Bedrock setups typically store an &lt;code&gt;AWS_ROLE_TO_ASSUME&lt;/code&gt; secret, which is a role ARN, not a key.&lt;/p&gt;

&lt;p&gt;For a wider comparison of auth and billing when Claude runs headless, see our &lt;a href="https://en.aobaworks.com/claude-code-headless-mode-flags-auth-and-cost-2026/" rel="noopener noreferrer"&gt;headless mode guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you cap cost and runaway runs in Claude Code GitHub Actions?
&lt;/h2&gt;

&lt;p&gt;Cap Claude Code GitHub Actions cost with &lt;code&gt;--max-turns&lt;/code&gt;, job &lt;code&gt;timeout-minutes&lt;/code&gt;, &lt;code&gt;concurrency&lt;/code&gt; groups and narrow triggers, plus a cheaper model for routine work. Each run uses both GitHub Actions minutes and Claude tokens (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The default ceiling is generous. GitHub's job timeout is "The maximum number of minutes to let a job run before GitHub automatically cancels it. Default: 360" (&lt;a href="https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax" rel="noopener noreferrer"&gt;GitHub workflow syntax&lt;/a&gt;). That's six hours of runner time if a run wanders. Setting &lt;code&gt;timeout-minutes: 10&lt;/code&gt; to &lt;code&gt;20&lt;/code&gt; on review and report jobs is a reasonable starting point (example values; tune to your repo).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;What it stops&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--max-turns N&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claude_args&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Long agent loops within one run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;timeout-minutes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Job&lt;/td&gt;
&lt;td&gt;Runner minutes burning after a hang&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;concurrency&lt;/code&gt; + &lt;code&gt;cancel-in-progress: true&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Workflow or job&lt;/td&gt;
&lt;td&gt;Stacked runs on rapid pushes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;paths:&lt;/code&gt; / &lt;code&gt;types:&lt;/code&gt; filters&lt;/td&gt;
&lt;td&gt;&lt;code&gt;on:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runs on docs-only or irrelevant changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;if: contains(github.event.comment.body, '@claude')&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Job&lt;/td&gt;
&lt;td&gt;Runner start on every comment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--model&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claude_args&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Paying top-tier rates for simple tasks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The concurrency option means "cancel any currently running job or workflow in the same concurrency group" (&lt;a href="https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax" rel="noopener noreferrer"&gt;GitHub workflow syntax&lt;/a&gt;). For PR review, group by PR number: &lt;code&gt;group: review-${{ github.event.pull_request.number }}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On pricing, API runs bill per token at the rates on &lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;Claude's pricing page&lt;/a&gt;. With an OAuth token, runs draw on your subscription instead, and current plan prices are listed on the same &lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt;. Subscription use is still subject to plan usage limits, so a busy repo may hit them. To see where tokens go, read our &lt;a href="https://en.aobaworks.com/claude-code-how-to-see-token-usage-and-reduce-it/" rel="noopener noreferrer"&gt;token usage guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is Claude Code failing with permission errors in GitHub Actions?
&lt;/h2&gt;

&lt;p&gt;Most Claude Code GitHub Actions permission errors come from one of five causes. Either a scope is missing from &lt;code&gt;permissions:&lt;/code&gt;, the Claude GitHub App isn't installed, the trigger user lacks write access, a tool isn't in &lt;code&gt;--allowedTools&lt;/code&gt;, or a &lt;code&gt;github_token&lt;/code&gt; was passed by mistake.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OIDC authentication error&lt;/td&gt;
&lt;td&gt;No &lt;code&gt;id-token: write&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Add it to the job's &lt;code&gt;permissions:&lt;/code&gt; (&lt;a href="https://github.com/anthropics/claude-code-action/blob/main/docs/faq.md" rel="noopener noreferrer"&gt;FAQ&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"You don't have permission" style rejection&lt;/td&gt;
&lt;td&gt;Commenter lacks write access&lt;/td&gt;
&lt;td&gt;Grant write, or use &lt;code&gt;allowed_non_write_users&lt;/code&gt; with care&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nothing happens on &lt;code&gt;@claude&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;App not installed, workflows disabled, or &lt;code&gt;/claude&lt;/code&gt; instead of &lt;code&gt;@claude&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Check each in the docs' troubleshooting list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;403 Resource not accessible by integration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;App token can't call the &lt;code&gt;/user&lt;/code&gt; endpoint&lt;/td&gt;
&lt;td&gt;See the FAQ entry; often caused by custom steps calling &lt;code&gt;/user&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude says it can't run a command&lt;/td&gt;
&lt;td&gt;Bash not allowed&lt;/td&gt;
&lt;td&gt;Add &lt;code&gt;Bash(cmd:*)&lt;/code&gt; to &lt;code&gt;--allowedTools&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Review finishes but no comments&lt;/td&gt;
&lt;td&gt;Missing &lt;code&gt;--comment&lt;/code&gt; or inline-comment tool&lt;/td&gt;
&lt;td&gt;Add both, per the review example&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI doesn't run on Claude's commits&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;github_token: ${{ secrets.GITHUB_TOKEN }}&lt;/code&gt; passed&lt;/td&gt;
&lt;td&gt;Remove it so the action uses the app, or use a custom app token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Comments not from &lt;code&gt;claude[bot]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;github_token&lt;/code&gt; provided&lt;/td&gt;
&lt;td&gt;Remove it unless using a custom app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scheduled run rejected&lt;/td&gt;
&lt;td&gt;Last cron editor is a bot&lt;/td&gt;
&lt;td&gt;Add that bot to &lt;code&gt;allowed_bots&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude won't edit workflow files&lt;/td&gt;
&lt;td&gt;App lacks workflow write via the action&lt;/td&gt;
&lt;td&gt;Edit &lt;code&gt;.github/workflows/&lt;/code&gt; yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sources: &lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code troubleshooting&lt;/a&gt; and &lt;a href="https://github.com/anthropics/claude-code-action/blob/main/docs/faq.md" rel="noopener noreferrer"&gt;action FAQ&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A quick diagnostic order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the failed run under &lt;strong&gt;Actions&lt;/strong&gt; and find the first red step.&lt;/li&gt;
&lt;li&gt;If the error mentions OIDC or token exchange, check &lt;code&gt;permissions:&lt;/code&gt; first.&lt;/li&gt;
&lt;li&gt;If Claude ran but did nothing useful, rerun with &lt;code&gt;--debug&lt;/code&gt; in &lt;code&gt;claude_args&lt;/code&gt; (private repos only, given public logs).&lt;/li&gt;
&lt;li&gt;Test the API key locally with &lt;code&gt;claude&lt;/code&gt; before debugging YAML further, as the docs suggest.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;If maintaining workflow files isn't the route you want, there are two alternatives.&lt;/p&gt;

&lt;p&gt;Anthropic's &lt;a href="https://code.claude.com/docs/en/code-review.md" rel="noopener noreferrer"&gt;Code Review&lt;/a&gt; runs automatic PR reviews without YAML. It suits teams that only want review. It doesn't suit you if you need cron jobs, issue-to-PR automation or custom tool lists. It also requires the official Claude GitHub App with its full permission set (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;). If cost predictability matters more than per-token billing, a Claude Pro or Max subscription with &lt;code&gt;CLAUDE_CODE_OAUTH_TOKEN&lt;/code&gt; is another route. Plans are listed on &lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;Claude's pricing page&lt;/a&gt;. It's a poor fit for org-wide shared secrets.&lt;/p&gt;

&lt;p&gt;Related guides on this site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/claude-code-permissions-best-practices-headless-template/" rel="noopener noreferrer"&gt;Claude Code Permissions Best Practices: Headless Template&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/claude-code-subagent-allowed-tools-syntax-examples-fixes/" rel="noopener noreferrer"&gt;Claude Code Subagent allowed-tools: Syntax, Examples, Fixes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/ai-agent-guardrails-example-copy-paste-claude-code-rules/" rel="noopener noreferrer"&gt;AI Agent Guardrails Example: Copy-Paste Claude Code Rules&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/ai-agent-monitoring-in-claude-code-catch-silent-failures/" rel="noopener noreferrer"&gt;AI Agent Monitoring in Claude Code: Catch Silent Failures&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Does Claude Code GitHub Actions need id-token: write?
&lt;/h3&gt;

&lt;p&gt;Yes, with the default Claude GitHub App authentication. The action uses the workflow's OIDC token to get its app token, and the &lt;a href="https://github.com/anthropics/claude-code-action/blob/main/docs/faq.md" rel="noopener noreferrer"&gt;action FAQ&lt;/a&gt; says you "must add the &lt;code&gt;id-token: write&lt;/code&gt; permission." Anthropic workload identity federation needs it too, even if you pass your own &lt;code&gt;github_token&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Can Claude Code push commits or open PRs from a workflow?
&lt;/h3&gt;

&lt;p&gt;It can push commits to a branch (prefix &lt;code&gt;claude/&lt;/code&gt; by default) when the job has write scopes. But "Claude doesn't create PRs by default." It posts a link to a pre-filled PR page so a human opens the PR and branch protection still applies (&lt;a href="https://github.com/anthropics/claude-code-action/blob/main/docs/faq.md" rel="noopener noreferrer"&gt;action FAQ&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I pin the claude-code-action to a commit SHA?
&lt;/h3&gt;

&lt;p&gt;The official examples use the moving &lt;code&gt;@v1&lt;/code&gt; tag, which picks up fixes automatically. You can see it in &lt;a href="https://github.com/anthropics/claude-code-action/blob/main/examples/claude.yml" rel="noopener noreferrer"&gt;examples/claude.yml&lt;/a&gt; and the workflows in the &lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;. GitHub's &lt;a href="https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions" rel="noopener noreferrer"&gt;security hardening guide&lt;/a&gt; says pinning an action to a full-length commit SHA is the only way to use it as an immutable release. That makes a SHA the safer choice for workflows that hold write scopes or secrets. The trade-off is that you have to bump it yourself. By the same guidance, a branch ref like &lt;code&gt;@main&lt;/code&gt; changes with every commit, so avoid it in production workflows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use Claude Code in GitHub Actions without an API key?
&lt;/h3&gt;

&lt;p&gt;Yes. You can use a &lt;code&gt;CLAUDE_CODE_OAUTH_TOKEN&lt;/code&gt; from &lt;code&gt;claude setup-token&lt;/code&gt; on a Claude subscription. You can also use Anthropic workload identity federation, or OIDC with Amazon Bedrock, Google Cloud's Agent Platform or Microsoft Foundry (&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;). Each option still needs the Claude GitHub App or a custom app for GitHub access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code GitHub Actions (official docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/github-actions-cloud-providers.md" rel="noopener noreferrer"&gt;Use Claude Code GitHub Actions with cloud providers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/authentication.md#generate-a-long-lived-token" rel="noopener noreferrer"&gt;Claude Code authentication: long-lived token&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/code-review.md" rel="noopener noreferrer"&gt;Claude Code Review&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/anthropics/claude-code-action/blob/main/docs/security.md" rel="noopener noreferrer"&gt;anthropics/claude-code-action: security.md&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/anthropics/claude-code-action/blob/main/docs/faq.md" rel="noopener noreferrer"&gt;anthropics/claude-code-action: faq.md&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md" rel="noopener noreferrer"&gt;anthropics/claude-code-action: usage.md&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/anthropics/claude-code-action/blob/main/examples/claude.yml" rel="noopener noreferrer"&gt;anthropics/claude-code-action: examples/claude.yml&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/controlling-permissions-for-github_token" rel="noopener noreferrer"&gt;GitHub Docs: Controlling permissions for GITHUB_TOKEN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax" rel="noopener noreferrer"&gt;GitHub Docs: Workflow syntax&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions" rel="noopener noreferrer"&gt;GitHub Docs: Using secrets in GitHub Actions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions" rel="noopener noreferrer"&gt;GitHub Docs: Security hardening for GitHub Actions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;Claude pricing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was created with the help of AI. Sources are linked inline; check the official documentation for the latest details.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>githubactions</category>
      <category>permissions</category>
      <category>cisecurity</category>
    </item>
    <item>
      <title>Claude Code Stuck on Compacting Conversation: Cron Watchdog</title>
      <dc:creator>Aoba Works</dc:creator>
      <pubDate>Tue, 03 Nov 2026 16:14:29 +0000</pubDate>
      <link>https://dev.to/aobaworks/claude-code-stuck-on-compacting-conversation-cron-watchdog-55ka</link>
      <guid>https://dev.to/aobaworks/claude-code-stuck-on-compacting-conversation-cron-watchdog-55ka</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://en.aobaworks.com/claude-code-stuck-on-compacting-conversation-cron-watchdog/?utm_source=devto&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unattended&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If Claude Code is stuck on "Compacting conversation," stop the process. Then start a fresh session with a short written handoff instead of waiting or resuming. In an unattended &lt;code&gt;claude -p&lt;/code&gt; run, wrap the command in a wall-clock timeout and use the &lt;code&gt;PreCompact&lt;/code&gt;/&lt;code&gt;PostCompact&lt;/code&gt; hooks to timestamp each compaction. If one runs too long, kill the run and retry clean. The rest of this guide covers why compaction stalls, what the real thresholds are, and how to make a cron job recover without you.&lt;/p&gt;

&lt;p&gt;The official docs warn that &lt;code&gt;/compact&lt;/code&gt; "reads the conversation it summarizes, so compacting a large context is itself a large request" (&lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;Claude Code docs: Manage costs&lt;/a&gt;). A large request can fail, get retried or run slowly, and in a headless run nobody sees the spinner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which option is right for you?
&lt;/h2&gt;

&lt;p&gt;Pick based on how you run Claude Code today. Each row gives the first page to open.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your situation&lt;/th&gt;
&lt;th&gt;What to pick first&lt;/th&gt;
&lt;th&gt;First page to open&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Beginner, interactive terminal, spinner frozen right now&lt;/td&gt;
&lt;td&gt;Press Esc, then &lt;code&gt;/compact&lt;/code&gt; with a focus, or &lt;code&gt;/clear&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/errors" rel="noopener noreferrer"&gt;Errors: Prompt is too long&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Daily work: cron or CI job that sometimes never finishes&lt;/td&gt;
&lt;td&gt;Wrapper script with a hard timeout and a compaction watchdog (below)&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Run Claude Code programmatically&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long multi-hour agent tasks&lt;/td&gt;
&lt;td&gt;Compact earlier with &lt;code&gt;CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=50&lt;/code&gt;, and split work into sessions&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/model-config" rel="noopener noreferrer"&gt;Set the auto-compact window&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Budget-sensitive (API key billing)&lt;/td&gt;
&lt;td&gt;Cap each run with &lt;code&gt;--max-budget-usd&lt;/code&gt; and &lt;code&gt;--max-turns&lt;/code&gt;, and turn auto-compact off for short jobs&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/cli-reference" rel="noopener noreferrer"&gt;CLI reference&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Not sure if it's compaction at all&lt;/td&gt;
&lt;td&gt;Check the stream for &lt;code&gt;api_retry&lt;/code&gt; events before blaming compaction&lt;/td&gt;
&lt;td&gt;&lt;a href="https://en.aobaworks.com/why-claude-code-stops-mid-automation-and-how-to-fix-it/" rel="noopener noreferrer"&gt;/why-claude-code-stops-mid-automation-and-how-to-fix-it/&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here's why each one fits. Interactive users can see and answer the error. Cron users can't, so they need a script that decides for them. Long tasks gain the most from compacting at a smaller, cheaper context. Budget users mainly need a ceiling so a stuck loop can't run up a bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does Claude Code get stuck on "Compacting conversation"?
&lt;/h2&gt;

&lt;p&gt;Claude Code gets stuck when the summarization request fails, retries or loops. Most often the context is too big to summarize, or the API is slow or throttled.&lt;/p&gt;

&lt;p&gt;The documented and reported causes fall into five groups:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The summary doesn't fit.&lt;/strong&gt; The errors page lists &lt;code&gt;Prompt is too long · automatic compaction failed: Compaction would make the conversation longer&lt;/code&gt;. That message means the summary would be bigger than the original, and the fix is &lt;code&gt;/clear&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/errors" rel="noopener noreferrer"&gt;Errors reference&lt;/a&gt;). The same page says a single-exchange conversation "cannot be compacted" at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retries that look like a hang.&lt;/strong&gt; Claude Code "retries transient failures up to 10 times with exponential backoff before showing you an error." Each attempt gets up to &lt;code&gt;API_TIMEOUT_MS&lt;/code&gt; (600000 ms, 10 minutes by default) to receive response headers (&lt;a href="https://code.claude.com/docs/en/errors" rel="noopener noreferrer"&gt;Errors: Automatic retries&lt;/a&gt;; &lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;Environment variables&lt;/a&gt;). A big compaction request that hits an overloaded API can therefore sit for a long time before it fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The retry watchdog you turned on.&lt;/strong&gt; Setting &lt;code&gt;CLAUDE_CODE_RETRY_WATCHDOG=1&lt;/code&gt; makes Claude Code retry &lt;code&gt;429&lt;/code&gt; and &lt;code&gt;529&lt;/code&gt; errors indefinitely. On v2.1.199 and later it also raises the retry count for other transient errors to 300, "roughly three hours of backoff" (&lt;a href="https://code.claude.com/docs/en/errors" rel="noopener noreferrer"&gt;Errors: Tune retry behavior&lt;/a&gt;). That's useful for cron. It also means a compaction can wait hours unless you set &lt;code&gt;CLAUDE_CODE_RETRY_WATCHDOG_MAX_WAIT_MS&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thrashing loops.&lt;/strong&gt; GitHub issue &lt;a href="https://github.com/anthropics/claude-code/issues/6004" rel="noopener noreferrer"&gt;#6004&lt;/a&gt; describes an endless cycle: read files, compact, lose the state, then read the same files again. It was reported on v1.0.83, still seen on v2.0.6x, and closed as "not planned" in February 2026. The usual trigger is one large file or tool output that fills the context again right after each summary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Summarizer overflow (older versions).&lt;/strong&gt; Issue &lt;a href="https://github.com/anthropics/claude-code/issues/23751" rel="noopener noreferrer"&gt;#23751&lt;/a&gt; reported &lt;code&gt;Conversation too long&lt;/code&gt; failures at about 48% context usage on v2.1.32–v2.1.34, with image-heavy sessions hit hardest. It is marked fixed as of v2.1.85. If your cron box runs an old pinned version, update it first.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's why headless makes this worse. An interactive user sees the error and presses Esc. A &lt;code&gt;claude -p&lt;/code&gt; process has no human, so a failed compaction ends in one of two ways. It either exits non-zero, which is easy to handle, or it keeps retrying or looping, which looks like a frozen job.&lt;/p&gt;

&lt;p&gt;Example: a nightly job that reads a 10,000-line log file can fill most of the context in one tool call. Compaction summarizes it, the agent reads the log again, and the cycle repeats until the job hits a limit or you notice in the morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  What triggers auto-compact, and can you change the threshold?
&lt;/h2&gt;

&lt;p&gt;By default, Claude Code auto-compacts when the conversation reaches the model's context limit. You can lower that point with a setting, a CLI flag or an environment variable.&lt;/p&gt;

&lt;p&gt;The "92–95%" figure you see quoted online doesn't match current docs. According to &lt;a href="https://code.claude.com/docs/en/model-config" rel="noopener noreferrer"&gt;Default auto-compact thresholds&lt;/a&gt; (as of October 2026):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Most sessions compact when the conversation reaches the model's context limit.&lt;/li&gt;
&lt;li&gt;Models with a native 1M window (Fable models, Sonnet 5 and later, Haiku 5.5, Opus 4.7 and later) compact "at about 967K tokens by default."&lt;/li&gt;
&lt;li&gt;Sonnet 4.6 and Opus 4.6 without extended context compact at the 200K boundary.&lt;/li&gt;
&lt;li&gt;With &lt;code&gt;CLAUDE_CODE_DISABLE_1M_CONTEXT=1&lt;/code&gt;, native-1M models compact at 200K.&lt;/li&gt;
&lt;li&gt;Unrecognized model IDs, such as an LLM gateway alias, compact at whatever window Claude Code assumes for that ID.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The four ways to change it
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;How&lt;/th&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;In a session&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/autocompact 500k&lt;/code&gt; (or &lt;code&gt;/autocompact auto&lt;/code&gt; to reset)&lt;/td&gt;
&lt;td&gt;Saved for the current model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Settings file&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"autoCompactWindow": 200000&lt;/code&gt; in &lt;code&gt;~/.claude/settings.json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Every model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One launch&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;claude --autocompact 500k&lt;/code&gt; (v2.1.221+)&lt;/td&gt;
&lt;td&gt;That run only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scripts/CI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Wins over the others&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Accepted values run from 100K to 1M tokens, and Claude Code caps the window at the model's context size (&lt;a href="https://code.claude.com/docs/en/model-config" rel="noopener noreferrer"&gt;model config&lt;/a&gt;; &lt;a href="https://code.claude.com/docs/en/settings-reference" rel="noopener noreferrer"&gt;settings reference&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;CLAUDE_AUTOCOMPACT_PCT_OVERRIDE&lt;/code&gt; to compact earlier as a percentage. The docs say "the variable can't raise the threshold, so values above the default percentage are ignored" (&lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;Environment variables&lt;/a&gt;). It only applies in sessions that compact before the model's context limit. So pair it with &lt;code&gt;CLAUDE_CODE_AUTO_COMPACT_WINDOW&lt;/code&gt; if you want it to take effect.&lt;/p&gt;

&lt;h3&gt;
  
  
  Turning it off
&lt;/h3&gt;

&lt;p&gt;Set &lt;code&gt;"autoCompactEnabled": false&lt;/code&gt; in a settings file (&lt;a href="https://code.claude.com/docs/en/settings-reference" rel="noopener noreferrer"&gt;settings reference&lt;/a&gt;). With auto-compact off, a full context produces a &lt;code&gt;Context limit reached&lt;/code&gt; error instead of a compaction (&lt;a href="https://code.claude.com/docs/en/errors" rel="noopener noreferrer"&gt;Errors&lt;/a&gt;). For a short cron job that's often what you want: a fast, loud failure your script can catch. For long jobs, it means the run just stops at the limit.&lt;/p&gt;

&lt;p&gt;There's also a hook-level off switch. A &lt;code&gt;PreCompact&lt;/code&gt; hook that exits with code 2 blocks compaction (&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Hooks reference&lt;/a&gt;). See &lt;a href="https://en.aobaworks.com/claude-code-hooks-lifecycle-every-event-explained/" rel="noopener noreferrer"&gt;/claude-code-hooks-lifecycle-every-event-explained/&lt;/a&gt; for where it fires in the lifecycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you detect a stuck compaction in a headless or cron run?
&lt;/h2&gt;

&lt;p&gt;To spot a stuck compaction in headless Claude Code, mark when each compaction starts and ends. If a start has no matching end within your limit, the run is stuck.&lt;/p&gt;

&lt;p&gt;Exit codes alone aren't enough. The docs say Claude Code "exits with code 0 on success and a non-zero code when the run fails" (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Headless docs&lt;/a&gt;). A hung process never exits, though, so you also need signals that work while it's still running. There are three:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Compaction markers from hooks.&lt;/strong&gt; &lt;code&gt;PreCompact&lt;/code&gt; fires "before context compaction begins" and &lt;code&gt;PostCompact&lt;/code&gt; fires "after context compaction completes successfully." Both accept an &lt;code&gt;auto&lt;/code&gt; or &lt;code&gt;manual&lt;/code&gt; matcher (&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Hooks reference&lt;/a&gt;). Have the first write a timestamp file and the second delete it. A timestamp file older than your limit means compaction is stuck.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stream inactivity.&lt;/strong&gt; Run with &lt;code&gt;--output-format stream-json --verbose&lt;/code&gt; and write stdout to a file. If the file hasn't changed in N minutes, something is wrong. Note that &lt;code&gt;PreCompact&lt;/code&gt; and &lt;code&gt;PostCompact&lt;/code&gt; "never produce a &lt;code&gt;hook_started&lt;/code&gt; event" in the stream (&lt;a href="https://code.claude.com/docs/en/cli-reference" rel="noopener noreferrer"&gt;CLI reference&lt;/a&gt;), so the hook marker files are the more reliable signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry events.&lt;/strong&gt; Before each retry, the stream includes a &lt;code&gt;system/api_retry&lt;/code&gt; event with &lt;code&gt;attempt&lt;/code&gt;, &lt;code&gt;max_retries&lt;/code&gt;, &lt;code&gt;retry_delay_ms&lt;/code&gt; and an &lt;code&gt;error&lt;/code&gt; category such as &lt;code&gt;rate_limit&lt;/code&gt; or &lt;code&gt;overloaded&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Headless docs&lt;/a&gt;). Several of these right after the marker file appears means compaction is fighting the API rather than looping.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Steps to add compaction markers
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Create a job folder, for example &lt;code&gt;mkdir -p ~/jobs/nightly/state&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Save this as &lt;code&gt;~/jobs/nightly/compact-watch.json&lt;/code&gt;. It's an example; change the paths to match yours:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PreCompact"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auto"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"date +%s &amp;gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$HOME/jobs/nightly/state/compact.started&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PostCompact"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auto"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rm -f &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$HOME/jobs/nightly/state/compact.started&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;; date &amp;gt;&amp;gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$HOME/jobs/nightly/state/compact.log&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Pass it with &lt;code&gt;--settings ./compact-watch.json&lt;/code&gt;. In &lt;code&gt;--bare&lt;/code&gt; mode Claude Code skips hooks it would normally discover on disk. Settings you pass with &lt;code&gt;--settings&lt;/code&gt; are still loaded (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Headless: bare mode&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Watch the &lt;code&gt;state/&lt;/code&gt; folder from your wrapper script (next section).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A common mistake is putting the hooks only in &lt;code&gt;.claude/settings.json&lt;/code&gt; and then running with &lt;code&gt;--bare&lt;/code&gt;. The hooks silently never fire. The fix is to pass them through &lt;code&gt;--settings&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you set a timeout and kill a hung Claude Code process?
&lt;/h2&gt;

&lt;p&gt;Claude Code has no built-in wall-clock limit for &lt;code&gt;claude -p&lt;/code&gt;. Wrap it in &lt;code&gt;timeout&lt;/code&gt; or a watchdog loop, send SIGTERM first, then SIGKILL after a grace period.&lt;/p&gt;

&lt;p&gt;What the docs give you for runaway work: &lt;code&gt;--max-turns&lt;/code&gt; "exits with an error when the limit is reached" and has no limit by default. &lt;code&gt;--max-budget-usd&lt;/code&gt; stops the run once estimated spend reaches the cap (&lt;a href="https://code.claude.com/docs/en/cli-reference" rel="noopener noreferrer"&gt;CLI reference&lt;/a&gt;). Neither one measures time. A single compaction that keeps retrying uses no extra turns, so you need a clock as well.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option A: GNU timeout (Linux)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;timeout&lt;/span&gt; &lt;span class="nt"&gt;-k&lt;/span&gt; 30s 90m claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;task.md&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--output-format&lt;/span&gt; json &lt;span class="nt"&gt;--max-turns&lt;/span&gt; 40
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"exit=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GNU &lt;code&gt;timeout&lt;/code&gt; sends TERM when the limit is reached and KILL 30 seconds later because of &lt;code&gt;-k&lt;/code&gt;. It exits with status 124 when the command timed out (&lt;a href="https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html" rel="noopener noreferrer"&gt;GNU coreutils: timeout invocation&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Here's what happens inside Claude Code on SIGTERM: "Claude Code exits with code 143," leaves the current turn unfinished, kills any running Bash process tree and runs &lt;code&gt;SessionEnd&lt;/code&gt; hooks (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Headless: Stop a run with SIGTERM&lt;/a&gt;). If you'd rather end the turn cleanly, the docs say to send SIGINT first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option B: watchdog loop (works anywhere, catches compaction specifically)
&lt;/h3&gt;

&lt;p&gt;This example script combines a total time limit, a compaction limit and a stream-inactivity check. The limits are example values; tune them to your job.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# run-agent.sh — example watchdog for one unattended Claude Code run&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
&lt;span class="nv"&gt;JOB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/jobs/nightly"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;STATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$JOB&lt;/span&gt;&lt;span class="s2"&gt;/state"&lt;/span&gt;
&lt;span class="nv"&gt;COMPACT_LIMIT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;900     &lt;span class="c"&gt;# example: 15 min max for one compaction&lt;/span&gt;
&lt;span class="nv"&gt;HARD_LIMIT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5400       &lt;span class="c"&gt;# example: 90 min for the whole run&lt;/span&gt;
&lt;span class="nv"&gt;IDLE_MIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20           &lt;span class="c"&gt;# example: no stream output for 20 min = stalled&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;/compact.started"&lt;/span&gt;

&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$JOB&lt;/span&gt;&lt;span class="s2"&gt;/repo"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1
claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$JOB&lt;/span&gt;&lt;span class="s2"&gt;/task.md"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--settings&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$JOB&lt;/span&gt;&lt;span class="s2"&gt;/compact-watch.json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output-format&lt;/span&gt; stream-json &lt;span class="nt"&gt;--verbose&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-turns&lt;/span&gt; 40 &lt;span class="nt"&gt;--permission-prompts&lt;/span&gt; none &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;/stream.jsonl"&lt;/span&gt; 2&amp;gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;/stderr.log"&lt;/span&gt; &amp;amp;
&lt;span class="nv"&gt;PID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;START&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;

stop&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;/watchdog.log"&lt;/span&gt;
         &lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="nt"&gt;-INT&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;10
         &lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="nt"&gt;-TERM&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;20
         &lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="nt"&gt;-KILL&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="nt"&gt;-0&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;30&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;NOW&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;/compact.started"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt;NOW &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;/compact.started"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$COMPACT_LIMIT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;stop &lt;span class="s2"&gt;"compaction stalled"&lt;/span&gt; 75
  &lt;span class="k"&gt;fi&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt;NOW &lt;span class="o"&gt;-&lt;/span&gt; START&lt;span class="k"&gt;))&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HARD_LIMIT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; stop &lt;span class="s2"&gt;"hard limit"&lt;/span&gt; 124
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;/stream.jsonl"&lt;/span&gt; &lt;span class="nt"&gt;-mmin&lt;/span&gt; +&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IDLE_MIN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; stop &lt;span class="s2"&gt;"stream idle"&lt;/span&gt; 76
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;wait&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exit codes 75 and 76 are this script's own convention, so your retry layer can tell a stalled compaction apart from a normal failure. &lt;code&gt;--permission-prompts none&lt;/code&gt; (v2.1.259+) stops the run from waiting on a permission prompt nobody will answer (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Headless docs&lt;/a&gt;). That's a separate kind of hang that looks the same from outside.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfalls
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Killing too early.&lt;/strong&gt; A normal compaction of a large context takes real time, and each API attempt can wait up to 10 minutes for headers by default. A 2-minute limit will kill healthy runs. Start around 15 minutes and look at &lt;code&gt;compact.log&lt;/code&gt; to see how long yours actually take.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mistaking background wait for a hang.&lt;/strong&gt; After the final turn, &lt;code&gt;claude -p&lt;/code&gt; can wait up to 10 minutes for background subagents or commands (&lt;code&gt;CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS&lt;/code&gt;, default 600000) (&lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;Environment variables&lt;/a&gt;). If stderr shows &lt;code&gt;Waiting for background work to finish&lt;/code&gt;, it isn't compaction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Killing only the parent.&lt;/strong&gt; If you launch through &lt;code&gt;bash -c&lt;/code&gt; or &lt;code&gt;npx&lt;/code&gt;, signal the process group. Otherwise a child &lt;code&gt;claude&lt;/code&gt; can keep running.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do you avoid hitting compaction at all?
&lt;/h2&gt;

&lt;p&gt;Keep each Claude Code session well below the auto-compact window. Filter big tool outputs, send verbose work to subagents, and give each task its own session.&lt;/p&gt;

&lt;p&gt;These are the levers that matter most for unattended jobs, roughly in order of payoff:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One job, one session.&lt;/strong&gt; Start every cron run fresh (no &lt;code&gt;--continue&lt;/code&gt;). The docs recommend &lt;code&gt;/clear&lt;/code&gt; "when switching to unrelated work," and a new &lt;code&gt;-p&lt;/code&gt; process does the same thing (&lt;a href="https://code.claude.com/docs/en/context-window" rel="noopener noreferrer"&gt;Context window docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter output before Claude sees it.&lt;/strong&gt; The docs' own example is a &lt;code&gt;PreToolUse&lt;/code&gt; hook that rewrites test commands to keep only failures. They point out that grepping a 10,000-line log for &lt;code&gt;ERROR&lt;/code&gt; can cut context "from tens of thousands of tokens to hundreds" (&lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;Manage costs&lt;/a&gt;). Copy-paste versions are in &lt;a href="https://en.aobaworks.com/claude-code-hooks-examples-7-copy-paste-scripts/" rel="noopener noreferrer"&gt;/claude-code-hooks-examples-7-copy-paste-scripts/&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delegate large reads to subagents.&lt;/strong&gt; Their file contents stay in their own context window, and only a summary comes back (&lt;a href="https://code.claude.com/docs/en/context-window" rel="noopener noreferrer"&gt;Context window docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;--bare&lt;/code&gt; for scripts.&lt;/strong&gt; The docs say "&lt;code&gt;--bare&lt;/code&gt; is the recommended mode for scripted and SDK calls" (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Headless docs&lt;/a&gt;). It skips CLAUDE.md, MCP servers, skills and auto memory, so the run starts with less context. Bare mode needs &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;, because it doesn't use your subscription login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trim always-loaded files.&lt;/strong&gt; Keep CLAUDE.md under 200 lines and move workflow-specific instructions into skills, which load only when invoked (&lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;Manage costs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compact earlier if you must compact.&lt;/strong&gt; A summary of a 150K context is a smaller request than a summary of 950K. On a native-1M model, example settings like &lt;code&gt;CLAUDE_CODE_AUTO_COMPACT_WINDOW=300000&lt;/code&gt; keep each compaction request moderate.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To measure what's filling the context, run &lt;code&gt;/context&lt;/code&gt; in an interactive session on the same repo. It "shows your conversation size in tokens" broken down by category (&lt;a href="https://code.claude.com/docs/en/errors" rel="noopener noreferrer"&gt;Errors&lt;/a&gt;). &lt;a href="https://en.aobaworks.com/claude-code-how-to-see-token-usage-and-reduce-it/" rel="noopener noreferrer"&gt;/claude-code-how-to-see-token-usage-and-reduce-it/&lt;/a&gt; walks through reading that output.&lt;/p&gt;

&lt;p&gt;A common mistake is asking an agent to "read all files in src/ and summarize". That's the quickest way to fill the window. Name the files instead, or have a subagent do the reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you resume a session safely after a compaction failure?
&lt;/h2&gt;

&lt;p&gt;After a failed compaction, don't resume the stuck Claude Code session. Start a new session seeded with a short handoff file, and keep the old transcript for debugging.&lt;/p&gt;

&lt;p&gt;Here's why. &lt;code&gt;--resume&lt;/code&gt; and &lt;code&gt;--continue&lt;/code&gt; reload the stored conversation. That's the same oversized history that just failed to compact, so the next turn is likely to trigger the same compaction. Two details make this worse after a kill:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;After SIGTERM, "Claude Code leaves the interrupted turn as it is, and your next prompt drives the conversation." Setting &lt;code&gt;CLAUDE_CODE_RESUME_INTERRUPTED_TURN=1&lt;/code&gt; makes it continue the interrupted turn instead (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Headless docs&lt;/a&gt;). That's the opposite of what you want here.&lt;/li&gt;
&lt;li&gt;If you resume with different system-prompt flags, they only take effect "once the conversation is compacted or when you start a new conversation" (&lt;a href="https://code.claude.com/docs/en/cli-reference" rel="noopener noreferrer"&gt;CLI reference&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Safer recovery steps
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Record the failed session ID.&lt;/strong&gt; With &lt;code&gt;--output-format stream-json&lt;/code&gt;, the &lt;code&gt;system/init&lt;/code&gt; event and the final &lt;code&gt;result&lt;/code&gt; carry &lt;code&gt;session_id&lt;/code&gt;. Save it to &lt;code&gt;state/last_session&lt;/code&gt; from your wrapper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a progress file the agent writes.&lt;/strong&gt; Add this line to &lt;code&gt;task.md&lt;/code&gt; (example wording): "After each completed step, append one line to PROGRESS.md: step, files changed, what's next."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start fresh with a handoff.&lt;/strong&gt; Example: &lt;code&gt;claude -p "Continue the task in task.md. Completed steps are in PROGRESS.md; verify the last one, then continue." --settings compact-watch.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only resume if you need the history.&lt;/strong&gt; Fork it so the original stays intact: &lt;code&gt;claude --resume "$ID" --fork-session&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/cli-reference" rel="noopener noreferrer"&gt;CLI reference&lt;/a&gt;). Then run &lt;code&gt;/compact&lt;/code&gt; with a narrow focus, for example &lt;code&gt;/compact keep only the list of changed files and the failing test name&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the code state.&lt;/strong&gt; A killed run may have left half-written files. &lt;code&gt;git status&lt;/code&gt; and &lt;code&gt;git stash&lt;/code&gt; handle most cases. &lt;a href="https://en.aobaworks.com/claude-code-checkpoints-how-rollback-works-in-automations/" rel="noopener noreferrer"&gt;/claude-code-checkpoints-how-rollback-works-in-automations/&lt;/a&gt; explains what Claude Code's own checkpoints do and don't cover.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In an interactive terminal, the errors page and community reports give a shorter path. Press Esc twice to step back a few messages, then run &lt;code&gt;/compact&lt;/code&gt; again. If that still has no room, use &lt;code&gt;/clear&lt;/code&gt;; the old conversation stays reachable through &lt;code&gt;/resume&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/errors" rel="noopener noreferrer"&gt;Errors&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you build auto-recovery for unattended runs (cron, CI)?
&lt;/h2&gt;

&lt;p&gt;A watchdog kills the stalled Claude Code run, and a wrapper retries it once in a fresh session. The wrapper also sorts exit codes and logs anything it can't fix.&lt;/p&gt;

&lt;p&gt;Building on the watchdog script above, a minimal recovery layer looks like this (example):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# nightly.sh — called from cron&lt;/span&gt;
&lt;span class="nv"&gt;JOB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/jobs/nightly"&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;attempt &lt;span class="k"&gt;in &lt;/span&gt;1 2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$JOB&lt;/span&gt;&lt;span class="s2"&gt;/run-agent.sh"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt; &lt;span class="k"&gt;in
    &lt;/span&gt;0&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; ok attempt=&lt;/span&gt;&lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$JOB&lt;/span&gt;&lt;span class="s2"&gt;/state/history.log"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0 &lt;span class="p"&gt;;;&lt;/span&gt;
    75|76|124|143&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c"&gt;# stalled compaction, idle stream, hard limit, SIGTERM&lt;/span&gt;
        &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; stalled code=&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt; attempt=&lt;/span&gt;&lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$JOB&lt;/span&gt;&lt;span class="s2"&gt;/state/history.log"&lt;/span&gt;
        &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$JOB&lt;/span&gt;&lt;span class="s2"&gt;/state/stream.jsonl"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$JOB&lt;/span&gt;&lt;span class="s2"&gt;/state/stream.&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.jsonl"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
    &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; failed code=&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$JOB&lt;/span&gt;&lt;span class="s2"&gt;/state/history.log"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;break&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;esac&lt;/span&gt;
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the crontab line, for example: &lt;code&gt;15 2 * * * /home/you/jobs/nightly/nightly.sh &amp;gt;&amp;gt; /home/you/jobs/nightly/state/cron.log 2&amp;gt;&amp;amp;1&lt;/code&gt;. Environment details, such as &lt;code&gt;PATH&lt;/code&gt; and auth tokens under cron, are covered in &lt;a href="https://en.aobaworks.com/claude-code-cron-job-setup-run-automations-overnight/" rel="noopener noreferrer"&gt;/claude-code-cron-job-setup-run-automations-overnight/&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Design decisions that matter
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retry once, not forever.&lt;/strong&gt; If the second fresh session also stalls at compaction, the task itself is too big for one session. Retrying again just burns tokens. Issue #6004 includes one user's report of $800 in API charges from a compaction loop (&lt;a href="https://github.com/anthropics/claude-code/issues/6004" rel="noopener noreferrer"&gt;GitHub #6004&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bound the API retry wait.&lt;/strong&gt; If you use &lt;code&gt;CLAUDE_CODE_RETRY_WATCHDOG=1&lt;/code&gt; to ride out rate limits, also set &lt;code&gt;CLAUDE_CODE_RETRY_WATCHDOG_MAX_WAIT_MS&lt;/code&gt; (v2.1.295+). Otherwise a single compaction request can wait out &lt;code&gt;429&lt;/code&gt;/&lt;code&gt;529&lt;/code&gt; errors without limit (&lt;a href="https://code.claude.com/docs/en/errors" rel="noopener noreferrer"&gt;Errors&lt;/a&gt;). Rate-limit handling for cron is covered in &lt;a href="https://en.aobaworks.com/claude-code-rate-limit-reached-retry-setup-for-cron-jobs/" rel="noopener noreferrer"&gt;/claude-code-rate-limit-reached-retry-setup-for-cron-jobs/&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap money as well as time.&lt;/strong&gt; Add &lt;code&gt;--max-budget-usd&lt;/code&gt; (print mode only). The docs warn spend "can pass the cap, so leave headroom" (&lt;a href="https://code.claude.com/docs/en/cli-reference" rel="noopener noreferrer"&gt;CLI reference&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alerting is up to you.&lt;/strong&gt; Claude Code doesn't page anyone. Have the wrapper's failure branch call whatever you already use: a Slack incoming webhook via &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;mail&lt;/code&gt;, or your CI's failure notification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lower retries if you want fast failure.&lt;/strong&gt; &lt;code&gt;CLAUDE_CODE_MAX_RETRIES&lt;/code&gt; defaults to 10. The docs suggest you "lower it to surface failures faster in scripts" (&lt;a href="https://code.claude.com/docs/en/errors" rel="noopener noreferrer"&gt;Errors&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A common mistake is letting the retry layer use &lt;code&gt;--continue&lt;/code&gt;. The retry then inherits the same bloated context and stalls the same way. Always retry with a new session plus the progress file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude Code auto-compact vs manual context management: which is more reliable for long runs?
&lt;/h2&gt;

&lt;p&gt;For unattended long runs, manual context management in Claude Code is more predictable. Auto-compact is convenient, but it adds a failure point your script can't control.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;How you set it&lt;/th&gt;
&lt;th&gt;Strength&lt;/th&gt;
&lt;th&gt;Limitation&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Default auto-compact&lt;/td&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;td&gt;Zero setup, the run continues past the limit&lt;/td&gt;
&lt;td&gt;Compaction happens at the largest, most expensive context, so it's more exposed to slow or failed requests&lt;/td&gt;
&lt;td&gt;Interactive work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Earlier auto-compact&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CLAUDE_CODE_AUTO_COMPACT_WINDOW&lt;/code&gt;, &lt;code&gt;CLAUDE_AUTOCOMPACT_PCT_OVERRIDE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Smaller summary requests&lt;/td&gt;
&lt;td&gt;Compacts more often; each summary can drop detail&lt;/td&gt;
&lt;td&gt;Multi-hour single tasks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto-compact off + chunked sessions&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"autoCompactEnabled": false&lt;/code&gt;, one task per &lt;code&gt;-p&lt;/code&gt; run, PROGRESS.md handoff&lt;/td&gt;
&lt;td&gt;Deterministic: a full context becomes a clean error&lt;/td&gt;
&lt;td&gt;You design the task split up front; a run can stop at the limit&lt;/td&gt;
&lt;td&gt;Cron jobs with clear steps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subagent isolation&lt;/td&gt;
&lt;td&gt;Delegate reads/tests to subagents&lt;/td&gt;
&lt;td&gt;Main context stays small&lt;/td&gt;
&lt;td&gt;Subagents have their own windows and can compact too (&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;sub-agents docs&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;Research-heavy jobs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Block via hook&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PreCompact&lt;/code&gt; exits 2&lt;/td&gt;
&lt;td&gt;Fine-grained control, e.g. block only during a critical step&lt;/td&gt;
&lt;td&gt;Blocking leaves you at the limit with no relief&lt;/td&gt;
&lt;td&gt;Rare, advanced cases&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My pick for the reader of this guide: for recurring jobs, use chunked sessions with auto-compact either off or set to a lower window, plus the watchdog. Here's why. A job that fails loudly at step 3 of 5 is easy to resume from PROGRESS.md. A job that freezes in a summary at 2 a.m. costs you a night.&lt;/p&gt;

&lt;p&gt;One pitfall with turning compaction off: if a single step needs more context than the window holds, it will always fail. Split that step further, or move its large reads into a subagent. &lt;a href="https://en.aobaworks.com/ai-agent-vs-automation-a-5-question-test-for-claude-code/" rel="noopener noreferrer"&gt;/ai-agent-vs-automation-a-5-question-test-for-claude-code/&lt;/a&gt; helps decide whether a step needs an agent at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do today (DIY):&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run &lt;code&gt;claude --version&lt;/code&gt; on the machine that runs your cron. If it's older than v2.1.85, update with &lt;code&gt;claude update&lt;/code&gt;. The summarizer overflow bug in issue #23751 was fixed in that version.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;compact-watch.json&lt;/code&gt; and &lt;code&gt;run-agent.sh&lt;/code&gt; from this guide to one job, with generous limits (15 min per compaction, 90 min total). Run it for a week and read &lt;code&gt;compact.log&lt;/code&gt; to see real compaction durations.&lt;/li&gt;
&lt;li&gt;Add a "write one line to PROGRESS.md per step" instruction to your task prompt, so any kill can be retried in a fresh session.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;If you'd rather not do it yourself:&lt;/strong&gt; Anthropic's official &lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code GitHub Actions&lt;/a&gt; integration runs Claude Code inside a CI job. The CI runner gives you job timeouts and failure notifications, so you don't maintain a cron box. It suits people already on GitHub. It doesn't suit jobs that need your local machine or private network, and you still pay for Actions minutes plus Claude usage.&lt;/p&gt;

&lt;p&gt;If your real problem is hitting usage limits rather than hangs, Claude subscriptions include Claude Code. Pro is "$20 if billed monthly" and Max starts "from $100 per month" (as of October 2026; check the &lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;official pricing page&lt;/a&gt;). A bigger plan doesn't fix a stuck compaction, though. It only gives you more headroom before rate limits slow the retries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related guides on this site:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/claude-code-cron-job-setup-run-automations-overnight/" rel="noopener noreferrer"&gt;Claude Code Cron Job Setup: Run Automations Overnight&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/why-claude-code-stops-mid-automation-and-how-to-fix-it/" rel="noopener noreferrer"&gt;Why Claude Code Stops Mid-Automation (and How to Fix It)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/claude-code-hooks-lifecycle-every-event-explained/" rel="noopener noreferrer"&gt;Claude Code Hooks Lifecycle: Every Event, Explained&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/claude-code-permissions-best-practices-headless-template/" rel="noopener noreferrer"&gt;Claude Code Permissions Best Practices: Headless Template&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Why is Claude Code frozen on compacting conversation?
&lt;/h3&gt;

&lt;p&gt;Usually the summary request is very large and is either retrying against a slow or throttled API, or caught in a re-read-and-compact loop. Each attempt can wait up to 10 minutes for response headers by default, and Claude Code retries up to 10 times (&lt;a href="https://code.claude.com/docs/en/errors" rel="noopener noreferrer"&gt;Errors&lt;/a&gt;). In a terminal, press Esc and run &lt;code&gt;/compact&lt;/code&gt; with a focus, or &lt;code&gt;/clear&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I force Claude Code to stop compacting?
&lt;/h3&gt;

&lt;p&gt;Interactively, press Esc to interrupt, then &lt;code&gt;/clear&lt;/code&gt; for a fresh context. In scripts, send SIGINT or SIGTERM to the process; SIGTERM exits with code 143 (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Headless docs&lt;/a&gt;). To prevent it, a &lt;code&gt;PreCompact&lt;/code&gt; hook that exits with code 2 blocks compaction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Claude Code auto-compact affect cron jobs?
&lt;/h3&gt;

&lt;p&gt;Yes. &lt;code&gt;claude -p&lt;/code&gt; runs use the same auto-compact thresholds as interactive sessions, but nobody sees the spinner. A stalled compaction holds the job until something kills it. Add a wall-clock timeout and hook-based compaction markers so the job fails and retries on its own.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I disable auto-compact in Claude Code?
&lt;/h3&gt;

&lt;p&gt;Yes. Set &lt;code&gt;"autoCompactEnabled": false&lt;/code&gt; in a settings file (&lt;a href="https://code.claude.com/docs/en/settings-reference" rel="noopener noreferrer"&gt;settings reference&lt;/a&gt;). After that, a full context ends with a &lt;code&gt;Context limit reached&lt;/code&gt; error instead of compacting. That suits short, chunked cron jobs better than long single tasks.&lt;/p&gt;

&lt;h3&gt;
  
  
  What exit code does Claude Code return on timeout?
&lt;/h3&gt;

&lt;p&gt;Claude Code has no built-in time limit for &lt;code&gt;-p&lt;/code&gt;, so the code depends on what stopped it. GNU &lt;code&gt;timeout&lt;/code&gt; returns 124, SIGTERM gives 143, and &lt;code&gt;--max-turns&lt;/code&gt; exits with an error status when the turn limit is hit (&lt;a href="https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html" rel="noopener noreferrer"&gt;GNU timeout&lt;/a&gt;; &lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Headless docs&lt;/a&gt;; &lt;a href="https://code.claude.com/docs/en/cli-reference" rel="noopener noreferrer"&gt;CLI reference&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Claude Code docs: Run Claude Code programmatically (headless)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/cli-reference" rel="noopener noreferrer"&gt;Claude Code docs: CLI reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/model-config" rel="noopener noreferrer"&gt;Claude Code docs: Model configuration — auto-compact window and default thresholds&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;Claude Code docs: Environment variables&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/settings-reference" rel="noopener noreferrer"&gt;Claude Code docs: Settings reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Claude Code docs: Hooks reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/errors" rel="noopener noreferrer"&gt;Claude Code docs: Errors, automatic retries and retry tuning&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/context-window" rel="noopener noreferrer"&gt;Claude Code docs: Explore the context window&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;Claude Code docs: Manage costs effectively&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs: GitHub Actions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;Claude Code docs: Subagents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/anthropics/claude-code/issues/6004" rel="noopener noreferrer"&gt;GitHub issue #6004: Infinite compaction loop&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/anthropics/claude-code/issues/23751" rel="noopener noreferrer"&gt;GitHub issue #23751: Compaction fails with "Conversation too long"&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html" rel="noopener noreferrer"&gt;GNU coreutils: timeout invocation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;Claude pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://clockedcode.com/blog/claude-code-stuck-on-compacting" rel="noopener noreferrer"&gt;ClockedCode: Claude Code Stuck on Compacting&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.duanlightfoot.com/posts/what-to-do-when-claude-code-starts-compacting/" rel="noopener noreferrer"&gt;Du'An Lightfoot: What to Do When Claude Code Starts Compacting&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was created with the help of AI. Sources are linked inline; check the official documentation for the latest details.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>compaction</category>
      <category>autocompact</category>
      <category>headless</category>
    </item>
    <item>
      <title>Claude Code Stop Hook: Block Early Exit Without Loops</title>
      <dc:creator>Aoba Works</dc:creator>
      <pubDate>Mon, 02 Nov 2026 16:07:45 +0000</pubDate>
      <link>https://dev.to/aobaworks/claude-code-stop-hook-block-early-exit-without-loops-416l</link>
      <guid>https://dev.to/aobaworks/claude-code-stop-hook-block-early-exit-without-loops-416l</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://en.aobaworks.com/claude-code-stop-hook-block-early-exit-without-loops/?utm_source=devto&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unattended&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A Claude Code Stop hook is a command that Claude Code runs when the main agent finishes responding. If the command prints &lt;code&gt;{"decision": "block", "reason": "..."}&lt;/code&gt;, Claude keeps working and treats the reason as its next instruction. To make an unattended run continue until a real check passes, have the hook run your tests or look for the output file, block with a specific reason while the check fails, and stop blocking after a fixed number of retries so the run can't loop forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which option is right for you?
&lt;/h2&gt;

&lt;p&gt;The best choice is the lightest Claude Code tool that can verify your "done" condition. Start from the row that matches your situation:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your situation&lt;/th&gt;
&lt;th&gt;What to pick first&lt;/th&gt;
&lt;th&gt;First page to open&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Beginner, interactive session, one task&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/goal&lt;/code&gt; with a condition such as "npm test exits 0"&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/goal" rel="noopener noreferrer"&gt;Keep Claude working toward a goal&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Needs no config file. It lasts for the current session only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Daily unattended runs (cron, CI, VPS) with a test command&lt;/td&gt;
&lt;td&gt;A command Stop hook plus your own retry counter&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Hooks reference: Stop&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;The check is deterministic and the same in every session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tight budget&lt;/td&gt;
&lt;td&gt;Command hook, a retry cap of 2–3, and &lt;code&gt;--max-budget-usd&lt;/code&gt; on the run&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;Manage costs effectively&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A shell check uses no model tokens. Only the retries do&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The "done" condition needs judgment (prose quality, completeness)&lt;/td&gt;
&lt;td&gt;A prompt-based Stop hook (&lt;code&gt;"type": "prompt"&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;Prompt-based hooks&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A model judges it, so it costs a model call each time it runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subagents should check their own work&lt;/td&gt;
&lt;td&gt;A SubagentStop hook&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Hooks reference: SubagentStop&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;It fires when a subagent finishes. Stop doesn't fire then&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you're unsure, pick the command hook. You can test it from a terminal, and it gives the same answer every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a Claude Code Stop hook and when does it run?
&lt;/h2&gt;

&lt;p&gt;A Claude Code Stop hook is a handler registered under the &lt;code&gt;Stop&lt;/code&gt; event. Claude Code runs it every time the main agent ends a turn. The hooks reference says Stop "&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Runs when the main Claude Code agent has finished responding&lt;/a&gt;". It doesn't run when you interrupt, and API errors fire a separate &lt;code&gt;StopFailure&lt;/code&gt; event instead (&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;This matters for unattended work because the model decides when it is finished, and it can be wrong. A Stop hook lets a script that you control decide whether the work is actually done.&lt;/p&gt;

&lt;p&gt;The hook receives JSON on stdin. These are the fields that matter for a "keep working" hook (&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;What it tells you&lt;/th&gt;
&lt;th&gt;Use it for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;session_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A unique ID for the session&lt;/td&gt;
&lt;td&gt;Naming a retry-counter file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stop_hook_active&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;true&lt;/code&gt; when Claude is already continuing because a Stop hook blocked&lt;/td&gt;
&lt;td&gt;Spotting a repeat pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;last_assistant_message&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The text of Claude's final reply&lt;/td&gt;
&lt;td&gt;Logging and notifications. The docs recommend it over reading &lt;code&gt;transcript_path&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;background_tasks&lt;/code&gt;, &lt;code&gt;session_crons&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Work still in flight or scheduled&lt;/td&gt;
&lt;td&gt;Telling "done" apart from "waiting on a background job"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;cwd&lt;/code&gt;, &lt;code&gt;transcript_path&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Working directory and transcript file&lt;/td&gt;
&lt;td&gt;Finding project files&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Some points to know before you write one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stop has no matcher support. It fires on every occurrence (&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt;). In an interactive session, a hook that runs tests also runs after a one-line answer to a quick question. For example, you could have your cron wrapper export a variable such as &lt;code&gt;UNATTENDED=1&lt;/code&gt; and make the script &lt;code&gt;exit 0&lt;/code&gt; when that variable isn't set.&lt;/li&gt;
&lt;li&gt;Stop hooks fire "whenever Claude finishes responding, not only at task completion" (&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt;). Your check has to work at any point in the task.&lt;/li&gt;
&lt;li&gt;A command hook has a 600-second default timeout (&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt;). A slow test suite needs a larger &lt;code&gt;timeout&lt;/code&gt; value.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For where Stop sits among the other events, see &lt;a href="https://en.aobaworks.com/claude-code-hooks-lifecycle-every-event-explained/" rel="noopener noreferrer"&gt;Claude Code Hooks Lifecycle: Every Event, Explained&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude Code Stop hook example: how do you keep working until tests pass?
&lt;/h2&gt;

&lt;p&gt;Run your real check and block with a specific reason until it passes. When it passes, exit 0 without a decision. The decision fields for Stop are &lt;code&gt;decision&lt;/code&gt; (&lt;code&gt;"block"&lt;/code&gt; prevents stopping; leave it out to allow the stop) and &lt;code&gt;reason&lt;/code&gt;, which the docs call "&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Required when &lt;code&gt;decision&lt;/code&gt; is &lt;code&gt;"block"&lt;/code&gt;&lt;/a&gt;".&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Register the hook
&lt;/h3&gt;

&lt;p&gt;Add this to &lt;code&gt;.claude/settings.json&lt;/code&gt; in your project. Use &lt;code&gt;.claude/settings.local.json&lt;/code&gt; if you don't want to commit it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Stop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$CLAUDE_PROJECT_DIR&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;/.claude/hooks/verify-before-stop.sh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;There is no &lt;code&gt;"matcher"&lt;/code&gt;, because Stop doesn't support one.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$CLAUDE_PROJECT_DIR&lt;/code&gt; avoids "command not found" errors when the working directory changes (&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;timeout&lt;/code&gt; is in seconds. 900 is an example value for a slow suite.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Step 2: Write the script
&lt;/h3&gt;

&lt;p&gt;Save the following as &lt;code&gt;.claude/hooks/verify-before-stop.sh&lt;/code&gt; and run &lt;code&gt;chmod +x .claude/hooks/verify-before-stop.sh&lt;/code&gt;. In this example, "done" means &lt;code&gt;npm test&lt;/code&gt; exits 0 and &lt;code&gt;output/report.md&lt;/code&gt; exists and isn't empty. Swap in your own checks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# Example Stop hook: block until tests pass and the report exists, max N retries.&lt;/span&gt;
&lt;span class="nv"&gt;INPUT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;SESSION_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INPUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.session_id'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;ACTIVE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INPUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.stop_hook_active'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;MAX_RETRIES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;STOP_HOOK_MAX_RETRIES&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nv"&gt;STATE_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_PROJECT_DIR&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/.claude/state"&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;COUNT_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/stop-&lt;/span&gt;&lt;span class="nv"&gt;$SESSION_ID&lt;/span&gt;&lt;span class="s2"&gt;.count"&lt;/span&gt;
&lt;span class="nv"&gt;LOG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/stop-hook.log"&lt;/span&gt;

&lt;span class="c"&gt;# A fresh stop (not a continuation) starts a new retry budget.&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ACTIVE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"true"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$COUNT_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;COUNT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$COUNT_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# The real check. Its output goes to a file, never to stdout.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;npm &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;--silent&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/last-test.txt"&lt;/span&gt; 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; output/report.md &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%FT%TZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$SESSION_ID&lt;/span&gt;&lt;span class="s2"&gt; PASS retries=&lt;/span&gt;&lt;span class="nv"&gt;$COUNT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$COUNT_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;0                      &lt;span class="c"&gt;# no decision = Claude may stop&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Retry budget spent: allow the stop, record the failure, warn the user.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$COUNT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ge&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MAX_RETRIES&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%FT%TZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$SESSION_ID&lt;/span&gt;&lt;span class="s2"&gt; GAVE_UP retries=&lt;/span&gt;&lt;span class="nv"&gt;$COUNT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$COUNT_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  jq &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;--arg&lt;/span&gt; n &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$COUNT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s1"&gt;'{systemMessage: ("Stop hook gave up after " + $n + " retries; checks still failing.")}'&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Block and say exactly what is still broken.&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt;COUNT &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$COUNT_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;FAILS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 30 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/last-test.txt"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
jq &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;--arg&lt;/span&gt; fails &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FAILS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'{
  decision: "block",
  reason: ("Not done. npm test must exit 0 and output/report.md must exist and be non-empty. Last test output:\n" + $fails)
}'&lt;/span&gt;
&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;systemMessage&lt;/code&gt; is a general JSON output field that shows a message to the user. The guide lists it as something a successful hook's JSON can surface (&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Test it before you run it unattended
&lt;/h3&gt;

&lt;p&gt;Pipe sample input into the script yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"session_id":"test1","stop_hook_active":false}'&lt;/span&gt; | .claude/hooks/verify-before-stop.sh
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see one JSON object with &lt;code&gt;"decision": "block"&lt;/code&gt; while the tests fail, nothing once they pass, and an exit code of 0 in both cases.&lt;/p&gt;

&lt;h3&gt;
  
  
  The exit-code-2 alternative
&lt;/h3&gt;

&lt;p&gt;There's a shorter version. Write the reason to stderr and exit 2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"npm test is failing; fix it before finishing"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
&lt;span class="nb"&gt;exit &lt;/span&gt;2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Stop, exit 2 "&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Prevents Claude from stopping, continues the conversation&lt;/a&gt;", and stderr reaches Claude the same way &lt;code&gt;reason&lt;/code&gt; does. The docs say to use one approach per hook (&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt;). The JSON form is the better fit for unattended runs, because the same script can also return &lt;code&gt;systemMessage&lt;/code&gt; when it gives up.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you use stop_hook_active so a Stop hook never loops forever?
&lt;/h2&gt;

&lt;p&gt;In Claude Code, &lt;code&gt;stop_hook_active&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt; when Claude is already continuing because a Stop hook blocked. Read it to tell a first stop from a repeat, and pair it with a counter to set a real cap.&lt;/p&gt;

&lt;p&gt;The guide's minimal pattern exits early when the flag is &lt;code&gt;true&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INPUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.stop_hook_active'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"true"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0  &lt;span class="c"&gt;# Allow Claude to stop&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That pattern prevents loops, but it allows exactly one retry. The first stop blocks. Every later stop in that continuation arrives with &lt;code&gt;stop_hook_active: true&lt;/code&gt;, so the hook lets it through. If tests are still red after one round of fixes, the run ends red. The counter in the script above allows up to &lt;code&gt;MAX_RETRIES&lt;/code&gt; blocks per task turn instead.&lt;/p&gt;

&lt;p&gt;Pitfalls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't key the counter on the project directory only.&lt;/strong&gt; Parallel sessions would use up each other's retries. Keying it by &lt;code&gt;session_id&lt;/code&gt; keeps two cron jobs in the same repo from sharing a budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put state under the project and outside git.&lt;/strong&gt; Add &lt;code&gt;.claude/state/&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt;, or Claude may commit your counter files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make "give up" visible.&lt;/strong&gt; Exiting 0 silently on the last retry looks the same as success in a cron log. Write a marker line your wrapper can grep for. &lt;a href="https://en.aobaworks.com/ai-agent-monitoring-in-claude-code-catch-silent-failures/" rel="noopener noreferrer"&gt;AI Agent Monitoring in Claude Code: Catch Silent Failures&lt;/a&gt; shows how to alert on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose a small cap.&lt;/strong&gt; Each retry is a full extra turn with your whole context. A cap of 2–3 (an example value, not an official recommendation) is enough for tests to go green after a real fix. If the suite still fails after that, the cause usually needs a human.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is CLAUDE_CODE_STOP_HOOK_BLOCK_CAP and how do you raise the limit?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;CLAUDE_CODE_STOP_HOOK_BLOCK_CAP&lt;/code&gt; is the Claude Code environment variable that raises the built-in limit on consecutive Stop hook continuations. The default is 8. The hooks reference says that after stop hooks have continued the turn eight times in a row, "Claude Code overrides the next block and ends the turn" (&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt;, as of November 2026).&lt;/p&gt;

&lt;p&gt;When the cap is reached, the guide describes the symptom: Claude "ends the turn with a warning that the Stop hook blocked too many consecutive times" (&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt;). That is where the "set claude_code_stop_hook_block_cap to raise this limit" text comes from.&lt;/p&gt;

&lt;p&gt;One detail matters here: "&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;The count of consecutive continuations resets each time Claude calls a tool.&lt;/a&gt;" The built-in cap catches a hook that blocks while Claude only replies with text. It does not catch the common unattended case, where Claude edits files and reruns tests on each pass. Each tool call resets the count, so going by the docs' wording, a test-gated hook can keep blocking well past 8. That's why your own counter is the cap that protects your token budget.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to raise it (only if you need to)
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;First confirm the hook isn't blocking on a condition that can never pass, such as a missing file path or a test that needs network access.&lt;/li&gt;
&lt;li&gt;Set the variable for the run. In a shell or cron wrapper:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="nv"&gt;CLAUDE_CODE_STOP_HOOK_BLOCK_CAP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;12 claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or in a settings file, using the &lt;code&gt;env&lt;/code&gt; key, which Claude Code reads "no matter how &lt;code&gt;claude&lt;/code&gt; was launched" (&lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;environment variables&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"CLAUDE_CODE_STOP_HOOK_BLOCK_CAP"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"12"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Keep your script's &lt;code&gt;MAX_RETRIES&lt;/code&gt; at or below what you're willing to pay for.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The guide's condition for raising it is when your hook "&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;legitimately needs more than eight iterations to converge&lt;/a&gt;". Keep the built-in cap in place on unattended runs.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;additionalContext&lt;/code&gt; output, which sends non-error feedback, goes through "the same loop protections" as &lt;code&gt;decision: "block"&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt;). Switching to it doesn't get around the cap.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you fix a Claude Code Stop hook "JSON validation failed" error?
&lt;/h2&gt;

&lt;p&gt;In Claude Code, this error means the hook's stdout parsed as JSON but didn't match the schema for the event. The guide explains that a JSON validation message means stdout "parsed as JSON but failed schema validation", and a parse message means it "looked like a JSON object but wasn't valid JSON". Both can happen "even on exit 0" (&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Common causes in Stop hooks:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What your hook printed&lt;/th&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;{"decision": "allow"}&lt;/code&gt; or &lt;code&gt;"approve"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Stop only documents &lt;code&gt;"block"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Leave &lt;code&gt;decision&lt;/code&gt; out, or print nothing, to allow the stop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;{"decision": "block"}&lt;/code&gt; with no &lt;code&gt;reason&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;reason&lt;/code&gt; is required with block&lt;/td&gt;
&lt;td&gt;Add a specific &lt;code&gt;reason&lt;/code&gt; string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{"hookSpecificOutput": {"permissionDecision": "deny"}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;That shape belongs to PreToolUse&lt;/td&gt;
&lt;td&gt;Use the top-level &lt;code&gt;decision&lt;/code&gt;/&lt;code&gt;reason&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;{"additionalContext": "..."}&lt;/code&gt; at the top level&lt;/td&gt;
&lt;td&gt;It belongs inside &lt;code&gt;hookSpecificOutput&lt;/code&gt; with &lt;code&gt;"hookEventName": "Stop"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Nest it. Fields at the top level are silently ignored (&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;{"reason": "Line 1\nfailed "quote""}&lt;/code&gt; built with echo&lt;/td&gt;
&lt;td&gt;Unescaped quotes or newlines cause a parse error&lt;/td&gt;
&lt;td&gt;Build it with &lt;code&gt;jq -n --arg&lt;/code&gt;. The guide recommends a JSON encoder "instead of string concatenation" (&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A related failure produces no error at all: the decision is simply ignored. The rule is "&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Print nothing but the JSON object to stdout.&lt;/a&gt;" Output that doesn't start with &lt;code&gt;{&lt;/code&gt; is read as plain text, and the usual cause is an &lt;code&gt;echo&lt;/code&gt; in &lt;code&gt;~/.zshrc&lt;/code&gt; or &lt;code&gt;~/.bashrc&lt;/code&gt;. The guide's fix is to wrap profile echoes in &lt;code&gt;if [[ $- == *i* ]]; then ... fi&lt;/code&gt; so they run only in interactive shells (&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Steps to fix:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start Claude Code with &lt;code&gt;claude --debug-file /tmp/claude.log&lt;/code&gt;, then trigger a stop.&lt;/li&gt;
&lt;li&gt;In the log, search for &lt;code&gt;Hook output does not start with {&lt;/code&gt; (extra output) or &lt;code&gt;Hook JSON output had unrecognized keys&lt;/code&gt; (fields at the wrong level). Both strings come from the &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Send all debug prints to stderr (&lt;code&gt;&amp;gt;&amp;amp;2&lt;/code&gt;) or a log file, and redirect test output to a file as the example script does.&lt;/li&gt;
&lt;li&gt;Run the script by hand and pipe its output through &lt;code&gt;jq .&lt;/code&gt;. If jq can't parse it, Claude Code can't either.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Claude Code Stop hook not working: what should you check?
&lt;/h2&gt;

&lt;p&gt;When a Claude Code Stop hook doesn't fire or doesn't block, the usual causes are registration, the path, permissions, or stdout. Work through this table from top to bottom:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;How&lt;/th&gt;
&lt;th&gt;Typical fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Is it loaded?&lt;/td&gt;
&lt;td&gt;Type &lt;code&gt;/hooks&lt;/code&gt;. It opens "&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;a read-only browser for your configured hooks&lt;/a&gt;"&lt;/td&gt;
&lt;td&gt;Fix the JSON (no trailing commas or comments) and the file location&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Correct key?&lt;/td&gt;
&lt;td&gt;It must be under &lt;code&gt;"Stop"&lt;/code&gt; (case-sensitive), inside a &lt;code&gt;"hooks"&lt;/code&gt; array&lt;/td&gt;
&lt;td&gt;Copy the registration block above&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Executable?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ls -l .claude/hooks/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;chmod +x&lt;/code&gt; the script&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. Right path?&lt;/td&gt;
&lt;td&gt;Look for "command not found" in the transcript&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;"$CLAUDE_PROJECT_DIR"/...&lt;/code&gt; or an absolute path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5. Clean stdout?&lt;/td&gt;
&lt;td&gt;Pipe sample JSON in by hand&lt;/td&gt;
&lt;td&gt;Move logs to stderr and silence profile echoes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6. Dependencies?&lt;/td&gt;
&lt;td&gt;Look for &lt;code&gt;jq: command not found&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Install jq or rewrite the script in Python&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7. Managed policy?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/hooks&lt;/code&gt; shows &lt;code&gt;Only hooks from managed settings run here&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Your organization set &lt;code&gt;allowManagedHooksOnly&lt;/code&gt;. Ask an admin (&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two Stop-specific traps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exit 1 doesn't block.&lt;/strong&gt; Exit codes other than 0 and 2 without valid JSON are treated as non-blocking errors on most events (&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt;), so &lt;code&gt;exit 1&lt;/code&gt; on failing tests lets Claude stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A broken exit-2 hook can trap Claude.&lt;/strong&gt; On Stop, exit 2 "sends Claude back to keep working, and Claude can't repair a hook that won't run" (&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt;). The reference treats one case as non-blocking: an exit 2 with empty stdout and a missing-file message such as &lt;code&gt;No such file or directory&lt;/code&gt; on stderr. Other crashes still block. This is another reason to prefer the JSON form, where a crashed script doesn't print &lt;code&gt;"decision": "block"&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To see what the hook did, press &lt;code&gt;Ctrl+O&lt;/code&gt; to open the transcript view. A block shows the reason, and a non-blocking error shows a &lt;code&gt;&amp;lt;hook name&amp;gt; hook error&lt;/code&gt; notice (&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt;). If Claude Code stops mid-run for reasons unrelated to hooks, see &lt;a href="https://en.aobaworks.com/why-claude-code-stops-mid-automation-and-how-to-fix-it/" rel="noopener noreferrer"&gt;Why Claude Code Stops Mid-Automation (and How to Fix It)&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop vs SubagentStop: which one covers subagents?
&lt;/h2&gt;

&lt;p&gt;In Claude Code, Stop covers the main agent and SubagentStop covers subagents. The hooks guide lists SubagentStop as firing "When a subagent finishes" (&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt;). A Stop hook alone won't check subagent output.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Stop&lt;/th&gt;
&lt;th&gt;SubagentStop&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fires when&lt;/td&gt;
&lt;td&gt;The main agent finishes a turn&lt;/td&gt;
&lt;td&gt;A subagent finishes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Matcher&lt;/td&gt;
&lt;td&gt;None, always fires&lt;/td&gt;
&lt;td&gt;Agent type, e.g. &lt;code&gt;Explore&lt;/code&gt; or a custom agent name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra input&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;stop_hook_active&lt;/code&gt;, &lt;code&gt;last_assistant_message&lt;/code&gt;, &lt;code&gt;background_tasks&lt;/code&gt;, &lt;code&gt;session_crons&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;stop_hook_active&lt;/code&gt;, &lt;code&gt;agent_id&lt;/code&gt;, &lt;code&gt;agent_type&lt;/code&gt;, &lt;code&gt;agent_transcript_path&lt;/code&gt;, &lt;code&gt;last_assistant_message&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Block effect&lt;/td&gt;
&lt;td&gt;Claude keeps working&lt;/td&gt;
&lt;td&gt;"keeps the subagent running and delivers the reason to it as its next instruction" (&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;SubagentStop uses the same decision format as Stop. The counter script works with one change: key the counter on &lt;code&gt;agent_id&lt;/code&gt; instead of &lt;code&gt;session_id&lt;/code&gt;, because one session can run many subagents.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"SubagentStop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test-writer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$CLAUDE_PROJECT_DIR&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;/.claude/hooks/verify-subagent.sh"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pitfall: internal agents, such as prompt suggestions and &lt;code&gt;/btw&lt;/code&gt; side questions, also trigger SubagentStop, sometimes with an empty &lt;code&gt;agent_type&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt;). Always set a matcher with your agent's name. To choose the agent's tools, see &lt;a href="https://en.aobaworks.com/claude-code-subagent-allowed-tools-syntax-examples-fixes/" rel="noopener noreferrer"&gt;Claude Code Subagent allowed-tools: Syntax, Examples, Fixes&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you disable a Claude Code hook quickly?
&lt;/h2&gt;

&lt;p&gt;To turn off all Claude Code hooks without deleting them, set &lt;code&gt;"disableAllHooks": true&lt;/code&gt; in a settings file. To turn off a single hook, remove its entry from the file that defines it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find which file defines it.&lt;/strong&gt; &lt;code&gt;/hooks&lt;/code&gt; lists configured hooks by event. Hooks can live in &lt;code&gt;~/.claude/settings.json&lt;/code&gt; (all projects), &lt;code&gt;.claude/settings.json&lt;/code&gt; (project, committed), &lt;code&gt;.claude/settings.local.json&lt;/code&gt; (project, not shared), managed policy, plugins, skills, or subagent frontmatter (&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn off one hook.&lt;/strong&gt; Delete its object from the &lt;code&gt;"Stop"&lt;/code&gt; array. JSON settings don't allow comments, so you can't comment it out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn everything off.&lt;/strong&gt; Add &lt;code&gt;"disableAllHooks": true&lt;/code&gt;. Precedence rules apply, so "a project's settings file can override yours", and managed hooks keep running unless the setting is also set in managed settings (&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt;). This also turns off &lt;code&gt;/goal&lt;/code&gt;, which runs on the hooks system (&lt;a href="https://code.claude.com/docs/en/goal" rel="noopener noreferrer"&gt;goal docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stop a run that's already looping.&lt;/strong&gt; Interrupt the session. Stop hooks don't run when you interrupt (&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt;). Fix the script, then restart.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Settings edits are normally picked up while Claude Code is running. If a change doesn't appear after a few seconds, restart the session (&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you watch token usage from Stop hook retries?
&lt;/h2&gt;

&lt;p&gt;Use Claude Code's &lt;code&gt;/usage&lt;/code&gt; command in a session, cap spend on headless runs, and count blocks in your hook's log. Every block starts another turn, and that turn re-sends your whole context.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Inside a session&lt;/strong&gt;, run &lt;code&gt;/usage&lt;/code&gt;. Its Session block shows total cost and usage by model. The docs call the dollar figure "an estimate" and point API users to the Console Usage page for real billing (&lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;costs&lt;/a&gt;). On Pro, Max, Ultra, Team, and Enterprise plans, the same screen shows plan usage bars and a usage breakdown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For headless runs&lt;/strong&gt;, add &lt;code&gt;--max-budget-usd&lt;/code&gt; as a hard ceiling. The costs page says session cost counts toward it (&lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;costs&lt;/a&gt;; flags in the &lt;a href="https://code.claude.com/docs/en/cli-reference" rel="noopener noreferrer"&gt;CLI reference&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;From your hook log&lt;/strong&gt;, count retries per run:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; GAVE_UP .claude/state/stop-hook.log
   &lt;span class="nb"&gt;grep &lt;/span&gt;PASS .claude/state/stop-hook.log | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If many runs pass only after 2–3 retries, your prompt or CLAUDE.md probably isn't telling Claude to run the tests before it finishes.&lt;/p&gt;

&lt;p&gt;For a sense of scale, Anthropic reports an enterprise average of "around $13 per developer per active day" (&lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;costs&lt;/a&gt;, as of November 2026). A hook that loops all night can push a single run well past that.&lt;/p&gt;

&lt;p&gt;To get alerted, have the hook notify you when it gives up. For example, on macOS add &lt;code&gt;osascript -e 'display notification "Stop hook gave up" with title "Claude Code"'&lt;/code&gt; to the give-up branch, or use &lt;code&gt;notify-send&lt;/code&gt; on a Linux desktop. On a headless server, append to a file that your cron wrapper checks. The &lt;code&gt;Notification&lt;/code&gt; event also exists for cases where Claude Code is waiting on you, such as &lt;code&gt;permission_prompt&lt;/code&gt; or &lt;code&gt;idle_prompt&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt;). More on cutting usage: &lt;a href="https://en.aobaworks.com/claude-code-how-to-see-token-usage-and-reduce-it/" rel="noopener noreferrer"&gt;Claude Code: How to See Token Usage (and Reduce It)&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Things you can do today:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Copy the registration block and script above. Change the check to your real "done" condition, set &lt;code&gt;STOP_HOOK_MAX_RETRIES=3&lt;/code&gt;, and pipe sample JSON into it until the output is a single clean JSON object.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;.claude/state/&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt;, and add a &lt;code&gt;grep GAVE_UP&lt;/code&gt; line to the end of your cron wrapper so a failed run doesn't look like a success.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;--max-budget-usd&lt;/code&gt; to your &lt;code&gt;claude -p&lt;/code&gt; command as a second limit on spend.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;If you'd rather not write a script:&lt;/strong&gt; &lt;code&gt;/goal&lt;/code&gt; is built in and costs nothing extra to set up. Its evaluator runs on the small fast model, and the docs say evaluation tokens are "typically negligible compared to main-turn spend" (&lt;a href="https://code.claude.com/docs/en/goal" rel="noopener noreferrer"&gt;goal docs&lt;/a&gt;). It suits interactive sessions and one-off &lt;code&gt;claude -p "/goal ..."&lt;/code&gt; runs. It is a poor fit if you need a deterministic check, because the evaluator "doesn't run commands or read files independently" and only judges what Claude has shown in the conversation. It also lasts for one session only.&lt;/p&gt;

&lt;p&gt;For running Claude Code itself, Anthropic's subscription plans (Pro, Max, Ultra, Team, Enterprise) are listed at &lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;claude.com/pricing&lt;/a&gt; (as of November 2026; check the official page). Pay-per-token API billing through the Claude Console lets you set workspace spend limits (&lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;costs&lt;/a&gt;). Neither one replaces a retry cap in your hook.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related guides on this site:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/claude-code-hooks-examples-7-copy-paste-scripts/" rel="noopener noreferrer"&gt;Claude Code Hooks Examples: 7 Copy-Paste Scripts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/claude-code-cron-job-setup-run-automations-overnight/" rel="noopener noreferrer"&gt;Claude Code Cron Job Setup: Run Automations Overnight&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/ai-agent-monitoring-in-claude-code-catch-silent-failures/" rel="noopener noreferrer"&gt;AI Agent Monitoring in Claude Code: Catch Silent Failures&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Where can I find Claude Code Stop hook examples on GitHub?
&lt;/h3&gt;

&lt;p&gt;Anthropic's own repository has a complete hook reference implementation, the &lt;a href="https://github.com/anthropics/claude-code/blob/main/examples/hooks/bash_command_validator_example.py" rel="noopener noreferrer"&gt;bash command validator example&lt;/a&gt;. It's a PreToolUse hook, but its input parsing and exit-code handling carry over to Stop. Community discussions also exist, such as &lt;a href="https://github.com/ianymu/claude-verify-before-stop/discussions/3" rel="noopener noreferrer"&gt;this thread in ianymu/claude-verify-before-stop&lt;/a&gt;, but they aren't official sources. Check any community example for a retry cap and for clean stdout before you rely on it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does Claude Code show "running stop hook" and how long should it take?
&lt;/h3&gt;

&lt;p&gt;That status means your Stop hook command is running. It lasts as long as your check takes, such as the full test suite. Command hooks time out after 600 seconds by default (&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt;). If it seems stuck, run the script by hand to time it, and raise &lt;code&gt;timeout&lt;/code&gt; or run a faster test subset.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can a Stop hook send me a notification when Claude finishes or gives up?
&lt;/h3&gt;

&lt;p&gt;Yes. A Stop hook can run any command, so add a desktop notification or a webhook call to the branch that allows the stop, and use &lt;code&gt;last_assistant_message&lt;/code&gt; for the summary text. The docs recommend that field over reading the transcript at Stop time (&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt;). On servers, a log line that your cron wrapper checks is more reliable than a desktop notification.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Hooks reference – Claude Code Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;Automate actions with hooks (hooks guide) – Claude Code Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;Environment variables – Claude Code Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/goal" rel="noopener noreferrer"&gt;Keep Claude working toward a goal (/goal) – Claude Code Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;Manage costs effectively – Claude Code Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/cli-reference" rel="noopener noreferrer"&gt;CLI reference – Claude Code Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;Claude pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/anthropics/claude-code/blob/main/examples/hooks/bash_command_validator_example.py" rel="noopener noreferrer"&gt;anthropics/claude-code: bash_command_validator_example.py&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ianymu/claude-verify-before-stop/discussions/3" rel="noopener noreferrer"&gt;ianymu/claude-verify-before-stop discussion&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was created with the help of AI. Sources are linked inline; check the official documentation for the latest details.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>hooks</category>
      <category>stophook</category>
      <category>automation</category>
    </item>
    <item>
      <title>Claude Code Subagent allowed-tools: Syntax, Examples, Fixes</title>
      <dc:creator>Aoba Works</dc:creator>
      <pubDate>Sun, 01 Nov 2026 16:15:10 +0000</pubDate>
      <link>https://dev.to/aobaworks/claude-code-subagent-allowed-tools-syntax-examples-fixes-432c</link>
      <guid>https://dev.to/aobaworks/claude-code-subagent-allowed-tools-syntax-examples-fixes-432c</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://en.aobaworks.com/claude-code-subagent-allowed-tools-syntax-examples-fixes/?utm_source=devto&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unattended&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Claude Code subagents don't have an &lt;code&gt;allowed-tools&lt;/code&gt; field. The key you want in a subagent's &lt;code&gt;.md&lt;/code&gt; frontmatter is &lt;code&gt;tools&lt;/code&gt;. It's an allowlist, written as a comma-separated string like &lt;code&gt;tools: Read, Grep, Glob&lt;/code&gt; or as a YAML list. &lt;code&gt;disallowedTools&lt;/code&gt; is the matching denylist. &lt;code&gt;allowed-tools&lt;/code&gt; belongs to Skills, and it does something else: it pre-approves tools for one turn and restricts nothing. If you leave &lt;code&gt;tools&lt;/code&gt; out, the subagent "&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;Inherits every tool available to subagents if omitted.&lt;/a&gt;"&lt;/p&gt;

&lt;h2&gt;
  
  
  Which option is right for you?
&lt;/h2&gt;

&lt;p&gt;The best place to start depends on whether you want a safe default fast, a dependable daily setup, or the lowest token cost.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your situation&lt;/th&gt;
&lt;th&gt;What to pick first&lt;/th&gt;
&lt;th&gt;First page to open&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Beginner, first custom subagent&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tools: Read, Grep, Glob&lt;/code&gt; (read-only). Nothing can break&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;Create custom subagents&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Daily work, subagent edits code and runs tests&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tools: Read, Edit, Bash, Grep, Glob&lt;/code&gt; plus a &lt;code&gt;permissions.deny&lt;/code&gt; rule for risky Bash commands&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;Configure permissions&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subagent needs one MCP server (GitHub, Slack)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tools: Read, mcp__github__*&lt;/code&gt;, or scope the server with &lt;code&gt;mcpServers&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;Sub-agents: MCP section&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You want everything except writes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;disallowedTools: Write, Edit&lt;/code&gt; and keep inheriting&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;Sub-agents: restrict tools&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Budget: keep context and tokens small&lt;/td&gt;
&lt;td&gt;Inline MCP servers in the subagent so the parent never loads their tool descriptions&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;Sub-agents: scope MCP servers&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unattended or cron runs&lt;/td&gt;
&lt;td&gt;Explicit &lt;code&gt;tools&lt;/code&gt; list plus a &lt;code&gt;PreToolUse&lt;/code&gt; hook as a second check&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Hooks reference&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The reason for each row: an explicit read-only list can't fail in a dangerous way. A Bash deny rule blocks particular commands without taking Bash away. A server wildcard keeps the list short. A denylist is the least work when you only fear one or two tools. Inline MCP keeps tool definitions out of the main context. Hooks still run when a frontmatter list turns out wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the allowed-tools field in Claude Code subagents?
&lt;/h2&gt;

&lt;p&gt;In Claude Code subagents, the "allowed-tools" job is done by the &lt;code&gt;tools&lt;/code&gt; frontmatter key, an allowlist that limits which tools that subagent can call. Many people search for &lt;code&gt;allowed-tools&lt;/code&gt; because they've seen it in Skills or the Agent SDK, so the three names are worth separating before you write any YAML.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where you write it&lt;/th&gt;
&lt;th&gt;Key name&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Subagent file (&lt;code&gt;.claude/agents/*.md&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tools&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Allowlist: the subagent can use only these tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subagent file&lt;/td&gt;
&lt;td&gt;&lt;code&gt;disallowedTools&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Denylist: removes tools from the inherited or listed pool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skill file (&lt;code&gt;SKILL.md&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;allowed-tools&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pre-approves listed tools for the turn that invokes the skill. Other tools stay callable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent SDK options&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;allowedTools&lt;/code&gt; / &lt;code&gt;allowed_tools&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Auto-approves tools before your permission callback runs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;a href="https://code.claude.com/docs/en/skills" rel="noopener noreferrer"&gt;Skills documentation&lt;/a&gt; says the Skills &lt;code&gt;allowed-tools&lt;/code&gt; grant clears when you send your next message, and that it does not restrict which tools are available. The &lt;a href="https://code.claude.com/docs/en/agent-sdk/permissions" rel="noopener noreferrer"&gt;Agent SDK permissions page&lt;/a&gt; treats &lt;code&gt;allowedTools&lt;/code&gt; as approval too. Put &lt;code&gt;allowed-tools:&lt;/code&gt; in a subagent file and you get neither behavior: it isn't a supported subagent field, so the subagent inherits everything.&lt;/p&gt;

&lt;p&gt;Here is a minimal, valid subagent. Save it as &lt;code&gt;.claude/agents/code-reviewer.md&lt;/code&gt; (example):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;code-reviewer&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Reviews diffs for bugs and style issues. Use after code changes.&lt;/span&gt;
&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Read, Grep, Glob&lt;/span&gt;
&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sonnet&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="s"&gt;You are a code reviewer. Read the changed files, report problems with file and line, and never modify files.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some official limits are worth knowing (as of October 2026, &lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;sub-agents docs&lt;/a&gt;). &lt;code&gt;name&lt;/code&gt; can be up to 256 characters and can't contain &lt;code&gt;:&lt;/code&gt;. Claude Code warns at startup when the combined descriptions of your custom subagents go over 15,000 tokens. By default, 20 subagents running at once is the ceiling, and the next spawn fails with &lt;code&gt;Concurrent subagent limit reached&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you write the allowed-tools syntax (wildcards, lists, MCP tool names)?
&lt;/h2&gt;

&lt;p&gt;Claude Code's subagent &lt;code&gt;tools&lt;/code&gt; field takes exact tool names, either as a comma-separated string or a YAML list. The documented wildcards are for MCP servers, such as &lt;code&gt;mcp__github__*&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Steps to add or change a tool list
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Open (or create) the file: &lt;code&gt;.claude/agents/&amp;lt;name&amp;gt;.md&lt;/code&gt; for one project, or &lt;code&gt;~/.claude/agents/&amp;lt;name&amp;gt;.md&lt;/code&gt; for all your projects. A subagent with the same name in the project folder wins over the one in your home folder. The &lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;priority table&lt;/a&gt; puts managed settings first, then the &lt;code&gt;--agents&lt;/code&gt; CLI flag, then project, then user, then plugins.&lt;/li&gt;
&lt;li&gt;Add a &lt;code&gt;tools:&lt;/code&gt; line between the &lt;code&gt;---&lt;/code&gt; markers. Spell tool names with their exact capitalization (&lt;code&gt;Read&lt;/code&gt;, not &lt;code&gt;read&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Save the file, then start a new session or ask Claude to use the subagent by name.&lt;/li&gt;
&lt;li&gt;On current versions, typing &lt;code&gt;/agents&lt;/code&gt; only reminds you to ask Claude or edit &lt;code&gt;.claude/agents/&lt;/code&gt; and &lt;code&gt;~/.claude/agents/&lt;/code&gt; directly. The interactive wizard with &lt;strong&gt;Running&lt;/strong&gt; and &lt;strong&gt;Library&lt;/strong&gt; tabs exists only on v2.1.197 and earlier (&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;sub-agents docs&lt;/a&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Syntax patterns from the official docs
&lt;/h3&gt;

&lt;p&gt;String form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Read, Grep, Glob, Bash&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;YAML list form (same meaning):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Read&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Grep&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Glob&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Bash&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every tool from one MCP server, plus Read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Read, mcp__github__*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything inherited except one MCP server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;disallowedTools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mcp__github&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No MCP tools at all, built-ins kept:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;disallowedTools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mcp__*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those three MCP patterns come straight from the &lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;sub-agents table&lt;/a&gt;. One MCP tool is named &lt;code&gt;mcp__&amp;lt;server&amp;gt;__&amp;lt;tool&amp;gt;&lt;/code&gt;, the same format &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hook matchers&lt;/a&gt; use, for example &lt;code&gt;mcp__memory__create_entities&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs don't promise
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Command-level patterns in &lt;code&gt;tools&lt;/code&gt;.&lt;/strong&gt; Skills accept &lt;code&gt;Bash(git add *)&lt;/code&gt;. For subagents, the docs only describe what a specifier does in &lt;code&gt;disallowedTools&lt;/code&gt;: "&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;An entry with a specifier, such as &lt;code&gt;Bash(git push *)&lt;/code&gt;, still removes the whole tool&lt;/a&gt;." To keep Bash and block only &lt;code&gt;git push&lt;/code&gt;, add &lt;code&gt;Bash(git push *)&lt;/code&gt; to &lt;code&gt;permissions.deny&lt;/code&gt; in settings. That rule covers the main conversation and subagents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Agent(worker, researcher)&lt;/code&gt;.&lt;/strong&gt; This allowlist of spawnable subagent types works only for an agent running as the main thread through &lt;code&gt;claude --agent&lt;/code&gt;. Inside an ordinary subagent definition, the docs say the type list in parentheses is ignored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Task&lt;/code&gt; vs &lt;code&gt;Agent&lt;/code&gt;.&lt;/strong&gt; In v2.1.63 the subagent-spawning tool was renamed from &lt;code&gt;Task&lt;/code&gt; to &lt;code&gt;Agent&lt;/code&gt;, and older &lt;code&gt;Task(...)&lt;/code&gt; references still work as aliases (&lt;a href="https://github.com/anthropics/claude-code/issues/29677" rel="noopener noreferrer"&gt;GitHub issue #29677&lt;/a&gt;). Hook scripts that check &lt;code&gt;tool_name == "Task"&lt;/code&gt; stopped matching, so use &lt;code&gt;Agent&lt;/code&gt; in anything new.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Defining the list from the CLI
&lt;/h3&gt;

&lt;p&gt;For one-off or scripted runs, the &lt;code&gt;--agents&lt;/code&gt; flag takes JSON, with &lt;code&gt;tools&lt;/code&gt; as an array (&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;sub-agents docs&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;--agents&lt;/span&gt; &lt;span class="s1"&gt;'{
  "code-reviewer": {
    "description": "Expert code reviewer. Use proactively after code changes.",
    "prompt": "You are a senior code reviewer.",
    "tools": ["Read", "Grep", "Glob", "Bash"],
    "model": "sonnet"
  }
}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In non-interactive mode you can pass a file path instead, such as &lt;code&gt;claude -p --agents ./agents.json "Review my changes"&lt;/code&gt;. Interactive sessions refuse a file path (&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;sub-agents docs&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Inherit vs restrict: should a subagent inherit all tools or list them explicitly?
&lt;/h2&gt;

&lt;p&gt;For a Claude Code subagent that runs without you watching, list its tools explicitly. Inheriting is fine for interactive helpers you supervise. If you leave &lt;code&gt;tools&lt;/code&gt; out, the subagent picks up every built-in and MCP tool the main conversation has, after a couple of filters. Listing tools gives you predictable behavior, and in exchange you have to update the list when the job changes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Omit &lt;code&gt;tools&lt;/code&gt; (inherit)&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;tools&lt;/code&gt; allowlist&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;disallowedTools&lt;/code&gt; denylist&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;New MCP servers you add later&lt;/td&gt;
&lt;td&gt;Picked up automatically&lt;/td&gt;
&lt;td&gt;Not available until you add them&lt;/td&gt;
&lt;td&gt;Picked up automatically&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Risk of surprise side effects&lt;/td&gt;
&lt;td&gt;Highest&lt;/td&gt;
&lt;td&gt;Lowest&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintenance&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Update when the job changes&lt;/td&gt;
&lt;td&gt;Update when a new risky tool appears&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Good for&lt;/td&gt;
&lt;td&gt;Ad-hoc helpers in an interactive session&lt;/td&gt;
&lt;td&gt;Cron jobs, reviewers, anything unattended&lt;/td&gt;
&lt;td&gt;"Everything but writes" style agents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure mode&lt;/td&gt;
&lt;td&gt;Does too much&lt;/td&gt;
&lt;td&gt;Missing tool, or launch refused if nothing resolves&lt;/td&gt;
&lt;td&gt;Forgetting a new dangerous tool&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When both fields are set: "&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;If both are set, &lt;code&gt;disallowedTools&lt;/code&gt; is applied first, then &lt;code&gt;tools&lt;/code&gt; is resolved against the remaining pool.&lt;/a&gt;" A tool that appears in both lists ends up removed.&lt;/p&gt;

&lt;p&gt;Two inheritance details tend to catch people:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Permission mode can be overridden.&lt;/strong&gt; When the main conversation runs in &lt;code&gt;bypassPermissions&lt;/code&gt;, &lt;code&gt;acceptEdits&lt;/code&gt;, or auto mode, the subagent runs in that same mode and the docs say your &lt;code&gt;permissionMode&lt;/code&gt; setting is ignored. The tool list is your real limit in those modes, so a tight list matters more than the permission mode you picked. To see how modes and rules combine for headless runs, read /claude-code-permissions-best-practices-headless-template/.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built-in agents already restrict.&lt;/strong&gt; The built-in Explore and Plan agents use "read-only tools; Write and Edit are denied," and general-purpose gets every tool available to subagents (&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;sub-agents docs&lt;/a&gt;). If one of those already fits the job, you don't need a custom file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A practical rule: if the subagent can run when you're not at the keyboard, such as from cron, CI, or a background session, write out its tools. If you start it yourself and watch the output, inheriting is acceptable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which tools can be assigned to a Claude Code subagent?
&lt;/h2&gt;

&lt;p&gt;A Claude Code subagent can use any built-in or connected MCP tool, except a few that are always withheld. Built-in names come from the &lt;a href="https://code.claude.com/docs/en/tools-reference" rel="noopener noreferrer"&gt;tools reference&lt;/a&gt;, which says they "&lt;a href="https://code.claude.com/docs/en/tools-reference" rel="noopener noreferrer"&gt;are the exact strings you use in permission rules, subagent tool lists, and hook matchers.&lt;/a&gt;"&lt;/p&gt;

&lt;h3&gt;
  
  
  Tools a background subagent keeps
&lt;/h3&gt;

&lt;p&gt;Subagents run in the background by default. A background subagent keeps every MCP tool but only these built-in tools (as of October 2026, &lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;sub-agents docs&lt;/a&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;Tool names&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Read and search&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Read&lt;/code&gt;, &lt;code&gt;Grep&lt;/code&gt;, &lt;code&gt;Glob&lt;/code&gt;, &lt;code&gt;LSP&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change files&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Edit&lt;/code&gt;, &lt;code&gt;Write&lt;/code&gt;, &lt;code&gt;NotebookEdit&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run commands&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Bash&lt;/code&gt;, &lt;code&gt;PowerShell&lt;/code&gt;, &lt;code&gt;Monitor&lt;/code&gt;, &lt;code&gt;TaskStop&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;WebFetch&lt;/code&gt;, &lt;code&gt;WebSearch&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workflow&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;TodoWrite&lt;/code&gt;, &lt;code&gt;Skill&lt;/code&gt;, &lt;code&gt;ToolSearch&lt;/code&gt;, &lt;code&gt;SendMessage&lt;/code&gt;, &lt;code&gt;Artifact&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worktrees&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;EnterWorktree&lt;/code&gt;, &lt;code&gt;ExitWorktree&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The list also includes &lt;code&gt;SubagentHandback&lt;/code&gt; for a subagent that reports through it, and &lt;code&gt;Agent&lt;/code&gt;, which follows the depth limit. Before v2.1.280, background subagents couldn't use &lt;code&gt;LSP&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;sub-agents docs&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Tools no subagent gets, even if listed
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;AskUserQuestion&lt;/code&gt;, &lt;code&gt;EndConversation&lt;/code&gt;, &lt;code&gt;EnterPlanMode&lt;/code&gt;, &lt;code&gt;ScheduleWakeup&lt;/code&gt;, &lt;code&gt;WaitForMcpServers&lt;/code&gt;, &lt;code&gt;Workflow&lt;/code&gt;, &lt;code&gt;ExitPlanMode&lt;/code&gt; (unless &lt;code&gt;permissionMode&lt;/code&gt; is &lt;code&gt;plan&lt;/code&gt;), and &lt;code&gt;Agent&lt;/code&gt; once the subagent hits the depth limit. Subagents can spawn their own subagents "up to three layers below the main conversation" by default. You change that limit with &lt;code&gt;CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That affects how you design the job. A subagent can't stop and ask you a question, so its prompt has to contain everything it needs. And because the background filter drops some tools silently, "&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;the same definition can resolve to different tools in the foreground and the background.&lt;/a&gt;"&lt;/p&gt;

&lt;h3&gt;
  
  
  Starter lists by job (examples)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Read-only auditor: &lt;code&gt;Read, Grep, Glob&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Bug fixer that runs tests: &lt;code&gt;Read, Edit, Bash, Grep, Glob&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Docs writer with citations: &lt;code&gt;Read, Write, WebFetch, WebSearch&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Issue triager: &lt;code&gt;Read, mcp__github__*&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What happens when a subagent's tools don't match its job (failure modes)?
&lt;/h2&gt;

&lt;p&gt;When a Claude Code subagent lacks a tool it needs, it either can't launch or does a weaker version of the task. When it has more tools than it needs, it can change things you never asked it to touch. Here are the failures you're most likely to hit, with fixes.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Every entry is wrong, so the subagent won't start
&lt;/h3&gt;

&lt;p&gt;If nothing in &lt;code&gt;tools&lt;/code&gt; resolves to a real tool, because of typos, lowercase names, or tools subagents can't have, Claude Code "usually refuses to launch the subagent," and the Agent tool returns an error naming the entries it couldn't resolve (&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;sub-agents docs&lt;/a&gt;; &lt;a href="https://code.claude.com/docs/en/errors" rel="noopener noreferrer"&gt;errors page&lt;/a&gt;). The error text contains "spawned with zero tools."&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; check capitalization against the tools reference, and remove anything from the withheld list above.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Some entries are wrong, and nothing tells you
&lt;/h3&gt;

&lt;p&gt;The error only appears when the list resolves to nothing. Suppose you write &lt;code&gt;tools: Read, edit, Bash&lt;/code&gt; (example). &lt;code&gt;edit&lt;/code&gt; doesn't resolve, so the subagent starts with Read and Bash. It may then try to make changes through shell commands, or report that it couldn't edit.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; after any edit to the list, ask the subagent to name the tools it has before the first real run.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Background runs lose a tool you listed
&lt;/h3&gt;

&lt;p&gt;A tool that isn't on the background keep-list gets removed with no error. A definition that works when you watch it in the foreground can then break overnight.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; build your lists only from the background table above, or set &lt;code&gt;background: false&lt;/code&gt; for interactive use. When a cron run stops halfway, also read /why-claude-code-stops-mid-automation-and-how-to-fix-it/.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. &lt;code&gt;allowed-tools&lt;/code&gt; in a subagent file
&lt;/h3&gt;

&lt;p&gt;That key isn't a subagent field, so the subagent inherits every tool, and the safety limit you thought you'd set isn't there.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; rename the key to &lt;code&gt;tools&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  5. A Bash specifier that removes all of Bash
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;disallowedTools: Bash(rm *)&lt;/code&gt; takes away the whole Bash tool, so test runs fail too.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; move the pattern to &lt;code&gt;permissions.deny&lt;/code&gt; in &lt;code&gt;.claude/settings.json&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  6. Too many tools on a read-only job
&lt;/h3&gt;

&lt;p&gt;For example, a "reviewer" with inherited Bash and &lt;code&gt;Write&lt;/code&gt; might run a formatter or rewrite files while it reviews. In &lt;code&gt;acceptEdits&lt;/code&gt; or auto mode, nothing asks you first.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; use an explicit read-only list, and add a hook (next sections) that denies writes from that agent type. For broader rule sets you can paste in, see /ai-agent-guardrails-example-copy-paste-claude-code-rules/.&lt;/p&gt;
&lt;h3&gt;
  
  
  7. A new MCP server reaches every inheriting agent
&lt;/h3&gt;

&lt;p&gt;Connect a Slack server, and every subagent without a &lt;code&gt;tools&lt;/code&gt; list can now post messages.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; add &lt;code&gt;disallowedTools: mcp__*&lt;/code&gt; to agents that should stay local, or define the server inline with &lt;code&gt;mcpServers&lt;/code&gt; so only one agent connects to it.&lt;/p&gt;
&lt;h2&gt;
  
  
  What are Claude Code hooks and how do they relate to subagents?
&lt;/h2&gt;

&lt;p&gt;Claude Code hooks are commands (or HTTP calls, MCP tools, prompts, or agents) that run automatically at fixed lifecycle points. They give you a second layer of enforcement that keeps working even when a subagent's tool list is wrong. The &lt;code&gt;tools&lt;/code&gt; field decides what a subagent can try. A hook can inspect each attempt as it happens and block it.&lt;/p&gt;

&lt;p&gt;Hooks reach subagents in two ways (&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;sub-agents docs&lt;/a&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Session-wide, from settings.&lt;/strong&gt; Hooks in settings files, managed policy, and plugins all apply inside subagents, so "&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;a &lt;code&gt;PreToolUse&lt;/code&gt; hook in &lt;code&gt;settings.json&lt;/code&gt; also runs before every tool a subagent uses.&lt;/a&gt;"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per subagent, from frontmatter.&lt;/strong&gt; A &lt;code&gt;hooks:&lt;/code&gt; block in the agent file runs only while that subagent is active and is removed when it finishes. A &lt;code&gt;Stop&lt;/code&gt; hook in frontmatter becomes &lt;code&gt;SubagentStop&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each hook gets JSON input that includes &lt;code&gt;agent_id&lt;/code&gt; and &lt;code&gt;agent_type&lt;/code&gt; (the subagent's &lt;code&gt;name&lt;/code&gt;), so one settings-level script can apply different rules to different agents. &lt;code&gt;SubagentStart&lt;/code&gt; and &lt;code&gt;SubagentStop&lt;/code&gt; also accept a matcher on the agent's name. Plugin-scoped names like &lt;code&gt;my-plugin:db-agent&lt;/code&gt; contain a colon, which makes them regex, so anchor them as &lt;code&gt;^my-plugin:db-agent$&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Example frontmatter hook, adapted from the docs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db-migrator&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Runs database migrations&lt;/span&gt;
&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Read, Bash&lt;/span&gt;
&lt;span class="na"&gt;hooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;PreToolUse&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bash"&lt;/span&gt;
      &lt;span class="na"&gt;hooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;command&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./scripts/security-check.sh"&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a full map of every event and when it fires, see /claude-code-hooks-lifecycle-every-event-explained/.&lt;/p&gt;

&lt;h2&gt;
  
  
  What do Claude Code hooks PreToolUse, PostToolUse, UserPromptSubmit, and Notification do?
&lt;/h2&gt;

&lt;p&gt;In Claude Code, PreToolUse runs before a tool call and can block it, and PostToolUse runs after the tool succeeds. UserPromptSubmit runs when a prompt is submitted. Notification runs when Claude Code sends a notification and is for side effects only.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;When it fires&lt;/th&gt;
&lt;th&gt;Matcher filters on&lt;/th&gt;
&lt;th&gt;Can it block?&lt;/th&gt;
&lt;th&gt;Typical subagent use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PreToolUse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Before a tool executes&lt;/td&gt;
&lt;td&gt;Tool name (&lt;code&gt;Bash&lt;/code&gt;, `Edit\&lt;/td&gt;
&lt;td&gt;Write&lt;code&gt;, &lt;/code&gt;mcp_&lt;em&gt;memory&lt;/em&gt;_.*`)&lt;/td&gt;
&lt;td&gt;Yes: exit code 2, or &lt;code&gt;permissionDecision: "deny"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PostToolUse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;After a tool succeeds&lt;/td&gt;
&lt;td&gt;Tool name&lt;/td&gt;
&lt;td&gt;Not the tool, which already ran. &lt;code&gt;decision: "block"&lt;/code&gt; stops Claude from continuing&lt;/td&gt;
&lt;td&gt;Run a linter after a subagent edits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UserPromptSubmit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When a prompt is submitted, before Claude processes it&lt;/td&gt;
&lt;td&gt;No matcher; fires every time&lt;/td&gt;
&lt;td&gt;Yes: exit code 2 or &lt;code&gt;decision: "block"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Add project context to every turn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Notification&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When Claude Code sends a notification&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;permission_prompt&lt;/code&gt;, &lt;code&gt;idle_prompt&lt;/code&gt;, &lt;code&gt;auth_success&lt;/code&gt;, &lt;code&gt;agent_needs_input&lt;/code&gt;, &lt;code&gt;agent_completed&lt;/code&gt;, and others&lt;/td&gt;
&lt;td&gt;No, exit code is ignored&lt;/td&gt;
&lt;td&gt;Alert you when an unattended run needs input&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All four come from the &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt; (as of October 2026). A few more details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PreToolUse decisions:&lt;/strong&gt; &lt;code&gt;permissionDecision&lt;/code&gt; accepts &lt;code&gt;"allow"&lt;/code&gt;, &lt;code&gt;"deny"&lt;/code&gt;, &lt;code&gt;"ask"&lt;/code&gt;, or &lt;code&gt;"defer"&lt;/code&gt;. &lt;code&gt;updatedInput&lt;/code&gt; can rewrite the tool's arguments before they run, and &lt;code&gt;additionalContext&lt;/code&gt; passes a note to Claude.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostToolUse:&lt;/strong&gt; &lt;code&gt;updatedToolOutput&lt;/code&gt; can replace what the tool returned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UserPromptSubmit&lt;/strong&gt; also fires on turns Claude Code starts by itself, which matters for automations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default timeouts:&lt;/strong&gt; 600 seconds for command, HTTP, and MCP tool hooks, 30 for prompt hooks, and 60 for agent hooks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Exit codes work like this. &lt;code&gt;0&lt;/code&gt; means success, and any JSON output is applied. &lt;code&gt;2&lt;/code&gt; is a blocking error on events that can block. Any other code is a non-blocking error, and the action goes ahead unless you set &lt;code&gt;onFailure: "block"&lt;/code&gt;. For ready-made scripts on these events, see /claude-code-hooks-examples-7-copy-paste-scripts/.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a Claude Code hook and how do you configure one?
&lt;/h2&gt;

&lt;p&gt;A Claude Code hook is a JSON settings entry that runs a handler when an event matches. The handler is usually a shell command, and the entry links it to an event and a matcher. Here is how to set up a hook that stops a read-only subagent from writing files, even if its tool list was set up wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  Steps
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Choose the scope. &lt;code&gt;.claude/settings.json&lt;/code&gt; is for one project and is shared through git. &lt;code&gt;.claude/settings.local.json&lt;/code&gt; is for one project and is gitignored. &lt;code&gt;~/.claude/settings.json&lt;/code&gt; covers all your projects (&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Create the script at &lt;code&gt;.claude/hooks/deny-reviewer-writes.sh&lt;/code&gt; (example):
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nv"&gt;INPUT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;AGENT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INPUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.agent_type // empty'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AGENT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"code-reviewer"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;jq &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "code-reviewer is read-only"
    }
  }'&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Make it executable: &lt;code&gt;chmod +x .claude/hooks/deny-reviewer-writes.sh&lt;/code&gt;. You also need &lt;code&gt;jq&lt;/code&gt; installed.&lt;/li&gt;
&lt;li&gt;Register it in &lt;code&gt;.claude/settings.json&lt;/code&gt;:
&lt;/li&gt;
&lt;/ol&gt;

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

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Open &lt;code&gt;/hooks&lt;/code&gt; in a session to confirm the hook is listed and to see which file it came from.&lt;/li&gt;
&lt;li&gt;Run the reviewer on a small diff and ask it to fix something. The write attempt should come back denied, with your reason.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Matcher rules to get right
&lt;/h3&gt;

&lt;p&gt;A matcher that is &lt;code&gt;"*"&lt;/code&gt;, &lt;code&gt;""&lt;/code&gt;, or left out matches everything. A value made only of letters, digits, &lt;code&gt;_&lt;/code&gt;, &lt;code&gt;-&lt;/code&gt;, spaces, &lt;code&gt;,&lt;/code&gt; and &lt;code&gt;|&lt;/code&gt; is treated as an exact name or a list. Anything else is treated as an unanchored JavaScript regex (&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt;). So &lt;code&gt;mcp__github__.*&lt;/code&gt; is a regex that covers every GitHub MCP tool. You can narrow further with the &lt;code&gt;if&lt;/code&gt; field, which takes permission-rule syntax such as &lt;code&gt;"Bash(rm *)"&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Common mistakes and fixes
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Script paths relative to &lt;code&gt;cwd&lt;/code&gt; break in worktrees.&lt;/strong&gt; Use &lt;code&gt;${CLAUDE_PROJECT_DIR}&lt;/code&gt;. In worktrees it stays at the main checkout, while &lt;code&gt;cwd&lt;/code&gt; points to the worktree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checking &lt;code&gt;tool_name == "Task"&lt;/code&gt;.&lt;/strong&gt; Since v2.1.63 the name is &lt;code&gt;Agent&lt;/code&gt; (&lt;a href="https://github.com/anthropics/claude-code/issues/29677" rel="noopener noreferrer"&gt;issue #29677&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expecting Notification to block.&lt;/strong&gt; It can't. Use PreToolUse or a permission rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A crashing script lets the action through.&lt;/strong&gt; A non-zero exit other than 2 doesn't block. For security hooks, set &lt;code&gt;"onFailure": "block"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hooks seem to do nothing.&lt;/strong&gt; Look for &lt;code&gt;"disableAllHooks": true&lt;/code&gt; in your settings, or a &lt;code&gt;--settings&lt;/code&gt; override in your cron command. Cron wrappers are covered in /claude-code-cron-job-setup-run-automations-overnight/.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do today (DIY):&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Search your agents for the wrong key: &lt;code&gt;grep -rn "allowed-tools" .claude/agents ~/.claude/agents&lt;/code&gt;. Rename every match to &lt;code&gt;tools&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;For each subagent that runs unattended, write out a &lt;code&gt;tools&lt;/code&gt; list built only from the background keep-list, then ask the subagent to list its tools before its next real run.&lt;/li&gt;
&lt;li&gt;Add one PreToolUse hook like the one above for your most sensitive agent, and put &lt;code&gt;Bash(git push *)&lt;/code&gt; in &lt;code&gt;permissions.deny&lt;/code&gt; if no agent should push.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;If you'd rather not set this up by hand:&lt;/strong&gt; the main official option is a Claude subscription that includes Claude Code, so you can ask Claude to write and check agent files for you. Claude Pro is $20/month ($17/month billed annually), and Max starts at $100/month with 5x or 20x Pro's usage (as of October 2026; check the &lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;official pricing page&lt;/a&gt;). Pro suits solo builders running a few scheduled subagents. Max suits people who run many parallel or long jobs and keep hitting limits. Neither plan reviews your configuration for you, and the Free plan doesn't include Claude Code. If your runs are small and occasional, pay-as-you-go API use may cost less. Token tracking is covered in /claude-code-how-to-see-token-usage-and-reduce-it/.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related guides on this site:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;/claude-code-permissions-best-practices-headless-template/&lt;/li&gt;
&lt;li&gt;/claude-code-hooks-lifecycle-every-event-explained/&lt;/li&gt;
&lt;li&gt;/claude-code-hooks-examples-7-copy-paste-scripts/&lt;/li&gt;
&lt;li&gt;/ai-agent-guardrails-example-copy-paste-claude-code-rules/&lt;/li&gt;
&lt;li&gt;/claude-code-checkpoints-how-rollback-works-in-automations/&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  What tools can a Claude Code subagent use?
&lt;/h3&gt;

&lt;p&gt;If you don't set a &lt;code&gt;tools&lt;/code&gt; field, it inherits the main conversation's built-in and MCP tools, minus a few that are always withheld, such as &lt;code&gt;AskUserQuestion&lt;/code&gt; and &lt;code&gt;EnterPlanMode&lt;/code&gt;. Background subagents, the default, keep only a fixed set of built-ins like Read, Edit, Write, Bash, Grep, Glob, WebFetch, and WebSearch, plus all MCP tools. Use &lt;code&gt;tools&lt;/code&gt; or &lt;code&gt;disallowedTools&lt;/code&gt; to narrow that set.&lt;/p&gt;

&lt;h3&gt;
  
  
  What are Claude Code hooks?
&lt;/h3&gt;

&lt;p&gt;Hooks are handlers you configure in settings files or agent frontmatter, usually shell commands, that run automatically at lifecycle events such as PreToolUse, PostToolUse, SessionStart, and SubagentStop. They get JSON describing the event and can allow, deny, or change some actions. Hooks set in settings also run inside subagents.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where are the docs for PreToolUse, PostToolUse, UserPromptSubmit, and Notification?
&lt;/h3&gt;

&lt;p&gt;All four are on the official &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt;, with input fields, matcher values, and decision options for each. The &lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;sub-agents page&lt;/a&gt; covers how those events behave inside subagents and the SubagentStart/SubagentStop matchers.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I make a Claude Code hook block a command?
&lt;/h3&gt;

&lt;p&gt;Register a &lt;code&gt;PreToolUse&lt;/code&gt; hook with a matcher such as &lt;code&gt;Bash&lt;/code&gt;, and either exit with code 2 or print JSON with &lt;code&gt;permissionDecision: "deny"&lt;/code&gt; and a reason. Add &lt;code&gt;"onFailure": "block"&lt;/code&gt; if a crashing script should also block. For simple command patterns, a &lt;code&gt;permissions.deny&lt;/code&gt; rule is often enough and needs no script.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;Create custom subagents – Claude Code Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Hooks reference – Claude Code Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/skills" rel="noopener noreferrer"&gt;Skills – Claude Code Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/tools-reference" rel="noopener noreferrer"&gt;Tools reference – Claude Code Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/errors" rel="noopener noreferrer"&gt;Errors – Claude Code Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;Configure permissions – Claude Code Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/agent-sdk/permissions" rel="noopener noreferrer"&gt;Configure permissions – Agent SDK&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/agent-sdk/subagents" rel="noopener noreferrer"&gt;Subagents in the SDK – Claude Code Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/anthropics/claude-code/issues/29677" rel="noopener noreferrer"&gt;Task→Agent tool rename in v2.1.63 – GitHub issue #29677&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;Claude pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.tembo.io/blog/claude-code-subagents" rel="noopener noreferrer"&gt;Claude Code Subagents: A 2026 Practical Guide – Tembo&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was created with the help of AI. Sources are linked inline; check the official documentation for the latest details.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>subagents</category>
      <category>allowedtools</category>
      <category>toolsfield</category>
    </item>
    <item>
      <title>Claude Code Output Format JSON: Fields, jq &amp; Python Parsing</title>
      <dc:creator>Aoba Works</dc:creator>
      <pubDate>Sat, 31 Oct 2026 16:27:17 +0000</pubDate>
      <link>https://dev.to/aobaworks/claude-code-output-format-json-fields-jq-python-parsing-3edj</link>
      <guid>https://dev.to/aobaworks/claude-code-output-format-json-fields-jq-python-parsing-3edj</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://en.aobaworks.com/claude-code-output-format-json-fields-jq-python-parsing/?utm_source=devto&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unattended&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;To get JSON from Claude Code, run &lt;code&gt;claude -p "your prompt" --output-format json&lt;/code&gt;. When the run finishes, it prints one JSON object with the answer in &lt;code&gt;result&lt;/code&gt; and run metadata next to it: &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;subtype&lt;/code&gt;, &lt;code&gt;is_error&lt;/code&gt;, &lt;code&gt;duration_ms&lt;/code&gt;, &lt;code&gt;num_turns&lt;/code&gt;, &lt;code&gt;session_id&lt;/code&gt;, &lt;code&gt;total_cost_usd&lt;/code&gt; and &lt;code&gt;usage&lt;/code&gt;. The official docs call this format "&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;structured JSON with result, session ID, and metadata&lt;/a&gt;". If you want events while the run is still going, use &lt;code&gt;--output-format stream-json&lt;/code&gt;. It prints one JSON object per line, and the last line is the same &lt;code&gt;result&lt;/code&gt; object.&lt;/p&gt;

&lt;p&gt;One thing to know before you write a parser: many older posts mention a &lt;code&gt;cost_usd&lt;/code&gt; field. The current &lt;a href="https://code.claude.com/docs/en/agent-sdk/python" rel="noopener noreferrer"&gt;Agent SDK reference&lt;/a&gt; calls the cost field &lt;code&gt;total_cost_usd&lt;/code&gt;. If your script reads &lt;code&gt;.cost_usd&lt;/code&gt;, it will quietly get &lt;code&gt;null&lt;/code&gt; back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which option is right for you?
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;json&lt;/code&gt; if you only need the final outcome. Use &lt;code&gt;stream-json&lt;/code&gt; if you need progress, tool calls or retry events while the run is in progress.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your situation&lt;/th&gt;
&lt;th&gt;What to pick first&lt;/th&gt;
&lt;th&gt;First page to open&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Beginner, first headless script&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--output-format json&lt;/code&gt; plus &lt;code&gt;jq -r '.result'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Run Claude Code programmatically&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nightly cron job that must alert on failure&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;json&lt;/code&gt;, then check &lt;code&gt;subtype&lt;/code&gt; and &lt;code&gt;is_error&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;a href="https://en.aobaworks.com/claude-code-cron-job-setup-run-automations-overnight/" rel="noopener noreferrer"&gt;/claude-code-cron-job-setup-run-automations-overnight/&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long runs you want to watch live, or retry visibility&lt;/td&gt;
&lt;td&gt;&lt;code&gt;stream-json --verbose&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/headless#stream-responses" rel="noopener noreferrer"&gt;Stream responses section&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Budget-conscious, tracking spend per run&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;json&lt;/code&gt;, log &lt;code&gt;total_cost_usd&lt;/code&gt; and &lt;code&gt;usage&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;a href="https://en.aobaworks.com/claude-code-how-to-see-token-usage-and-reduce-it/" rel="noopener noreferrer"&gt;/claude-code-how-to-see-token-usage-and-reduce-it/&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You need typed fields (lists, flags) and not prose&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;json&lt;/code&gt; + &lt;code&gt;--json-schema&lt;/code&gt;, read &lt;code&gt;structured_output&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/headless#get-structured-output" rel="noopener noreferrer"&gt;Get structured output&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You only want to read a saved output file nicely&lt;/td&gt;
&lt;td&gt;VS Code's built-in JSON formatter&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.visualstudio.com/docs/languages/json" rel="noopener noreferrer"&gt;VS Code JSON docs&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Why each pick works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Beginner:&lt;/strong&gt; &lt;code&gt;json&lt;/code&gt; gives you one object, so one &lt;code&gt;jq&lt;/code&gt; call answers "did it work, and what did it say?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cron job:&lt;/strong&gt; Claude Code returns a non-zero exit code when a run fails, but the JSON tells you &lt;em&gt;how&lt;/em&gt; it failed (&lt;code&gt;error_max_turns&lt;/code&gt; and &lt;code&gt;error_max_budget_usd&lt;/code&gt; call for different fixes).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live monitoring:&lt;/strong&gt; &lt;code&gt;stream-json&lt;/code&gt; shows &lt;code&gt;system/api_retry&lt;/code&gt; events, so you can tell a stalled run apart from one that is waiting on a retry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget:&lt;/strong&gt; &lt;code&gt;total_cost_usd&lt;/code&gt; gives you a spend figure for every run without logging into a dashboard.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does --output-format json return in Claude Code?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--output-format json&lt;/code&gt; returns one result object when the run ends. That object holds the final text in &lt;code&gt;result&lt;/code&gt;, plus status, timing, cost and token metadata.&lt;/p&gt;

&lt;p&gt;The headless docs describe the payload and say it includes "&lt;code&gt;total_cost_usd&lt;/code&gt; and a per-model cost breakdown" (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;). The full field list comes from the &lt;code&gt;ResultMessage&lt;/code&gt; definition in the &lt;a href="https://code.claude.com/docs/en/agent-sdk/python" rel="noopener noreferrer"&gt;Python Agent SDK reference&lt;/a&gt;, which describes the same result message the CLI prints (as of October 2026):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it tells your script&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;type&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;Always &lt;code&gt;"result"&lt;/code&gt; for this object. In &lt;code&gt;stream-json&lt;/code&gt;, use it to find the final line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;subtype&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"success"&lt;/code&gt;, &lt;code&gt;"error_during_execution"&lt;/code&gt;, &lt;code&gt;"error_max_turns"&lt;/code&gt;, &lt;code&gt;"error_max_budget_usd"&lt;/code&gt; or &lt;code&gt;"error_max_structured_output_retries"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;is_error&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;boolean&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;true&lt;/code&gt; when the run ended in an error state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;result&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string or absent&lt;/td&gt;
&lt;td&gt;The final text answer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;structured_output&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;any, optional&lt;/td&gt;
&lt;td&gt;Present only if you passed &lt;code&gt;--json-schema&lt;/code&gt; and the run produced valid output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;duration_ms&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;integer&lt;/td&gt;
&lt;td&gt;Wall-clock time for the whole run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;duration_api_ms&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;integer&lt;/td&gt;
&lt;td&gt;Time spent waiting on model API calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;num_turns&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;integer&lt;/td&gt;
&lt;td&gt;How many agent turns ran (compare it with your &lt;code&gt;--max-turns&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;session_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;Pass to &lt;code&gt;--resume&lt;/code&gt; to continue this conversation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stop_reason&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string or null&lt;/td&gt;
&lt;td&gt;Why the model stopped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;total_cost_usd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;number&lt;/td&gt;
&lt;td&gt;Estimated spend for the conversation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;usage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;object&lt;/td&gt;
&lt;td&gt;Token counts (input, output, cache creation, cache read)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;per-model usage&lt;/td&gt;
&lt;td&gt;object&lt;/td&gt;
&lt;td&gt;Per-model breakdown. Named &lt;code&gt;modelUsage&lt;/code&gt; in the &lt;a href="https://code.claude.com/docs/en/agent-sdk/typescript" rel="noopener noreferrer"&gt;TypeScript reference&lt;/a&gt;, &lt;code&gt;model_usage&lt;/code&gt; in the Python SDK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;permission_denials&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;array&lt;/td&gt;
&lt;td&gt;Tool calls that were denied, which is useful in unattended runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;errors&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;array, optional&lt;/td&gt;
&lt;td&gt;Error details when the run failed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Python reference says it plainly: "The &lt;code&gt;subtype&lt;/code&gt; field determines which other fields are populated." So read &lt;code&gt;subtype&lt;/code&gt; first and the other fields after it.&lt;/p&gt;

&lt;h3&gt;
  
  
  An illustrative json result
&lt;/h3&gt;

&lt;p&gt;Here is the shape of the output, with example values made up for illustration and some fields left out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subtype"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"is_error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;18432&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"duration_api_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15110&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"num_turns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Found 2 failing tests in auth.spec.ts and fixed both."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"session_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"example-session-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"total_cost_usd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.0412&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"usage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"input_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"output_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;640&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact set of keys can change between Claude Code versions. Before you hard-code anything, run this once on your machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Say hi"&lt;/span&gt; &lt;span class="nt"&gt;--output-format&lt;/span&gt; json | jq &lt;span class="s1"&gt;'keys'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Three facts about cost and exit codes
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost is an estimate.&lt;/strong&gt; The docs say: "&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Both figures are client-side estimates and can differ from your actual bill.&lt;/a&gt;" Use it for trends and alerts. Don't use it for invoicing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost adds up across a session.&lt;/strong&gt; If you use &lt;code&gt;--continue&lt;/code&gt; or &lt;code&gt;--resume&lt;/code&gt;, the run reports the conversation's whole total, including what earlier runs spent (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;). Summing &lt;code&gt;total_cost_usd&lt;/code&gt; across resumed runs therefore double-counts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exit code plus stdout.&lt;/strong&gt; Claude Code "exits with code 0 on success and a non-zero code when the run fails." Invalid flags go to stderr. But "&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;When a failure happens inside the run, such as missing authentication, Claude Code prints the failure as the result on stdout.&lt;/a&gt;" So stdout can contain an error message sitting in the &lt;code&gt;result&lt;/code&gt; field.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Getting typed data with --json-schema
&lt;/h3&gt;

&lt;p&gt;If your monitor needs fields and not prose, add &lt;code&gt;--json-schema&lt;/code&gt;. The validated data shows up in &lt;code&gt;structured_output&lt;/code&gt;. The docs warn that when a run ends without valid structured output, "the response has no &lt;code&gt;structured_output&lt;/code&gt; field" (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;). Check that the field exists before you read it. If the schema itself is invalid, &lt;code&gt;claude&lt;/code&gt; exits with &lt;code&gt;Error: --json-schema is not a valid JSON Schema&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you use Claude Code output format stream-json?
&lt;/h2&gt;

&lt;p&gt;Claude Code's &lt;code&gt;stream-json&lt;/code&gt; format prints newline-delimited JSON events while the agent works, and finishes with a &lt;code&gt;result&lt;/code&gt; line. Read it one line at a time.&lt;/p&gt;

&lt;p&gt;The documented command for live token streaming is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Explain recursion"&lt;/span&gt; &lt;span class="nt"&gt;--output-format&lt;/span&gt; stream-json &lt;span class="nt"&gt;--verbose&lt;/span&gt; &lt;span class="nt"&gt;--include-partial-messages&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;The last line of the stream is a &lt;code&gt;result&lt;/code&gt; message with the final response text, cost, and session metadata.&lt;/a&gt;" Before that line you'll see these event types (from the &lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event (&lt;code&gt;type&lt;/code&gt; / &lt;code&gt;subtype&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;When it appears&lt;/th&gt;
&lt;th&gt;Useful for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;system&lt;/code&gt; / &lt;code&gt;init&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;First, unless hook or plugin-install events come before it&lt;/td&gt;
&lt;td&gt;Model, tools, MCP servers, plugins, &lt;code&gt;plugin_errors&lt;/code&gt;, &lt;code&gt;mcp_server_errors&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;assistant&lt;/code&gt;, &lt;code&gt;user&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Every turn, including subagent messages (&lt;code&gt;parent_tool_use_id&lt;/code&gt; set)&lt;/td&gt;
&lt;td&gt;Logging tool calls and results&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stream_event&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Only with &lt;code&gt;--include-partial-messages&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Live text deltas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;system&lt;/code&gt; / &lt;code&gt;api_retry&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Before each retry of a retryable API error&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;attempt&lt;/code&gt;, &lt;code&gt;max_retries&lt;/code&gt;, &lt;code&gt;retry_delay_ms&lt;/code&gt;, &lt;code&gt;error&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;system&lt;/code&gt; / &lt;code&gt;permission_denied&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;When a tool call is denied&lt;/td&gt;
&lt;td&gt;Finding out which permissions are missing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;result&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Last line&lt;/td&gt;
&lt;td&gt;Same fields as the &lt;code&gt;json&lt;/code&gt; format&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Steps: consume stream-json in a pipeline
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Run with &lt;code&gt;--output-format stream-json --verbose&lt;/code&gt;. Add &lt;code&gt;--include-partial-messages&lt;/code&gt; only if you need token-level text.&lt;/li&gt;
&lt;li&gt;Read stdout line by line. Each line is a complete JSON object, and the stream as a whole is &lt;em&gt;not&lt;/em&gt; one JSON document.&lt;/li&gt;
&lt;li&gt;When you see &lt;code&gt;type == "system"&lt;/code&gt; with &lt;code&gt;subtype == "init"&lt;/code&gt;, check &lt;code&gt;mcp_server_errors&lt;/code&gt; and &lt;code&gt;plugin_errors&lt;/code&gt;. Claude Code skips invalid &lt;code&gt;--mcp-config&lt;/code&gt; entries and "the run continues and exits cleanly," so a non-empty array is your only signal (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;On &lt;code&gt;system/api_retry&lt;/code&gt;, log &lt;code&gt;attempt&lt;/code&gt; and &lt;code&gt;error&lt;/code&gt;. The &lt;code&gt;error&lt;/code&gt; values include &lt;code&gt;rate_limit&lt;/code&gt;, &lt;code&gt;overloaded&lt;/code&gt;, &lt;code&gt;billing_error&lt;/code&gt; and &lt;code&gt;authentication_failed&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;On &lt;code&gt;type == "result"&lt;/code&gt;, apply the same success checks you'd apply to the &lt;code&gt;json&lt;/code&gt; format.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  stream-json pitfalls
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Running &lt;code&gt;jq '.result'&lt;/code&gt; on the whole stream.&lt;/strong&gt; jq applies the filter to every line, so you get a column of &lt;code&gt;null&lt;/code&gt;s with the answer at the end. Filter with &lt;code&gt;select(.type == "result")&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No text deltas.&lt;/strong&gt; Without &lt;code&gt;--include-partial-messages&lt;/code&gt;, you won't get &lt;code&gt;stream_event&lt;/code&gt; lines. You get whole messages only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A slow reader holds the process open.&lt;/strong&gt; If your consumer reads slowly, Claude Code waits for queued output to drain before it exits, "capped at 30 seconds" (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subagent noise.&lt;/strong&gt; Messages from subagents appear in the same stream. If you only want the main conversation, filter on &lt;code&gt;parent_tool_use_id == null&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do you parse Claude Code JSON output with jq?
&lt;/h2&gt;

&lt;p&gt;Pipe &lt;code&gt;claude -p ... --output-format json&lt;/code&gt; into &lt;code&gt;jq -r '.result'&lt;/code&gt; for the text, or into &lt;code&gt;jq&lt;/code&gt; field paths such as &lt;code&gt;.total_cost_usd&lt;/code&gt; and &lt;code&gt;.is_error&lt;/code&gt; for monitoring values.&lt;/p&gt;

&lt;p&gt;The jq flags that matter here, quoted from the &lt;a href="https://jqlang.org/manual/" rel="noopener noreferrer"&gt;jq manual&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-r&lt;/code&gt;: a string result "will be written directly to standard output rather than being formatted as a JSON string with quotes."&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-j&lt;/code&gt;: "Like -r but jq won't print a newline after each output."&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-e&lt;/code&gt;: sets the exit status to 1 "if the last output value was either false or null," which makes jq usable as a test.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-c&lt;/code&gt;: puts "each JSON object on a single line," which is handy for appending to a log.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Copy-paste jq snippets
&lt;/h3&gt;

&lt;p&gt;Save the output once, then read it as many times as you need. Each of these is an example; adjust paths to your setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Run once, keep the raw JSON&lt;/span&gt;
&lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Run the test suite and summarize failures"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output-format&lt;/span&gt; json &lt;span class="nt"&gt;--allowedTools&lt;/span&gt; &lt;span class="s2"&gt;"Bash,Read"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# 2. Just the answer&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.result'&lt;/span&gt;

&lt;span class="c"&gt;# 3. Monitoring values in one line (tab-separated)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'[.subtype, .is_error, .duration_ms, .num_turns, .total_cost_usd] | @tsv'&lt;/span&gt;

&lt;span class="c"&gt;# 4. Fail the script unless the run succeeded&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'.subtype == "success" and .is_error == false'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Claude run failed: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.subtype'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# 5. Append a compact record to a JSONL log for later analysis&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'{ts: now, subtype, is_error, duration_ms, num_turns, total_cost_usd, session_id}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/claude-runs.jsonl

&lt;span class="c"&gt;# 6. Default a missing field instead of printing null&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.structured_output // "NO_STRUCTURED_OUTPUT"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Snippet 6 relies on jq's alternative operator &lt;code&gt;//&lt;/code&gt;, which returns the right side when the left side is false or null (&lt;a href="https://jqlang.org/manual/" rel="noopener noreferrer"&gt;jq manual&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;stream-json&lt;/code&gt;, the docs filter text deltas like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Write a poem"&lt;/span&gt; &lt;span class="nt"&gt;--output-format&lt;/span&gt; stream-json &lt;span class="nt"&gt;--verbose&lt;/span&gt; &lt;span class="nt"&gt;--include-partial-messages&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  jq &lt;span class="nt"&gt;-rj&lt;/span&gt; &lt;span class="s1"&gt;'select(.type == "stream_event" and .event.delta.type? == "text_delta") | .event.delta.text'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To keep only the final record from a stream, as an example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Nightly dependency audit"&lt;/span&gt; &lt;span class="nt"&gt;--output-format&lt;/span&gt; stream-json &lt;span class="nt"&gt;--verbose&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;tee &lt;/span&gt;run.jsonl &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'select(.type == "result") | {subtype, is_error, total_cost_usd}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tee&lt;/code&gt; saves the full transcript, so you can debug later without re-running and paying again.&lt;/p&gt;

&lt;h3&gt;
  
  
  jq pitfalls
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Quoting.&lt;/strong&gt; Put jq programs in single quotes so the shell doesn't expand &lt;code&gt;$&lt;/code&gt; or &lt;code&gt;!&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;jq -e&lt;/code&gt; and &lt;code&gt;false&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;jq -e '.is_error'&lt;/code&gt; exits 1 when &lt;code&gt;is_error&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;, which is the healthy case. Test the condition you want to be true, as snippet 4 does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parsing twice.&lt;/strong&gt; Each &lt;code&gt;claude -p&lt;/code&gt; call is a new paid run. Capture the output in a variable or file first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do you parse Claude Code JSON output with Python?
&lt;/h2&gt;

&lt;p&gt;For the &lt;code&gt;json&lt;/code&gt; format, pass the subprocess stdout to &lt;code&gt;json.loads()&lt;/code&gt;. For &lt;code&gt;stream-json&lt;/code&gt;, call &lt;code&gt;json.loads()&lt;/code&gt; on each line. In both cases, branch on &lt;code&gt;subtype&lt;/code&gt; and &lt;code&gt;is_error&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Python's &lt;code&gt;json.loads&lt;/code&gt; handles one document. "&lt;a href="https://docs.python.org/3/library/json.html" rel="noopener noreferrer"&gt;If the data being deserialized is not a valid JSON document, a JSONDecodeError will be raised.&lt;/a&gt;" That's why stream output needs a per-line loop.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example: a minimal monitor for json output
&lt;/h3&gt;

&lt;p&gt;This is an illustrative script. Replace &lt;code&gt;alert()&lt;/code&gt; with whatever notifier you already use:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALERT: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# swap for your webhook, mail, etc.
&lt;/span&gt;
&lt;span class="n"&gt;proc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Summarize yesterday&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s error log in logs/app.log&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--output-format&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--allowedTools&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No valid JSON (exit &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;). stderr: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;subtype&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subtype&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;is_error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;subtype&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;success&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Run failed: subtype=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;subtype&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; turns=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;num_turns&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_cost_usd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;1.00&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# example threshold
&lt;/span&gt;    &lt;span class="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Run cost $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, above threshold&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep &lt;code&gt;stdout&lt;/code&gt; and &lt;code&gt;stderr&lt;/code&gt; separate (&lt;code&gt;capture_output=True&lt;/code&gt; does that). The stderr text then shows up in your alert without breaking the JSON parse.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example: reading stream-json line by line
&lt;/h3&gt;



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

&lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Refactor utils.py and run tests&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--output-format&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream-json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--verbose&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Popen&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="n"&gt;stdout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PIPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;final&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subtype&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;init&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mcp_server_errors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MCP config problem:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mcp_server_errors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api_retry&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retry &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;attempt&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;max_retries&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;final&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;final&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Stream ended without a result line (killed or crashed?)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;final&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;is_error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Failed:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;final&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subtype&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Python pitfalls
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Treating &lt;code&gt;final is None&lt;/code&gt; as success.&lt;/strong&gt; If something sends the process SIGTERM, Claude Code "exits with code 143" and "records no result" for the turn in progress (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;). A missing result line means failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using &lt;code&gt;subprocess.run&lt;/code&gt; for long streams.&lt;/strong&gt; It buffers everything until the process exits, so you lose the live view. Use &lt;code&gt;Popen&lt;/code&gt; and iterate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python SDK names versus raw JSON names.&lt;/strong&gt; If you later switch to the Python Agent SDK, the per-model field becomes &lt;code&gt;model_usage&lt;/code&gt;. In raw CLI JSON, check the key name with &lt;code&gt;jq 'keys'&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want the bigger picture on catching runs that "succeed" but did nothing useful, see &lt;a href="https://en.aobaworks.com/ai-agent-monitoring-in-claude-code-catch-silent-failures/" rel="noopener noreferrer"&gt;/ai-agent-monitoring-in-claude-code-catch-silent-failures/&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you format a JSON file in VS Code?
&lt;/h2&gt;

&lt;p&gt;Open the &lt;code&gt;.json&lt;/code&gt; file and run &lt;strong&gt;Format Document&lt;/strong&gt;. The shortcut is ⇧⌥F on Mac, Shift+Alt+F on Windows, or Ctrl+Shift+I on Linux, or you can right-click and choose &lt;strong&gt;Format Document&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That shortcut comes straight from the &lt;a href="https://code.visualstudio.com/docs/languages/json" rel="noopener noreferrer"&gt;VS Code JSON docs&lt;/a&gt;: "You can format your JSON document using ⇧⌥F (Windows Shift+Alt+F, Linux Ctrl+Shift+I) or Format Document from the context menu."&lt;/p&gt;

&lt;p&gt;Steps for a saved Claude Code output:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Save the output: &lt;code&gt;claude -p "..." --output-format json &amp;gt; run.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;In VS Code, choose &lt;strong&gt;File &amp;gt; Open File...&lt;/strong&gt; and select &lt;code&gt;run.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Check that the status bar in the bottom-right says &lt;strong&gt;JSON&lt;/strong&gt;. If it says Plain Text, click it and pick &lt;strong&gt;JSON&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Press the Format Document shortcut, or right-click &amp;gt; &lt;strong&gt;Format Document&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;To format only part of the file, select it and use &lt;strong&gt;Format Selection&lt;/strong&gt; (⌘K ⌘F on Mac, Ctrl+K Ctrl+F on Windows/Linux), per the &lt;a href="https://code.visualstudio.com/docs/editing/codebasics" rel="noopener noreferrer"&gt;VS Code basic editing docs&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Formatting pitfalls
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;stream-json files aren't JSON documents.&lt;/strong&gt; A &lt;code&gt;.jsonl&lt;/code&gt; file with many objects won't format as one document. Convert it to an array first, for example &lt;code&gt;jq -s '.' run.jsonl &amp;gt; run.json&lt;/code&gt;, or pull out just the result line with &lt;code&gt;jq 'select(.type=="result")' run.jsonl &amp;gt; result.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON with Comments warnings.&lt;/strong&gt; In JSONC mode, VS Code "accepts trailing commas, but they are discouraged and the editor will display a warning" (&lt;a href="https://code.visualstudio.com/docs/languages/json" rel="noopener noreferrer"&gt;VS Code JSON docs&lt;/a&gt;). Claude Code output contains neither comments nor trailing commas, so a warning there usually means the file was edited by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"There is no formatter for 'plaintext' files."&lt;/strong&gt; The language mode is wrong. Fix it as in step 3.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  VS Code JSON formatter: built-in or Prettier?
&lt;/h2&gt;

&lt;p&gt;For reading Claude Code output, VS Code's built-in JSON formatter is enough. Prettier is worth adding only when a team needs the same formatting rules everywhere.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Built-in JSON formatter&lt;/th&gt;
&lt;th&gt;Prettier extension (&lt;code&gt;esbenp.prettier-vscode&lt;/code&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Install&lt;/td&gt;
&lt;td&gt;Ships with VS Code&lt;/td&gt;
&lt;td&gt;Marketplace extension. Free, listed with over 72 million installs (&lt;a href="https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode" rel="noopener noreferrer"&gt;Marketplace&lt;/a&gt;, as of October 2026; check the official page)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config&lt;/td&gt;
&lt;td&gt;VS Code settings only&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.prettierrc&lt;/code&gt; in the repo, shared with CI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Defaults&lt;/td&gt;
&lt;td&gt;Follows your editor indent settings&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;printWidth&lt;/code&gt; 80, &lt;code&gt;tabWidth&lt;/code&gt; 2 (&lt;a href="https://prettier.io/docs/options" rel="noopener noreferrer"&gt;Prettier options&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same rules in CLI/CI&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (&lt;code&gt;npx prettier --check&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Good for&lt;/td&gt;
&lt;td&gt;Reading &lt;code&gt;run.json&lt;/code&gt;, quick fixes&lt;/td&gt;
&lt;td&gt;Teams committing JSON fixtures or config&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Who Prettier is not for:&lt;/strong&gt; if you just want to read the occasional output file, Prettier adds an extension, a config file and a second formatter that can fight with the built-in one. One limitation: once two formatters are installed, VS Code may ask you to pick a default, and formatting does nothing until you do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who the built-in formatter is not for:&lt;/strong&gt; teams that want a pre-commit or CI check. It only runs inside the editor.&lt;/p&gt;

&lt;p&gt;My pick for this audience is the built-in formatter for viewing output, plus &lt;code&gt;jq .&lt;/code&gt; on the command line, which pretty-prints by default. Add Prettier only when JSON files go into a shared repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you auto-format JSON on save in Visual Studio Code?
&lt;/h2&gt;

&lt;p&gt;To auto-format JSON in Visual Studio Code, turn on &lt;code&gt;editor.formatOnSave&lt;/code&gt; and set a default formatter for the &lt;code&gt;[json]&lt;/code&gt; language in &lt;code&gt;settings.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;VS Code documents three automatic triggers: &lt;code&gt;editor.formatOnType&lt;/code&gt;, &lt;code&gt;editor.formatOnSave&lt;/code&gt; ("Format a file on save") and &lt;code&gt;editor.formatOnPaste&lt;/code&gt; (&lt;a href="https://code.visualstudio.com/docs/editing/codebasics" rel="noopener noreferrer"&gt;basic editing docs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the Command Palette (⇧⌘P on Mac, Ctrl+Shift+P on Windows/Linux).&lt;/li&gt;
&lt;li&gt;Run &lt;strong&gt;Preferences: Open User Settings (JSON)&lt;/strong&gt;, or &lt;strong&gt;Preferences: Open Workspace Settings (JSON)&lt;/strong&gt; to apply it to one project only.&lt;/li&gt;
&lt;li&gt;Add a per-language block. Example with the built-in formatter:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"[json]"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"editor.defaultFormatter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vscode.json-language-features"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"editor.formatOnSave"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"[jsonc]"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"editor.defaultFormatter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vscode.json-language-features"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"editor.formatOnSave"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;To use Prettier instead, swap the formatter ID for &lt;code&gt;esbenp.prettier-vscode&lt;/code&gt;. The Marketplace page shows the same per-language pattern.&lt;/li&gt;
&lt;li&gt;Save the file, open a &lt;code&gt;run.json&lt;/code&gt;, change something and save it. It should re-indent.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two catches. Format-on-save doesn't run when a script writes the file. It only runs when &lt;em&gt;you&lt;/em&gt; save in the editor. And workspace settings override user settings, so if nothing happens, check &lt;code&gt;.vscode/settings.json&lt;/code&gt; in the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks when you pipe Claude Code JSON into monitoring scripts?
&lt;/h2&gt;

&lt;p&gt;Four things break most pipelines: mixed stderr, exit-code-only checks, a missing result line, and misread cost.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;jq: parse error&lt;/code&gt; or &lt;code&gt;JSONDecodeError&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;You used &lt;code&gt;2&amp;gt;&amp;amp;1&lt;/code&gt;, so stderr text landed in the JSON stream&lt;/td&gt;
&lt;td&gt;Send stderr to its own file: &lt;code&gt;2&amp;gt;&amp;gt;claude.err&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alert never fires but the work wasn't done&lt;/td&gt;
&lt;td&gt;Script checks exit code only, or reads &lt;code&gt;.cost_usd&lt;/code&gt; (null) (&lt;a href="https://code.claude.com/docs/en/agent-sdk/python" rel="noopener noreferrer"&gt;Python SDK reference&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;Check &lt;code&gt;subtype == "success"&lt;/code&gt; and &lt;code&gt;is_error == false&lt;/code&gt;, and use &lt;code&gt;total_cost_usd&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost charts jump upward&lt;/td&gt;
&lt;td&gt;Summing &lt;code&gt;total_cost_usd&lt;/code&gt; across &lt;code&gt;--resume&lt;/code&gt; runs that report cumulative totals (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;Log &lt;code&gt;session_id&lt;/code&gt; and keep only the latest value per session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;structured_output&lt;/code&gt; is null&lt;/td&gt;
&lt;td&gt;Run declined or failed schema retries (&lt;code&gt;error_max_structured_output_retries&lt;/code&gt;) (&lt;a href="https://code.claude.com/docs/en/agent-sdk/python" rel="noopener noreferrer"&gt;Python SDK reference&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;Check the field exists, alert on that subtype&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP tools silently missing&lt;/td&gt;
&lt;td&gt;Invalid &lt;code&gt;--mcp-config&lt;/code&gt; entry skipped, run "exits cleanly" (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;Fail on non-empty &lt;code&gt;mcp_server_errors&lt;/code&gt; in &lt;code&gt;system/init&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No result line at all&lt;/td&gt;
&lt;td&gt;Process killed with SIGTERM (exit 143) (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;Treat a missing result as failure, and send SIGINT for graceful stops&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run hangs after the answer&lt;/td&gt;
&lt;td&gt;Waiting on background work, 10-minute idle cap by default (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;Set &lt;code&gt;CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS&lt;/code&gt; or avoid background tasks in &lt;code&gt;-p&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Piped input rejected&lt;/td&gt;
&lt;td&gt;Piped stdin is capped at 10MB (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;Write the data to a file and reference its path in the prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sources for the table rows: &lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt; and the &lt;a href="https://code.claude.com/docs/en/agent-sdk/python" rel="noopener noreferrer"&gt;Python SDK reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The stderr row is the one people hit most often. The docs note that the "Waiting for background work to finish" message goes to stderr, and that with JSON output it prints only when stdout isn't a terminal, "so the JSON your script reads never carries it" (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;). If you merge the two streams yourself, that guarantee is gone.&lt;/p&gt;

&lt;p&gt;For CI and cron, the docs recommend &lt;code&gt;--bare&lt;/code&gt;, which skips local hooks, plugins, MCP servers and CLAUDE.md, so every machine gets the same &lt;code&gt;system/init&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;). It needs &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; because bare mode doesn't use your subscription login. The headless flags and auth trade-offs are covered in &lt;a href="https://en.aobaworks.com/claude-code-headless-mode-flags-auth-and-cost-2026/" rel="noopener noreferrer"&gt;/claude-code-headless-mode-flags-auth-and-cost-2026/&lt;/a&gt;. If your alerts keep showing &lt;code&gt;rate_limit&lt;/code&gt; retries, see &lt;a href="https://en.aobaworks.com/claude-code-rate-limit-reached-retry-setup-for-cron-jobs/" rel="noopener noreferrer"&gt;/claude-code-rate-limit-reached-retry-setup-for-cron-jobs/&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Three things you can do today:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run &lt;code&gt;claude -p "Say hi" --output-format json | jq 'keys'&lt;/code&gt; and save the key list. That's the schema for your installed version.&lt;/li&gt;
&lt;li&gt;Wrap your existing cron command with jq snippet 4 (fail unless &lt;code&gt;subtype == "success"&lt;/code&gt;) and snippet 5 (append a JSONL record). That gives you an alert and a cost log in about ten lines.&lt;/li&gt;
&lt;li&gt;If any job uses &lt;code&gt;--mcp-config&lt;/code&gt;, switch it to &lt;code&gt;stream-json --verbose&lt;/code&gt; and fail on a non-empty &lt;code&gt;mcp_server_errors&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;If you'd rather not build and maintain this yourself:&lt;/strong&gt; Claude Code's official &lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;GitHub Actions integration&lt;/a&gt; runs headless jobs inside GitHub, so run status and logs live in the Actions UI and you don't need your own parser. It suits people whose recurring work already lives in a GitHub repo. It's a poor fit for jobs that need files on your own server, or for anyone who wants per-run cost alerts (you'd still parse the JSON for those). Costs are your GitHub Actions minutes plus your Claude usage, through an API key or a plan. Claude Pro is listed at "$20 if billed monthly" and Max "From $100 Per month" on &lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;Claude's pricing page&lt;/a&gt; (as of October 2026; check the official page). For scripted &lt;code&gt;--bare&lt;/code&gt; runs, remember they need an API key and won't use your subscription login.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related guides on this site:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://en.aobaworks.com/claude-code-cron-job-setup-run-automations-overnight/" rel="noopener noreferrer"&gt;/claude-code-cron-job-setup-run-automations-overnight/&lt;/a&gt;: scheduling the runs you're now parsing&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://en.aobaworks.com/ai-agent-monitoring-in-claude-code-catch-silent-failures/" rel="noopener noreferrer"&gt;/ai-agent-monitoring-in-claude-code-catch-silent-failures/&lt;/a&gt;: what to check beyond &lt;code&gt;is_error&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://en.aobaworks.com/claude-code-how-to-see-token-usage-and-reduce-it/" rel="noopener noreferrer"&gt;/claude-code-how-to-see-token-usage-and-reduce-it/&lt;/a&gt;: turning &lt;code&gt;usage&lt;/code&gt; numbers into savings&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  How do I format a JSON file in VS Code?
&lt;/h3&gt;

&lt;p&gt;Open the file, check that the status bar language mode is JSON, then press ⇧⌥F on Mac, Shift+Alt+F on Windows or Ctrl+Shift+I on Linux. You can also right-click and choose &lt;strong&gt;Format Document&lt;/strong&gt;. For Claude Code &lt;code&gt;stream-json&lt;/code&gt; files, convert to an array first with &lt;code&gt;jq -s '.'&lt;/code&gt;, because a multi-object file isn't one JSON document.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which VS Code JSON formatter should I use?
&lt;/h3&gt;

&lt;p&gt;For reading and occasionally fixing JSON, use the built-in formatter that ships with VS Code. Pick Prettier if your team commits JSON files and wants the same rules checked in CI. Running both without setting &lt;code&gt;editor.defaultFormatter&lt;/code&gt; leads to "which formatter?" prompts.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is Claude Code output format stream-json?
&lt;/h3&gt;

&lt;p&gt;It's a newline-delimited JSON format. Each line is an event (&lt;code&gt;system/init&lt;/code&gt;, &lt;code&gt;assistant&lt;/code&gt;, &lt;code&gt;user&lt;/code&gt;, &lt;code&gt;system/api_retry&lt;/code&gt;, optional &lt;code&gt;stream_event&lt;/code&gt; deltas), and the final line is a &lt;code&gt;result&lt;/code&gt; object with the answer, cost and session metadata. The docs use it with &lt;code&gt;--verbose&lt;/code&gt;, and with &lt;code&gt;--include-partial-messages&lt;/code&gt; if you want token-level text.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I format JSON in Visual Studio Code automatically?
&lt;/h3&gt;

&lt;p&gt;Add &lt;code&gt;"editor.formatOnSave": true&lt;/code&gt; and an &lt;code&gt;"editor.defaultFormatter"&lt;/code&gt; inside a &lt;code&gt;"[json]"&lt;/code&gt; block in your settings JSON. Formatting happens when you save in the editor. Files written by scripts stay as they are until you open and save them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Run Claude Code programmatically (headless docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/cli-reference" rel="noopener noreferrer"&gt;Claude Code CLI reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/agent-sdk/python" rel="noopener noreferrer"&gt;Agent SDK reference: Python (ResultMessage)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/agent-sdk/typescript" rel="noopener noreferrer"&gt;Agent SDK reference: TypeScript&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code GitHub Actions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;Claude pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://jqlang.org/manual/" rel="noopener noreferrer"&gt;jq manual&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.python.org/3/library/json.html" rel="noopener noreferrer"&gt;Python json module documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.visualstudio.com/docs/languages/json" rel="noopener noreferrer"&gt;VS Code: JSON editing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.visualstudio.com/docs/editing/codebasics" rel="noopener noreferrer"&gt;VS Code: Basic editing (Formatting)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://prettier.io/docs/options" rel="noopener noreferrer"&gt;Prettier options&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode" rel="noopener noreferrer"&gt;Prettier extension on VS Code Marketplace&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was created with the help of AI. Sources are linked inline; check the official documentation for the latest details.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>headlessmode</category>
      <category>json</category>
      <category>streamjson</category>
    </item>
    <item>
      <title>Claude Code Regression Testing After Prompt Changes: Steps</title>
      <dc:creator>Aoba Works</dc:creator>
      <pubDate>Fri, 30 Oct 2026 16:16:57 +0000</pubDate>
      <link>https://dev.to/aobaworks/claude-code-regression-testing-after-prompt-changes-steps-4cdi</link>
      <guid>https://dev.to/aobaworks/claude-code-regression-testing-after-prompt-changes-steps-4cdi</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://en.aobaworks.com/claude-code-regression-testing-after-prompt-changes-steps/?utm_source=devto&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unattended&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;To regression-test Claude Code after a prompt change, run a fixed set of test cases several times with your current CLAUDE.md and save the results. Then run the same cases with the edited file, grade both sets the same way, and compare pass counts per case. Use a threshold so one random failure doesn't block a good edit and a real drop doesn't slip through. Anthropic's own eval guide says regression evals ask "Does the agent still handle all the tasks it used to?" and &lt;a href="https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents" rel="noopener noreferrer"&gt;"should have a nearly 100% pass rate."&lt;/a&gt; This guide covers that workflow end to end, with scripts you can adapt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which option is right for you?
&lt;/h2&gt;

&lt;p&gt;Choose your setup by how often the agent runs unattended and how much you want to spend on test runs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your situation&lt;/th&gt;
&lt;th&gt;What to pick first&lt;/th&gt;
&lt;th&gt;First page to open&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Beginner with one recurring Claude Code job&lt;/td&gt;
&lt;td&gt;5 cases × 3 runs, run by hand with a shell script before each CLAUDE.md edit&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Headless mode docs&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Daily work use (agent runs every night)&lt;/td&gt;
&lt;td&gt;10–15 cases × 5 runs, triggered by GitHub Actions on any PR touching &lt;code&gt;CLAUDE.md&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;GitHub Actions docs&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tight budget&lt;/td&gt;
&lt;td&gt;Code-only graders (no LLM judge) and a per-run &lt;code&gt;--max-budget-usd&lt;/code&gt; cap&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Headless mode: cost fields&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Switching models (e.g. Sonnet to Opus)&lt;/td&gt;
&lt;td&gt;Same suite, with the full model ID and effort level pinned on both sides&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/model-config" rel="noopener noreferrer"&gt;Model configuration&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You'd rather use a ready-made eval tool&lt;/td&gt;
&lt;td&gt;Promptfoo's Claude Agent SDK provider&lt;/td&gt;
&lt;td&gt;&lt;a href="https://www.promptfoo.dev/docs/providers/claude-agent-sdk/" rel="noopener noreferrer"&gt;Promptfoo provider page&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you haven't set up an eval loop yet, start with the pillar guide, &lt;a href="https://en.aobaworks.com/ai-agent-evaluation-framework-claude-code-eval-loop-steps/" rel="noopener noreferrer"&gt;AI Agent Evaluation Framework: Claude Code Eval Loop Steps&lt;/a&gt;. This article covers one narrower job: comparing before and after a change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do prompt changes to CLAUDE.md silently break agent behavior?
&lt;/h2&gt;

&lt;p&gt;CLAUDE.md edits change the context Claude reasons over. A rule that used to work can get less weight or be contradicted by a new line, and nothing throws an error.&lt;/p&gt;

&lt;p&gt;The Claude Code memory docs say this directly: "&lt;a href="https://code.claude.com/docs/en/memory" rel="noopener noreferrer"&gt;Claude treats them as context, not enforced configuration.&lt;/a&gt;" The same page adds that "the more specific and concise your instructions, the more consistently Claude follows them." So there's no parser that rejects a bad CLAUDE.md. A vague new rule, or one that conflicts with an old one, just changes the odds of what the agent does.&lt;/p&gt;

&lt;p&gt;Anthropic shipped a regression like this in Claude Code itself. Its &lt;a href="https://www.anthropic.com/engineering/april-23-postmortem" rel="noopener noreferrer"&gt;April 2026 postmortem&lt;/a&gt; traces one of three quality problems to a system prompt line added on April 16 that capped text between tool calls and final responses at short word counts. A broader eval later "showed a 3% drop for both Opus 4.6 and 4.7," and the prompt was reverted in the April 20 release. In Anthropic's words, "one addition to the system prompt caused an outsized effect on intelligence in Claude Code." Their first eval set didn't catch it. The fix they committed to was "a broad suite of per-model evals for every system prompt change" and ablations "to understand the impact of each line."&lt;/p&gt;

&lt;p&gt;For example, suppose your nightly dependency-update agent has a CLAUDE.md rule saying "never edit files under &lt;code&gt;migrations/&lt;/code&gt;." You add a line for brevity: "Keep final reports under 50 words." Nothing obvious breaks. But the shorter report may drop the "files changed" list your morning check depends on. The agent may also skip the step where it re-reads the rules before editing. You'd only see this by comparing runs.&lt;/p&gt;

&lt;p&gt;Pitfalls specific to CLAUDE.md:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Edits outside the file you're looking at. User-level &lt;code&gt;~/.claude/CLAUDE.md&lt;/code&gt;, &lt;code&gt;.claude/rules/&lt;/code&gt;, and auto memory all load too (&lt;a href="https://code.claude.com/docs/en/memory" rel="noopener noreferrer"&gt;memory docs&lt;/a&gt;), so the "before" and "after" may differ in more than one file.&lt;/li&gt;
&lt;li&gt;Hard rules written as prose. If an action must never happen, the docs point to a PreToolUse hook rather than a CLAUDE.md line. A regression suite measures how well guidance is followed. A hook enforces it. Our &lt;a href="https://en.aobaworks.com/ai-agent-guardrails-example-copy-paste-claude-code-rules/" rel="noopener noreferrer"&gt;guardrails guide&lt;/a&gt; shows which rules belong where.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do you set up a baseline run before editing a prompt?
&lt;/h2&gt;

&lt;p&gt;Run your current CLAUDE.md against a fixed set of representative inputs and save every output before you change anything. That snapshot is your baseline.&lt;/p&gt;

&lt;p&gt;There's one trap to clear first. Headless mode has a &lt;code&gt;--bare&lt;/code&gt; flag that skips "auto-discovery of hooks, skills, … auto memory, and CLAUDE.md" (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;). The same page says "&lt;code&gt;--bare&lt;/code&gt; is the recommended mode for scripted and SDK calls, and will become the default for &lt;code&gt;-p&lt;/code&gt; in a future release." That's the right choice for most scripts. For a test of CLAUDE.md, though, it would quietly test nothing. So the baseline runs a normal &lt;code&gt;claude -p&lt;/code&gt; inside a fresh copy of a fixture project, with only the CLAUDE.md you're testing in it.&lt;/p&gt;

&lt;p&gt;Steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create an &lt;code&gt;evals/&lt;/code&gt; folder in your repo with &lt;code&gt;cases/&lt;/code&gt; (one JSON file per case), &lt;code&gt;fixtures/&lt;/code&gt; (small sample projects), and &lt;code&gt;schema.json&lt;/code&gt; (the output shape you'll grade).&lt;/li&gt;
&lt;li&gt;Pin everything that isn't the variable you're testing: the full model ID (&lt;code&gt;claude-opus-5-5&lt;/code&gt;, per the &lt;a href="https://code.claude.com/docs/en/model-config" rel="noopener noreferrer"&gt;model config page&lt;/a&gt;), the effort level, and the Claude Code version. The setup page shows how to install an exact version, for example &lt;code&gt;curl -fsSL https://claude.ai/install.sh | bash -s 2.1.89&lt;/code&gt;, and how to stop background updates with &lt;code&gt;DISABLE_AUTOUPDATER&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/setup" rel="noopener noreferrer"&gt;setup docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Ask for structured output. With &lt;code&gt;--output-format json --json-schema '&amp;lt;schema&amp;gt;'&lt;/code&gt;, the response puts schema-shaped data in a &lt;code&gt;structured_output&lt;/code&gt; field. If the run ends without valid output, that field is missing (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Copy the current CLAUDE.md to &lt;code&gt;evals/baseline/CLAUDE.md&lt;/code&gt; and commit it, so the baseline is reproducible.&lt;/li&gt;
&lt;li&gt;Run every case N times (3 to start) and save each run's JSON, the list of changed files, and the test exit code.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;An example &lt;code&gt;evals/schema.json&lt;/code&gt; for a "fix failing tests" job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"enum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"done"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"blocked"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"files_changed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"array"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tests_passed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"boolean"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"files_changed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tests_passed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An example case file, &lt;code&gt;evals/cases/fix-date-parse.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fix-date-parse"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fixture"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"evals/fixtures/date-utils"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"prompt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Run the tests and fix the failing date parsing test. Do not touch the migrations folder."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"allowed_paths"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^src/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"assert"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".structured_output.status == &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;done&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runner script, &lt;code&gt;evals/run.sh&lt;/code&gt; (an example to adapt; it assumes &lt;code&gt;jq&lt;/code&gt; and &lt;code&gt;git&lt;/code&gt; are installed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# usage: evals/run.sh &amp;lt;label&amp;gt; &amp;lt;path/to/CLAUDE.md&amp;gt; [runs]&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;ROOT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;LABEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;MEMORY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;RUNS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;MODEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;EVAL_MODEL&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;claude&lt;/span&gt;&lt;span class="p"&gt;-opus-5-5&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;EFFORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;EVAL_EFFORT&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;high&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;SCHEMA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;evals/schema.json&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for case&lt;/span&gt; &lt;span class="k"&gt;in &lt;/span&gt;evals/cases/&lt;span class="k"&gt;*&lt;/span&gt;.json&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; .id &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$case&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; .prompt &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$case&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;fixture&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; .fixture &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$case&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RUNS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nv"&gt;work&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ROOT&lt;/span&gt;&lt;span class="s2"&gt;/results/&lt;/span&gt;&lt;span class="nv"&gt;$LABEL&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;/run&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$fixture&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/. &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$work&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/ &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MEMORY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$work&lt;/span&gt;&lt;span class="s2"&gt;/CLAUDE.md"&lt;/span&gt;
    &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$work&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git init &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git add &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-qm&lt;/span&gt; base&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$work&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
       &lt;span class="nt"&gt;--model&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MODEL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--effort&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EFFORT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
       &lt;span class="nt"&gt;--output-format&lt;/span&gt; json &lt;span class="nt"&gt;--json-schema&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCHEMA&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
       &lt;span class="nt"&gt;--allowedTools&lt;/span&gt; &lt;span class="s2"&gt;"Read,Edit,Bash(npm test *)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
       &lt;span class="nt"&gt;--permission-mode&lt;/span&gt; dontAsk &lt;span class="nt"&gt;--max-turns&lt;/span&gt; 30 &lt;span class="nt"&gt;--max-budget-usd&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
       &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;/result.json"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;/claude_exit"&lt;/span&gt;
    &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$work&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git diff &lt;span class="nt"&gt;--name-only&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;/changed.txt"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
       &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; npm &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;--silent&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;/tests_exit"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;done
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each run starts from a clean copy because "each trial should be 'isolated' by starting from a clean environment" (&lt;a href="https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;). Leftover files from run 2 shouldn't help or hurt run 3. Run the baseline with &lt;code&gt;bash evals/run.sh baseline evals/baseline/CLAUDE.md 3&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The flags above are explained in more depth in &lt;a href="https://en.aobaworks.com/claude-code-headless-mode-flags-auth-and-cost-2026/" rel="noopener noreferrer"&gt;Claude Code Headless Mode: Flags, Auth and Cost (2026)&lt;/a&gt;. In short, &lt;code&gt;dontAsk&lt;/code&gt; denies anything that would prompt, so the run can't hang waiting for approval.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you build a before/after diff workflow for prompt changes?
&lt;/h2&gt;

&lt;p&gt;Run the same cases through the old and new CLAUDE.md, then compare graded structured fields per case. Don't compare raw text, which differs on every run even when behavior hasn't changed.&lt;/p&gt;

&lt;p&gt;The workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Save your edit as &lt;code&gt;evals/candidate/CLAUDE.md&lt;/code&gt;. Leave the real CLAUDE.md alone until the comparison passes.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;bash evals/run.sh candidate evals/candidate/CLAUDE.md 3&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Grade both folders with the same grader (next section).&lt;/li&gt;
&lt;li&gt;Compare per-case pass counts with &lt;code&gt;evals/compare.sh&lt;/code&gt;, and apply the threshold from the flaky-run section.&lt;/li&gt;
&lt;li&gt;For every case that got worse, open the transcripts of a failing candidate run next to a passing baseline run. Anthropic's guide warns: "You won't know if your graders are working well unless you read the transcripts" (&lt;a href="https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents" rel="noopener noreferrer"&gt;source&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Only then copy the candidate into the real &lt;code&gt;CLAUDE.md&lt;/code&gt; and commit it, with the comparison summary in the commit message.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Example &lt;code&gt;evals/compare.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# usage: evals/compare.sh baseline candidate&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nb"&gt;join&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="s2"&gt;"results/&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;/summary.tsv"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="s2"&gt;"results/&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;/summary.tsv"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; |
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nb"&gt;id &lt;/span&gt;base_pass base_n cand_pass cand_n&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;verdict&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"ok"&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt;   &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cand_pass&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$base_pass&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt;base_pass &lt;span class="o"&gt;-&lt;/span&gt; cand_pass&lt;span class="k"&gt;))&lt;/span&gt; &lt;span class="nt"&gt;-ge&lt;/span&gt; 2 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nv"&gt;verdict&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"REGRESSION"&lt;/span&gt;
  &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cand_pass&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$base_pass&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nv"&gt;verdict&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"flaky-rerun"&lt;/span&gt;
  &lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"%-24s base %s/%s  cand %s/%s  %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$base_pass&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$base_n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cand_pass&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cand_n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$verdict&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; | &lt;span class="nb"&gt;tee&lt;/span&gt; &lt;span class="s2"&gt;"results/compare-&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;.txt"&lt;/span&gt;
&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; REGRESSION &lt;span class="s2"&gt;"results/compare-&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;.txt"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last line makes the script exit non-zero when any case regressed. A CI job can then fail the pull request.&lt;/p&gt;

&lt;p&gt;Pitfalls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Changing two things at once. If you edit CLAUDE.md and bump the model in the same run, you can't tell which one caused a drop. Anthropic's postmortem calls this kind of one-change-at-a-time testing "ablations."&lt;/li&gt;
&lt;li&gt;Diffing &lt;code&gt;summary&lt;/code&gt;. Free-text fields should be graded by a rubric, not a string diff.&lt;/li&gt;
&lt;li&gt;Forgetting cost. The JSON output includes &lt;code&gt;total_cost_usd&lt;/code&gt; per run (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;), described there as a client-side estimate. Sum it with &lt;code&gt;jq -s 'map(.total_cost_usd // 0) | add' results/candidate/*/*/result.json&lt;/code&gt; so you know what each comparison costs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What makes a good grader for automated regression checks?
&lt;/h2&gt;

&lt;p&gt;A good grader checks what "done" means for your task, using code first. It's a rule-based check, or a separate Claude call with an explicit rubric. "Looks right" isn't a grader.&lt;/p&gt;

&lt;p&gt;Start with code. Anthropic's eval guide describes code-based graders as "fast, cheap, objective, reproducible, easy to debug," and advises that "it's often better to grade what the agent produced, not the path it took" (&lt;a href="https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents" rel="noopener noreferrer"&gt;source&lt;/a&gt;). For a coding agent, the outcome usually lives in the workspace: did the tests pass, which files changed, does the structured output say &lt;code&gt;done&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;Example &lt;code&gt;evals/grade.sh&lt;/code&gt;, which writes one &lt;code&gt;summary.tsv&lt;/code&gt; line per case (&lt;code&gt;id&lt;/code&gt;, passes, runs):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# usage: evals/grade.sh &amp;lt;label&amp;gt;&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;LABEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; : &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"results/&lt;/span&gt;&lt;span class="nv"&gt;$LABEL&lt;/span&gt;&lt;span class="s2"&gt;/summary.tsv"&lt;/span&gt;
&lt;span class="k"&gt;for case&lt;/span&gt; &lt;span class="k"&gt;in &lt;/span&gt;evals/cases/&lt;span class="k"&gt;*&lt;/span&gt;.json&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; .id &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$case&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;allow&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; .allowed_paths &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$case&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;assert&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; .assert &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$case&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;pass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
  &lt;span class="k"&gt;for &lt;/span&gt;run &lt;span class="k"&gt;in &lt;/span&gt;results/&lt;span class="nv"&gt;$LABEL&lt;/span&gt;/&lt;span class="nv"&gt;$id&lt;/span&gt;/run&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;n+1&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
    jq &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'.structured_output'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$run&lt;/span&gt;&lt;span class="s2"&gt;/result.json"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$run&lt;/span&gt;&lt;span class="s2"&gt;/tests_exit"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"0"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
    &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-vE&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$allow&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$run&lt;/span&gt;&lt;span class="s2"&gt;/changed.txt"&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0      &lt;span class="c"&gt;# touched a forbidden path&lt;/span&gt;
    jq &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$assert&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$run&lt;/span&gt;&lt;span class="s2"&gt;/result.json"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$ok&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 1 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;pass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;pass+1&lt;span class="k"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;done
  &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"%s&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;%s&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;%s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pass&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"results/&lt;/span&gt;&lt;span class="nv"&gt;$LABEL&lt;/span&gt;&lt;span class="s2"&gt;/summary.tsv"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a model-based grader only for parts code can't check, like whether a report is accurate or follows a tone rule. Make it a separate &lt;code&gt;claude -p --bare&lt;/code&gt; call. Bare mode is correct here, because the judge shouldn't load your agent's CLAUDE.md, and in bare mode Claude Code needs &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; set (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;). Rules that keep it honest:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Grade one dimension per judge call. Anthropic recommends grading "each dimension with an isolated LLM-as-judge rather than using one to grade all dimensions."&lt;/li&gt;
&lt;li&gt;Give it a way out. Allow &lt;code&gt;"unknown"&lt;/code&gt; as a verdict, as the same guide suggests, and count unknowns as "needs a human look," not as passes.&lt;/li&gt;
&lt;li&gt;Use a detailed rubric and ask for reasons before the score. Anthropic's &lt;a href="https://platform.claude.com/docs/en/test-and-evaluate/develop-tests" rel="noopener noreferrer"&gt;test-development docs&lt;/a&gt; list detailed rubrics and reasoning-first grading as LLM-grading tips.&lt;/li&gt;
&lt;li&gt;Calibrate. Hand-grade a sample of runs and check the judge agrees with you. Anthropic says judges "should be closely calibrated with human experts."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;An example judge call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.structured_output.summary'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$run&lt;/span&gt;&lt;span class="s2"&gt;/result.json"&lt;/span&gt; | claude &lt;span class="nt"&gt;--bare&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"Rubric: PASS only if the summary lists every changed file by path. FAIL if any changed file is missing. UNKNOWN if you cannot tell. Changed files: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$run&lt;/span&gt;&lt;span class="s2"&gt;/changed.txt"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;. Explain briefly, then give the verdict."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--model&lt;/span&gt; claude-opus-5-5 &lt;span class="nt"&gt;--output-format&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--json-schema&lt;/span&gt; &lt;span class="s1"&gt;'{"type":"object","properties":{"reason":{"type":"string"},"verdict":{"enum":["pass","fail","unknown"]}},"required":["reason","verdict"]}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.structured_output.verdict'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Limitation: an LLM judge is also nondeterministic and costs tokens on every graded run. If you're on a tight budget, keep judges for the two or three cases where prose quality actually matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you set a flaky-run threshold to avoid false alarms?
&lt;/h2&gt;

&lt;p&gt;Run each Claude Code test case several times (3–5) and flag a regression only when the candidate's pass count drops by a margin set in advance. One random failure shouldn't block a good edit.&lt;/p&gt;

&lt;p&gt;The need for this comes straight from Anthropic's guide: "Agent behavior varies between runs, which makes evaluation results harder to interpret." The guide's math shows why one run isn't enough. With a 75% per-trial success rate and three trials, "the probability of passing all three is (0.75)³ ≈ 42%" (&lt;a href="https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents" rel="noopener noreferrer"&gt;source&lt;/a&gt;). A single pass or fail tells you very little about a case like that.&lt;/p&gt;

&lt;p&gt;Two rules make the threshold workable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only cases that pass reliably in the baseline belong in the regression suite. Anthropic says high-pass-rate capability evals can "graduate" to become a regression suite. A case that passes 3/5 on the baseline is a capability problem, so fix it separately.&lt;/li&gt;
&lt;li&gt;Decide the threshold before you look at results, and write it into &lt;code&gt;compare.sh&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example thresholds to start from. These are starting points, not Anthropic numbers, so tune them to your budget:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Runs per case&lt;/th&gt;
&lt;th&gt;Keep case in suite if baseline is&lt;/th&gt;
&lt;th&gt;Candidate result: ok&lt;/th&gt;
&lt;th&gt;Candidate result: rerun (flaky)&lt;/th&gt;
&lt;th&gt;Candidate result: regression&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;2/3 → run 3 more, judge on 6&lt;/td&gt;
&lt;td&gt;1/3 or 0/3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;5/5&lt;/td&gt;
&lt;td&gt;5/5&lt;/td&gt;
&lt;td&gt;4/5 → run 5 more, judge on 10&lt;/td&gt;
&lt;td&gt;3/5 or lower&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;9/10 or 10/10&lt;/td&gt;
&lt;td&gt;within 1 of baseline&lt;/td&gt;
&lt;td&gt;2 below baseline&lt;/td&gt;
&lt;td&gt;3+ below baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When a case lands in the "rerun" band, rerun only that case. After the extra runs, call it a regression if the combined candidate rate is clearly below the baseline rate, and read the failed transcripts either way. Our metrics article, &lt;a href="https://en.aobaworks.com/ai-agent-evaluation-metrics-pass-rate-across-repeat-runs/" rel="noopener noreferrer"&gt;AI Agent Evaluation Metrics: Pass Rate Across Repeat Runs&lt;/a&gt;, explains when to report pass@k and when to report pass^k.&lt;/p&gt;

&lt;p&gt;Pitfalls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Retrying until green. If you rerun a failing case until it passes, you've measured pass@k, which is the "at least one success" number. An unattended agent needs the "every run succeeds" view.&lt;/li&gt;
&lt;li&gt;Ignoring infrastructure failures. A run that died from a rate limit isn't a prompt regression. Record the Claude Code exit code (&lt;code&gt;claude_exit&lt;/code&gt;) and exclude API errors from the pass count. For handling them, see &lt;a href="https://en.aobaworks.com/claude-code-rate-limit-reached-retry-setup-for-cron-jobs/" rel="noopener noreferrer"&gt;Claude Code Rate Limit Reached: Retry Setup for Cron Jobs&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Expecting a temperature knob. The headless docs don't document a sampling-temperature flag for &lt;code&gt;claude -p&lt;/code&gt;, so repeat runs are how you deal with variance, not settings.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do you regression-test after swapping models (e.g. Sonnet to Opus)?
&lt;/h2&gt;

&lt;p&gt;Treat a Claude Code model swap exactly like a prompt change. Rerun the same baseline suite with the new model pinned, then compare. Different models read the same CLAUDE.md differently.&lt;/p&gt;

&lt;p&gt;Steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pin both sides by full ID, not alias. The model config page warns that "aliases point to the recommended version for your provider and update over time," and says to use the full name (for example &lt;code&gt;claude-opus-5-5&lt;/code&gt;) or &lt;code&gt;ANTHROPIC_DEFAULT_OPUS_MODEL&lt;/code&gt; to pin (&lt;a href="https://code.claude.com/docs/en/model-config" rel="noopener noreferrer"&gt;model config&lt;/a&gt;). If your cron job says &lt;code&gt;--model opus&lt;/code&gt;, the model can change under you with no edit on your side.&lt;/li&gt;
&lt;li&gt;Pin effort too. Default effort differs by model. As of October 2026, the docs list &lt;code&gt;medium&lt;/code&gt; as the default for Opus 5.5, Sonnet 5.5 and Haiku 5.5, &lt;code&gt;xhigh&lt;/code&gt; for Opus 4.7, and &lt;code&gt;high&lt;/code&gt; for the others (&lt;a href="https://code.claude.com/docs/en/model-config" rel="noopener noreferrer"&gt;model config&lt;/a&gt;). Anthropic's postmortem traces another of its quality issues to a default effort change from &lt;code&gt;high&lt;/code&gt; to &lt;code&gt;medium&lt;/code&gt;. Pass &lt;code&gt;--effort&lt;/code&gt; explicitly on both runs.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;EVAL_MODEL=&amp;lt;old-id&amp;gt; bash evals/run.sh model-old evals/baseline/CLAUDE.md 5&lt;/code&gt;, then the same with the new ID and label &lt;code&gt;model-new&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Grade both, compare, and read failing transcripts.&lt;/li&gt;
&lt;li&gt;If the new model regresses only on cases tied to one CLAUDE.md rule, rewrite that rule and test again. That rewrite is now a prompt change, so it goes through the same loop.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For example, a subagent that was moved to a cheaper model may stop writing its &lt;code&gt;files_changed&lt;/code&gt; list. Your schema check catches that, which a string diff wouldn't. If you're choosing models per role, &lt;a href="https://en.aobaworks.com/claude-code-subagents-model-selection-role-by-role-table/" rel="noopener noreferrer"&gt;Claude Code Subagents Model Selection: Role-by-Role Table&lt;/a&gt; covers that choice. This suite is how you confirm it.&lt;/p&gt;

&lt;p&gt;Pitfalls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Testing the new model on a different Claude Code version. The CLI updates itself by default. The &lt;code&gt;stable&lt;/code&gt; channel serves "a version that is typically about one week old, skipping releases with major regressions" (&lt;a href="https://code.claude.com/docs/en/setup" rel="noopener noreferrer"&gt;setup docs&lt;/a&gt;), but pinning a version during the comparison is more reliable.&lt;/li&gt;
&lt;li&gt;Retiring the old model too early. Keep the old run's results. They're your evidence if the new model's problems show up a week later.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What should go into a minimal test case suite for an agent?
&lt;/h2&gt;

&lt;p&gt;A minimal suite needs 5–15 cases drawn from your real recurring job. Include everyday tasks, edge inputs, one case per critical CLAUDE.md rule, and every past failure.&lt;/p&gt;

&lt;p&gt;Anthropic's guide says "20-50 simple tasks drawn from real failures is a great start" (&lt;a href="https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents" rel="noopener noreferrer"&gt;source&lt;/a&gt;). That's the target for a mature agent. For one solo-run job, 5–15 cases at 3–5 runs each already means 15–75 runs per side, and that's what keeps a comparison affordable enough to run every time. Grow toward 20–50 as failures give you new cases. Anthropic's test docs lean the same way: "Prioritize volume over quality" (&lt;a href="https://platform.claude.com/docs/en/test-and-evaluate/develop-tests" rel="noopener noreferrer"&gt;develop tests&lt;/a&gt;). More cases with automated grading beat a few hand-graded ones.&lt;/p&gt;

&lt;p&gt;An example suite plan for one nightly job:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Slot&lt;/th&gt;
&lt;th&gt;How many&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Everyday task&lt;/td&gt;
&lt;td&gt;3–5&lt;/td&gt;
&lt;td&gt;Fix one failing unit test; update one dependency pin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One per critical CLAUDE.md rule&lt;/td&gt;
&lt;td&gt;2–4&lt;/td&gt;
&lt;td&gt;A task where the easy fix is in &lt;code&gt;migrations/&lt;/code&gt; (rule says never touch it)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Past real failures&lt;/td&gt;
&lt;td&gt;All you have&lt;/td&gt;
&lt;td&gt;The run that rewrote a lockfile; the run that reported "done" with failing tests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge inputs&lt;/td&gt;
&lt;td&gt;1–3&lt;/td&gt;
&lt;td&gt;Empty issue text; an overly long log; a task that should end in &lt;code&gt;blocked&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stop case&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;A request it should refuse or escalate, graded on &lt;code&gt;status == "blocked"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rule-specific cases do a second job. If CLAUDE.md quietly stops loading, for example because a future release makes &lt;code&gt;--bare&lt;/code&gt; the default for &lt;code&gt;-p&lt;/code&gt;, those cases fail in both runs. That tells you the harness broke, not the prompt.&lt;/p&gt;

&lt;p&gt;Write each case so the result is clear-cut. Anthropic's test is that "a good task is one where two domain experts would independently reach the same pass/fail verdict." If you can't write the &lt;code&gt;assert&lt;/code&gt; line, the case isn't ready yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you automate regression tests to run on every prompt change?
&lt;/h2&gt;

&lt;p&gt;Wrap the run, grade and compare steps in one script, and trigger it from CI. Run it whenever &lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;.claude/&lt;/code&gt;, model settings or the eval files change.&lt;/p&gt;

&lt;p&gt;Add a single entry point, &lt;code&gt;evals/check.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;RUNS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;RUNS&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
bash evals/run.sh baseline evals/baseline/CLAUDE.md &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RUNS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
bash evals/run.sh candidate CLAUDE.md &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RUNS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
bash evals/grade.sh baseline &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; bash evals/grade.sh candidate
bash evals/compare.sh baseline candidate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add a GitHub Actions workflow, &lt;code&gt;.github/workflows/prompt-regression.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prompt regression&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLAUDE.md"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.claude/**"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;evals/**"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;evals&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install pinned Claude Code&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;curl -fsSL https://claude.ai/install.sh | bash -s 2.1.89&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run regression suite&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ANTHROPIC_API_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;DISABLE_AUTOUPDATER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
          &lt;span class="na"&gt;EVAL_MODEL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;claude-opus-5-5&lt;/span&gt;
          &lt;span class="na"&gt;RUNS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash evals/check.sh&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;eval-results&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;results/&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes on this setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Version &lt;code&gt;2.1.89&lt;/code&gt; is the example version from the &lt;a href="https://code.claude.com/docs/en/setup" rel="noopener noreferrer"&gt;setup docs&lt;/a&gt;. Replace it with the version you actually run in production.&lt;/li&gt;
&lt;li&gt;Store the key as a GitHub secret. The &lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;GitHub Actions docs&lt;/a&gt; say "Never commit API keys or OAuth tokens directly to your repository," and accept &lt;code&gt;CLAUDE_CODE_OAUTH_TOKEN&lt;/code&gt; (generated with &lt;code&gt;claude setup-token&lt;/code&gt;) if you'd rather bill a Pro or Max subscription.&lt;/li&gt;
&lt;li&gt;Each run uses GitHub Actions minutes plus API tokens, per the same page. Set &lt;code&gt;timeout-minutes&lt;/code&gt; and keep &lt;code&gt;RUNS&lt;/code&gt; low on pull requests.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;claude-code-action&lt;/code&gt; (&lt;code&gt;anthropics/claude-code-action@v1&lt;/code&gt;) is built for having Claude do work in a repo. For an eval harness, calling the CLI directly as above is simpler.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To catch drift you didn't cause, like a CLI update or a model alias moving, also run the suite on a schedule against the current production CLAUDE.md. Note the docs' warning that GitHub "disables the schedule after 60 days without repository activity" in public repos. If your agent runs on a VPS, the same &lt;code&gt;check.sh&lt;/code&gt; fits into the crontab described in &lt;a href="https://en.aobaworks.com/claude-code-cron-job-setup-run-automations-overnight/" rel="noopener noreferrer"&gt;Claude Code Cron Job Setup: Run Automations Overnight&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are common pitfalls when catching prompt regressions?
&lt;/h2&gt;

&lt;p&gt;The usual Claude Code regression-testing mistakes are diffing raw text, using too few runs, and leaving the grader itself unversioned. Each one makes the comparison measure the wrong thing.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pitfall&lt;/th&gt;
&lt;th&gt;What goes wrong&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Raw text diff&lt;/td&gt;
&lt;td&gt;Every run "differs," so real changes get lost in the noise&lt;/td&gt;
&lt;td&gt;Grade &lt;code&gt;structured_output&lt;/code&gt; fields and workspace outcomes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One run per side&lt;/td&gt;
&lt;td&gt;A 75%-reliable case looks broken or fine by chance&lt;/td&gt;
&lt;td&gt;3–5 runs plus a rerun band (see threshold table)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unversioned grader&lt;/td&gt;
&lt;td&gt;You edit the judge rubric and the "regression" is really a grader change&lt;/td&gt;
&lt;td&gt;Keep grader scripts and judge prompts in git; change them in a separate PR and re-grade the old results&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;--bare&lt;/code&gt; on the agent run&lt;/td&gt;
&lt;td&gt;CLAUDE.md never loads, so both sides test nothing&lt;/td&gt;
&lt;td&gt;Bare mode for the judge only; rule-specific cases as a load check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alias model names&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;opus&lt;/code&gt; moves to a new version between baseline and candidate&lt;/td&gt;
&lt;td&gt;Full model IDs plus explicit &lt;code&gt;--effort&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host config leaking in&lt;/td&gt;
&lt;td&gt;Your local &lt;code&gt;~/.claude&lt;/code&gt; hooks or auto memory shape results on your laptop but not in CI&lt;/td&gt;
&lt;td&gt;Run comparisons on a clean CI runner, or one machine for both sides&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Saturated suite&lt;/td&gt;
&lt;td&gt;Everything passes 5/5 forever and catches nothing&lt;/td&gt;
&lt;td&gt;Add every new production failure as a case; Anthropic notes "eval saturation occurs when an agent passes all of the solvable tasks"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For example, if you tighten the judge rubric and grade only the candidate with it, scores drop even though the agent didn't change. Re-grade both sides with the same grader version every time.&lt;/p&gt;

&lt;p&gt;Once a change ships, regression tests stop and production monitoring takes over. &lt;a href="https://en.aobaworks.com/ai-agent-monitoring-in-claude-code-catch-silent-failures/" rel="noopener noreferrer"&gt;AI Agent Monitoring in Claude Code: Catch Silent Failures&lt;/a&gt; covers that half.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;Do these today:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Copy your current CLAUDE.md to &lt;code&gt;evals/baseline/CLAUDE.md&lt;/code&gt;, write 5 case files from last month's real jobs (include at least one failure), and run &lt;code&gt;evals/run.sh&lt;/code&gt; 3 times per case.&lt;/li&gt;
&lt;li&gt;Change every &lt;code&gt;--model opus&lt;/code&gt;/&lt;code&gt;sonnet&lt;/code&gt; in your cron jobs and scripts to a full model ID, and add an explicit &lt;code&gt;--effort&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Make your next CLAUDE.md edit go through &lt;code&gt;evals/check.sh&lt;/code&gt; before it reaches the file your agent actually reads. The structure in &lt;a href="https://en.aobaworks.com/claude-md-best-practices-a-template-for-unattended-agents/" rel="noopener noreferrer"&gt;CLAUDE.md Best Practices: A Template for Unattended Agents&lt;/a&gt; makes rule-specific cases easier to write.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you'd rather not build the harness yourself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.promptfoo.dev/docs/providers/claude-agent-sdk/" rel="noopener noreferrer"&gt;Promptfoo&lt;/a&gt; is an open-source eval tool with a Claude Agent SDK provider (&lt;code&gt;anthropic:claude-agent-sdk&lt;/code&gt;, alias &lt;code&gt;anthropic:claude-code&lt;/code&gt;). It supports options like &lt;code&gt;working_dir&lt;/code&gt;, &lt;code&gt;max_turns&lt;/code&gt; and &lt;code&gt;copy_working_dir&lt;/code&gt; for isolated runs. It suits developers who want config files and a results UI instead of shell scripts. It's not a good fit if you can't run Node/npm in your environment. One limitation: you still have to write the cases and assertions, which is where most of the effort goes.&lt;/li&gt;
&lt;li&gt;For billing, CI runs that use an API key are charged per token (&lt;a href="https://claude.com/platform/api" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;). A subscription token instead bills your plan: Claude Pro is listed at $20 if billed monthly, and Max plans start at $100 per month (as of October 2026; check the &lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;official pricing page&lt;/a&gt;). A subscription suits a solo builder running small suites. It doesn't suit large nightly suites shared across a team, where an API key with a budget cap is easier to control.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Related guides on this site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/ai-agent-evaluation-framework-claude-code-eval-loop-steps/" rel="noopener noreferrer"&gt;AI Agent Evaluation Framework: Claude Code Eval Loop Steps&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/ai-agent-evaluation-metrics-pass-rate-across-repeat-runs/" rel="noopener noreferrer"&gt;AI Agent Evaluation Metrics: Pass Rate Across Repeat Runs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/claude-code-headless-mode-flags-auth-and-cost-2026/" rel="noopener noreferrer"&gt;Claude Code Headless Mode: Flags, Auth and Cost (2026)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  How often should you re-run regression tests on a Claude Code agent?
&lt;/h3&gt;

&lt;p&gt;Run them on every change to CLAUDE.md, &lt;code&gt;.claude/&lt;/code&gt; settings, the model ID or the effort level, plus on a schedule (weekly is a reasonable example) to catch drift from CLI or model updates. The scheduled run uses the production CLAUDE.md as both baseline and candidate inputs over time, so compare it against the last saved results. If nothing changed and nothing failed, the run only cost tokens.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can you use Claude itself as the grader for regression testing?
&lt;/h3&gt;

&lt;p&gt;Yes, for parts code can't check, such as whether a summary is accurate. Run it as a separate &lt;code&gt;claude --bare -p&lt;/code&gt; call with a written rubric, one dimension per call, and an &lt;code&gt;unknown&lt;/code&gt; option, as Anthropic's &lt;a href="https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents" rel="noopener noreferrer"&gt;eval guide&lt;/a&gt; recommends. Calibrate it against your own hand grading, and keep the rubric in git so grader changes don't look like agent regressions.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's a good sample size to avoid flaky test results?
&lt;/h3&gt;

&lt;p&gt;Three runs per case is the cheapest useful setting, and five is a solid default for agents that run unattended every day. Add a rerun band so borderline results (2/3 or 4/5) get more runs before you decide. Anthropic's pass^k example shows that a 75%-reliable task passes three straight runs only about 42% of the time, so one or two runs per side can't separate a flaky case from a broken one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does editing CLAUDE.md require a full regression suite every time?
&lt;/h3&gt;

&lt;p&gt;For an agent that runs unattended, yes: run the full suite, because a line about one task can change behavior on another, as Anthropic's own &lt;a href="https://www.anthropic.com/engineering/april-23-postmortem" rel="noopener noreferrer"&gt;postmortem&lt;/a&gt; shows. Keep the suite small (5–15 cases) so this stays cheap. Pure typo fixes are the only reasonable exception, and even then the scheduled run will catch anything you missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents" rel="noopener noreferrer"&gt;Anthropic Engineering: Demystifying evals for AI agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.anthropic.com/engineering/april-23-postmortem" rel="noopener noreferrer"&gt;Anthropic Engineering: An update on Claude Code quality reports (April 2026 postmortem)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Claude Code docs: Run Claude Code programmatically (headless)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/memory" rel="noopener noreferrer"&gt;Claude Code docs: How Claude remembers your project (memory)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/model-config" rel="noopener noreferrer"&gt;Claude Code docs: Model configuration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/setup" rel="noopener noreferrer"&gt;Claude Code docs: Advanced setup (install, versions, updates)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/github-actions" rel="noopener noreferrer"&gt;Claude Code docs: GitHub Actions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://platform.claude.com/docs/en/test-and-evaluate/develop-tests" rel="noopener noreferrer"&gt;Claude Platform docs: Develop tests&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.promptfoo.dev/docs/providers/claude-agent-sdk/" rel="noopener noreferrer"&gt;Promptfoo: Claude Agent SDK provider&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;Claude pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://claude.com/platform/api" rel="noopener noreferrer"&gt;Claude Platform API pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Top search results reviewed for coverage gaps: &lt;a href="https://testgrid.io/blog/test-applications-built-with-claude-code/" rel="noopener noreferrer"&gt;TestGrid&lt;/a&gt;, &lt;a href="https://www.shiplight.ai/blog/claude-code-testing" rel="noopener noreferrer"&gt;Shiplight&lt;/a&gt;, &lt;a href="https://diffy.website/blog/visual-regression-testing-with-claude-code/" rel="noopener noreferrer"&gt;Diffy&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was created with the help of AI. Sources are linked inline; check the official documentation for the latest details.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agentevals</category>
      <category>claudecode</category>
      <category>regressiontesting</category>
      <category>claudemd</category>
    </item>
    <item>
      <title>Claude Code MCP Servers: Setup, Config &amp; Cron-Safe Template</title>
      <dc:creator>Aoba Works</dc:creator>
      <pubDate>Thu, 29 Oct 2026 16:20:46 +0000</pubDate>
      <link>https://dev.to/aobaworks/claude-code-mcp-servers-setup-config-cron-safe-template-4hal</link>
      <guid>https://dev.to/aobaworks/claude-code-mcp-servers-setup-config-cron-safe-template-4hal</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://en.aobaworks.com/claude-code-mcp-servers-setup-config-cron-safe-template/?utm_source=devto&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unattended&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Claude Code MCP servers are external programs or hosted endpoints that give Claude Code extra tools, such as a browser, GitHub, Sentry or a database. You add one with &lt;code&gt;claude mcp add&lt;/code&gt;, check it with &lt;code&gt;claude mcp list&lt;/code&gt; and remove it with &lt;code&gt;claude mcp remove &amp;lt;name&amp;gt;&lt;/code&gt;. In the words of the &lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;official MCP docs&lt;/a&gt;, "MCP servers give Claude Code access to your tools, databases, and APIs." Most guides stop at the interactive setup. This one also covers what changes when nobody is at the keyboard: which servers a cron run loads, how to scope their permissions, which timeouts apply, and what happens when a server goes down partway through a job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which option is right for you?
&lt;/h2&gt;

&lt;p&gt;Choose the scope and transport that fit how you work, then open the matching page. Each row gives a one-line reason.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your situation&lt;/th&gt;
&lt;th&gt;What to pick first&lt;/th&gt;
&lt;th&gt;First page to open&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Beginner, never added a server&lt;/td&gt;
&lt;td&gt;One hosted HTTP server at the default &lt;code&gt;local&lt;/code&gt; scope. It needs no install or sign-in&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;Connect to MCP servers (quickstart)&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Daily interactive work across many repos&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--scope user&lt;/code&gt; for the 1–3 servers you use everywhere, because they load in every project&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;MCP reference: scopes&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small team sharing one repo&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--scope project&lt;/code&gt; (&lt;code&gt;.mcp.json&lt;/code&gt; committed), so teammates get the same config&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;Quickstart: Edit .mcp.json directly&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cron / headless jobs&lt;/td&gt;
&lt;td&gt;A separate JSON file passed with &lt;code&gt;--mcp-config&lt;/code&gt; plus &lt;code&gt;--strict-mcp-config&lt;/code&gt;, so the job loads only what you list&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Run Claude Code programmatically&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tight token budget&lt;/td&gt;
&lt;td&gt;Fewer servers, because every connected server's tool names load into each session&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;Quickstart note on context&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a cron job, keep the automation's server list out of your personal config. The rest of this guide explains why.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a Claude Code MCP server?
&lt;/h2&gt;

&lt;p&gt;A Claude Code MCP server is a process or hosted URL that exposes extra tools to Claude Code over the Model Context Protocol. Claude Code calls those tools like its built-in ones. The &lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt; describes it as letting Claude Code use tools "beyond its built-in set, such as searching an issue tracker, querying a database, or controlling a web browser."&lt;/p&gt;

&lt;p&gt;Servers come in two shapes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Local (stdio):&lt;/strong&gt; Claude Code starts the program as a subprocess on your machine. The Playwright server is one example. Use this shape for anything that needs local resources such as a browser, the filesystem or a database socket (&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote (HTTP):&lt;/strong&gt; the vendor hosts the server and you register its URL. Anthropic's &lt;a href="https://claude.com/blog/claude-code-remote-mcp" rel="noopener noreferrer"&gt;remote MCP announcement&lt;/a&gt; pitches this as the lower-maintenance option because you have no local server to manage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each tool appears to Claude under a prefixed name, &lt;code&gt;mcp__&amp;lt;server&amp;gt;__&amp;lt;tool&amp;gt;&lt;/code&gt;. That name matters later, because permission rules match against it (&lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;permissions docs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;So, can Claude Code use MCP servers? Yes, in every surface. The CLI, the desktop app, VS Code and cloud sessions can all connect. Connectors you add at claude.ai also load in the CLI when you sign in with that account (&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart: Connect from other surfaces&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you install Claude Code MCP servers?
&lt;/h2&gt;

&lt;p&gt;For most servers, installing is the same step as registering. You run &lt;code&gt;claude mcp add&lt;/code&gt;, and either Claude Code fetches the package through &lt;code&gt;npx&lt;/code&gt; the first time it starts the server, or you point it at a hosted URL.&lt;/p&gt;

&lt;p&gt;Prerequisites, per the &lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install and authenticate Claude Code.&lt;/li&gt;
&lt;li&gt;Open a terminal in a project directory. An empty directory works.&lt;/li&gt;
&lt;li&gt;For &lt;code&gt;npx&lt;/code&gt;-based local servers, install Node.js. The Playwright server, for example, requires Node.js 18 or later.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then run the add command in your shell, not inside a &lt;code&gt;claude&lt;/code&gt; session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Remote example from the docs: no auth needed&lt;/span&gt;
claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http claude-code-docs https://code.claude.com/docs/mcp

&lt;span class="c"&gt;# Local example from the docs: Playwright browser server&lt;/span&gt;
claude mcp add playwright &lt;span class="nt"&gt;--&lt;/span&gt; npx &lt;span class="nt"&gt;-y&lt;/span&gt; @playwright/mcp@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Common install mistakes and fixes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Added" but &lt;code&gt;✘ Failed to connect&lt;/code&gt;.&lt;/strong&gt; The Added message only means the entry was saved. On the first check, &lt;code&gt;npx&lt;/code&gt; may still be downloading the package, so wait and run &lt;code&gt;claude mcp list&lt;/code&gt; again (&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Startup times out.&lt;/strong&gt; The default startup timeout is 30 seconds. Raise it with &lt;code&gt;MCP_TIMEOUT=60000 claude&lt;/code&gt;. The value is in milliseconds (&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart troubleshooting&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Need to diagnose a local server.&lt;/strong&gt; Run the server command yourself, for example &lt;code&gt;npx -y @playwright/mcp@latest&lt;/code&gt;. If it starts and waits for input, the server works and the problem is in your Claude Code entry.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you already set up servers in the Claude Desktop chat app, &lt;code&gt;claude mcp add-from-claude-desktop&lt;/code&gt; copies them into the CLI on macOS or WSL (&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you configure Claude Code MCP servers (config file)?
&lt;/h2&gt;

&lt;p&gt;Claude Code stores MCP server configuration in &lt;code&gt;~/.claude.json&lt;/code&gt; or a project's &lt;code&gt;.mcp.json&lt;/code&gt;, depending on scope. &lt;code&gt;~/.claude.json&lt;/code&gt; holds the local and user scopes, and &lt;code&gt;.mcp.json&lt;/code&gt; in the project root holds the project scope. The file depends on the &lt;code&gt;--scope&lt;/code&gt; you pick (&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt;).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Who gets it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;local&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;~/.claude.json&lt;/code&gt;, under this project's entry&lt;/td&gt;
&lt;td&gt;Only you, only this project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;project&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.mcp.json&lt;/code&gt; in project root&lt;/td&gt;
&lt;td&gt;Everyone who clones the repo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;~/.claude.json&lt;/code&gt;, top-level &lt;code&gt;mcpServers&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Only you, all projects&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On Windows, &lt;code&gt;~/.claude.json&lt;/code&gt; resolves to &lt;code&gt;%USERPROFILE%\.claude.json&lt;/code&gt;. If &lt;code&gt;CLAUDE_CONFIG_DIR&lt;/code&gt; is set, Claude Code reads the file from that directory instead (&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt;). When one name exists in several scopes, precedence runs local, then project, then user, then plugins, then claude.ai connectors (&lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;MCP reference&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Here is a hand-written &lt;code&gt;.mcp.json&lt;/code&gt; example. The first two entries come from the docs. The &lt;code&gt;${...}&lt;/code&gt; variables and the &lt;code&gt;timeout&lt;/code&gt; field are documented options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"claude-code-docs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://code.claude.com/docs/mcp"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"playwright"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stdio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@playwright/mcp@latest"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"internal-api"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${API_BASE_URL:-https://api.example.com}/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer ${API_KEY}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;600000&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The details that matter, all from the &lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;MCP reference&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;${VAR}&lt;/code&gt; and &lt;code&gt;${VAR:-default}&lt;/code&gt; expand in &lt;code&gt;command&lt;/code&gt;, &lt;code&gt;args&lt;/code&gt; and &lt;code&gt;env&lt;/code&gt; for stdio servers, and in &lt;code&gt;url&lt;/code&gt; and &lt;code&gt;headers&lt;/code&gt; for HTTP servers. Keep tokens in the environment, not in a committed file.&lt;/li&gt;
&lt;li&gt;Some credentials always read as empty in remote URLs and headers, on purpose: &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;, &lt;code&gt;ANTHROPIC_AUTH_TOKEN&lt;/code&gt;, &lt;code&gt;NPM_TOKEN&lt;/code&gt; and a few others. If your header comes out blank, this is likely why.&lt;/li&gt;
&lt;li&gt;An entry with a &lt;code&gt;url&lt;/code&gt; but no &lt;code&gt;type&lt;/code&gt; is an error. Add &lt;code&gt;"type": "http"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A per-server &lt;code&gt;timeout&lt;/code&gt; in milliseconds overrides &lt;code&gt;MCP_TOOL_TIMEOUT&lt;/code&gt; for that server only.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pitfall: editing the wrong file.&lt;/strong&gt; Claude Code does not read &lt;code&gt;~/.claude/.mcp.json&lt;/code&gt;, &lt;code&gt;~/.claude/mcp.json&lt;/code&gt;, &lt;code&gt;~/.claude/config/mcp.json&lt;/code&gt; or &lt;code&gt;%APPDATA%\Claude\mcp.json&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart troubleshooting&lt;/a&gt;). Edits to &lt;code&gt;.mcp.json&lt;/code&gt; take effect only when a new session starts (&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart troubleshooting&lt;/a&gt;). A malformed entry is skipped while the others still load, so run &lt;code&gt;claude mcp list&lt;/code&gt; and look for the parse warning (&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart troubleshooting&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you add and remove a Claude Code MCP server?
&lt;/h2&gt;

&lt;p&gt;You add a Claude Code MCP server with &lt;code&gt;claude mcp add&lt;/code&gt; (or &lt;code&gt;add-json&lt;/code&gt;) and remove it with &lt;code&gt;claude mcp remove &amp;lt;name&amp;gt;&lt;/code&gt;. Both commands print the file they modified (&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add, by server type&lt;/strong&gt; (&lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;MCP reference&lt;/a&gt;):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Remote HTTP: &lt;code&gt;claude mcp add --transport http &amp;lt;name&amp;gt; &amp;lt;url&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Remote with a static token: append &lt;code&gt;--header "Authorization: Bearer &amp;lt;token&amp;gt;"&lt;/code&gt;. The docs' GitHub example is &lt;code&gt;claude mcp add --transport http github https://api.githubcopilot.com/mcp/ --header "Authorization: Bearer YOUR_GITHUB_PAT"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Local stdio: &lt;code&gt;claude mcp add [options] &amp;lt;name&amp;gt; -- &amp;lt;command&amp;gt; [args...]&lt;/code&gt;, for example &lt;code&gt;--env AIRTABLE_API_KEY=YOUR_KEY&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Raw JSON: &lt;code&gt;claude mcp add-json &amp;lt;name&amp;gt; '&amp;lt;json&amp;gt;'&lt;/code&gt;, which is also the only way to add a WebSocket (&lt;code&gt;ws&lt;/code&gt;) server.&lt;/li&gt;
&lt;li&gt;OAuth servers such as Sentry (&lt;code&gt;https://mcp.sentry.dev/mcp&lt;/code&gt;): add the URL, start &lt;code&gt;claude&lt;/code&gt;, run &lt;code&gt;/mcp&lt;/code&gt;, select the server and choose &lt;strong&gt;Authenticate&lt;/strong&gt;. You can also run &lt;code&gt;claude mcp login &amp;lt;name&amp;gt;&lt;/code&gt; from the shell.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;code&gt;--&lt;/code&gt; separator is the most common mistake. Your own options (&lt;code&gt;--transport&lt;/code&gt;, &lt;code&gt;--env&lt;/code&gt;, &lt;code&gt;--scope&lt;/code&gt;) go before it, and the docs say "Everything after &lt;code&gt;--&lt;/code&gt; is passed to the server untouched" (&lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;MCP reference&lt;/a&gt;). If &lt;code&gt;claude mcp get &amp;lt;name&amp;gt;&lt;/code&gt; shows a different command from the one you typed, remove the server and add it again with &lt;code&gt;--&lt;/code&gt; in place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Remove or pause:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp remove claude-code-docs                 &lt;span class="c"&gt;# default scope&lt;/span&gt;
claude mcp remove claude-code-docs &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="nb"&gt;local&lt;/span&gt;   &lt;span class="c"&gt;# when it exists in several scopes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the name exists in more than one scope, &lt;code&gt;remove&lt;/code&gt; reports &lt;code&gt;exists in multiple scopes&lt;/code&gt;, and you choose one with &lt;code&gt;--scope&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt;). To pause a server without deleting it, toggle it off in the &lt;code&gt;/mcp&lt;/code&gt; panel. Claude Code records that choice per project in a &lt;code&gt;disabledMcpServers&lt;/code&gt; list in &lt;code&gt;~/.claude.json&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;MCP reference&lt;/a&gt;). You can't change a server's scope in place. Remove it and add it again at the new scope (&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you connect Claude Code to local vs remote MCP servers?
&lt;/h2&gt;

&lt;p&gt;Claude Code connects to local servers over stdio and to remote servers over HTTP. SSE still works but is deprecated, and WebSocket can only be configured through JSON (&lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;MCP reference&lt;/a&gt;). For unattended runs, the biggest difference between them is how each transport fails and recovers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Local (stdio)&lt;/th&gt;
&lt;th&gt;Remote (HTTP)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Runs where&lt;/td&gt;
&lt;td&gt;Subprocess on your machine&lt;/td&gt;
&lt;td&gt;Vendor's infrastructure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth&lt;/td&gt;
&lt;td&gt;Env vars (&lt;code&gt;--env&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Header token, OAuth, or &lt;code&gt;headersHelper&lt;/code&gt; script&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default idle timeout per tool call&lt;/td&gt;
&lt;td&gt;1,800,000 ms (30 min)&lt;/td&gt;
&lt;td&gt;300,000 ms (5 min)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;If it drops mid-session&lt;/td&gt;
&lt;td&gt;In &lt;code&gt;claude -p&lt;/code&gt;, retried on the next tool call&lt;/td&gt;
&lt;td&gt;Up to 5 retries with exponential backoff starting at 1 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;If first connect fails&lt;/td&gt;
&lt;td&gt;Marked failed; use &lt;code&gt;/mcp&lt;/code&gt; &lt;strong&gt;Reconnect&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Transient errors retried up to 3 times&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main risk unattended&lt;/td&gt;
&lt;td&gt;Missing runtime (Node), slow first &lt;code&gt;npx&lt;/code&gt; download&lt;/td&gt;
&lt;td&gt;Network, expired token, vendor outage&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The idle timeout figures come from the &lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;environment variables reference&lt;/a&gt; (&lt;code&gt;CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT&lt;/code&gt;, v2.1.187 or later). The retry behavior comes from the &lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;MCP reference&lt;/a&gt;. Authentication errors are different: Claude Code stops retrying and waits for you to sign in again. A cron job can't do that, which is why OAuth servers fit unattended runs poorly unless their credentials stay valid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quick connectivity check.&lt;/strong&gt; For an HTTP server, run &lt;code&gt;curl -I &amp;lt;url&amp;gt;&lt;/code&gt;. A &lt;code&gt;404&lt;/code&gt; or &lt;code&gt;405&lt;/code&gt; still means the server is up, because many MCP endpoints answer only POST. A &lt;code&gt;401&lt;/code&gt; or &lt;code&gt;403&lt;/code&gt; means you need to authenticate. No response at all points to the URL or the network (&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart troubleshooting&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you use a Claude Code MCP server in a session?
&lt;/h2&gt;

&lt;p&gt;You use a Claude Code MCP server by asking for the task in plain language. Claude picks the relevant tool on its own, and you approve the first call when prompted (&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt;).&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run &lt;code&gt;claude mcp list&lt;/code&gt; and confirm the server shows &lt;code&gt;✔ Connected&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Start &lt;code&gt;claude&lt;/code&gt; in the same project. Servers at local scope are tied to the directory where you added them.&lt;/li&gt;
&lt;li&gt;Ask for something that needs the server, for example: &lt;code&gt;Use playwright to open https://example.com and tell me the page title&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Approve the permission prompt. Each tool call is labeled with the server name, such as &lt;code&gt;browser_navigate&lt;/code&gt; from &lt;code&gt;playwright&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;/mcp&lt;/code&gt; at any time to see status and tool lists, reconnect a server or authenticate.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Naming the server in your prompt isn't required. The docs do it in the walkthrough so the test definitely goes through the new server rather than a built-in tool such as web fetch.&lt;/p&gt;

&lt;p&gt;Watch the output size. Claude Code shows a warning when an MCP tool returns more than 10,000 tokens and caps output at 25,000 tokens by default. &lt;code&gt;MAX_MCP_OUTPUT_TOKENS&lt;/code&gt; changes the cap (&lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;MCP reference&lt;/a&gt;). Large database or log queries hit that cap quickly. For ways to trim them, see &lt;a href="https://en.aobaworks.com/claude-code-how-to-see-token-usage-and-reduce-it/" rel="noopener noreferrer"&gt;how to see token usage and reduce it&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Status meanings you'll see&lt;/strong&gt; (&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;! Needs authentication&lt;/code&gt;: the server is reachable but needs a browser sign-in or a token.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;! Connected · tools fetch failed&lt;/code&gt;: the server connected but couldn't list its tools. Run &lt;code&gt;claude mcp get &amp;lt;name&amp;gt;&lt;/code&gt; for details.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;⏸ Pending approval&lt;/code&gt;: a project-scoped server you haven't approved yet.&lt;/li&gt;
&lt;li&gt;The server connects but shows no tools: usually a missing required env var, such as an API key.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Claude Code MCP servers list: which ones are worth running?
&lt;/h2&gt;

&lt;p&gt;For recurring solo-builder work, start with two or three servers that each cover a job you would otherwise script by hand. Each server you connect adds context cost and permission surface (&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt;).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Server&lt;/th&gt;
&lt;th&gt;Transport&lt;/th&gt;
&lt;th&gt;Good for&lt;/th&gt;
&lt;th&gt;Not for / limitation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code docs (&lt;code&gt;https://code.claude.com/docs/mcp&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;HTTP, no auth&lt;/td&gt;
&lt;td&gt;Your first test; looking up flags&lt;/td&gt;
&lt;td&gt;Only searches docs, no real work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub (&lt;code&gt;https://api.githubcopilot.com/mcp/&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;HTTP + PAT&lt;/td&gt;
&lt;td&gt;Triage issues and PRs on a schedule&lt;/td&gt;
&lt;td&gt;The token's scope is the blast radius; use a fine-grained PAT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Playwright (&lt;code&gt;@playwright/mcp&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;stdio, Node 18+&lt;/td&gt;
&lt;td&gt;Smoke-checking a deployed page&lt;/td&gt;
&lt;td&gt;Needs a browser on the host, heavy on a small VPS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sentry (&lt;code&gt;https://mcp.sentry.dev/mcp&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;HTTP + OAuth&lt;/td&gt;
&lt;td&gt;Morning error summaries&lt;/td&gt;
&lt;td&gt;OAuth expiry can block cron runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filesystem / Git / Fetch / Time (reference servers)&lt;/td&gt;
&lt;td&gt;stdio&lt;/td&gt;
&lt;td&gt;Learning, narrow local tasks&lt;/td&gt;
&lt;td&gt;Reference code only (see below)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sources: the &lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt; for docs, Playwright and Sentry, the &lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;MCP reference&lt;/a&gt; for GitHub, and the &lt;a href="https://github.com/modelcontextprotocol/servers" rel="noopener noreferrer"&gt;modelcontextprotocol/servers repo&lt;/a&gt; for the reference set.&lt;/p&gt;

&lt;p&gt;Two things many "best MCP servers" lists get wrong as of October 2026. First, the official repo now maintains seven reference servers: Everything, Fetch, Filesystem, Git, Memory, Sequential Thinking and Time. Second, it archived thirteen others, including GitHub, PostgreSQL, Slack, Puppeteer and SQLite, in &lt;a href="https://github.com/modelcontextprotocol/servers-archived" rel="noopener noreferrer"&gt;servers-archived&lt;/a&gt; (&lt;a href="https://github.com/modelcontextprotocol/servers" rel="noopener noreferrer"&gt;servers repo&lt;/a&gt;). If a tutorial tells you to install the old GitHub or Postgres package from that repo, look for the vendor's own server instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where do you find and vet Claude Code MCP servers on GitHub?
&lt;/h2&gt;

&lt;p&gt;Find Claude Code MCP servers in the official &lt;a href="https://registry.modelcontextprotocol.io/" rel="noopener noreferrer"&gt;MCP Registry&lt;/a&gt;, Anthropic's reviewed &lt;a href="https://claude.ai/directory" rel="noopener noreferrer"&gt;Anthropic Directory&lt;/a&gt;, or vendor GitHub repos. Vet each one before an automation depends on it.&lt;/p&gt;

&lt;p&gt;The official repo says plainly that its servers are educational examples, "not as production-ready solutions" (&lt;a href="https://github.com/modelcontextprotocol/servers" rel="noopener noreferrer"&gt;modelcontextprotocol/servers&lt;/a&gt;). Anthropic's MCP page adds: "Verify you trust each server before connecting it." It also warns that servers which fetch external content carry prompt injection risk (&lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;MCP reference&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;A short vetting checklist before a server goes into a scheduled job:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Who publishes it?&lt;/strong&gt; Prefer the vendor's own server (GitHub, Sentry, Microsoft's Playwright) over a third-party wrapper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What does the token allow?&lt;/strong&gt; A server can only do what its credential permits. Create a dedicated token with read-only or single-repo scope where the vendor offers it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is it pinned?&lt;/strong&gt; &lt;code&gt;@latest&lt;/code&gt; in &lt;code&gt;npx&lt;/code&gt; args can change under a cron job overnight. Pin a version in automation configs, for example &lt;code&gt;@playwright/mcp@&amp;lt;version&amp;gt;&lt;/code&gt;, and update it on purpose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does it read untrusted content?&lt;/strong&gt; Web fetchers, issue readers and inbox readers can carry injected instructions. Pair them with narrow allow rules (next section).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is it still maintained?&lt;/strong&gt; A recent commit history and open-issue activity matter more than star count.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Also be careful with similar names: projects like &lt;code&gt;steipete/claude-code-mcp&lt;/code&gt; run Claude Code itself as an MCP server for other agents (&lt;a href="https://github.com/steipete/claude-code-mcp" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;). That's the opposite direction from this guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you run Claude Code MCP servers headlessly and safely?
&lt;/h2&gt;

&lt;p&gt;Give each headless job its own server file, allowlist named MCP tools, set short timeouts, and check server status in the output. A &lt;code&gt;claude -p&lt;/code&gt; run has nobody to answer prompts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why your interactive setup is risky in cron
&lt;/h3&gt;

&lt;p&gt;Without &lt;code&gt;--bare&lt;/code&gt;, &lt;code&gt;claude -p&lt;/code&gt; loads the MCP servers in the working directory's &lt;code&gt;.mcp.json&lt;/code&gt;, and the &lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt; note: "A &lt;code&gt;-p&lt;/code&gt; session shows no workspace trust dialog and no per-server approval prompt." The approval that protects you interactively is skipped. Two flags close the gap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--bare&lt;/code&gt; skips auto-discovery of MCP servers, hooks and CLAUDE.md, and connects only servers you pass with &lt;code&gt;--mcp-config&lt;/code&gt;. The limitation: bare mode never reads OAuth or keychain credentials, so it needs &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; and won't use your Pro or Max login (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--strict-mcp-config&lt;/code&gt; with &lt;code&gt;--mcp-config&lt;/code&gt; loads only the servers in that file and ignores other MCP configuration (&lt;a href="https://code.claude.com/docs/en/cli-reference" rel="noopener noreferrer"&gt;CLI reference&lt;/a&gt;). Use this one if you run on a subscription. One open report says it does not override a project's &lt;code&gt;disabledMcpServers&lt;/code&gt; list (&lt;a href="https://github.com/anthropics/claude-code/issues/14490" rel="noopener noreferrer"&gt;issue #14490&lt;/a&gt;), so don't disable your automation's servers in &lt;code&gt;/mcp&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Permission scoping
&lt;/h3&gt;

&lt;p&gt;From v2.1.302, a &lt;code&gt;-p&lt;/code&gt; run starts in Manual mode when nothing else sets a mode. Any call that needs approval is denied unless an allow rule, a &lt;code&gt;PermissionRequest&lt;/code&gt; hook or a permission prompt tool approves it (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;). Use allow rules to list exactly which MCP tools the job may call:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mcp__github&lt;/code&gt; allows every tool from the &lt;code&gt;github&lt;/code&gt; server.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mcp__github__get_issue&lt;/code&gt; allows a single tool.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule format is described in the &lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;permissions docs&lt;/a&gt;. Prefer naming single tools for jobs that write anything. Also add &lt;code&gt;--permission-prompts none&lt;/code&gt; (v2.1.259 or later). With it, Claude is told nobody can approve, so it stops retrying denied calls, and any MCP elicitation request that no hook answers is cancelled (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;). For a complete rule set, see the &lt;a href="https://en.aobaworks.com/claude-code-permissions-best-practices-headless-template/" rel="noopener noreferrer"&gt;headless permissions template&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Timeouts that fail fast
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;What it bounds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MCP_TIMEOUT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;30 s&lt;/td&gt;
&lt;td&gt;Server startup; with &lt;code&gt;--mcp-config&lt;/code&gt; in &lt;code&gt;-p&lt;/code&gt;, the first turn waits up to this&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CLAUDE_CODE_MCP_STARTUP_WAIT_MS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(uses the startup wait)&lt;/td&gt;
&lt;td&gt;How long the first non-interactive turn waits for servers that are still connecting (v2.1.274+)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5 min network / 30 min stdio&lt;/td&gt;
&lt;td&gt;A tool call with no response or progress&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-server &lt;code&gt;timeout&lt;/code&gt; in JSON&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;Overall tool time for that server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;120,000 ms&lt;/td&gt;
&lt;td&gt;When a long MCP call moves to the background&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sources: &lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;, &lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;env vars&lt;/a&gt;, &lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;MCP reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The last row matters most for cron. After 2 minutes, a long MCP call moves to the background. The headless docs say that when a &lt;code&gt;-p&lt;/code&gt; run ends, for a call that automatic backgrounding moved, "the call's result is lost". Setting &lt;code&gt;CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS=0&lt;/code&gt; turns automatic backgrounding off (&lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;env vars&lt;/a&gt;). Also set &lt;code&gt;CLAUDE_CODE_MCP_ALLOWLIST_ENV=1&lt;/code&gt;. Stdio servers then get only a safe baseline environment plus their configured &lt;code&gt;env&lt;/code&gt;, instead of inheriting every secret in your shell (&lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;env vars&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens when a server is unreachable
&lt;/h3&gt;

&lt;p&gt;The run does not crash on its own. An entry that fails validation is skipped and the run "continues and exits cleanly" (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;). A server that never connects means its tools are simply missing, so Claude may finish the task without them and still exit 0. Your script has to check for that. The &lt;code&gt;system/init&lt;/code&gt; event in &lt;code&gt;stream-json&lt;/code&gt; output lists &lt;code&gt;mcp_servers&lt;/code&gt; (each with &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;status&lt;/code&gt;) and &lt;code&gt;mcp_server_errors&lt;/code&gt;. A redirected stderr shows no warning, so read these fields instead. Exit code 143 means the process got SIGTERM (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The following example wrapper adapts documented flags. Check the status strings your version emits before relying on the filter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nb"&gt;cd&lt;/span&gt; /srv/myproject
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;MCP_TIMEOUT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;15000
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;120000
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_CODE_MCP_ALLOWLIST_ENV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1

&lt;span class="nb"&gt;timeout &lt;/span&gt;20m claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Summarize issues labeled bug opened in the last 24h"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mcp-config&lt;/span&gt; ./automation.mcp.json &lt;span class="nt"&gt;--strict-mcp-config&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allowedTools&lt;/span&gt; &lt;span class="s2"&gt;"mcp__github__list_issues,mcp__github__get_issue"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--permission-prompts&lt;/span&gt; none &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output-format&lt;/span&gt; stream-json &lt;span class="nt"&gt;--verbose&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; run.jsonl

&lt;span class="c"&gt;# Fail the job if any server is not connected (example filter)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;jq &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'select(.type=="system" and .subtype=="init")
  | (.mcp_server_errors // []) + [.mcp_servers[] | select(.status!="connected")]
  | length &amp;gt; 0'&lt;/span&gt; run.jsonl &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"MCP server missing; result not trusted"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;2
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scheduling, retries and alerting belong in your cron layer. See the &lt;a href="https://en.aobaworks.com/claude-code-cron-job-setup-run-automations-overnight/" rel="noopener noreferrer"&gt;cron job setup guide&lt;/a&gt; and &lt;a href="https://en.aobaworks.com/ai-agent-monitoring-in-claude-code-catch-silent-failures/" rel="noopener noreferrer"&gt;catching silent failures&lt;/a&gt;. For every &lt;code&gt;-p&lt;/code&gt; flag and the auth trade-offs, see the &lt;a href="https://en.aobaworks.com/claude-code-headless-mode-flags-auth-and-cost-2026/" rel="noopener noreferrer"&gt;headless mode guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do this today:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run &lt;code&gt;claude mcp add --transport http claude-code-docs https://code.claude.com/docs/mcp&lt;/code&gt;, then &lt;code&gt;claude mcp list&lt;/code&gt;. That takes about two minutes and confirms your setup works.&lt;/li&gt;
&lt;li&gt;Create &lt;code&gt;automation.mcp.json&lt;/code&gt; containing only the servers your cron job needs, with pinned versions and &lt;code&gt;${VAR}&lt;/code&gt; tokens.&lt;/li&gt;
&lt;li&gt;Run the wrapper above once by hand and inspect the &lt;code&gt;system/init&lt;/code&gt; line in &lt;code&gt;run.jsonl&lt;/code&gt; before you schedule it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;If you'd rather not run servers yourself:&lt;/strong&gt; use vendor-hosted remote servers from the &lt;a href="https://claude.ai/directory" rel="noopener noreferrer"&gt;Anthropic Directory&lt;/a&gt;, so the vendor handles uptime and updates. You still configure scopes and tokens. Claude Code itself requires a paid plan: Pro is $20/month billed monthly ($17/month on annual billing), and Max starts at $100/month (as of October 2026; check the &lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;official pricing page&lt;/a&gt;). Pro suits one person running a few scheduled jobs. It's a poor fit if you need &lt;code&gt;--bare&lt;/code&gt; everywhere, because bare mode needs an API key billed separately through the Claude Console (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related guides on this site:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/claude-code-headless-mode-flags-auth-and-cost-2026/" rel="noopener noreferrer"&gt;Claude Code Headless Mode: Flags, Auth and Cost (2026)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/claude-code-permissions-best-practices-headless-template/" rel="noopener noreferrer"&gt;Claude Code Permissions Best Practices: Headless Template&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/claude-code-cron-job-setup-run-automations-overnight/" rel="noopener noreferrer"&gt;Claude Code Cron Job Setup: Run Automations Overnight&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/ai-agent-monitoring-in-claude-code-catch-silent-failures/" rel="noopener noreferrer"&gt;AI Agent Monitoring in Claude Code: Catch Silent Failures&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  What happens if an MCP server becomes unreachable mid-run in Claude Code?
&lt;/h3&gt;

&lt;p&gt;For HTTP servers, Claude Code retries up to 5 times with exponential backoff starting at 1 second. For stdio servers in &lt;code&gt;claude -p&lt;/code&gt;, it retries on the next tool call (&lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;MCP reference&lt;/a&gt;). If the server stays down, that server's tools fail but the run can still exit 0. Check &lt;code&gt;mcp_servers&lt;/code&gt; in the &lt;code&gt;system/init&lt;/code&gt; event and treat a missing server as a failed job.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can Claude Code use multiple MCP servers at once?
&lt;/h3&gt;

&lt;p&gt;Yes. List as many as you need under &lt;code&gt;mcpServers&lt;/code&gt; in one file. Each server's tool names and instructions load into the context window, though, so remove ones you don't use (&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt;). Tool search reduces that cost when you have many tools (&lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;MCP reference&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Where is the Claude Code MCP servers config file on each OS?
&lt;/h3&gt;

&lt;p&gt;Local and user scopes live in &lt;code&gt;~/.claude.json&lt;/code&gt; on macOS and Linux, and in &lt;code&gt;%USERPROFILE%\.claude.json&lt;/code&gt; on Windows. Project scope is &lt;code&gt;.mcp.json&lt;/code&gt; in the repo root. Run &lt;code&gt;claude mcp get &amp;lt;name&amp;gt;&lt;/code&gt; to see which scope holds a server (&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it safe to run Claude Code MCP servers in a cron job without review?
&lt;/h3&gt;

&lt;p&gt;It's reasonably safe only when the job loads a fixed server list (&lt;code&gt;--strict-mcp-config&lt;/code&gt; or &lt;code&gt;--bare&lt;/code&gt;), allows named MCP tools only, and uses narrowly scoped tokens. Without those, a &lt;code&gt;-p&lt;/code&gt; run connects whatever &lt;code&gt;.mcp.json&lt;/code&gt; the directory contains, with no approval prompt (&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless docs&lt;/a&gt;). Servers that read web pages or issues can also carry prompt injection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;Claude Code Docs: MCP reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/mcp-quickstart" rel="noopener noreferrer"&gt;Claude Code Docs: Connect to MCP servers (quickstart)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Claude Code Docs: Run Claude Code programmatically (headless)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;Claude Code Docs: Environment variables&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;Claude Code Docs: Configure permissions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/cli-reference" rel="noopener noreferrer"&gt;Claude Code Docs: CLI reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://claude.com/blog/claude-code-remote-mcp" rel="noopener noreferrer"&gt;Anthropic: Remote MCP support in Claude Code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/modelcontextprotocol/servers" rel="noopener noreferrer"&gt;GitHub: modelcontextprotocol/servers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/modelcontextprotocol/servers-archived" rel="noopener noreferrer"&gt;GitHub: modelcontextprotocol/servers-archived&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://registry.modelcontextprotocol.io/" rel="noopener noreferrer"&gt;MCP Registry&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://claude.ai/directory" rel="noopener noreferrer"&gt;Anthropic Directory&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/anthropics/claude-code/issues/14490" rel="noopener noreferrer"&gt;GitHub issue #14490: --strict-mcp-config and disabledMcpServers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/steipete/claude-code-mcp" rel="noopener noreferrer"&gt;GitHub: steipete/claude-code-mcp&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;Claude pricing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was created with the help of AI. Sources are linked inline; check the official documentation for the latest details.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>mcp</category>
      <category>mcpservers</category>
      <category>headless</category>
    </item>
    <item>
      <title>Claude Code Sandbox Mode: Exact Config and What It Blocks</title>
      <dc:creator>Aoba Works</dc:creator>
      <pubDate>Wed, 28 Oct 2026 18:18:30 +0000</pubDate>
      <link>https://dev.to/aobaworks/claude-code-sandbox-mode-exact-config-and-what-it-blocks-309p</link>
      <guid>https://dev.to/aobaworks/claude-code-sandbox-mode-exact-config-and-what-it-blocks-309p</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://en.aobaworks.com/claude-code-sandbox-mode-exact-config-and-what-it-blocks/?utm_source=devto&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unattended&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Claude Code sandbox mode is a built-in, OS-enforced boundary around the Bash commands Claude runs. You turn it on with &lt;code&gt;/sandbox&lt;/code&gt; or &lt;code&gt;"sandbox": {"enabled": true}&lt;/code&gt; in settings.json. Anthropic's docs describe it as &lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;"a boundary that the operating system enforces around the shell commands Claude runs on your machine"&lt;/a&gt;. It limits where those commands can write and which hosts they can reach. It does not cover Claude's own file tools, hooks or MCP servers. That gap matters most if you plan to leave an agent running unattended.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which option is right for you?
&lt;/h2&gt;

&lt;p&gt;On macOS, Linux or WSL2, turn on the built-in sandbox in auto-allow mode. Use a container or VM only if you need stronger isolation than that.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your situation&lt;/th&gt;
&lt;th&gt;What to pick first&lt;/th&gt;
&lt;th&gt;First page to open&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Beginner on macOS&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/sandbox&lt;/code&gt; → auto-allow mode. Seatbelt is built in, so there's nothing to install (&lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;docs&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;Sandboxing docs&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Daily work on Linux or WSL2&lt;/td&gt;
&lt;td&gt;Install &lt;code&gt;bubblewrap&lt;/code&gt; and &lt;code&gt;socat&lt;/code&gt;, then enable the sandbox with &lt;code&gt;failIfUnavailable: true&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;Sandboxing docs, Linux setup&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native Windows (no WSL)&lt;/td&gt;
&lt;td&gt;Move Claude Code into a WSL2 distribution. Native Windows runs commands unsandboxed&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;Sandboxing docs&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cron jobs or headless runs on a VPS&lt;/td&gt;
&lt;td&gt;Sandbox plus &lt;code&gt;allowUnsandboxedCommands: false&lt;/code&gt; plus deny rules&lt;/td&gt;
&lt;td&gt;&lt;a href="https://en.aobaworks.com/claude-code-permissions-best-practices-headless-template/" rel="noopener noreferrer"&gt;/claude-code-permissions-best-practices-headless-template/&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zero budget&lt;/td&gt;
&lt;td&gt;The sandbox costs nothing extra. It ships with Claude Code&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/permission-modes" rel="noopener noreferrer"&gt;Permission modes docs&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You want to skip every prompt (YOLO)&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;bypassPermissions&lt;/code&gt; only inside a container or VM&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/permission-modes" rel="noopener noreferrer"&gt;Permission modes docs&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Why these picks: on macOS the sandbox needs no setup. On Linux the most common failure is missing dependencies, and &lt;code&gt;failIfUnavailable&lt;/code&gt; makes that failure loud. Windows needs WSL2 before any sandbox setting does anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Claude Code sandbox mode?
&lt;/h2&gt;

&lt;p&gt;Claude Code sandbox mode wraps every Bash command Claude runs in an OS-level jail that controls filesystem writes and outbound network access. The point is to let Claude run commands without asking you each time, while capping the damage a bad command can do.&lt;/p&gt;

&lt;p&gt;It's built on standard OS tools. On macOS it uses the built-in Seatbelt framework. On Linux and WSL2 it needs the &lt;code&gt;bubblewrap&lt;/code&gt; and &lt;code&gt;socat&lt;/code&gt; packages (&lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;). Anthropic published the sandbox runtime as open source in the &lt;code&gt;@anthropic-ai/sandbox-runtime&lt;/code&gt; package (&lt;a href="https://github.com/anthropics/sandbox-runtime" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;When Anthropic introduced it on October 20, 2025, the engineering team wrote: &lt;a href="https://www.anthropic.com/engineering/claude-code-sandboxing" rel="noopener noreferrer"&gt;"In our internal usage, we've found that sandboxing safely reduces permission prompts by 84%."&lt;/a&gt; That number comes from Anthropic's own internal use. It is not a benchmark.&lt;/p&gt;

&lt;p&gt;Here is what the sandbox allows by default (&lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;docs&lt;/a&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Writes:&lt;/strong&gt; the current working directory, the per-user temp directory, and any directory added with &lt;code&gt;--add-dir&lt;/code&gt;, &lt;code&gt;/add-dir&lt;/code&gt; or &lt;code&gt;permissions.additionalDirectories&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reads:&lt;/strong&gt; most of the machine, credential files like &lt;code&gt;~/.ssh&lt;/code&gt; and &lt;code&gt;~/.aws/credentials&lt;/code&gt; included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network:&lt;/strong&gt; no direct route out. Traffic goes through a local proxy that checks each host against &lt;code&gt;allowedDomains&lt;/code&gt;, and that list starts empty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pitfall:&lt;/strong&gt; many people assume a sandbox means "can't see my secrets." By default the sandbox restricts writes, not reads. If you want credential files hidden, you must deny them explicitly (see the config section below).&lt;/p&gt;

&lt;h2&gt;
  
  
  Does Claude Code have sandbox mode?
&lt;/h2&gt;

&lt;p&gt;Yes. Claude Code has a built-in sandbox that you enable with the &lt;code&gt;/sandbox&lt;/code&gt; command or the &lt;code&gt;sandbox.enabled&lt;/code&gt; key in settings.json. It works on macOS, Linux and WSL2, but not on native Windows.&lt;/p&gt;

&lt;p&gt;There are three official ways to turn it on (&lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;docs&lt;/a&gt;):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;In a session:&lt;/strong&gt; type &lt;code&gt;/sandbox&lt;/code&gt; to open the sandbox panel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistently:&lt;/strong&gt; add &lt;code&gt;"sandbox": {"enabled": true}&lt;/code&gt; to &lt;code&gt;~/.claude/settings.json&lt;/code&gt; or the project's &lt;code&gt;.claude/settings.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For one run:&lt;/strong&gt; pass &lt;code&gt;--settings '{"sandbox": {"enabled": true}}'&lt;/code&gt; when you start &lt;code&gt;claude&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Some third-party guides mention a &lt;code&gt;claude --sandbox&lt;/code&gt; startup flag. The official sandboxing page lists no such flag, so use &lt;code&gt;--settings&lt;/code&gt; for one-off runs and scripts.&lt;/p&gt;

&lt;p&gt;Once it's on, you choose one of two modes. In auto-allow mode, &lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;"Claude Code approves a command automatically, with no prompt, when the command runs inside the sandbox"&lt;/a&gt;. In regular permissions mode, every Bash command still goes through the normal approval flow, but it runs inside the boundary. The filesystem and network limits are the same in both modes. The only difference is whether you get prompted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude Code auto mode vs sandbox mode: what's the difference?
&lt;/h2&gt;

&lt;p&gt;Claude Code auto mode decides whether an action needs your approval. Sandbox mode limits what a command can reach once it runs. They solve different problems, and you can use them together.&lt;/p&gt;

&lt;p&gt;The permission modes page puts it this way: &lt;a href="https://code.claude.com/docs/en/permission-modes" rel="noopener noreferrer"&gt;"the Bash sandbox and outer isolation boundaries decide what an action can reach once it runs."&lt;/a&gt; Permission modes only decide whether Claude asks first.&lt;/p&gt;

&lt;p&gt;A common mix-up is treating auto mode and "YOLO mode" as the same thing. They aren't. Auto mode sends actions to a separate classifier model, Claude Sonnet 5 by default, which blocks things like &lt;code&gt;curl | bash&lt;/code&gt;, force pushes and &lt;code&gt;terraform destroy&lt;/code&gt;. YOLO is &lt;code&gt;bypassPermissions&lt;/code&gt; (alias &lt;code&gt;--dangerously-skip-permissions&lt;/code&gt;), which approves everything (&lt;a href="https://code.claude.com/docs/en/permission-modes" rel="noopener noreferrer"&gt;permission modes docs&lt;/a&gt;).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Auto mode&lt;/th&gt;
&lt;th&gt;bypassPermissions ("YOLO")&lt;/th&gt;
&lt;th&gt;Sandbox (auto-allow)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it controls&lt;/td&gt;
&lt;td&gt;Whether to prompt&lt;/td&gt;
&lt;td&gt;Whether to prompt&lt;/td&gt;
&lt;td&gt;What Bash can touch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How it decides&lt;/td&gt;
&lt;td&gt;Classifier model reviews each action&lt;/td&gt;
&lt;td&gt;Approves everything&lt;/td&gt;
&lt;td&gt;OS rules (Seatbelt / bubblewrap)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Covers file edit tools&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No, Bash only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hard guarantee?&lt;/td&gt;
&lt;td&gt;No: "does not guarantee safety"&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, within its scope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Official "best for"&lt;/td&gt;
&lt;td&gt;Long tasks, fewer prompts&lt;/td&gt;
&lt;td&gt;Isolated containers and VMs only&lt;/td&gt;
&lt;td&gt;Bash-heavy work on macOS/Linux/WSL2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra requirements&lt;/td&gt;
&lt;td&gt;Supported model (e.g. Opus 4.6+, Sonnet 4.6+)&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bubblewrap&lt;/code&gt; and &lt;code&gt;socat&lt;/code&gt; on Linux&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The docs say plainly: &lt;a href="https://code.claude.com/docs/en/permission-modes" rel="noopener noreferrer"&gt;"Auto mode reduces permission prompts but does not guarantee safety."&lt;/a&gt; Auto mode also pauses and goes back to prompting after 3 blocks in a row or 20 blocks in total in a session (&lt;a href="https://code.claude.com/docs/en/permission-modes" rel="noopener noreferrer"&gt;same page&lt;/a&gt;). For unattended runs, that means a "stuck" job can actually be a job waiting at a prompt. See &lt;a href="https://en.aobaworks.com/why-claude-code-stops-mid-automation-and-how-to-fix-it/" rel="noopener noreferrer"&gt;why Claude Code stops mid-automation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How they combine.&lt;/strong&gt; With auto mode and the sandbox both on, sandboxed Bash commands run inside the boundary. If a command fails because of the sandbox and Claude retries it with &lt;code&gt;dangerouslyDisableSandbox&lt;/code&gt;, the classifier reviews the retry. In &lt;code&gt;bypassPermissions&lt;/code&gt; mode, that unsandboxed retry runs with no prompt at all (&lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;sandboxing docs&lt;/a&gt;). So if you combine YOLO with the sandbox, also set &lt;code&gt;allowUnsandboxedCommands: false&lt;/code&gt;. Otherwise the sandbox has an exit you never see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which to choose:&lt;/strong&gt; for daily interactive work, use the sandbox in auto-allow mode plus auto mode. For unattended jobs, use the sandbox in strict mode plus explicit permission rules. Don't rely on a classifier in a job nobody is watching.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you run Claude Code in sandbox mode?
&lt;/h2&gt;

&lt;p&gt;Type &lt;code&gt;/sandbox&lt;/code&gt; in a session and choose auto-allow mode. For a persistent setup, enable &lt;code&gt;sandbox&lt;/code&gt; in settings.json, then check that a forbidden command fails.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step-by-step
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Linux/WSL2 only: install the dependencies.&lt;/strong&gt; Run &lt;code&gt;sudo apt-get install bubblewrap socat&lt;/code&gt; on Ubuntu/Debian, or &lt;code&gt;sudo dnf install bubblewrap socat&lt;/code&gt; on Fedora (&lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ubuntu 24.04 and later only:&lt;/strong&gt; run &lt;code&gt;sysctl kernel.apparmor_restrict_unprivileged_userns&lt;/code&gt;. If it returns &lt;code&gt;1&lt;/code&gt;, AppArmor is blocking the user namespaces bubblewrap needs. Add the &lt;code&gt;bwrap&lt;/code&gt; AppArmor profile shown on the docs page and run &lt;code&gt;sudo systemctl reload apparmor&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start Claude Code and type &lt;code&gt;/sandbox&lt;/code&gt;.&lt;/strong&gt; The panel has a &lt;strong&gt;Mode&lt;/strong&gt; tab (auto-allow or regular permissions), an &lt;strong&gt;Overrides&lt;/strong&gt; tab (strict sandbox mode) and a &lt;strong&gt;Config&lt;/strong&gt; tab showing the resolved settings. On Linux, a &lt;strong&gt;Dependencies&lt;/strong&gt; tab appears if the optional seccomp filter is missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose auto-allow on the Mode tab.&lt;/strong&gt; Claude Code saves this choice to &lt;code&gt;.claude/settings.local.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For unattended use, open Overrides and turn on strict sandbox mode.&lt;/strong&gt; This sets &lt;code&gt;allowUnsandboxedCommands: false&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify.&lt;/strong&gt; Ask Claude to run the two test commands from the docs:&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test command&lt;/th&gt;
&lt;th&gt;Expected result inside the sandbox&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;touch ~/sandbox-probe&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Operation not permitted&lt;/code&gt; (macOS) or &lt;code&gt;Read-only file system&lt;/code&gt; (Linux/WSL2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;curl --noproxy '*' https://example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Could not resolve host&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If either command succeeds, the sandbox isn't active. Check &lt;code&gt;/sandbox&lt;/code&gt; → &lt;strong&gt;Config&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example settings.json for an unattended project
&lt;/h3&gt;

&lt;p&gt;This example combines keys from the &lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;official sandboxing page&lt;/a&gt;. The domain names and paths are sample values to replace with your own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sandbox"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"autoAllowBashIfSandboxed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allowUnsandboxedCommands"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"failIfUnavailable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"filesystem"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"allowWrite"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"/tmp/build"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"denyRead"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"~/private"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"network"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"allowedDomains"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"github.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*.npmjs.org"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"strictAllowlist"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"credentials"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"files"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"~/.aws/credentials"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deny"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"~/.ssh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deny"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"envVars"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GITHUB_TOKEN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deny"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each piece does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;failIfUnavailable: true&lt;/code&gt; stops Claude Code at startup if the sandbox can't run. Without it, a missing &lt;code&gt;bubblewrap&lt;/code&gt; on a fresh VPS can leave you running with no sandbox.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;allowUnsandboxedCommands: false&lt;/code&gt; removes the retry escape. The docs say Claude Code then "ignores the &lt;code&gt;dangerouslyDisableSandbox&lt;/code&gt; parameter."&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;strictAllowlist: true&lt;/code&gt; blocks any host outside the allowlist instead of prompting. Per the docs, this only takes effect in user, managed or CLI &lt;code&gt;--settings&lt;/code&gt; settings, so a project file alone won't do it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;allowedDomains&lt;/code&gt; entries without a port allow every port on that host. Wildcards like &lt;code&gt;*.example.com&lt;/code&gt; work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Settings precedence, from highest to lowest, is: CLI &lt;code&gt;--settings&lt;/code&gt;, managed, user (&lt;code&gt;~/.claude/settings.json&lt;/code&gt;), project (&lt;code&gt;.claude/settings.json&lt;/code&gt;), local project. Filesystem arrays from different scopes are merged, not replaced (&lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;docs&lt;/a&gt;). For cron and &lt;code&gt;claude -p&lt;/code&gt; runs, keep the sandbox block in user settings or pass it with &lt;code&gt;--settings&lt;/code&gt;. The headless guide covers the rest of the flags: &lt;a href="https://en.aobaworks.com/claude-code-headless-mode-flags-auth-and-cost-2026/" rel="noopener noreferrer"&gt;/claude-code-headless-mode-flags-auth-and-cost-2026/&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Common mistakes and fixes
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;docker&lt;/code&gt; fails inside the sandbox.&lt;/strong&gt; The docs call &lt;code&gt;docker&lt;/code&gt; incompatible. Fix it with a narrow exclusion such as &lt;code&gt;"excludedCommands": ["docker compose *"]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;git push&lt;/code&gt; over SSH fails on macOS even when the host is allowed.&lt;/strong&gt; Switch the remote to HTTPS, or exclude &lt;code&gt;git fetch *&lt;/code&gt;, &lt;code&gt;git pull *&lt;/code&gt; and &lt;code&gt;git push *&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A database client can't connect to an allowed host.&lt;/strong&gt; Tools that ignore proxy environment variables can't get out. Exclude that specific command.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A local dev server can't be reached.&lt;/strong&gt; On macOS, set &lt;code&gt;network.allowLocalBinding: true&lt;/code&gt;. On Linux/WSL2 that setting has no effect, so use &lt;code&gt;excludedCommands&lt;/code&gt; instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Does Claude Code sandbox mode work on Windows?
&lt;/h2&gt;

&lt;p&gt;Not natively. On native Windows, Claude Code runs commands unsandboxed, so you need to run Claude Code inside WSL2 to get the sandbox.&lt;/p&gt;

&lt;p&gt;The docs say it directly: &lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;"Claude Code runs commands unsandboxed. To use the sandbox on a Windows machine, run Claude Code inside a WSL2 distribution"&lt;/a&gt;. Inside WSL2 the setup is the same as on Linux: install &lt;code&gt;bubblewrap&lt;/code&gt; and &lt;code&gt;socat&lt;/code&gt;, and apply the AppArmor fix if your distro is Ubuntu 24.04 or newer.&lt;/p&gt;

&lt;p&gt;There's one WSL2-specific catch. Setting &lt;code&gt;network.allowAllUnixSockets: true&lt;/code&gt; opens every Unix socket to sandboxed commands. On WSL2 that includes the socket WSL uses to launch Windows binaries, so a sandboxed command could start a Windows program outside the boundary (&lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;docs&lt;/a&gt;). Leave that setting off unless you know which socket you need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pitfall:&lt;/strong&gt; turning on &lt;code&gt;sandbox.enabled&lt;/code&gt; in native Windows PowerShell gives no error and no protection. Add &lt;code&gt;failIfUnavailable: true&lt;/code&gt; so a misconfigured machine fails loudly instead of quietly running everything unsandboxed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does Claude Code sandbox mode actually block, and what slips through?
&lt;/h2&gt;

&lt;p&gt;Claude Code sandbox mode blocks Bash writes outside allowed paths and network traffic to hosts not on the allowlist. Built-in tools, hooks, MCP servers, excluded commands and default reads all fall outside it.&lt;/p&gt;

&lt;p&gt;This is the part most guides skip. The docs list everything the sandbox does not wrap: built-in tools like Read, Edit, Write, WebFetch and WebSearch "follow permission rules instead," and "a &lt;code&gt;denyRead&lt;/code&gt; entry doesn't stop the Read tool, and &lt;code&gt;allowedDomains&lt;/code&gt; doesn't limit WebFetch" (&lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;sandboxing docs&lt;/a&gt;). Command hooks, local MCP servers, plugin monitors, LSP servers, your status line command and &lt;code&gt;apiKeyHelper&lt;/code&gt; all "run with your full access."&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Blocked by sandbox?&lt;/th&gt;
&lt;th&gt;What actually controls it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;rm -rf ~/Documents&lt;/code&gt; via Bash&lt;/td&gt;
&lt;td&gt;Yes (write outside allowed paths)&lt;/td&gt;
&lt;td&gt;OS sandbox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;curl&lt;/code&gt; to a host not in &lt;code&gt;allowedDomains&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes (or a prompt, depending on mode)&lt;/td&gt;
&lt;td&gt;Sandbox proxy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;cat ~/.ssh/id_ed25519&lt;/code&gt; via Bash&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt; by default (reads are open)&lt;/td&gt;
&lt;td&gt;Add &lt;code&gt;credentials.files&lt;/code&gt; deny or &lt;code&gt;denyRead&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude's &lt;strong&gt;Read&lt;/strong&gt; tool opening &lt;code&gt;~/.aws/credentials&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Permission deny rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Edit/Write&lt;/strong&gt; tool changing files&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Permission rules / permission mode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;WebFetch&lt;/strong&gt; to any domain&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;WebFetch(domain:...)&lt;/code&gt; permission rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A command hook script&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt;, full access&lt;/td&gt;
&lt;td&gt;Your own script review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local MCP server&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt;, full access&lt;/td&gt;
&lt;td&gt;MCP config / permission rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A command in &lt;code&gt;excludedCommands&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt;, full access&lt;/td&gt;
&lt;td&gt;Keep patterns narrow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writes to &lt;code&gt;.claude/&lt;/code&gt;, &lt;code&gt;.bashrc&lt;/code&gt;, &lt;code&gt;.git/hooks&lt;/code&gt;, &lt;code&gt;.mcp.json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes (protected paths)&lt;/td&gt;
&lt;td&gt;Built-in sandbox protection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exfiltration to an &lt;em&gt;allowed&lt;/em&gt; domain&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt;, hostname-only check&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tlsTerminate&lt;/code&gt; (experimental) or a tighter allowlist&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few rows need more explanation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Excluded commands are fully outside.&lt;/strong&gt; &lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;"An excluded command runs with your full access."&lt;/a&gt; A broad pattern like &lt;code&gt;docker *&lt;/code&gt; hands Claude everything Docker can do, and that includes mounting your home directory into a container. Write the narrowest pattern that works, such as &lt;code&gt;docker compose up *&lt;/code&gt; rather than &lt;code&gt;docker *&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The proxy only checks the hostname.&lt;/strong&gt; By default the proxy "does not terminate or inspect TLS traffic" (&lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;docs&lt;/a&gt;). If you allow &lt;code&gt;github.com&lt;/code&gt;, a command can push anything to any repo on github.com. The experimental &lt;code&gt;network.tlsTerminate&lt;/code&gt; setting lets the proxy inspect traffic, and credential masking depends on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Some settings weaken the boundary.&lt;/strong&gt; &lt;code&gt;allowNestedUserNamespaces&lt;/code&gt; (Linux/WSL2) lets code end up outside the sandbox. &lt;code&gt;allowAppleEvents&lt;/code&gt; (macOS) lets commands launch other apps unsandboxed. &lt;code&gt;enableWeakerNestedSandbox&lt;/code&gt; exposes process information. Only turn these on for a specific tool that needs them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it does protect well.&lt;/strong&gt; Writes to protected paths are denied even inside your project. That covers &lt;code&gt;.claude&lt;/code&gt; settings, &lt;code&gt;.mcp.json&lt;/code&gt;, &lt;code&gt;.gitconfig&lt;/code&gt;, &lt;code&gt;.bashrc&lt;/code&gt;, &lt;code&gt;.zshrc&lt;/code&gt;, &lt;code&gt;.vscode&lt;/code&gt;, &lt;code&gt;.idea&lt;/code&gt;, and Git internals like &lt;code&gt;HEAD&lt;/code&gt;, &lt;code&gt;refs&lt;/code&gt; and &lt;code&gt;.git/hooks&lt;/code&gt;. If a symlink appears at a protected settings file's path mid-session, writes to its target are denied too (&lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;docs&lt;/a&gt;). So a prompt-injected command can't quietly rewrite your settings or plant a Git hook.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example of a full closure (sample values):&lt;/strong&gt; to keep a nightly job from reading your AWS keys through any route, you need two things. A sandbox &lt;code&gt;credentials.files&lt;/code&gt; entry with &lt;code&gt;"mode": "deny"&lt;/code&gt; stops Bash. A permission deny rule for the Read tool on that path stops the Read tool. You can copy deny-rule templates from &lt;a href="https://en.aobaworks.com/ai-agent-guardrails-example-copy-paste-claude-code-rules/" rel="noopener noreferrer"&gt;/ai-agent-guardrails-example-copy-paste-claude-code-rules/&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do Claude Code hooks fit with sandbox mode?
&lt;/h2&gt;

&lt;p&gt;Claude Code hooks are scripts that run at lifecycle events like &lt;code&gt;PreToolUse&lt;/code&gt;. They can block tool calls the sandbox ignores, but they run outside the sandbox themselves.&lt;/p&gt;

&lt;p&gt;Hooks are defined in settings files (&lt;code&gt;~/.claude/settings.json&lt;/code&gt;, &lt;code&gt;.claude/settings.json&lt;/code&gt;, &lt;code&gt;.claude/settings.local.json&lt;/code&gt; or managed policy). The &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt; lists events such as &lt;code&gt;SessionStart&lt;/code&gt;, &lt;code&gt;UserPromptSubmit&lt;/code&gt;, &lt;code&gt;PreToolUse&lt;/code&gt;, &lt;code&gt;PostToolUse&lt;/code&gt;, &lt;code&gt;PostToolUseFailure&lt;/code&gt; and &lt;code&gt;Stop&lt;/code&gt;. &lt;code&gt;PreToolUse&lt;/code&gt; runs "before a tool call executes" and can block it. You block either by exiting with code 2 or by returning &lt;code&gt;permissionDecision: "deny"&lt;/code&gt; with a reason.&lt;/p&gt;

&lt;p&gt;That makes hooks the right tool for the gaps in the table above. The sandbox can't stop the Edit tool from touching &lt;code&gt;migrations/&lt;/code&gt;, but a &lt;code&gt;PreToolUse&lt;/code&gt; hook matched to &lt;code&gt;Edit|Write&lt;/code&gt; can. It can also add dynamic logic that static config can't express, such as "deny pushes after 6 p.m." or "deny writes when the test suite is red."&lt;/p&gt;

&lt;p&gt;To add one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Save a script, for example &lt;code&gt;~/.claude/hooks/guard.sh&lt;/code&gt;, that reads the JSON on stdin and exits 2 to block.&lt;/li&gt;
&lt;li&gt;Make it executable: &lt;code&gt;chmod +x ~/.claude/hooks/guard.sh&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add it under &lt;code&gt;hooks.PreToolUse&lt;/code&gt; with a &lt;code&gt;matcher&lt;/code&gt; (for example &lt;code&gt;"Edit|Write"&lt;/code&gt;) in settings.json.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;/hooks&lt;/code&gt; and confirm it's listed under PreToolUse (&lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;costs docs&lt;/a&gt; use the same check).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The tradeoff: command hooks "run with your full access" (&lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;sandboxing docs&lt;/a&gt;). A hook that downloads something or calls an API isn't held back by your &lt;code&gt;allowedDomains&lt;/code&gt;. Keep hook scripts short, review them like production code, and store them where the sandbox denies writes. &lt;code&gt;.claude/&lt;/code&gt; is a protected path, so Claude can't rewrite hooks you keep there. For ready-made scripts, see &lt;a href="https://en.aobaworks.com/claude-code-hooks-examples-7-copy-paste-scripts/" rel="noopener noreferrer"&gt;/claude-code-hooks-examples-7-copy-paste-scripts/&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you check Claude Code usage?
&lt;/h2&gt;

&lt;p&gt;You check Claude Code usage with the &lt;code&gt;/usage&lt;/code&gt; command in a session. Authoritative billing lives in the Claude Console for API users and on claude.ai for subscribers.&lt;/p&gt;

&lt;p&gt;Sandboxing makes Claude do more without stopping, which can also mean more tokens. So check usage after you turn on auto-allow. Your options, per the &lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;Claude Code costs docs&lt;/a&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/usage&lt;/code&gt; in a session.&lt;/strong&gt; The Session block shows total cost, API duration and tokens by model. Claude Code computes the dollar figure locally at list price, so it's an estimate. It resets when you run &lt;code&gt;/clear&lt;/code&gt; (since v2.1.211).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan breakdown (Pro, Max, Team, Enterprise).&lt;/strong&gt; The same screen shows usage bars, attribution to skills, subagents and MCP servers, and recommendations. Press &lt;code&gt;d&lt;/code&gt; or &lt;code&gt;w&lt;/code&gt; to switch between 24 hours and 7 days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API billing.&lt;/strong&gt; The &lt;a href="https://platform.claude.com/usage" rel="noopener noreferrer"&gt;Console usage page&lt;/a&gt; is the authoritative source. The first time you authenticate, a "Claude Code" workspace is created automatically, and you can set workspace spend limits there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Headless runs.&lt;/strong&gt; Cap a single run with &lt;code&gt;--max-budget-usd&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For scale, Anthropic reports that across enterprise deployments, the average cost is "around $13 per developer per active day" and $150–250 per developer per month (as of October 2026; &lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;costs docs&lt;/a&gt;). Background features use a small amount even when idle, typically under $0.04 per session. More detail is in &lt;a href="https://en.aobaworks.com/claude-code-how-to-see-token-usage-and-reduce-it/" rel="noopener noreferrer"&gt;/claude-code-how-to-see-token-usage-and-reduce-it/&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;Start with the three DIY steps below. All of them work on any plan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DIY today:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run &lt;code&gt;/sandbox&lt;/code&gt;, choose auto-allow, then run the two test commands (&lt;code&gt;touch ~/sandbox-probe&lt;/code&gt; and &lt;code&gt;curl --noproxy '*' https://example.com&lt;/code&gt;). Both should fail.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;failIfUnavailable: true&lt;/code&gt; and &lt;code&gt;allowUnsandboxedCommands: false&lt;/code&gt; to &lt;code&gt;~/.claude/settings.json&lt;/code&gt; on any machine that runs jobs unattended.&lt;/li&gt;
&lt;li&gt;Close the read gap: deny &lt;code&gt;~/.ssh&lt;/code&gt; and &lt;code&gt;~/.aws/credentials&lt;/code&gt; in &lt;code&gt;sandbox.credentials&lt;/code&gt;, and add matching Read deny rules.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;If you'd rather not build this yourself:&lt;/strong&gt; Claude Code is included with Anthropic's Pro plan at $20/month and Max from $100/month (as of October 2026; check the &lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;official pricing page&lt;/a&gt;). Paying doesn't configure the sandbox for you; it buys more usage. Team plans ($25/seat monthly for Standard, per the &lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;official pricing page&lt;/a&gt;) add central administration, so an admin can enforce the sandbox for everyone through managed settings with &lt;code&gt;failIfUnavailable: true&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;sandboxing docs&lt;/a&gt;). That suits a small team where one person owns security. It's overkill for a solo builder, who gets the same sandbox for free in user settings. If you need isolation that also covers the Read and Edit tools, a dev container or VM is the official recommendation for &lt;code&gt;bypassPermissions&lt;/code&gt;. The cost of that is extra setup and upkeep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related guides on this site:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/claude-code-permissions-best-practices-headless-template/" rel="noopener noreferrer"&gt;Claude Code Permissions Best Practices: Headless Template&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/claude-code-on-vps-systemd-setup-that-survives-reboots/" rel="noopener noreferrer"&gt;Claude Code on VPS: systemd Setup That Survives Reboots&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.aobaworks.com/claude-code-hooks-examples-7-copy-paste-scripts/" rel="noopener noreferrer"&gt;Claude Code Hooks Examples: 7 Copy-Paste Scripts&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  What does Reddit say about Claude Code sandbox mode?
&lt;/h3&gt;

&lt;p&gt;Community threads mostly cover practical friction, such as Docker, SSH git remotes and database clients failing inside the sandbox. All of these are documented with fixes on the &lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;official sandboxing page&lt;/a&gt;. The usual fix is a narrow &lt;code&gt;excludedCommands&lt;/code&gt; entry or switching git to HTTPS. Check any forum advice against the docs, because settings like &lt;code&gt;allowNestedUserNamespaces&lt;/code&gt; quietly weaken the boundary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use Claude Code sandbox and auto mode together?
&lt;/h3&gt;

&lt;p&gt;Yes. They work independently and combine. The sandbox limits what Bash can reach, and auto mode's classifier reviews other actions, including unsandboxed retries (&lt;a href="https://code.claude.com/docs/en/permission-modes" rel="noopener noreferrer"&gt;permission modes docs&lt;/a&gt;). Auto mode requires a supported model, such as Opus 4.6+ or Sonnet 4.6+ on the Anthropic API.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it safe to use Claude Code YOLO mode with the sandbox?
&lt;/h3&gt;

&lt;p&gt;Only partly. &lt;code&gt;--dangerously-skip-permissions&lt;/code&gt; still lets the Edit, Write and Read tools act outside the sandbox. It also auto-approves unsandboxed retries unless you set &lt;code&gt;allowUnsandboxedCommands: false&lt;/code&gt;. Anthropic says to use bypass mode only in containers or VMs, ideally without internet access (&lt;a href="https://code.claude.com/docs/en/permission-modes" rel="noopener noreferrer"&gt;docs&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Is there a Claude Code CLI flag for sandbox mode?
&lt;/h3&gt;

&lt;p&gt;The official sandboxing page lists no dedicated flag. For one run, pass &lt;code&gt;claude --settings '{"sandbox": {"enabled": true}}'&lt;/code&gt;. For every run, put the &lt;code&gt;sandbox&lt;/code&gt; block in &lt;code&gt;~/.claude/settings.json&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;docs&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I check Claude Code usage after enabling the sandbox?
&lt;/h3&gt;

&lt;p&gt;Run &lt;code&gt;/usage&lt;/code&gt; in the session for an estimated cost and token breakdown. Use the &lt;a href="https://platform.claude.com/usage" rel="noopener noreferrer"&gt;Console usage page&lt;/a&gt; for authoritative API billing. Subscribers see plan usage bars on the same &lt;code&gt;/usage&lt;/code&gt; screen (&lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;costs docs&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/sandboxing" rel="noopener noreferrer"&gt;Configure the sandboxed Bash tool – Claude Code Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/permission-modes" rel="noopener noreferrer"&gt;Permission modes – Claude Code Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Hooks reference – Claude Code Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;Manage costs effectively – Claude Code Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.anthropic.com/engineering/claude-code-sandboxing" rel="noopener noreferrer"&gt;Making Claude Code more secure and autonomous with sandboxing – Anthropic Engineering&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/anthropics/sandbox-runtime" rel="noopener noreferrer"&gt;anthropics/sandbox-runtime – GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;Claude pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://platform.claude.com/usage" rel="noopener noreferrer"&gt;Claude Console usage&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was created with the help of AI. Sources are linked inline; check the official documentation for the latest details.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>sandbox</category>
      <category>security</category>
      <category>settingsjson</category>
    </item>
    <item>
      <title>AI Agent Evaluation Metrics: Pass Rate Across Repeat Runs</title>
      <dc:creator>Aoba Works</dc:creator>
      <pubDate>Tue, 27 Oct 2026 16:45:32 +0000</pubDate>
      <link>https://dev.to/aobaworks/ai-agent-evaluation-metrics-pass-rate-across-repeat-runs-2oo9</link>
      <guid>https://dev.to/aobaworks/ai-agent-evaluation-metrics-pass-rate-across-repeat-runs-2oo9</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://en.aobaworks.com/ai-agent-evaluation-metrics-pass-rate-across-repeat-runs/?utm_source=devto&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unattended&lt;/a&gt;.&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;
Track three AI agent evaluation metrics for Claude Code runs that nobody watches. The first is &lt;span class="gs"&gt;**pass@k**&lt;/span&gt;: can the agent solve the task at all within k tries? The second is &lt;span class="gs"&gt;**pass^k**&lt;/span&gt;, or pass rate across repeat runs: does it succeed every time? The third is &lt;span class="gs"&gt;**graded-score drift**&lt;/span&gt;: is the quality score from your grader slowly falling on input that hasn't changed? Run each task 10–20 times in a clean environment and log a pass/fail result plus a score for every run. Then compute these numbers before you let a cron job run the agent alone.

Anthropic defines the second metric as one that "measures the probability that all k trials succeed" (&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Anthropic: Demystifying evals for AI agents&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;). That's the question an overnight automation actually asks. The &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;pillar guide on the eval loop&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://en.aobaworks.com/ai-agent-evaluation-framework-claude-code-eval-loop-steps/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; names these metrics. This guide shows how to choose and compute them.

&lt;span class="gu"&gt;## Which option is right for you?&lt;/span&gt;

If you're starting out, use a bash loop around &lt;span class="sb"&gt;`claude -p`&lt;/span&gt; plus a short Python script. Move to a framework only once you have more than a handful of tasks.

| Your situation | What to pick first | First page to open |
|---|---|---|
| Beginner, one or two recurring tasks | Bash loop + &lt;span class="sb"&gt;`claude -p --output-format json`&lt;/span&gt; + pass rate over 10 reruns | &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Claude Code headless docs&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://code.claude.com/docs/en/headless&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; |
| Daily work use, cron jobs already running | pass^k on a fixed set of 20–50 tasks, gated before every prompt/model change | &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Anthropic: Demystifying evals for AI agents&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; |
| Many tasks, want a results table and assertions | Promptfoo with &lt;span class="sb"&gt;`--repeat`&lt;/span&gt; and the &lt;span class="sb"&gt;`anthropic:claude-agent-sdk`&lt;/span&gt; provider | &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Promptfoo: Evaluate Coding Agents&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://www.promptfoo.dev/docs/guides/evaluate-coding-agents/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; |
| Tight budget | Code-based graders only (tests, file checks); skip LLM judges at first | &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Promptfoo command line docs&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://www.promptfoo.dev/docs/usage/command-line/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; |
| Output is prose or reviews, not code | Add an LLM-judge score and track its drift | Section on graded-score drift below |

Here's why each pick fits. A bash loop teaches you what the numbers mean before a tool hides them. pass^k is the metric that matches "runs without me". Promptfoo saves you from writing your own repeat-and-cache plumbing. Code graders cost nothing per run. And prose can't be checked by &lt;span class="sb"&gt;`npm test`&lt;/span&gt;.

&lt;span class="gu"&gt;## What Are Evaluation Metrics in AI Agent Testing?&lt;/span&gt;

In Claude Code testing, an evaluation metric is a rule that turns each run's result into a pass/fail or a score. That lets you compare runs instead of eyeballing transcripts.

Anthropic's evals post uses four words you'll need throughout this guide:
&lt;span class="p"&gt;
-&lt;/span&gt; A &lt;span class="gs"&gt;**task**&lt;/span&gt; is "&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;a single test with defined inputs and success criteria&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;".
&lt;span class="p"&gt;-&lt;/span&gt; A &lt;span class="gs"&gt;**trial**&lt;/span&gt; is one attempt at a task. You run several, "&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;because model outputs vary between runs&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;".
&lt;span class="p"&gt;-&lt;/span&gt; A &lt;span class="gs"&gt;**grader**&lt;/span&gt; is the logic that scores the trial.
&lt;span class="p"&gt;-&lt;/span&gt; The &lt;span class="gs"&gt;**outcome**&lt;/span&gt; is the final state of the environment when the trial ends.

A metric sits on top of these. It's what you get when you add up grader results across trials and tasks.

For example, say your nightly job asks Claude Code to "bump the patch version, update CHANGELOG.md, and make the test suite pass". The task is that prompt plus a frozen copy of the repo. A grader might check three things: &lt;span class="sb"&gt;`npm test`&lt;/span&gt; exits 0, &lt;span class="sb"&gt;`package.json`&lt;/span&gt; has the expected version, and &lt;span class="sb"&gt;`CHANGELOG.md`&lt;/span&gt; has a new top entry. One trial gives one pass/fail. Ten trials give a pass rate. That pass rate is the metric.

&lt;span class="gs"&gt;**Pitfalls:**&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="gs"&gt;**Treating the exit code as the metric.**&lt;/span&gt; Claude Code exits with code 0 on success (&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;headless docs&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://code.claude.com/docs/en/headless&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;). That means the run ended normally, not that the work is right. A run can exit 0 after editing the wrong file. Always grade the outcome (tests, files, diff) separately from the exit status.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Scoring the transcript text.**&lt;/span&gt; A confident summary like "All tests pass!" isn't evidence. Run the tests yourself in the grader.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**No reference solution.**&lt;/span&gt; Anthropic recommends tasks that are unambiguous, with a known solution that proves they can be solved. If you can't write the passing answer yourself, the metric can't tell a model failure from a broken task.

&lt;span class="gu"&gt;## What Is Evaluation Metrics in Machine Learning (and How Agent Evals Differ)?&lt;/span&gt;

Classic machine learning metrics score one fixed prediction against a fixed label. Agent evals for tools like Claude Code score multi-step runs that can succeed in different ways or fail quietly partway through.

scikit-learn describes &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;accuracy&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://scikit-learn.org/stable/modules/model_evaluation.html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; as "the fraction (default) or the count (normalize=False) of correct predictions". Precision, recall and F1 refine that for imbalanced classes. These metrics assume something that agent runs break: the same input always produces the same output, and there's one correct label to compare against.

| Property | Classic ML metrics (accuracy, F1) | Coding-agent evals (Claude Code) |
|---|---|---|
| Unit scored | One prediction | A whole trial: tool calls, edits, final repo state |
| Determinism | A fixed model gives the same prediction | Same prompt, different paths and results per run |
| Ground truth | A label | A checkable end state (tests pass, file exists, no stray changes) |
| Valid answers | Usually one | Many diffs can be correct |
| Repeats needed | Once per example | Multiple trials per task to estimate a rate |
| Silent failure mode | Wrong label | Plausible summary over a broken or partial change |

The practical difference is the repeats. A classifier's accuracy on a test set is fixed once you freeze the model. A Claude Code task's pass rate is a probability, and you can only estimate it by sampling.

That's why the Codex paper that made pass@k popular reported two numbers. Its model solved 28.8% of HumanEval problems with 1 sample per problem (&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Chen et al.&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://arxiv.org/abs/2107.03374&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;), and "&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;70.2% of our problems with 100 samples per problem&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://arxiv.org/abs/2107.03374&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;". Same model, same problems. The only difference was how many tries it got.

&lt;span class="gs"&gt;**Pitfall:**&lt;/span&gt; Don't borrow precision/recall unless your agent really is classifying something. A triage agent that labels issues fits that frame. An agent that writes code needs pass rates.

&lt;span class="gu"&gt;## What Is Evaluation in AI (Agent Context)?&lt;/span&gt;

For Claude Code, evaluation means rerunning a task many times and checking the final state it leaves. A plausible-looking output isn't enough; the outcome has to match what you intended.

Anthropic's rule for this is to "&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;grade what the agent produced, not the path it took&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;". If Claude fixes a bug by editing &lt;span class="sb"&gt;`utils.ts`&lt;/span&gt; when you expected &lt;span class="sb"&gt;`auth.ts`&lt;/span&gt;, and the tests pass with no unrelated changes, that counts as a pass. Grading the exact steps penalizes valid alternatives and makes your metric noisier.

The same post warns that "&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;unnecessary shared state between runs (leftover files, cached data, resource exhaustion) can cause correlated failures&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;". That's why a single trial needs a clean environment.

Steps for one evaluation trial:
&lt;span class="p"&gt;
1.&lt;/span&gt; &lt;span class="gs"&gt;**Reset the environment.**&lt;/span&gt; Make a fresh checkout or worktree of a pinned commit. Don't reuse the last trial's folder.
&lt;span class="p"&gt;2.&lt;/span&gt; &lt;span class="gs"&gt;**Run the agent headless.**&lt;/span&gt; Use &lt;span class="sb"&gt;`claude -p "&amp;lt;task prompt&amp;gt;" --output-format json`&lt;/span&gt; so the result and metadata come back as JSON (&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;headless docs&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://code.claude.com/docs/en/headless&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;).
&lt;span class="p"&gt;3.&lt;/span&gt; &lt;span class="gs"&gt;**Grade the outcome.**&lt;/span&gt; Run tests, check files, and run &lt;span class="sb"&gt;`git diff --stat`&lt;/span&gt; to spot changes outside the expected paths.
&lt;span class="p"&gt;4.&lt;/span&gt; &lt;span class="gs"&gt;**Record one row.**&lt;/span&gt; Save the task id, trial number, pass/fail, score if any, cost and duration.
&lt;span class="p"&gt;5.&lt;/span&gt; &lt;span class="gs"&gt;**Read some transcripts.**&lt;/span&gt; Anthropic notes that 0% pass@100 usually points to a broken task spec, not a hopeless agent (&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;). You only find that out by reading what happened.

If you already run Claude Code from cron, this is the same command your job uses, pointed at a frozen copy of the repo. See &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Claude Code Headless Mode: Flags, Auth and Cost&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://en.aobaworks.com/claude-code-headless-mode-flags-auth-and-cost-2026/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; for the flags and auth.

&lt;span class="gu"&gt;## What Are the Core AI Agent Performance Metrics?&lt;/span&gt;

The core metrics are pass@k, pass^k, success rate, cost, latency and turns per task. Compute all of them on a fixed task set.

Here are the definitions and the numbers to compute:

| Metric | What it answers | How to compute from n trials with c passes | Where the data comes from |
|---|---|---|---|
| pass@1 (success rate) | How often does one run succeed? | c / n | Your grader |
| pass@k | Can it solve this at all within k tries? | 1 − C(n−c, k) / C(n, k) | Your grader |
| pass^k | Will k runs in a row all succeed? | C(c, k) / C(n, k) | Your grader |
| Cost per task | What does one trial cost? | Mean of &lt;span class="sb"&gt;`total_cost_usd`&lt;/span&gt; | &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;`--output-format json`&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://code.claude.com/docs/en/headless&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; |
| Latency per task | How long does a trial take? | Mean or p90 of &lt;span class="sb"&gt;`duration_ms`&lt;/span&gt; | &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;SDK result message&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://code.claude.com/docs/en/agent-sdk/typescript&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; |
| Turns per task | How much back-and-forth did it need? | Mean of &lt;span class="sb"&gt;`num_turns`&lt;/span&gt; | &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;SDK result message&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://code.claude.com/docs/en/agent-sdk/typescript&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; |
| Cost per successful task | What do you pay per good result? | Total cost / c | Both |

The pass@k formula is the unbiased estimator from the Codex paper: pass@k := E&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;1 − C(n−c, k)/C(n, k)]. The paper calls the shortcut 1 − (1 − p̂)^k "[biased&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://arxiv.org/html/2107.03374&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;", and its authors drew n=200 samples per problem for k ≤ 100 (&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Chen et al., section 2.1&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://arxiv.org/html/2107.03374&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;).

pass^k comes from τ-bench, which introduced it as "&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;a new metric to evaluate the reliability of agent behavior over multiple trials&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://arxiv.org/abs/2406.12045&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;". The C(c,k)/C(n,k) form above uses the same combinatorial idea: the share of k-sized subsets of your trials in which every trial passed.

The two metrics move in opposite directions as k grows. Anthropic's example uses a 75% per-trial success rate, where the chance that all 3 of 3 trials pass is about 42% (0.75³) (&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;). As k rises, pass@k heads toward 100% and pass^k falls. τ-bench found leading function-calling agents were "&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;quite inconsistent (pass^8 &amp;lt;25% in retail)&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://arxiv.org/abs/2406.12045&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;".

To see how this works out, here's an example calculation (illustrative numbers, not a measurement). You run a task 10 times and 7 pass.
&lt;span class="p"&gt;
-&lt;/span&gt; pass@1 = 0.70
&lt;span class="p"&gt;-&lt;/span&gt; pass@3 = 1 − C(3,3)/C(10,3) = 1 − 1/120 ≈ 0.99
&lt;span class="p"&gt;-&lt;/span&gt; pass^3 = C(7,3)/C(10,3) = 35/120 ≈ 0.29

"It basically always works" (pass@3) and "three nights in a row, it fails at least once about 70% of the time" (pass^3) describe the same 10 runs. For unattended work, believe pass^k.

&lt;span class="gs"&gt;**Which k should you use?**&lt;/span&gt; Match k to how you actually run the job. If a human picks the best of three drafts, pass@3 is fine. If cron runs it daily and nobody checks, use pass^k with k equal to how many runs you'd let happen before you look. For example, k=7 if you review weekly.

&lt;span class="gu"&gt;## How Do AI Agent Performance Metrics Work in Amazon Connect?&lt;/span&gt;

Amazon Connect's agent metrics measure contact-center agents and AI assistants, not code correctness. Only the idea of "a rate over many interactions" carries over to Claude Code.

AWS defines metrics like these in the &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Amazon Connect metric definitions&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://docs.aws.amazon.com/connect/latest/adminguide/metrics-definitions.html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;:

| Amazon Connect metric (API id) | What AWS says it measures | Nearest Claude Code equivalent | Does it transfer? |
|---|---|---|---|
| Average handle time (&lt;span class="sb"&gt;`AVG_HANDLE_TIME`&lt;/span&gt;) | Agent interaction, hold and after-contact work time | Mean &lt;span class="sb"&gt;`duration_ms`&lt;/span&gt; per trial | Partly: latency only |
| AI handoff rate (&lt;span class="sb"&gt;`AI_HANDOFF_RATE`&lt;/span&gt;) | Share of AI-handled contacts escalated to humans or extra support | Share of runs that need your manual fix | Yes, as an idea |
| AI agent invocation success rate (&lt;span class="sb"&gt;`AI_AGENT_INVOCATION_SUCCESS_RATE`&lt;/span&gt;) | Share of invocations that "&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;executed successfully without technical failures&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://docs.aws.amazon.com/connect/latest/adminguide/metrics-definitions.html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;" | Share of runs with exit code 0 | Only as a health check |
| Agent answer rate (&lt;span class="sb"&gt;`AGENT_ANSWER_RATE`&lt;/span&gt;) | (Contacts accepted / total routing attempts) × 100 | None | No |

If you came here from a search about Connect, the useful lesson is the gap between the last two rows. "Invocation success" in Connect, and exit code 0 in Claude Code, both tell you that nothing crashed. Neither tells you the answer was right.

&lt;span class="gs"&gt;**Pitfall:**&lt;/span&gt; Reporting invocation success as your quality metric. A coding agent can have 100% clean exits and a 60% pass rate. Track both. The pass rate is the one that matters, and clean exits are only a health signal.

&lt;span class="gs"&gt;**Fix:**&lt;/span&gt; Pair any health metric with an outcome grader. In Connect terms, that's like pairing invocation success with handoff rate. In Claude Code, pair exit status with your test-based grader.

&lt;span class="gu"&gt;## What Are the Common AI Agent Evaluation Metrics for Coding Agents?&lt;/span&gt;

Three metrics matter most: pass rate across N reruns, run-to-run variance, and graded-score drift. This holds especially for solo builders who run Claude Code repeatedly.

Most top-ranking pages on this query list categories: task completion, tool-call accuracy, latency, business KPIs. They rarely show how to separate a flaky task from a reliable one. That's the part that decides whether a cron job is safe.

&lt;span class="gu"&gt;### Pass rate across N reruns&lt;/span&gt;

Run each task n times (10 is a sensible minimum, 20 if the task is cheap). Record c passes per task and compute pass@1, pass@k and pass^k as shown above. Anthropic suggests starting with "&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;20-50 simple tasks&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;" taken from real failures, so you don't need a big benchmark.

&lt;span class="gu"&gt;### Variance across reruns (flakiness)&lt;/span&gt;

Flakiness is simple to detect. A task is flaky when 0 &amp;lt; c &amp;lt; n: it passed sometimes and failed sometimes on the same input. The harder question is how sure you can be about its pass rate. A pass rate p from n trials has a standard error of about √(p(1−p)/n). Here's an example: at p=0.7 and n=10, that's √(0.021) ≈ 0.145, so roughly ±14 percentage points. That's arithmetic, not a measurement.

So with 10 reruns, you can't reliably tell a 70% task from an 85% task. Two rules follow:
&lt;span class="p"&gt;
1.&lt;/span&gt; &lt;span class="gs"&gt;**Count flaky tasks, not just the average.**&lt;/span&gt; "18 of 20 tasks are 10/10, two are 4/10" is far more useful than "88% overall". The two flaky tasks are where your overnight failures will come from.
&lt;span class="p"&gt;2.&lt;/span&gt; &lt;span class="gs"&gt;**Rerun before you conclude.**&lt;/span&gt; If a prompt change moves a task from 7/10 to 9/10, run another 10 before you believe it.

Promptfoo's coding-agent guide puts the next step bluntly: "&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;If a prompt fails 50% of the time, the prompt is ambiguous.&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://www.promptfoo.dev/docs/guides/evaluate-coding-agents/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;" Its advice is to fix the instructions rather than add retries. Retries raise pass@k but leave pass^k where it was.

&lt;span class="gu"&gt;### Graded-score drift&lt;/span&gt;

Drift is a slow fall in a 1–5 or 0–100 quality score on input that hasn't changed. It matters for output that tests can't fully check, like PR review comments, changelogs or docs. You track it by keeping a baseline and comparing each new batch against it.

Steps:
&lt;span class="p"&gt;
1.&lt;/span&gt; Freeze 10–20 inputs, and have an LLM judge score each output with a fixed rubric (for example, 1–5 for "covers every changed function").
&lt;span class="p"&gt;2.&lt;/span&gt; &lt;span class="gs"&gt;**Measure judge noise first.**&lt;/span&gt; Grade the &lt;span class="ge"&gt;*same*&lt;/span&gt; saved transcripts twice. Anthropic notes model-based graders are "&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;non-deterministic&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;", so part of any movement is the judge, not the agent.
&lt;span class="p"&gt;3.&lt;/span&gt; Set the baseline as the mean score of your first full batch.
&lt;span class="p"&gt;4.&lt;/span&gt; On each later batch, flag drift when the mean drops by more than about twice the standard error of the batch mean, or by an absolute threshold you choose (for example, 0.3 on a 5-point scale). These numbers are illustrative.
&lt;span class="p"&gt;5.&lt;/span&gt; When drift fires, diff the transcripts from a high-scoring baseline run and a low-scoring new run. Look at what changed, such as a new model, a CLAUDE.md edit or an MCP server.

&lt;span class="gs"&gt;**Pitfall:**&lt;/span&gt; Changing the judge prompt or judge model mid-series. Every judge change resets the baseline. Keep a &lt;span class="sb"&gt;`judge_version`&lt;/span&gt; column and never compare scores across versions.

&lt;span class="gu"&gt;## How Do You Set Up Metrics Visualization for an AI Agent Evaluation Framework?&lt;/span&gt;

A minimal Claude Code framework appends one JSON line per trial to a file and then plots pass rate and score per task over time.

Here's the logging loop. It's an example script, so adapt the paths and the grader to your project. It uses only flags from the &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;headless docs&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://code.claude.com/docs/en/headless&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;: &lt;span class="sb"&gt;`-p`&lt;/span&gt;, &lt;span class="sb"&gt;`--output-format json`&lt;/span&gt;, &lt;span class="sb"&gt;`--allowedTools`&lt;/span&gt;, and &lt;span class="sb"&gt;`--bare`&lt;/span&gt;. Bare mode skips local hooks, plugins, MCP servers and CLAUDE.md, so every trial starts the same way. It needs &lt;span class="sb"&gt;`ANTHROPIC_API_KEY`&lt;/span&gt;, because bare mode "doesn't use your subscription login" (&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;headless docs&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://code.claude.com/docs/en/headless&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;).

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
bash&lt;/p&gt;
&lt;h1&gt;
  
  
  !/usr/bin/env bash
&lt;/h1&gt;
&lt;h1&gt;
  
  
  eval.sh      (example)
&lt;/h1&gt;

&lt;p&gt;set -u&lt;br&gt;
TASK="$1"; PROMPT=$(cat "$2"); RUNS="${3:-10}"&lt;br&gt;
for i in $(seq 1 "$RUNS"); do&lt;br&gt;
  DIR=$(mktemp -d)&lt;br&gt;
  git worktree add --detach "$DIR" eval-base &amp;gt;/dev/null 2&amp;gt;&amp;amp;1&lt;br&gt;
  OUT=$(cd "$DIR" &amp;amp;&amp;amp; claude --bare -p "$PROMPT" \&lt;br&gt;
        --allowedTools "Read,Edit,Bash" --output-format json)&lt;br&gt;
  EXIT=$?&lt;br&gt;
  (cd "$DIR" &amp;amp;&amp;amp; npm test &amp;gt;/dev/null 2&amp;gt;&amp;amp;1) &amp;amp;&amp;amp; PASS=1 || PASS=0&lt;br&gt;
  echo "$OUT" | jq -c --arg t "$TASK" --argjson i "$i" \&lt;br&gt;
    --argjson p "$PASS" --argjson e "$EXIT" \&lt;br&gt;
    '{task:$t, trial:$i, pass:$p, exit:$e, cost:.total_cost_usd,&lt;br&gt;
      ts:(now|todate)}' &amp;gt;&amp;gt; results.jsonl&lt;br&gt;
  git worktree remove --force "$DIR"&lt;br&gt;
done&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Then summarize and plot the results (also an example):

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
python&lt;br&gt;
import json, math, collections&lt;br&gt;
import matplotlib.pyplot as plt&lt;br&gt;
rows = [json.loads(l) for l in open("results.jsonl")]&lt;br&gt;
by = collections.defaultdict(list)&lt;br&gt;
for r in rows: by[r["task"]].append(r["pass"])&lt;br&gt;
def pass_at(n,c,k): return 1 - math.comb(n-c,k)/math.comb(n,k)&lt;br&gt;
def pass_hat(n,c,k): return math.comb(c,k)/math.comb(n,k)&lt;br&gt;
for t, v in by.items():&lt;br&gt;
    n, c = len(v), sum(v)&lt;br&gt;
    print(t, f"p1={c/n:.2f}", f"pass@3={pass_at(n,c,3):.2f}",&lt;br&gt;
          f"pass^3={pass_hat(n,c,3):.2f}", "FLAKY" if 0&amp;lt;c&amp;lt;n else "")&lt;br&gt;
tasks = list(by)&lt;br&gt;
plt.bar(tasks, [sum(by[t])/len(by[t]) for t in tasks])&lt;br&gt;
plt.ylabel("pass rate"); plt.ylim(0,1); plt.savefig("pass_rate.png")&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


What to chart, in order of usefulness:

1. **Bar chart of pass rate per task**, with flaky tasks highlighted. This is your daily view.
2. **Line chart of pass^k for the whole suite**, one point per eval batch, with batches labeled by model or prompt version. This shows regressions.
3. **Line chart of mean judge score** with a band of ± two standard errors. This shows drift.
4. **Cost per successful task** per batch. Retries and long loops show up here before they show up on your bill. The guide to [seeing and reducing token usage in Claude Code](https://en.aobaworks.com/claude-code-how-to-see-token-usage-and-reduce-it/) covers where the tokens go.

Two caveats from the docs apply. First, `total_cost_usd` is a client-side estimate that "can differ from your actual bill" ([headless docs](https://code.claude.com/docs/en/headless)). Second, if a run ends without valid structured output, the JSON has no `structured_output` field, so check the field exists before your grader reads it.

**When to switch to Promptfoo instead:** use it when you have dozens of tasks and want a results table without writing your own. `promptfoo eval --repeat 3` runs each test 3 times ([command line docs](https://www.promptfoo.dev/docs/usage/command-line/)). Each repeat gets its own cache namespace, and the docs say to add `--no-cache` when you want fresh calls every time ([command line docs](https://www.promptfoo.dev/docs/usage/command-line/)). The Claude Agent SDK provider id is `anthropic:claude-agent-sdk` ([coding agents guide](https://www.promptfoo.dev/docs/guides/evaluate-coding-agents/)).

Promptfoo isn't for you if you have two tasks and want zero new dependencies. It also has a limitation for this guide: it reports per-test pass/fail, and you still compute pass^k from those results yourself.

## How Do You Build an AI Agent Evaluation Framework? A Metrics Checklist

Pick fixed tasks, grade them, rerun them, and block changes that fall below a threshold. In between, compute pass@k and pass^k for every task.

Follow this order:

1. **Pick 20–50 tasks from real work.** Use failures from your own cron logs first. The [silent-failure monitoring guide](https://en.aobaworks.com/ai-agent-monitoring-in-claude-code-catch-silent-failures/) explains how to spot them.
2. **Write a reference solution for each task**, so you know the task can be solved.
3. **Write a code-based grader for each task**: tests, file checks, and an allow-list of paths the diff may touch. Add an LLM judge only for output tests can't check.
4. **Pin the environment.** Tag a commit (`eval-base` in the script above), use `--bare`, and give every trial a fresh worktree.
5. **Run n=10 trials per task** (20 for tasks you'll run unattended daily).
6. **Compute per task:** pass@1, pass^k (k = runs between your reviews), a flaky flag, mean cost, and judge mean if you use one.
7. **Set gates.** For example, every unattended task must hit pass^k ≥ 0.9 with no new flaky tasks compared with the last batch. These thresholds are illustrative, so pick ones that match how costly a bad night is for you.
8. **Rerun the full suite after every change**: model, prompt, CLAUDE.md, hooks, MCP servers, or a Claude Code upgrade. Anthropic calls these regression evals, which ask "[Does the agent still handle all the tasks it used to?](https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents)"
9. **Read transcripts for every task at 0% or 100%** at least once. A 0% usually means a broken task. A long run of 100% means the task is saturated and no longer tells you anything.
10. **Only then schedule it.** Follow the [cron job setup guide](https://en.aobaworks.com/claude-code-cron-job-setup-run-automations-overnight/) and keep the same flags as your eval.

Common mistakes and fixes:

| Mistake | What it does to the metric | Fix |
|---|---|---|
| Reusing the same folder across trials | Correlated failures; pass rate looks worse (or better) than it is | Fresh worktree per trial |
| Running without `--bare` | A local hook or MCP server changes behavior between machines | `--bare`, and pass context with flags |
| Promptfoo repeats served from cache | Every repeat is identical, so variance looks like 0 | Add `--no-cache` |
| Adding retries to "fix" a flaky task | pass@k rises, pass^k stays flat | Clarify the prompt or the task, then rerun |
| Judging with the same model and prompt you're testing | Judge shares the agent's blind spots | Use a fixed rubric and check it against your own labels on a few samples |
| Comparing 7/10 vs 8/10 as a real change | Inside noise (±14 pts at n=10) | Rerun; compare only large or repeated shifts |

If a batch regresses after a change, roll back the prompt or config first, then investigate. The [checkpoints and rollback guide](https://en.aobaworks.com/claude-code-checkpoints-how-rollback-works-in-automations/) explains what Claude Code can and can't undo.

## Next steps

Do these today:

1. Pick the one cron task that worries you most. Freeze a commit, and run `eval.sh` on it with `RUNS=10`.
2. Compute pass@1 and pass^7 with the Python snippet. If pass^7 is below what you'd accept for a week unattended, the task isn't ready to run alone.
3. If any task is flaky (0 &amp;lt; c &amp;lt; 10), rewrite its prompt to remove the ambiguity and rerun 10 more before changing anything else.

If you'd rather not build this yourself, here are some options:

- **A Claude subscription that includes Claude Code**: Pro is "$20 if billed monthly" ([Claude pricing](https://claude.com/pricing)), and Max starts "From $100 Per month" ([Claude pricing](https://claude.com/pricing)) (as of October 2026; check the official page). It suits builders who run evals interactively and often. It's not the right fit for the `--bare` script above, which bills to an `ANTHROPIC_API_KEY` on the Anthropic API instead of your subscription.
- **Promptfoo**: a ready-made runner with repeats, assertions and a provider for the Claude Agent SDK ([guide](https://www.promptfoo.dev/docs/guides/evaluate-coding-agents/)). It suits people with many tasks. It's not worth it for one or two tasks, and you still compute pass^k yourself.
- **Anthropic's evals post** is a free reference if you want to design graders more carefully before you automate anything ([read it](https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents)).

Related guides on this site:

- [AI Agent Evaluation Framework: Claude Code Eval Loop Steps](https://en.aobaworks.com/ai-agent-evaluation-framework-claude-code-eval-loop-steps/), the overview this guide expands on
- [AI Agent Monitoring in Claude Code: Catch Silent Failures](https://en.aobaworks.com/ai-agent-monitoring-in-claude-code-catch-silent-failures/)
- [Claude Code Headless Mode: Flags, Auth and Cost (2026)](https://en.aobaworks.com/claude-code-headless-mode-flags-auth-and-cost-2026/)
- [Claude Code Cron Job Setup: Run Automations Overnight](https://en.aobaworks.com/claude-code-cron-job-setup-run-automations-overnight/)

## FAQ

### What is evaluation metrics in machine learning?

Evaluation metrics in machine learning are formulas that score a model's predictions against known labels, such as accuracy, precision, recall and F1 in [scikit-learn](https://scikit-learn.org/stable/modules/model_evaluation.html). They assume a fixed model gives the same prediction for the same input. AI agents break that assumption, so agent evals add repeated trials and rates like pass@k.

### What is evaluation in AI?

In the AI agent context, evaluation means running a defined task, grading the outcome with code or a model, and repeating it enough times to estimate how often the agent succeeds. For coding agents like Claude Code, you grade the final repo state (tests, files, diff), not the explanation the agent writes.

### What are ai agent performance metrics in Amazon Connect?

In Amazon Connect they're contact-center metrics such as average handle time, AI handoff rate and AI agent invocation success rate, defined in the [AWS metric definitions](https://docs.aws.amazon.com/connect/latest/adminguide/metrics-definitions.html). They measure customer conversations, not code correctness. Invocation success only means no technical failure occurred.

### What are common ai agent evaluation metrics?

The common ones are task success rate (pass@1), pass@k, pass^k, tool-call accuracy, cost per task, latency and turns per task. For unattended Claude Code jobs, pass^k and the number of flaky tasks are the most useful, because they predict failures across consecutive runs.

### How does ai agent evaluation framework metrics visualization work?

You log one record per trial (task, pass/fail, score, cost, timestamp, version) and chart it. A per-task pass-rate bar chart shows flaky tasks. A pass^k line across batches shows regressions after changes, and a judge-score line with an error band shows drift.

## Sources

- [Anthropic: Demystifying evals for AI agents](https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents)
- [Claude Code docs: Run Claude Code programmatically (headless)](https://code.claude.com/docs/en/headless)
- [Claude Agent SDK TypeScript reference (result message fields)](https://code.claude.com/docs/en/agent-sdk/typescript)
- [Chen et al., Evaluating Large Language Models Trained on Code (arXiv:2107.03374)](https://arxiv.org/abs/2107.03374) and [HTML version, section 2.1](https://arxiv.org/html/2107.03374)
- [Yao et al., τ-bench (arXiv:2406.12045)](https://arxiv.org/abs/2406.12045)
- [scikit-learn: Metrics and scoring](https://scikit-learn.org/stable/modules/model_evaluation.html)
- [AWS: Amazon Connect metric definitions](https://docs.aws.amazon.com/connect/latest/adminguide/metrics-definitions.html)
- [Promptfoo: Evaluate Coding Agents](https://www.promptfoo.dev/docs/guides/evaluate-coding-agents/)
- [Promptfoo: Command line](https://www.promptfoo.dev/docs/usage/command-line/)
- [Claude pricing](https://claude.com/pricing)
- [SWE-bench evaluation guide](https://www.swebench.com/SWE-bench/guides/evaluation/)
- Top results reviewed for the query: [Amplitude](https://amplitude.com/explore/analytics/ai-agent-evaluation-metrics), [Weights &amp;amp; Biases](https://wandb.ai/onlineinference/genai-research/reports/AI-agent-evaluation-Metrics-strategies-and-best-practices--VmlldzoxMjM0NjQzMQ), [IBM](https://www.ibm.com/think/topics/ai-agent-evaluation)

---

*This article was created with the help of AI. Sources are linked inline; check the official documentation for the latest details.*
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>agentevals</category>
      <category>claudecode</category>
      <category>passk</category>
      <category>evaluationmetrics</category>
    </item>
  </channel>
</rss>
