To run your own check before Claude Code uses a tool, add a PreToolUse entry under hooks in .claude/settings.json, set its matcher to the tool name (Bash below), and point its command at a script. Claude Code hands the script the pending tool call as JSON on stdin. The script exits 0 to stay out of the way, or exits 2 to block the call, and whatever it wrote to stderr becomes the reason Claude is given. This entry and a seven-line script refuse a force-push:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/no-force-push.sh",
            "args": []
          }
        ]
      }
    ]
  }
}
mkdir -p .claude/hooks
cat > .claude/hooks/no-force-push.sh <<'EOF'
#!/usr/bin/env bash
# no-force-push.sh: refuse any Bash command that force-pushes
cmd=$(python3 -c 'import json, sys; print(json.load(sys.stdin)["tool_input"].get("command", ""))')
case "$cmd" in
  *"push --force"* | *"push -f"*) echo "Blocked: no force-push from Claude" >&2; exit 2 ;;
esac
exit 0
EOF
chmod +x .claude/hooks/no-force-push.sh

Piped a force-push and then an ordinary push, in the shape Claude Code sends, it refuses the first and passes the second:

echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' | .claude/hooks/no-force-push.sh; echo "exit $?"
echo '{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}' | .claude/hooks/no-force-push.sh; echo "exit $?"
exit 2
exit 0

On stderr:

Blocked: no force-push from Claude

The "args": [] is not decoration. It switches the hook to exec form, which the docs recommend here: “Set args whenever the hook references a path placeholder, since each element is passed as one argument with no quoting.” The placeholder ${CLAUDE_PROJECT_DIR} is “the project root where the session started”, so the entry works from any subdirectory. The script is a substring test and nothing more: a push written as git push origin +main gets past it. The guard hook in the post on blocking dangerous commands splits the command into words and catches that form too.

Everything below is the same exchange taken apart: what the matcher matches, what JSON each event sends, what the exit code means, and when to print JSON instead. Every docs passage in quotes is from the Claude Code hooks reference, read on 2026-10-13. On this machine claude --version printed 2.1.296 (Claude Code):

claude --version; echo "exit $?"
2.1.296 (Claude Code)
exit 0

The three layers of a hook entry

The reference describes three levels of nesting, and the JSON above has all three. First, “Choose a hook event to respond to, like PreToolUse or Stop”: that is the key under hooks. Second, “Add a matcher group to filter when it fires”, which is the object holding matcher. Third, “Define one or more hook handlers to run when matched”: the inner hooks array, where type is command for a script. One event can hold several matcher groups, and one group can hold several handlers.

The file decides who gets the hook. In the reference’s table of locations, .claude/settings.json is the row for a “Single project” and is marked “Yes, can be committed to the repo”, while ~/.claude/settings.json applies to “All your projects”. The five dankdev hooks further down live in the user file, because they are personal habits rather than project rules.

The matcher: what a value matches

How Claude Code reads a matcher depends only on which characters it contains. The reference’s three cases, in its own words:

  • "*", "", or omitted: “Match all”. The hook “fires on every occurrence of the event”.
  • Only letters, digits, _, -, spaces, , and |: “Exact string, or list of exact strings separated by | or , with optional surrounding whitespace”. For example, “Bash matches only the Bash tool; Edit|Write and Edit, Write each match either tool exactly”.
  • Any other character: “JavaScript regular expression, unanchored”. For example, “^Notebook matches any tool whose name starts with Notebook; mcp__memory__.* matches every tool from the memory server”.

Three consequences are easy to miss. A regular expression is unanchored, so “Edit.* matches both Edit and NotebookEdit; wrap the pattern in ^ and $, as in ^Edit$, when you need a whole-string match.” A bare server prefix matches nothing: “The .* is required: a matcher like mcp__memory or mcp__brave-search contains only exact-match characters, so it is compared as an exact string and matches no tool.” And on events that have no matcher, such as Stop, the field does nothing at all: “If you add a matcher field to an event without matcher support, it is silently ignored.”

What the matcher is compared with differs by event. For the tool events it is the tool name: “For tool events, that field is tool_name.” For Notification it is the “notification type”, and Stop is listed with “no matcher support”, so it “always fires on every occurrence”.

The five dankdev hook scripts use four events and three matchers, read from each post’s own settings block:

ScriptFrom the postEventmatcher
claude-guard.shblock dangerous commandsPreToolUseBash|Read|Edit|Write
claude-allow.shallow safe commandsPreToolUseBash
claude-format.shformat on editPostToolUseEdit|Write
claude-notify.shnotification when doneNotification and Stopomitted
keep-going-once.shhow to stop Claude CodeStopomitted

What each event sends, and a dankdev script run on it

For the runs below, each of those five scripts was copied out of its post’s built page into ~/.claude/hooks/ of a scratch home directory and made executable; this page does not reprint them, so follow the link for the full script. Each was then fed the example payload the hooks reference prints for its event, pasted unchanged into a file. Every event’s payload starts from the same common fields, which include session_id, transcript_path, cwd (“Current working directory when the hook is invoked”), permission_mode and hook_event_name (“Name of the event that fired”).

PreToolUse: the tool call before it runs

“In addition to the common input fields, PreToolUse hooks receive tool_name, tool_input, and tool_use_id.” For Bash, tool_input carries command (“The shell command to execute”), description, timeout and run_in_background. The reference’s own example is a pending npm test. Here it goes into the allow hook and then the guard hook:

cat > pre.json <<'EOF'
{
  "session_id": "abc123",
  "prompt_id": "550e8400-e29b-41d4-a716-446655440000",
  "transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
  "cwd": "/home/user/my-project",
  "scratchpad_dir": "/tmp/claude-1000/-home-user-my-project/abc123/scratchpad",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "npm test",
    "description": "Run test suite",
    "timeout": 120000,
    "run_in_background": false
  },
  "tool_use_id": "toolu_01ABC123..."
}
EOF
~/.claude/hooks/claude-allow.sh < pre.json; echo "exit $?"
~/.claude/hooks/claude-guard.sh < pre.json; echo "exit $?"
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"claude-allow: test runner npm"}}
exit 0
exit 0

The two scripts answer the same payload in the two ways a PreToolUse hook can. claude-allow.sh recognised a test runner with no options and printed a decision as JSON, then exited 0. claude-guard.sh found nothing to object to, printed nothing, and exited 0, which leaves the call to the normal permission rules. Both read only tool_name and tool_input; neither looks at the session fields.

PostToolUse: after the tool has run

“The input includes both tool_input, the arguments sent to the tool, and tool_response, the result it returned.” The reference’s example is a Write of /path/to/file.txt. The format hook needs a file that exists, so the run below swaps that one path for a real CSS file and leaves every other field as the docs print it:

mkdir -p web && printf 'body{color:red}\n' > web/style.css
cat > post.json <<'EOF'
{
  "session_id": "abc123",
  "transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
  "cwd": "/Users/...",
  "permission_mode": "default",
  "hook_event_name": "PostToolUse",
  "tool_name": "Write",
  "tool_input": {
    "file_path": "/path/to/file.txt",
    "content": "file content"
  },
  "tool_response": {
    "filePath": "/path/to/file.txt",
    "type": "create"
  },
  "tool_use_id": "toolu_01ABC123...",
  "duration_ms": 12
}
EOF
sed "s#/path/to/file.txt#$PWD/web/style.css#" post.json > edit.json
~/.claude/hooks/claude-format.sh < edit.json; echo "exit $?"
exit 0

No formatter is installed on this machine, so the script found nothing to run for a .css file and exited 0 in silence, which is the behaviour its post promises. To see the other branch without pretending, the next run puts a stand-in prettier in the project’s node_modules/.bin. It is three lines of shell that fail on purpose and say so; it is not Prettier:

mkdir -p web/node_modules/.bin
cat > web/node_modules/.bin/prettier <<'EOF'
#!/bin/sh
echo "stand-in prettier, not the real one: refusing ${2##*/}" >&2
exit 2
EOF
chmod +x web/node_modules/.bin/prettier
~/.claude/hooks/claude-format.sh < edit.json; echo "exit $?"
{"decision": "block", "reason": "prettier exited 2 on style.css:\nstand-in prettier, not the real one: refusing style.css"}
exit 0

The formatter’s failure came back as a top-level decision of block with a reason, and the hook itself still exited 0. On this event that does not undo anything: “"block" adds the reason next to the tool result. Claude still sees the original output; to replace it, use updatedToolOutput”.

Notification: Claude Code wants your attention

“Notification hooks receive message with the notification text, an optional title, and notification_type indicating which type fired.” The notification hook calls notify-send on Linux, which this machine does not have, so a stand-in on PATH prints the arguments it was given instead of drawing a bubble:

mkdir -p bin
cat > bin/notify-send <<'EOF'
#!/bin/sh
printf 'notify-send got:'; printf ' [%s]' "$@"; echo
EOF
chmod +x bin/notify-send
cat > note.json <<'EOF'
{
  "session_id": "abc123",
  "transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
  "cwd": "/Users/...",
  "hook_event_name": "Notification",
  "message": "Claude needs your permission",
  "title": "Permission needed",
  "notification_type": "permission_prompt"
}
EOF
PATH="$PWD/bin:$PATH" ~/.claude/hooks/claude-notify.sh < note.json; echo "exit $?"
notify-send got: [--app-name=Claude Code] [Needs you: ...] [Claude needs your permission]
exit 0

The script builds its title from the last part of cwd, and the reference’s example has the placeholder /Users/... there, which is why the title reads Needs you: .... It takes the body from message and ignores title and notification_type. A notification hook cannot change what happens next: “Notification hooks can’t block or modify notifications.”

Stop: Claude has finished its turn

“In addition to the common input fields, Stop hooks receive stop_hook_active, last_assistant_message, background_tasks, and session_crons.” The flag matters most: “The stop_hook_active field is true when Claude Code is already continuing as a result of a stop hook.” The reference’s example has it set to true. The run feeds that payload to keep-going-once.sh from the post on stopping Claude Code, then the same payload with the flag set to false and cwd pointed at a project holding a .claude/keep-going file, then the original to the notification hook:

cat > stop.json <<'EOF'
{
  "session_id": "abc123",
  "transcript_path": "~/.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
  "cwd": "/Users/...",
  "permission_mode": "default",
  "hook_event_name": "Stop",
  "stop_hook_active": true,
  "last_assistant_message": "I've completed the refactoring. Here's a summary...",
  "background_tasks": [
    {
      "id": "task-001",
      "type": "shell",
      "status": "running",
      "description": "tail logs",
      "command": "tail -f /var/log/syslog"
    }
  ],
  "session_crons": [
    {
      "id": "cron-001",
      "schedule": "0 9 * * 1-5",
      "recurring": true,
      "prompt": "check the build"
    }
  ]
}
EOF
~/.claude/hooks/keep-going-once.sh < stop.json; echo "exit $?"
touch .claude/keep-going
sed -e 's/"stop_hook_active": true/"stop_hook_active": false/' -e "s#\"/Users/...\"#\"$PWD\"#" stop.json | ~/.claude/hooks/keep-going-once.sh; echo "exit $?"
PATH="$PWD/bin:$PATH" ~/.claude/hooks/claude-notify.sh < stop.json; echo "exit $?"
exit 0
{"decision": "block", "reason": "The .claude/keep-going file exists. Finish the task it names, then delete the file."}
exit 0
notify-send got: [--app-name=Claude Code] [Done: ...] [I've completed the refactoring. Here's a summary...]
exit 0

With stop_hook_active true the script let Claude stop: one extra turn has already been granted, and asking again is how a Stop hook loops. With the flag false and the file present, it printed a block decision whose reason tells Claude what to do next. Even a hook that forgot the check would not loop for ever, because the reference caps it: “after stop hooks have continued the turn eight times in a row, Claude Code overrides the next block and ends the turn.” The notification hook took its body from last_assistant_message, the field the docs recommend for exactly this: “use this field rather than reading transcript_path”.

Exit codes: 0, 2, and anything else

The reference sorts every finished run into three outcomes:

  • Exit 0: “your hook exits 0. Claude Code applies any JSON output fields your hook printed, and the action goes ahead unless those fields block or deny it.”
  • Exit 2: “your hook exits 2. On events that can block, Claude Code stops the action.”
  • Any other code: “your hook exits with any other code, or fails in some other way, such as not starting or printing invalid JSON. The action goes ahead”.

Exit 1 is the trap, since it is what most failing commands return: “Without valid JSON on stdout, Claude Code treats exit code 1 as a non-blocking error, even though 1 is the conventional Unix failure code. If your hook is meant to enforce a policy, use exit 2.” Where the message goes is fixed too: “The message that comes with the block is your hook’s stderr.” And on a 0, stderr goes nowhere useful: “Claude never sees stderr from a hook that exits 0.”

Whether exit 2 can block depends on the event. The four used on this page, from the reference’s per-event table:

Hook eventCan block?What happens on exit 2
PreToolUseYesBlocks the tool call
PostToolUseNoShows stderr to Claude; the tool already ran
NotificationNoExit code and stderr are ignored
StopYesPrevents Claude from stopping, continues the conversation

claude-guard.sh is the dankdev script built on exit 2. Here it gets the reference’s PreToolUse payload from above with the command swapped for one it refuses:

sed 's/"npm test"/"git reset --hard HEAD~1"/' pre.json | ~/.claude/hooks/claude-guard.sh; echo "exit $?"
exit 2

On stderr:

Blocked by claude-guard: git reset --hard throws away uncommitted work

That stderr line is what Claude would be told. Per the reference, “A hook that blocks by exiting 2 routes the same way as "deny": Claude sees the stderr message as the denial reason.” An exit 2 also outranks anything the same hook printed: “Even a permissionDecision of "allow" doesn’t let the action through.”

JSON on stdout instead of an exit code

“Exit codes only let you block or stay silent, but JSON output gives you finer-grained control.” The rule for mixing the two is short: “Choose one approach per hook: either use exit codes alone for signaling, or exit 0 and print JSON for structured control.” The JSON has to be alone on stdout: “Print nothing but the JSON object to stdout.”

Which fields carry the decision depends on the event, and the runs above printed both patterns:

  • PreToolUse: a hookSpecificOutput object holding hookEventName, permissionDecision and permissionDecisionReason, printed above by claude-allow.sh.
  • PostToolUse: top-level decision and reason, printed above by claude-format.sh.
  • Stop: top-level decision and reason, printed above by keep-going-once.sh.
  • Notification: nothing. It has no decision control, and claude-notify.sh is all side effect.

PreToolUse is the odd one out, and older examples online still show the old way: “PreToolUse previously used top-level decision and reason fields, but these are deprecated for this event.” Its four outcomes are allow, deny, ask and defer, and “When multiple PreToolUse hooks return different decisions, precedence is deny > defer > ask > allow.” That is why the guard and allow hooks can sit on the same event: when both answer, a deny wins. For Stop, the field means the opposite of stopping: “"block" prevents Claude from stopping. Omit to allow Claude to stop”.

What ran here and what did not

Ran: claude --version, the seven-line no-force-push.sh, and the five dankdev hook scripts, each fed a payload by hand in a scratch directory with no network. The four larger payloads are the hooks reference’s own examples, changed only where a run says so. Not run: any Claude Code session. No hook on this page was fired by Claude Code itself, so what Claude Code does with each exit code and each JSON field is quoted from the reference, not observed.

← Back to all posts

Follow new posts by RSS: paste dankdev.com/feed.xml into any feed reader. It is a plain XML file — no signup, no email address, no account. Posts land there the same day they go up, and there is nothing to unsubscribe from later.

Back to the top