← WRITING

auto-approve Claude Code plan mode via PermissionRequest

Claude Code's plan approval dialog can be auto-approved by hooking PermissionRequest correctly and matching ExitPlanMode instead of Stop.

The useful bits
  • The approval dialog is emitted as a PermissionRequest for ExitPlanMode, not as a Stop hook.
  • The hook must consume stdin and return hookSpecificOutput with hookEventName set to PermissionRequest.
  • Plan markdown can be archived in the background without blocking the approval response.

if you’re using Claude Code’s plan mode, you’ve probably seen the “ready to code?” approval dialog. as I always approve Opus 4.6’s plan, no need to do this thing manually anymore. turns out you can automate that click with a single hook once you target the right event.

this also highlights a tiny docs gap tracked in anthropic’s repo: issue #11891 (“[DOCS] Missing PermissionRequest hook details in Hooks Guide and Input Reference schema”). + not well-documented and we need to do better job on documenting JSON payload details as Claude Code is not open-source, we had to do basic reverse engineering (not cool as its name, basically, just add event listener that save JSONs into a folder, and read from there, not assembly patched xD)

the key detail: it’s PermissionRequest (not Stop)

the approval dialog is a PermissionRequest for the tool ExitPlanMode.

Stop fires after a run is done. plan approval happens while Claude is waiting for user input, so Stop is the wrong place to intercept it.

what PermissionRequest sends (stdin)

here’s the (currently under-documented) shape you get on stdin when the dialog pops:

{
  "session_id": "abc123-def4-5678-ghij-klmnopqrstuv",
  "transcript_path": "/Users/you/.claude/projects/-Users-you-my-project/abc123.jsonl",
  "cwd": "/Users/you/my-project",
  "permission_mode": "plan",
  "hook_event_name": "PermissionRequest",
  "tool_name": "ExitPlanMode",
  "tool_input": {
    "allowedPrompts": [
      { "tool": "Bash", "prompt": "run tests" },
      { "tool": "Bash", "prompt": "install deps" }
    ],
    "plan": "# Plan: Implement Feature X\n\n## Context\n\n..."
  }
}

two useful bits:

  • tool_name tells you what is asking (here: ExitPlanMode)
  • tool_input.plan contains the full plan markdown (which you can archive elsewhere)

minimal auto-approve hook (copy/paste)

script: ~/.claude/hooks/auto-approve-plan.sh

#!/bin/bash
cat >/dev/null  # consume stdin (important on Windows/WSL)
cat <<'EOF'
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow","message":"auto-approved"}}}
EOF

config (only match ExitPlanMode, not everything):

{
  "hooks": {
    "PermissionRequest": [
      {
        "matcher": "ExitPlanMode",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/auto-approve-plan.sh"
          }
        ]
      }
    ]
  }
}

notes that save time:

  • your hook should read stdin (otherwise you can hang)
  • your output must be wrapped under hookSpecificOutput with hookEventName: "PermissionRequest"

optional: archive each plan to Craft.do

because the plan markdown is already in tool_input.plan, you can ship it to Craft.do in the background and still return the approval immediately.

the only “gotcha”: don’t pull the markdown into a shell variable and then re-embed it into JSON (multiline content will bite you). build the Craft.do payload in a single jq pass instead.

#!/bin/bash
TMPFILE="$(mktemp)"
cat > "$TMPFILE"

fire-and-forget archive (don’t block the approval response)

(
jq 
--arg ts "$(date '+%Y-%m-%d %H:%M')" 
--arg pageId "$CRAFT_PAGE_ID" 
--arg home "$HOME" 
'{
blocks: [{
type: "page",
textStyle: "card",
markdown: ("[" + (.cwd | sub($home; "~")) + "] - [" + $ts + "]"),
content: [{ type: "text", markdown: .tool_input.plan }]
}],
position: { position: "end", pageId: $pageId }
}' < "$TMPFILE" 
| curl -sS -X POST "$CRAFT_API_URL/blocks" -H "Content-Type: application/json" -d @- 
>/dev/null 2>&1
) &

rm -f "$TMPFILE"

approve the dialog

cat <<'EOF'
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow","message":"auto-approved + archived"}}}
EOF

if you want this packaged: yigitkonur/hooks-claude-approve

i bundled the “auto-approve + (optional) Craft.do archive” flow into yigitkonur/hooks-claude-approve.

modes:

modeauto-approveCraft.do archiveuse case
1yesnoskip the approval dialog
2yesyesskip approval and archive every plan
3noyesarchive plans but still click approve manually

install:

bash <(curl -fsSL https://raw.githubusercontent.com/yigitkonur/hooks-claude-approve/main/install.sh)

Sources & further reading

  1. Anthropic hooks guide
  2. Docs gap issue #11891
  3. hooks-claude-approve

Questions & answers

Why does Stop not work for plan approval automation?

Because the model is paused waiting for user approval before the run stops. The approval prompt happens during PermissionRequest, not after execution finishes.

What is the minimum hook behavior needed?

Read stdin, match ExitPlanMode, and return an allow decision wrapped in hookSpecificOutput for the PermissionRequest event.

See you down the next rabbit hole.

More notes →

Start typing to follow a rabbit hole.

↵ open · Esc close