Claude Code Hooks: 10 Production-Ready Scripts for 2026

Last updated: April 15, 2026

Claude Code Hooks That Actually Earn a Spot

TLDR verdict: Hooks are shell scripts Claude Code runs before or after a tool call. A small, well-chosen set catches dangerous commands, keeps the tree linted, runs tests automatically, and audits every action to a log file. Ten hooks cover 90 percent of what most teams want from the hook system.

This page ships ten production-ready hooks with the JSON config for each one. Copy, paste, adjust paths, and you have a safer and more automated agent in twenty minutes.

Hook basics

Four hook types:

  • PreToolUse - runs before a tool call. Exit 0 allows the call, non-zero blocks it.
  • PostToolUse - runs after a tool call. Useful for side effects like lint, format, notify.
  • Notification - runs when the agent emits a status change.
  • Stop - runs when the session ends.

Hooks live in .claude/settings.json under the hooks key. The project file overrides the global ~/.claude/settings.json. For team rules, commit the project file; for personal rules, keep them in the global.

A hook script gets the tool call as JSON on stdin and can write to stdout (captured by the agent) or stderr (shown to you). A non-zero exit code blocks the tool call in PreToolUse.

Hook 1: block dangerous shell commands

First line of defense. Stops rm -rf /, dd to the wrong device, and other catastrophes.

Config in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": ".claude/hooks/block-dangerous.sh" }]
      }
    ]
  }
}

Script at .claude/hooks/block-dangerous.sh:

#!/usr/bin/env bash
cmd=$(jq -r .tool_input.command)
if echo "$cmd" | grep -qE 'rm\s+-rf\s+/($|\s)|mkfs|dd\s+.*of=/dev/'; then
  echo "Blocked dangerous command: $cmd" >&2
  exit 1
fi
exit 0

The regex is narrow on purpose. rm -rf ./build is fine; rm -rf / is not. Tune for your repo.

Hook 2: auto-lint after every edit

Every file the agent touches gets linted immediately. Catches style issues in the same session rather than in the CI log.

Config:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": ".claude/hooks/lint.sh" }]
      }
    ]
  }
}

Script:

#!/usr/bin/env bash
file=$(jq -r .tool_input.file_path)
case "$file" in
  *.ts|*.tsx|*.js|*.jsx) pnpm eslint "$file" --fix ;;
  *.py) ruff check --fix "$file" ;;
  *.go) gofmt -w "$file" ;;
esac
exit 0

Always exit 0. A failed lint should not block the agent; you want the signal in the log so the agent sees it and reacts.

Hook 3: auto-run tests for touched test files

When the agent edits a test, run just that test. Fast feedback loop.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": ".claude/hooks/run-test.sh" }]
      }
    ]
  }
}

Script:

#!/usr/bin/env bash
file=$(jq -r .tool_input.file_path)
if [[ "$file" == *.test.ts || "$file" == *.spec.ts ]]; then
  pnpm vitest run "$file"
fi
exit 0

For Python, check for test_*.py and run pytest on the single file. Keeps test feedback tight without running the full suite on every edit.

Hook 4: block commits to main

Branch protection lives on the server side, but an agent that tries to push to main still wastes time. Block it locally.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": ".claude/hooks/no-main-push.sh" }]
      }
    ]
  }
}

Script:

#!/usr/bin/env bash
cmd=$(jq -r .tool_input.command)
if echo "$cmd" | grep -qE '^git\s+(push|commit).*\b(main|master)\b'; then
  branch=$(git rev-parse --abbrev-ref HEAD)
  if [[ "$branch" == "main" || "$branch" == "master" ]]; then
    echo "Refusing to commit or push to $branch from local hook" >&2
    exit 1
  fi
fi
exit 0

This catches the common failure mode: agent forgot to branch, tries to commit directly.

Hook 5: log every shell command

Auditing is the cheap answer to the question "what did the agent actually do?" A PostToolUse hook writes every shell command to a log file.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": ".claude/hooks/log-bash.sh" }]
      }
    ]
  }
}

Script:

#!/usr/bin/env bash
ts=$(date -u +%FT%TZ)
cmd=$(jq -r .tool_input.command)
echo "$ts $cmd" >> .claude/bash.log
exit 0

A week of logs often reveals patterns worth turning into skills or hooks.

Hook 6: require approval for git push

Even if branch protection blocks a bad push, the attempt burns time. Make the agent wait for human approval on every push.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": ".claude/hooks/confirm-push.sh" }]
      }
    ]
  }
}

Script:

#!/usr/bin/env bash
cmd=$(jq -r .tool_input.command)
if echo "$cmd" | grep -qE '^git\s+push'; then
  read -r -p "Agent wants to run: $cmd [y/N] " ans < /dev/tty
  [[ "$ans" == "y" ]] || { echo "Push cancelled by user" >&2; exit 1; }
fi
exit 0

The read < /dev/tty is important because the hook stdin is the tool-call JSON; you need the terminal explicitly.

Hook 7: auto-format writes with prettier

Every file the agent writes gets formatted immediately.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "hooks": [{ "type": "command", "command": ".claude/hooks/format.sh" }]
      }
    ]
  }
}

Script:

#!/usr/bin/env bash
file=$(jq -r .tool_input.file_path)
case "$file" in
  *.ts|*.tsx|*.js|*.jsx|*.json|*.md) npx prettier --write "$file" ;;
esac
exit 0

Works well with Hook 2; lint first, then format. The order matches CI.

Hook 8: sandbox writes to the project directory

Blocks the agent from writing outside the project directory. Helpful on laptops where a stray path could touch dotfiles or system locations.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [{ "type": "command", "command": ".claude/hooks/sandbox.sh" }]
      }
    ]
  }
}

Script:

#!/usr/bin/env bash
file=$(jq -r .tool_input.file_path)
project=$(pwd)
case "$file" in
  "$project"*) exit 0 ;;
  *) echo "Refusing write outside project: $file" >&2; exit 1 ;;
esac

Absolute paths outside the project get rejected. Relative paths resolve against CWD, which is the project root.

Hook 9: Slack notification on task completion

For long-running agent tasks, ping Slack when the session ends.

{
  "hooks": {
    "Stop": [
      { "hooks": [{ "type": "command", "command": ".claude/hooks/slack-notify.sh" }] }
    ]
  }
}

Script:

#!/usr/bin/env bash
WEBHOOK="$SLACK_WEBHOOK_URL"
repo=$(basename "$(pwd)")
curl -s -X POST -H 'Content-type: application/json' \
  --data "{\"text\":\"Claude finished a session in $repo\"}" \
  "$WEBHOOK" > /dev/null
exit 0

Put the webhook URL in an env var loaded from .env.local, never in the committed script.

Hook 10: auto-diff after every edit

Show the diff inline after every edit so you can spot unexpected changes without waiting for a formal review.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": ".claude/hooks/show-diff.sh" }]
      }
    ]
  }
}

Script:

#!/usr/bin/env bash
file=$(jq -r .tool_input.file_path)
git --no-pager diff -- "$file" | head -40
exit 0

Top 40 lines of diff keeps the output readable. For large changes, the agent can run git diff explicitly.

Combining hooks

All ten can coexist. The order of execution is the order they appear in settings.json for each type. Typical order for PostToolUse on Write or Edit: auto-diff, lint, format, test, log.

For PreToolUse on Bash: block-dangerous, no-main-push, confirm-push. Order matters because an early exit 1 stops the rest.

One thing to watch: a hook that exits non-zero on a false positive blocks real work. Log hooks should always exit 0. Guard hooks should exit 1 only on confirmed matches.

Testing hooks before deploy

Hooks that fail silently are a drag. Before shipping any hook to a team:

  1. Write the script.
  2. Feed it a sample JSON payload via stdin: echo \'{"tool_input":{"command":"rm -rf /"}}\' | ./hook.sh
  3. Check exit code and stderr.
  4. Wire it into settings.json and run a trivial session against it.
  5. Review the log to confirm the hook fired exactly once per matching tool call.

Skip testing and you will find a hook that never runs or one that runs twice per call. Both waste time.

Short rules

One hook, one job.

Always read stdin as JSON.

Log liberally, block sparingly.

Never embed secrets in the script.

Commit project hooks to git.

Where this fits

Hooks are the safety layer around an otherwise free-form agent. They do not replace code review; they catch the obvious mistakes fast. A team that runs these ten hooks finds that the agent stays inside the lines without a human tapping it on the shoulder, which is the point. The cost is ten scripts, a hundred lines of JSON, and a half-hour of setup. The reward is an agent you can let run on larger tasks without holding your breath.

Why hooks matter more than skills for safety

Skills change what the agent wants to do. Hooks change what the agent is allowed to do. That distinction is the whole reason hooks exist as a separate primitive. A clever prompt can make the agent ignore a skill, but a hook that exits non-zero before a Bash call is a hard stop that the agent cannot argue with. For any action whose failure would be expensive (pushes to main, writes outside the project, curl calls to external services with credentials in them) the hook is the right tool. The skill is guidance; the hook is law. When the two disagree, the hook wins every time. This is why most production Claude Code setups grow in a specific order: start with a strong CLAUDE.md, add a few skills for patterns, then add hooks as the agent gets to run longer and farther away from the human gaze. By the time the agent is opening PRs overnight, the hooks are what keep the blast radius contained.

One more pattern: hooks as telemetry

Beyond blocking and auto-running, hooks double as a cheap telemetry surface. A PostToolUse hook that writes JSON lines to a file gives you a full audit trail of what the agent did, in a format that feeds straight into any log aggregator. Five minutes of jq analysis over a week of logs reveals the tool calls that fail most often, the files the agent reads most often, and the shell commands it runs most often. That data is gold for improving CLAUDE.md and for deciding which skills to write next. Most teams never look at this data because they never set up the hook. Set it up once and you will find uses for it every week.

Short closing

Ten hooks. One afternoon to install. Clear payoff from day one.

Pick the five most valuable for your team, commit them, iterate. You will know the hook setup is right when nobody on the team notices it anymore.

The agent just stays inside the lines, and you stop thinking about whether it might not.

Frequently asked questions

Where does Claude Code read hook config from?

`.claude/settings.json` in the project root takes precedence, then `~/.claude/settings.json` for personal defaults. Both are merged, with project rules overriding global ones.

Can a hook modify the tool call payload?

No. Hooks only decide allow or block via exit code and can write audit output. To change tool behavior, rewrite the prompt, use a skill, or wrap the tool in an MCP server.

Do hooks run in headless mode?

Yes. Headless sessions execute hooks the same way interactive ones do. Hooks that need a TTY (for prompts) should detect headless mode and skip the interactive step.

What if jq is not installed?

Rewrite the script to parse JSON with a tool you have. Python one-liners work fine: `python3 -c 'import sys,json; print(json.load(sys.stdin)["tool_input"]["command"])'` as a drop-in for jq.

Can hooks call each other?

Indirectly. A hook script is just a shell script, so you can source helpers or invoke other scripts. The hook system does not chain hooks; it runs every registered hook for the matching type.

How do I disable a hook temporarily?

Comment it out of settings.json or rename the script so the command is not found. The hook system does not have a disable flag, so file moves are the fastest workaround.