Stacks·7 min read·Jul 11, 2026

Claude Code Hooks: Stop Asking Claude to Follow Your Rules

Prompts get ignored. Hooks run every time. A hook is a shell command that fires automatically on events inside Claude Code: before a tool runs, after a file is edited, when a session starts. This is the practical setup: a PreToolUse guardrail that hard-blocks rm -rf and .env access, a PostToolUse autopilot that formats and tests after every edit, and the copy-paste starter config that does both.

Claude Code hooks are shell commands that fire automatically on events inside a session: before a tool runs, after a file is edited, when a session starts. They live in .claude/settings.jsonand run whether or not Claude "remembers" to do the thing you asked. A prompt is a request. A hook is enforcement.

The problem nobody talks about

You tell Claude "always run the tests after you edit a file." It does it twice. Then, thirty messages deep, the context is full and it quietly stops. Nothing errors. The code looks fine. You find out later that a change went in untested.

This is not a Claude defect. It is what a prompt is. Everything you type into the conversation is a suggestion competing for attention against everything else in the context window. The longer the session, the more your early instructions get crowded out. "Stay out of .env" and "format with Prettier" degrade the same way.

The fix is to stop storing your rules in the conversation and start storing them in the project. That is exactly what a hook does.

Why this changes how you work

Three things separate a hook from a prompt.

It fires on an event, not on goodwill. A hook is bound to a lifecycle point. PreToolUse runs before Claude executes any tool. PostToolUse runs right after. SessionStart runs when a session opens. The trigger is mechanical, so there is nothing to forget.

A PreToolUse hook can hard-block an action. When a PreToolUse hook exits with code 2, or returns permissionDecision: "deny", the tool call is cancelled before it runs. That is the difference between "please don't run rm -rf" and a command that physically cannot execute. Worth knowing: exit code 2 only blocks on the pre-action events. On PostToolUse the tool has already run, so a non-zero exit just surfaces an error to Claude, it does not undo anything.

The rule ships with the repo. Because the config lives in .claude/settings.json, it is versioned and shared. A new session inherits it. A teammate who clones the repo inherits it. Your standard stops living in your head and starts living in the codebase. This is the part that makes hooks feel less like a prompting trick and more like CI running locally against your agent.

Where hooks live

Everything goes in .claude/settings.json in your project root. Save the file and Claude Code picks it up. No restart, no separate daemon.

The structure nests three levels: the event name, a matcher group that filters by tool, and the actual hooks to run.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.sh"
          }
        ]
      }
    ]
  }
}

matcher is the tool name the group applies to (Bash, Edit, Write, and so on). Each entry in the hooks array is a command that receives a JSON payload on stdin describing what Claude is about to do.

Step 1: A guardrail that blocks dangerous commands

Create .claude/hooks/guard.sh. It reads the command Claude wants to run, and denies anything that touches .env or runs a recursive delete.

#!/bin/bash
# .claude/hooks/guard.sh
command=$(jq -r '.tool_input.command' < /dev/stdin)

if echo "$command" | grep -Eq 'rm -rf|(^|[[:space:]])\.env'; then
  jq -n '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "Blocked by project guardrail: no rm -rf, no .env access."
    }
  }'
else
  exit 0
fi

Make it executable with chmod +x .claude/hooks/guard.sh. The JSON on stdin always carries tool_name and tool_input for tool events, which is how the script sees the exact command. Return permissionDecision: "deny" and the command never runs. Exit 0 and the normal permission flow continues as if the hook were not there.

Step 2: Autopilot that formats and tests after every edit

This is the PostToolUse half. After Claude edits or writes a file, run your formatter and your tests without asking.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "npx prettier --write . && npm test"
          }
        ]
      }
    ]
  }
}

The matcher here fires for both the Edit and Write tools. Prettier cleans up whatever just changed, then the test suite runs. If the tests fail, the non-zero exit surfaces the failure back to Claude as context, so it sees the broken build and can react in the same turn. You get clean, checked code without a single manual step.

Step 3: The starter config, both halves together

Drop this into .claude/settings.json and the guardrail plus the autopilot are both live.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "npx prettier --write . && npm test"
          }
        ]
      }
    ]
  }
}

That is a real workflow: nothing destructive gets through, and every edit lands formatted and tested.

Honest limitations

Hooks are shell commands you wrote, which means they fail the way shell scripts fail.

A guardrail that matches too broadly will block you as much as it blocks Claude. If your grep pattern is loose, a legitimate command gets denied and you spend ten minutes confused about why nothing runs. Keep the match tight and test it on a throwaway command before you trust it.

Running the full test suite on every edit gets slow on a large project. Once it hurts, scope the PostToolUse command to the tests that matter, or move the heavy checks to a Stop hook that fires once when Claude finishes rather than after each edit.

And a hook only enforces what you can express in a script. It is a guardrail against known mistakes, not a substitute for reading the diff. Start with one hook, confirm it behaves, then add the next. A wall of ten untested hooks on day one is how you end up fighting your own config.

Quick reference

  • Config path: .claude/settings.json, picked up on save, no restart.
  • Common events: PreToolUse (before a tool runs), PostToolUse (after), SessionStart (on open).
  • Block an action: exit code 2 or permissionDecision: "deny" from a PreToolUse hook. Blocking only works on pre-action events.
  • The stdin payload carries tool_name and tool_input; PostToolUse also gets tool_result.
  • Filter by tool with matcher, for example "Bash" or "Edit|Write".

The AI Side Hustle Cookbook

Liked this guide? Shout me a coffee.

$4.99 gets you the full playbook: 50 recipes you can build, ship, and get paid for with Claude Code. Working code in every one. The pricing, the deploy, the pitfalls. Every revision free for life.

Shout me a coffee