Aditya Sharma

Claude Code Hooks Explained: A Real 35-Hook Setup, Tested

On this page, 9 sections

Claude Code hooks are commands that Claude Code runs for you at fixed points in its lifecycle, such as before a tool call or when a turn ends.

They can also be HTTP endpoints, MCP tool calls, LLM prompts or subagents. Unlike a line in CLAUDE.md, a hook runs every time its event fires, and a hook that exits with code 2 can stop the action.

Three things decide how a hook behaves: the event it listens to, the matcher that filters it, and what it returns, either an exit code or a JSON object. The hooks reference I read on 11 October 2026 lists 33 events; I counted the rows in its lifecycle table.

I also read my own setup. My ~/.claude/settings.json holds 35 hook entries across 8 events, backed by 41 shell scripts in ~/.claude/hooks. Below is how each piece works, what the docs say, and the outputs from the ones I ran on 11 October 2026.

Where hooks fire

The reference groups events into three cadences. Per session: SessionStart and SessionEnd. Per turn: UserPromptSubmit, Stop and StopFailure. On every tool call inside the agentic loop: PreToolUse and PostToolUse.

These are the ones that matter in practice, with the reference’s own wording shortened:

EventWhen it firesBlocking or context
UserPromptSubmitA prompt is submitted, before Claude processes itYes, exit 2 rejects the prompt
PreToolUseBefore a tool call executesYes
PostToolUseAfter a tool call succeedsNo, the tool already ran
StopClaude finishes respondingYes, it can keep Claude working
SessionStartA session begins or resumesPlain-text stdout is added as context

The last column comes from the reference’s exit code section and event pages. On PostToolUse the tool has already run, so the reference says to exit 2 if you want Claude to see your stderr.

Matchers

A matcher filters when a hook runs. For tool events it matches the tool name. The reference sorts matchers into three kinds:

  • "*", an empty string or no matcher: match everything.
  • Only letters, digits, underscore, hyphen, spaces, commas and pipes: an exact name, or an exact list such as Edit|Write.
  • Anything else: a JavaScript regular expression, unanchored.

The unanchored part is a trap. The reference says Edit.* matches both Edit and NotebookEdit, and to use ^Edit$ when you want a whole-name match. MCP tools are matchable too: mcp__memory__.* matches every tool from a server named memory. If you have not met MCP tool names yet, start with what an MCP server is.

Exit codes and JSON

Exit code 0 means success. Exit code 2 is a blocking error on events that can block, and your stderr becomes the reason.

Any other code, with plain text or nothing on stdout, is a non-blocking error: the action goes ahead and you see a hook error notice. That last row is the one that bites people, because a hook that exits 1 on a check it meant as a block does nothing.

There is a second way to block that does not use exit 2. A PreToolUse hook can exit 0 and print JSON with permissionDecision set to deny. One more fact from the reference: Claude never sees stderr from a hook that exits 0.

A real deny hook, run

My deny-dangerous.sh uses the JSON route. I fed it two tool calls on 11 October 2026, building the first command string in code so my own session would not trip the same rule. Input for the first:

{"tool_name": "Bash", "tool_input": {"command": "git add -A"}}

Output, with exit code 0:

{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"git add -A / --all is banned. CLAUDE.md: 'stage files by name, never git add -A (avoids .env).' Stage individual files."}}

For a harmless ls the same script printed nothing and exited 0. That silence is the allow path: no output, exit 0, the tool call proceeds.

My setup, by event

I counted hook commands per event in ~/.claude/settings.json on 11 October 2026:

EventHook entries
UserPromptSubmit10
SessionStart7
Stop7
PreToolUse6
PostToolUse2
PreCompact1
SessionEnd1
SubagentStop1
Total35

Most of the weight sits in the two places where I want something to happen every time without remembering it: when I submit a prompt, and when a session starts. The tool-call hooks are fewer but they are the ones that block.

The PreToolUse hooks

MatcherScriptTimeout
Bashsecret-scan.sh5 s
Bashdeny-dangerous.shnone set
Write|Edit|MultiEditanti-slop-gate.shnone set
WebSearchprefer-crawl.sh10 s
Bashverify-package-exists.shnone set
Agentsubagent-budget.sh5 s
  • secret-scan.sh scans Bash commands for secret patterns such as Stripe, AWS, GitHub and Anthropic key formats and exits 2 on a match. Its own header explains why it is a hook: hooks are deterministic, CLAUDE.md is advisory.
  • deny-dangerous.sh blocks commands that break rules I wrote down, such as staging every file at once in git and force pushes. The output above is from this script.
  • anti-slop-gate.sh runs on writes to text files such as .md and .html and denies content with patterns I have banned, including the long dash. It is the reason this post could not be written with one.
  • verify-package-exists.sh blocks an install if the package name does not exist on its registry, a guard against invented package names.

That last hook blocked me while I prepared this post. On 11 October 2026 I needed to install a package into a fresh virtual environment, and the hook rejected my install command several times. When the command carried shell punctuation (a pipe or a semicolon) the hook read it as part of the package name. A bare install command with nothing around it went through.

So the hook does what its header says on a clean command and misreads a composed one. I have not fixed it. It is a good reminder that a hook is code, and code has edge cases, including the code that guards you.

The Stop hooks

Seven entries run on Stop. Three I can describe from their own headers: stop-until-green.sh blocks a “done” claim if the project has a check script that has not passed in this session, no-question-back-gate.sh catches a turn that ends by asking permission instead of continuing, and chat-slop-gate.sh checks the final message for banned patterns.

A Stop hook that blocks makes Claude continue, which means it can loop. The reference documents the guard: the input carries stop_hook_active, which is true when Claude Code is already continuing because of a stop hook, and after eight consecutive continuations Claude Code overrides the next block and ends the turn.

I grepped my scripts: chat-slop-gate.sh and evidence-gate.sh both exit 0 when that flag is true.

The header of stop-until-green.sh also records a bug I had. It looked for the project’s check script in the exact working directory only, so sprints started from a subdirectory never triggered it, for about a week before I noticed on 11 September 2026.

It now walks up to the project root. A gate that silently does not fire is worse than no gate, because you believe it ran.

Timeouts

The reference gives command hooks a default timeout of 600 seconds on most events. That is ten minutes in which a hung script holds the event. Three of the six PreToolUse hooks above have no timeout in my settings, so they sit on that default.

The other three carry 5 or 10 seconds, and the header of stop-until-green.sh warns that a hang there would stall every Stop event for exactly this reason.

What hooks do not cover

  • PreToolUse runs only when Claude calls a tool. Files you attach with @ in a prompt are inserted without a tool call, so a hook matching Read never sees them. The reference says to use a Read deny rule for that.
  • Setting "disableAllHooks": true turns your hooks off, but it cannot disable hooks set in managed policy settings, per the reference.
  • Hooks merge across settings levels. User, project and local settings add hooks without removing managed ones.

Questions people ask

Where do I put a hook?

In ~/.claude/settings.json for all your projects, .claude/settings.json for one project you can commit, or .claude/settings.local.json for one project, uncommitted. Plugins and skills can carry hooks too. Type /hooks in Claude Code to see what is registered; the hooks guide says new hooks appear there.

Is a hook better than a CLAUDE.md rule?

For a rule that must hold every time, yes, because the model can forget an instruction but cannot skip a hook that fires on the event. For judgement calls, a rule in CLAUDE.md is still the right place. I use both, and the hooks exist because a rule was broken at least once.

Why did my hook not block anything?

Check the exit code first. Exit 2 blocks. So does valid JSON with a blocking decision, such as permissionDecision deny on PreToolUse or decision block on Stop. Exit 1 with plain text or nothing on stdout is a non-blocking error. The reference also lists an onFailure: "block" option for hooks you want to fail closed.

Do this today

Add one deny hook and test it by hand before you trust it. This one blocks a table drop. I syntax-checked it with bash -n and ran it on 11 October 2026: for DROP TABLE users it printed the deny JSON and exited 0, and for SELECT 1 it printed nothing and exited 0.

#!/usr/bin/env bash
# PreToolUse hook for Bash: deny SQL that drops a table.
cmd=$(jq -r '.tool_input.command // ""')
if printf '%s' "$cmd" | grep -qiE 'drop +table'; then
  jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse",
    permissionDecision: "deny",
    permissionDecisionReason: "DROP TABLE is blocked by a hook"}}'
fi
exit 0

Save it as ~/.claude/hooks/block-sql.sh, then register it:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "bash $HOME/.claude/hooks/block-sql.sh", "timeout": 5 }
        ]
      }
    ]
  }
}

Test it with piped JSON first, the way I did, and only then restart the session. The first time a hook misfires, you want to see it in a terminal rather than in the middle of a task. For the wider setup it plugs into, see running Claude Code against WordPress.

Resources

Tell me where I am wrong

Your email is not published and I do not add it to any list. Corrections with a source are the ones I act on fastest.