Claude Code Hooks: Full Reference for Safety and Automation
Last updated: April 15, 2026
Claude Code Hooks
Quick answer
Hooks are shell commands that fire on Claude Code tool lifecycle events. There are four hook types: PreToolUse (before a tool runs, can block with exit code 1), PostToolUse (after a tool runs, cannot block), Notification (when Claude wants to alert the user), and Stop (when the session ends). Hooks are configured in .claude/settings.json under the hooks key. They are the right place for safety rails, lint-on-save, test runners, and session logging.
What hooks are for
Hooks let external systems observe and interact with what Claude Code is doing; they are one of the few deterministic controls available to you in an agent loop where the model itself is free to decide what action to take next, which matters because a CLAUDE.md rule that says "never run rm -rf" is still advisory to the model, whereas a PreToolUse hook that pattern-matches rm -rf and exits with code 1 is a hard stop enforced by your shell, independent of whatever reasoning Claude produced; the same hardness applies to lint enforcement, to secret scanning, to test-after-edit, and to every other policy where the cost of a rare miss is higher than the cost of the latency that hook execution adds to each tool call. The main use cases:
- Block dangerous shell commands before they run.
- Run linters and formatters after every edit so code stays clean.
- Trigger tests on relevant file changes.
- Log session activity for audit or cost tracking.
- Notify a human or a service when something happens.
The alternative to hooks is adding the rule to CLAUDE.md and hoping Claude follows it. Hooks are deterministic: a shell script with exit code 1 blocks the action every time, regardless of what Claude decides to do.
The four hook types
PreToolUse
Fires before Claude calls a tool. Receives the tool name and input arguments as a JSON object on stdin. The hook can:
- Exit 0: allow the tool call to proceed.
- Exit 1: block the tool call. Anything the hook wrote to stdout becomes feedback to Claude, so Claude knows why the call failed and can adapt.
- Exit 2+: treated as a fatal error and surfaces in the session.
This is the hook type for safety: dangerous command blockers, secret scanners, path-restriction checks.
PostToolUse
Fires after Claude calls a tool. The tool has already executed, so this hook cannot block. Receives tool name, input, and output as JSON on stdin. Use it for:
- Running lint or format on edited files.
- Running the relevant test file after a code edit.
- Updating a local audit log.
- Triggering a CI-like check.
PostToolUse stdout is not fed back to Claude by default. If you want Claude to see the output, write to stderr instead and exit non-zero, which surfaces the output in the session.
Notification
Fires when Claude wants to alert the user, for example when it needs approval on a long-running task or has produced a result the user should see. The hook receives the notification message on stdin. Common uses:
- Post a desktop notification via
osascripton macOS. - Send an alert to a Slack webhook when a long task finishes.
- Flash the terminal title bar.
Stop
Fires when the session ends, whether by the user quitting or the agent deciding work is done. Good for cleanup and reporting:
- Log the session's cost and duration.
- Remove any temporary worktrees created during the session.
- Post a session summary to a shared channel.
- Invalidate stale caches.
Configuration
All hooks live in .claude/settings.json under the hooks key. Each event type is an array; each entry has a matcher (regex against the tool name) and a command:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"command": ".claude/hooks/pre-bash.sh"
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"command": ".claude/hooks/post-edit-lint.sh"
},
{
"matcher": "Edit",
"command": ".claude/hooks/run-related-tests.sh"
}
],
"Notification": [
{
"command": ".claude/hooks/notify-slack.sh"
}
],
"Stop": [
{
"command": ".claude/hooks/log-cost.sh"
}
]
}
}
The matcher field is optional for Notification and Stop since those events are not tool-specific. For PreToolUse and PostToolUse, leave it off to match every tool.
stdin payload format
PreToolUse stdin is a JSON object with the shape:
{
"tool": "Bash",
"input": { "command": "git status", "description": "check state" }
}
PostToolUse stdin adds the output:
{
"tool": "Edit",
"input": { "file_path": "src/foo.ts", "old_string": "...", "new_string": "..." },
"output": { "success": true }
}
Parse with jq or a scripting language.
Exit code behavior
Exit codes carry meaning in PreToolUse hooks:
0: permitted. The tool call runs.1: blocked. stdout is fed back to Claude as an error message.2+: fatal error. Surfaces in the session as a hook failure.
For PostToolUse, Notification, and Stop, exit codes are logged but do not change session flow.
Five working hook examples
Example 1: block destructive shell commands
.claude/hooks/pre-bash.sh:
#!/usr/bin/env bash
set -euo pipefail
cmd=$(jq -r '.input.command // empty')
blocklist=(
'rm -rf /'
'rm -rf ~'
'rm -rf \*'
'git push --force'
'git reset --hard'
'DROP TABLE'
'DROP DATABASE'
'npm publish'
':(){:|:&};:'
)
for pat in "${blocklist[@]}"; do
if echo "$cmd" | grep -qiE "$pat"; then
echo "Blocked: command matches dangerous pattern '$pat'. Pick a safer alternative."
exit 1
fi
done
exit 0
Make it executable with chmod +x .claude/hooks/pre-bash.sh and reference it from settings.json under PreToolUse with matcher Bash.
Example 2: lint on save
.claude/hooks/post-edit-lint.sh:
#!/usr/bin/env bash
set -euo pipefail
file=$(jq -r '.input.file_path // empty')
if [[ -z "$file" ]]; then exit 0; fi
if [[ "$file" == *.ts || "$file" == *.tsx || "$file" == *.js || "$file" == *.jsx ]]; then
pnpm eslint --fix "$file" >&2 || exit 1
fi
exit 0
This runs ESLint on every edited TypeScript or JavaScript file and reports any errors back to Claude by writing to stderr and exiting non-zero.
Example 3: run related tests after an edit
.claude/hooks/run-related-tests.sh:
#!/usr/bin/env bash
set -euo pipefail
file=$(jq -r '.input.file_path // empty')
if [[ -z "$file" ]]; then exit 0; fi
test_file="${file%.ts}.test.ts"
if [[ -f "$test_file" ]]; then
pnpm vitest run "$test_file" >&2 || exit 1
fi
exit 0
Fires on Edit and runs the matching *.test.ts if it exists. Fast feedback, no whole-suite run.
Example 4: Slack notification on Stop
.claude/hooks/notify-slack.sh:
#!/usr/bin/env bash
set -euo pipefail
if [[ -z "${SLACK_WEBHOOK:-}" ]]; then exit 0; fi
msg="Claude Code session ended in $(basename $PWD) at $(date +%H:%M)."
curl -s -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"$msg\"}" \
"$SLACK_WEBHOOK"
exit 0
Requires SLACK_WEBHOOK in your shell environment or in the env field of settings.json.
Example 5: cost logger
.claude/hooks/log-cost.sh:
#!/usr/bin/env bash
set -euo pipefail
log_file="$HOME/.claude/logs/cost.log"
mkdir -p "$(dirname $log_file)"
project=$(basename $PWD)
timestamp=$(date +%Y-%m-%dT%H:%M:%S)
echo "$timestamp $project session ended" >> "$log_file"
exit 0
Over time the log gives you a simple cost trail per project, which you can grep and total.
Debugging hooks
Hook scripts run with your shell's working directory set to the project root. To debug:
- Test the script manually. Pipe a sample JSON payload into it:
echo '{"input":{"command":"rm -rf /"}}' | .claude/hooks/pre-bash.sh. Confirm exit code withecho $?. - Add debug logging. Write to
/tmp/hook-debug.logfrom inside the script to capture what the hook sees. - Check
/statusinside a session. It shows which hooks are loaded. - Check exit codes. Non-zero exits in PreToolUse block the action; they can also accidentally block benign calls if your script has a bug.
Security
Hooks run with your shell's permissions. A malicious hook in a committed .claude/settings.json could delete files, exfiltrate secrets, or chain into anything your user account can do. Audit any settings.json you inherit before running Claude Code in that repo.
Guidelines:
- Read every hook script before merging a PR that adds one.
- Keep hook scripts small and readable.
- Avoid
evalon input; always quote variables with"$var". - Do not fetch secrets inside a hook; use environment variables that were already exported.
Hooks vs skills
Both add behavior to Claude Code, but they trigger differently:
- Hooks: automatic, on every matching event. No user action.
- Skills: manual, invoked by typing
/skill-name.
A "lint on save" rule belongs as a PostToolUse hook, not a skill, because you want it every time without remembering to ask for it. A "write a PR description" rule belongs as a skill, since you only need it when you actually open a PR.
Global vs project hooks
Hooks in ~/.claude/settings.json apply to every session you run. Hooks in a project's .claude/settings.json apply only inside that repo. When both exist, entries are concatenated, so a global "block rm -rf" hook plus a project-specific "run pnpm test" hook both fire.
If you have a security-oriented hook you trust, put it in global settings so it protects every project you open, including ones with no local settings.
Hook execution order
Within a single event type, hooks run in the order they appear in settings.json. If any PreToolUse hook exits non-zero, the remaining hooks still run (so multiple safety checks layer together) but the tool call is blocked.
For PostToolUse, all hooks run regardless of exit codes, but stderr from any hook is surfaced in the session.
Frequently asked questions
What is the stdin format for Claude Code hooks?
Hooks receive a JSON object on stdin. PreToolUse gets `{tool, input}`, PostToolUse adds `output`, Notification gets a message string, and Stop gets minimal session metadata. Parse with `jq` or any scripting language.
Can a hook modify the tool input before Claude runs it?
No. Hooks can permit or block a tool call, but they cannot rewrite the arguments. To influence what Claude does next, exit non-zero from PreToolUse and write an explanatory message to stdout so Claude retries with different input.
How do I test a hook without running a full session?
Pipe a sample JSON payload into the script manually: `echo '{"input":{"command":"rm -rf /"}}' | .claude/hooks/pre-bash.sh`. Check the exit code with `echo $?` and verify any side effects like log writes or API calls.
Do hooks work in CI with --dangerously-skip-permissions?
Yes. Hooks fire regardless of permission mode, which makes them the main safety net in headless runs where approval prompts are suppressed. A PreToolUse hook with exit code 1 still blocks the call even when permissions are skipped.
Should I put hooks in global or project settings?
Security-oriented hooks like dangerous-command blockers belong in `~/.claude/settings.json` so they protect every project. Project-specific hooks like `pnpm lint` go in `.claude/settings.json` and ship with the repo.
In what order do hooks execute?
Hooks run in the order listed in settings.json for the matching event type. If any PreToolUse hook exits non-zero, the tool call is blocked but remaining hooks still run, so multiple safety checks can layer together without short-circuiting.