CLSkills Hub
← Back to the blog
July 5, 2026Oliver

The Claude Code Hook Pattern Nobody Documents: Pre-Commit + settings.json Inheritance

Claude Code hooks are one of the most powerful features in the CLI, and the way settings.json inherits across user, project, and local scopes is where teams end up with hook chaos. Full breakdown of the inheritance model, when hooks actually fire, and three battle-tested patterns to prevent hook explosion in team repos.

claude codehookssettings.jsondeveloper toolsteam workflows

The problem this post solves

You add a Claude Code hook to your project's .claude/settings.json. It fires on your machine. Your teammate pulls the repo and the hook doesn't fire for them. Or it fires twice. Or it fires but doesn't do what you expected because their user-level ~/.claude/settings.json has a conflicting hook that shadows yours.

The docs cover hooks. The docs cover settings.json. What the docs don't do a good job of is explaining exactly how these files inherit across scopes, what happens when they conflict, and how to design hooks in a way that survives being shared with a team.

This post walks through the inheritance model with a working example, then gives you three patterns we use in production repos at Samarth Industries to keep team hook setups predictable.

The three scopes

Claude Code reads settings from three files in this order:

  1. ~/.claude/settings.json (user scope, applies everywhere)
  2. <repo>/.claude/settings.json (project scope, checked into git, applies to everyone using the repo)
  3. <repo>/.claude/settings.local.json (local scope, gitignored, per-developer overrides)

Each file is optional. When multiple files exist, they merge. That merge is where the interesting behavior lives.

The merge model is not "last write wins." It's not straight object merge either. For hooks specifically, the merge is additive at the event level and depth-first at the matcher level. That sentence probably didn't help. Let me show you.

What actually happens when hooks merge

Here's a minimal setup across all three scopes.

~/.claude/settings.json (user):

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "echo '[user] bash tool used' >> ~/claude-log.txt"
          }
        ]
      }
    ]
  }
}

.claude/settings.json (project):

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/check-bash-safety.sh"
          }
        ]
      },
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/run-linter.sh"
          }
        ]
      }
    ]
  }
}

.claude/settings.local.json (local):

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "echo '[local] file being edited' >&2"
          }
        ]
      }
    ]
  }
}

When Claude Code is about to run the Bash tool, all of these fire:

  1. The user-scope echo '[user] bash tool used'.
  2. The project-scope ./scripts/check-bash-safety.sh.

When it's about to run Write or Edit:

  1. The project-scope ./scripts/run-linter.sh.
  2. The local-scope echo '[local] file being edited'.

The user-scope Bash hook and the project-scope Write hook are both matched by their respective tool calls. They don't override each other. They stack.

The order of execution is user, project, local. The order matters because if any hook blocks (returns a non-zero exit code with a permissionDecision of deny), the tool call is blocked and subsequent hooks don't run.

The mental model that finally clicked

Think of settings.json hooks as three sets of subscribers to the same event bus. Each scope is a subscriber list. When an event fires (a tool call, a user prompt, a stop), Claude Code walks all three lists in order, checks each matcher, and runs the ones that match. Any hook can veto the event.

This is important because it means:

  • You can't "turn off" a user-scope hook from a project scope. If you want a hook off, remove it from the file that added it.
  • You can add hooks in any scope without worrying about breaking the ones in other scopes.
  • If two hooks in different scopes try to modify the same thing (e.g., write to the same file), they'll both run. Race conditions are your problem.

When each hook event actually fires

The hook events documented by Anthropic are PreToolUse, PostToolUse, UserPromptSubmit, Stop, SubagentStop, Notification, PreCompact, and SessionStart. The docs list them; the docs don't always explain the ordering. Here's the flow for a typical Claude Code turn:

SessionStart      (fires once at session start)
     |
     v
UserPromptSubmit  (fires on each user message before Claude sees it)
     |
     v
[Claude reasons about the message]
     |
     v
PreToolUse        (fires before every tool call)
     |
     v
[Tool runs]
     |
     v
PostToolUse       (fires after every tool call, even on failure)
     |
     v
[Loop back to PreToolUse if Claude makes more tool calls]
     |
     v
Stop              (fires when Claude finishes its turn)

Along the way, Notification fires when Claude Code shows a system notification (like a permission prompt), and PreCompact fires when Claude Code is about to auto-compact the context window.

The SubagentStop is the one people miss. It fires when a subagent (spawned via the Agent tool) finishes. If you want a hook that fires when your top-level Claude finishes but not when subagents finish, use Stop alone. If you want both, subscribe to both.

The pre-commit-style pattern that works

A lot of teams try to use Claude Code hooks the way they use git pre-commit hooks: as a checkpoint before code changes ship. The pattern that works is putting the check in PostToolUse on Write|Edit, not PreToolUse.

Here's why. PreToolUse fires before the tool runs, so if you want to reject a bad edit, you have to know it's bad without seeing the resulting file. PostToolUse fires after the tool runs but before Claude proceeds, so you can lint the actual result and, if it's bad, tell Claude via the hook's output that the change needs fixing.

Working pattern for a linter check:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/lint-touched-files.sh"
          }
        ]
      }
    ]
  }
}

With scripts/lint-touched-files.sh doing something like:

#!/usr/bin/env bash
set -euo pipefail

# Claude Code sends the hook input as JSON on stdin.
input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path')

# Only lint files that exist and are code.
[[ ! -f "$file_path" ]] && exit 0
case "$file_path" in
  *.ts|*.tsx|*.js|*.jsx) ;;
  *) exit 0 ;;
esac

# Run the linter. If it fails, emit a message Claude can read.
if ! output=$(npx eslint "$file_path" 2>&1); then
  cat <<EOF
{
  "hookSpecificOutput": {
    "hookEventName": "PostToolUse",
    "additionalContext": "Lint failed on $file_path:\n$output\nFix the lint errors before proceeding."
  }
}
EOF
  exit 0
fi

The hook prints structured JSON that Claude Code injects back into the conversation as context. Claude sees the lint failure and (usually) fixes it in the next turn.

This is genuinely better than trying to prevent the edit up front. You let Claude write the code, you check it, and you feed the result back. That's the loop, not a gate.

The three patterns that survive team use

After watching a lot of teams add hooks and then rip them out three weeks later, three patterns hold up.

Pattern 1: keep project hooks stateless and idempotent

Hooks that write to shared files, mutate global state, or depend on ordering across events break in ways that are painful to debug. The hooks that survive are the ones that read input, produce output, and modify nothing outside their own scope.

Good project hook: "run the linter on the file that was just edited and report failures."

Bad project hook: "append every edited file to a shared audit log at /tmp/team-audit.log." The first team member to run Claude Code on a fresh machine will hit a missing-file error. Two team members running concurrently will race on the write.

If you want to log, log to per-session files. If you want to notify, notify via a service, not a shared file.

Pattern 2: put opinions in project, put preferences in local

Project scope is checked into git. It's the shared contract. Only put hooks there that everyone on the team must run.

Local scope is per-developer. Put personal preferences there: your own notification hook, your custom logging, your "remind me to commit" nudge on Stop.

The failure mode is putting personal preferences in project scope. Someone adds a "send a Slack message when Claude finishes" hook to .claude/settings.json, checks it in, and now everyone on the team is silently trying to hit a webhook they don't have credentials for.

Pattern 3: no hook should take longer than 500ms

Hooks run synchronously and block the tool call. A hook that takes 5 seconds adds 5 seconds to every Write or Edit. Multiplied across a Claude Code session with 30 edits, that's 2.5 minutes of dead time.

If you need to run something slow (a full test suite, a type check across the repo), don't do it in a hook. Fire an async job from the hook, or run it on Stop instead of PostToolUse so it only fires once per turn instead of once per edit.

We measure hook overhead with a wrapper:

#!/usr/bin/env bash
# scripts/timed-hook.sh <inner-command>
start=$(date +%s%N)
"$@"
code=$?
end=$(date +%s%N)
elapsed_ms=$(( (end - start) / 1000000 ))
echo "[hook-timing] $1 took ${elapsed_ms}ms" >&2
exit $code

Any hook that logs more than 500ms goes on the list to speed up or move to Stop.

Debugging hooks that don't fire

Six things to check, in order:

  1. Is the JSON valid? Run cat .claude/settings.json | jq and confirm it parses. A single trailing comma will silently disable the whole file.
  2. Is the matcher right? Matchers are regex against the tool name. "Bash" matches Bash. "Write|Edit" matches either. "*" matches all. "Read" matches Read but not the Grep tool (each tool is its own name).
  3. Is the hook script executable? chmod +x on the script if it's a file. If it's an inline command in the command field, it runs in the user's shell.
  4. Is the hook script exiting non-zero for the wrong reason? A hook that exits non-zero counts as a failure and its output goes into the context. If your script has a stray set -e and a benign command fails, the hook counts as failed.
  5. Is there a user-scope hook shadowing behavior? Check ~/.claude/settings.json. If there's a permissionDecision: "deny" in a user-scope hook that matches your tool call, the tool never runs.
  6. Are you looking at the right session's log? Claude Code logs hook execution to ~/.claude/logs/. tail -f the latest log while triggering the hook and you'll see whether it fired.

The pre-commit + settings.json anti-pattern

The pattern people reach for and shouldn't: putting a hook on Stop that runs git commit -m "claude changes". Auto-commit hooks are how you get repos full of 400 half-broken commits. Claude finishes a turn mid-refactor, the hook commits the broken state, you can't easily undo.

Better pattern: run a hook on Stop that lists changed files and reminds you to review before committing. Or, for team repos, run a Stop hook that runs the test suite and warns if anything broke. Human commits when human is ready.

Full working example: a team-safe project setup

Here's the .claude/settings.json we use on the claude-skills-hub project. It's short on purpose.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/lint-touched-files.sh"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/summarize-session.sh"
          }
        ]
      }
    ]
  },
  "permissions": {
    "allow": [
      "Bash(npm run *)",
      "Bash(git status)",
      "Bash(git diff*)",
      "Bash(git log*)",
      "Bash(ls*)"
    ]
  }
}
  • PostToolUse on Write and Edit lints the touched file.
  • Stop runs a session summary script that lists changed files and reminds the developer to review.
  • permissions.allow reduces the permission prompts for common safe commands. Nothing that could damage the repo is in the allowlist.

Developers add their own hooks in .claude/settings.local.json (which is in .gitignore). If someone wants a Slack notification on Stop, that goes in local, not project.

This setup has been in production on our team for two months. We've had zero "my hook broke your workflow" incidents.

Related reading

FAQ

Do hooks work with the Claude Code Web UI? No. Hooks are a CLI-only feature. The Web UI has no equivalent extension point today.

Can I have different hooks for different subdirectories in a monorepo? Not natively. Claude Code reads a single project .claude/settings.json from the repo root. You can work around this by having your hook script check the file path from the tool input and decide what to do.

What's the difference between settings.json and CLAUDE.md? CLAUDE.md is a context file that Claude reads as part of every conversation. It shapes what Claude knows and how it behaves. settings.json is a behavioral file that Claude Code the CLI reads. It shapes hooks, permissions, and defaults. See our CLAUDE.md guide for the content side.

Can hooks call other Claude Code instances? Yes, but be very careful about recursion. A hook that invokes claude will trigger its own hooks. If the hook doesn't have a stop condition, you'll loop.

Is there a way to test hooks without triggering them in a real session? Not officially. What we do: run the hook script directly with a synthetic JSON input on stdin. If it behaves correctly with that input, it'll behave correctly when Claude Code calls it.

Refund on the cheat sheet? Digital product, all sales final. If it didn't land for you, email team@clskills.in and we'll grant you access to the broader Skills Library as a goodwill gesture.

The Cheat Sheet is where the rest of this lives

160+ prompt patterns, each with the temperature, top_p, and system prompt we actually use, why we picked it, and what breaks when you get it wrong. If a lookup table is what you needed, this is the same thing at 20x the depth.

Get the Cheat Sheet, from $10 →Free 75-page guide first
More reading

Recent posts

Jul 12, 2026
Claude Fast Mode Removed July 24: What Breaks and Fix

Claude Opus 4.7 fast mode is deleted July 24, 2026. Requests error, no fallback. Here is the exact migration path to Opus 4.8 and the 3x price cut you get.

Read post →
Jul 10, 2026
Claude Prompt Caching: The Real Setup Guide (Cut API Costs Up to 90%)

How Claude's prompt caching actually works, when it saves you money, when it costs you more, and the exact break-point pattern that gets a 90% discount. Verified against Anthropic's official spec.

Read post →
Jul 9, 2026
Claude Fable + Token Monitoring: How to Cut Your Claude Code Bill Without Cutting Quality

Fable is the fast light Claude 5 model built for cost efficiency. Here is when to use Fable vs Sonnet vs Opus, how to monitor tokens live inside VS Code, and the honest math on what each saves.

Read post →