Hooks are commands Claude Code runs at fixed points in a session: before a tool call, after it, when a turn ends. They are how you format files, refuse edits to .env, and log shell commands without remembering to ask. Slash commands that a human types live in custom slash commands. This page is the automatic path.
Where hooks are configured
A hook definition has three levels: the event, a matcher group, and one or more handlers.
| Piece | Example |
|---|---|
| Event | PreToolUse, PostToolUse, Stop |
| Matcher | Tool name or notification type. Omit it only on events that ignore matchers |
| Handler | "type": "command" plus a shell command |
| File | Scope | Share with git |
|---|---|---|
~/.claude/settings.json |
All projects on this machine | No |
.claude/settings.json |
This repo | Yes |
.claude/settings.local.json |
This repo, you only | Gitignored when Claude Code saves a setting there |
On Windows, ~/.claude is %USERPROFILE%\.claude. Managed policy and plugins can add more hooks. The five examples below belong in the project file so the team shares them.
Handlers can be a shell command, an HTTP POST, a prompt, an agent, or an MCP tool. These examples use type: command because the script is visible in the repo. Command hooks receive the event JSON on stdin.
$CLAUDE_PROJECT_DIR is the project root Claude Code exports for hook commands. Quote it. Paths with spaces otherwise split.
mkdir -p .claude/hooks
chmod +x .claude/hooks/*.sh
On macOS and Linux the script must be executable. On native Windows, install Git for Windows. If a hook reports that Git Bash was not found, the hook can block the tool call until Bash is available. The install steps cover that installer.
/hooks lists what the session loaded. "disableAllHooks": true in a settings file turns hooks off at that level after precedence is applied. A managed policy can still force hooks on.
If you edit the settings file while a session is open, the file watcher usually reloads hooks on its own.
Events worth knowing
The reference documents many events. These are the ones the examples use, plus the neighbors you will see in /hooks.
| Event | When it fires | Matcher |
|---|---|---|
PreToolUse |
Before a tool runs. Can block it | Tool name: Bash, Edit, Write |
PostToolUse |
After a tool succeeds | Tool name |
Notification |
When Claude Code emits a notification | Notification type, including permission_prompt, idle_prompt, auth_success, agent_needs_input, agent_completed |
Stop |
When Claude finishes a response | None. It always runs |
SubagentStop |
When a subagent finishes | Agent type |
UserPromptSubmit |
When a prompt is submitted, before Claude handles it. Also on turns Claude Code starts itself | None. It always runs |
agent_completed fires only while agent view is open, so it is a poor “the task is done” signal. Use Stop for the end of a turn.
Also documented, and left out of the scripts here: SessionStart, SessionEnd, Setup, PostToolUseFailure, PermissionRequest, PermissionDenied, SubagentStart, PreCompact, PostCompact, StopFailure, FileChanged, ConfigChange. Matchers and JSON fields for those live in the hooks reference. EndConversation tool calls skip PreToolUse and PostToolUse.
A matcher is a JavaScript regular expression tested against the field, and a match anywhere in the value counts. Edit therefore also matches NotebookEdit. The patterns below use ^Edit$|^Write$ so only those two tools run the script. The reference accepts | between alternatives on tool events. Official examples often show the shorter Edit|Write. Use the anchored form when a longer tool name must stay out.
Stop and UserPromptSubmit ignore matchers. A matcher on those events does not filter them.
Common fields on stdin include session_id, cwd, and hook_event_name. Tool events add tool_name, tool_input, and tool_use_id. For Bash, the shell string is tool_input.command. For Edit and Write, the path is tool_input.file_path.
Exit codes and JSON
| Exit code | Result |
|---|---|
| 0 | Success. One JSON object on stdout is applied. Other stdout is plain text |
| 2 | Blocking error on events that can block. stderr is the reason, unless JSON already carried a decision reason |
| Anything else | Non-blocking error. The action continues, and the transcript shows a hook error |
On PreToolUse, this JSON denies the call without relying on exit 2:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Protected path"
}
}
permissionDecision may be deny, ask, or allow. Exit 2 still blocks even if the JSON says allow.
Stdout is JSON only when it is a single object and nothing else is printed. jq -n is a reliable way to print that object. Two log lines before the JSON turn the output into plain text.
On PostToolUse, the tool has already finished. Exit 2 does not undo the edit. It does feed stderr back to Claude. Exit 0 stdout from PostToolUse goes to the debug log, not to the model. UserPromptSubmit is different: plain-text stdout is added to context Claude can read, and exit 2 rejects the prompt.
Stop can block and ask Claude to keep going. After eight blocks in a row with no tool call between them, Claude Code continues anyway. The scripts in this lab exit 0 on Stop so they only notify.
Five hooks
The scripts assume jq is installed (brew install jq or your distro package). They are original wrappers around the stdin fields above.
1. Format with Prettier or Black
.claude/hooks/format-file.sh
#!/bin/bash
# Format the file Claude just wrote, by extension.
input=$(cat)
path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
[ -z "$path" ] && exit 0
[ -f "$path" ] || exit 0
case "$path" in
*.js|*.jsx|*.ts|*.tsx|*.css|*.md|*.json|*.yml|*.yaml)
npx prettier --write "$path" || exit 0
;;
*.py)
if command -v black >/dev/null 2>&1; then
black "$path" || exit 0
fi
;;
esac
exit 0
Formatter failures exit 0 on purpose. A missing Prettier install should not look like a blocked edit. If you want Claude to see a formatter crash, exit 2 and print the error on stderr instead.
Register it on PostToolUse:
{
"hooks": {
"PostToolUse": [
{
"matcher": "^Edit$|^Write$",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-file.sh",
"timeout": 30
}
]
}
]
}
}
timeout is seconds. The default for a command hook is 600, which is too long for a formatter. A Bash command that rewrites a file does not match Edit or Write. A FileChanged hook is the event that watches a path no matter which tool wrote it.
Prettier and Black both rewrite the file on disk after Claude’s edit. The next read sees the formatted version.
2. Block edits to protected files
.claude/hooks/protect-files.sh
#!/bin/bash
# Deny Edit/Write on secrets, lockfiles, and git internals.
input=$(cat)
path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
path=${path//\\//}
case "$path" in
*.env|*.env.*|*/.env|*/.env.*)
echo "Blocked: $path is an env file. Edit a sample template instead." >&2
exit 2
;;
*/.git/*|*.git/*)
echo "Blocked: $path is inside .git." >&2
exit 2
;;
*package-lock.json|*pnpm-lock.yaml|*yarn.lock|*poetry.lock|*Cargo.lock)
echo "Blocked: $path is a lockfile. Update it through the package manager." >&2
exit 2
;;
esac
exit 0
{
"hooks": {
"PreToolUse": [
{
"matcher": "^Edit$|^Write$",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
}
]
}
}
Claude receives the stderr text and can pick another path. This does not stop a Bash command such as rm .env or a redirect. Add a second PreToolUse group with matcher ^Bash$ if shell writes to those paths matter. For a hard deny that still exits 0, print the permissionDecision JSON from the exit-code section and exit 0.
3. Log shell commands
.claude/hooks/log-bash.sh
#!/bin/bash
# Append each Bash command to a local log. Never block.
input=$(cat)
log="${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/bash-log.jsonl"
printf '%s\n' "$input" | jq -c '{t: (now | todate), cmd: .tool_input.command, cwd: .cwd}' >> "$log"
exit 0
Add .claude/hooks/bash-log.jsonl to .gitignore. The log will contain whatever Claude put in the command string, including secrets if a command embeds them.
{
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/log-bash.sh"
}
]
}
]
}
}
if can narrow this further on tool events, using permission-rule syntax such as Bash(git *). Without if, every Bash call appends a line. An if that does not match skips the process entirely.
4. Notify when the turn finishes
Stop has no matcher. Keep the command short. The default timeout is long, and a stuck notifier holds the end of the turn.
macOS, after Script Editor is allowed to notify:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Turn finished\" with title \"Claude Code\"'",
"timeout": 5
}
]
}
]
}
}
Linux, when a notification daemon is running:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "notify-send 'Claude Code' 'Turn finished'",
"timeout": 5
}
]
}
]
}
}
Windows PowerShell opens a dialog, which can land behind the terminal:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "powershell.exe -NoProfile -Command \"[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null; [System.Windows.Forms.MessageBox]::Show('Turn finished','Claude Code') | Out-Null\"",
"timeout": 15
}
]
}
]
}
}
notify-send does nothing useful over SSH or in a container. Test the command in a normal terminal before you put it in settings. A Notification hook with matcher permission_prompt is the separate case “Claude is waiting for you”, which is what you want during a long pause, not at the end.
SubagentStop is the matching event for a subagent finishing. Matcher is the agent name, for example code-reviewer. A Stop hook in that agent’s own front matter is delivered as SubagentStop.
5. Run tests after an edit
Running a full suite after every keystroke is noisy. This script runs only for Python and JavaScript test files, and only when a known entrypoint exists.
.claude/hooks/test-after-edit.sh
#!/bin/bash
# Re-run a narrow test file after Claude edits it.
input=$(cat)
path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
case "$path" in
*test_*.py|*tests/*.py)
pytest "$path" >&2 || exit 2
;;
*.test.js|*.test.ts|*.spec.js|*.spec.ts)
# Extra args are appended to the npm test script. Point this at the
# runner binary if that script ignores a file path.
npm test -- "$path" >&2 || exit 2
;;
esac
exit 0
Pytest writes to stderr here so a failure is the blocking reason Claude reads. Exit 2 on PostToolUse does not revert the file. It tells Claude the edit landed and the test command failed.
{
"hooks": {
"PostToolUse": [
{
"matcher": "^Edit$|^Write$",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/test-after-edit.sh",
"timeout": 120
}
]
}
]
}
}
Replace npm test with the runner your repo already documents. A slash command named /test is the better fit when a human should decide to run the suite. The hook is for the edits you do not want to merge without a red/green check.
One settings file
Merge the groups. Two PostToolUse groups are normal. Handlers in a group run when the matcher matches.
{
"hooks": {
"PreToolUse": [
{
"matcher": "^Edit$|^Write$",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" }
]
},
{
"matcher": "^Bash$",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/log-bash.sh" }
]
}
],
"PostToolUse": [
{
"matcher": "^Edit$|^Write$",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-file.sh", "timeout": 30 },
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/test-after-edit.sh", "timeout": 120 }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "notify-send 'Claude Code' 'Turn finished'", "timeout": 5 }
]
}
]
}
}
Swap the Stop command for the macOS or Windows line if you are not on Linux. Ask Claude to edit a file under src/ and confirm the log grew and the formatter ran. Ask it to edit .env and confirm the tool call is refused.
Hooks are local automation. They are not a substitute for CI, and they are not an MCP server. An MCP server adds tools Claude can call. A hook wraps tools Claude already has.
FAQ
Where do Claude Code hooks go?
Add a hooks object to .claude/settings.json to share hooks with the repo, or to ~/.claude/settings.json to run them in every project. .claude/settings.local.json holds personal project overrides and is gitignored when Claude Code writes a setting there. Run /hooks in a session to see the merged list. Set disableAllHooks to true to turn hooks off at that settings level.
What is the difference between exit code 0 and exit code 2?
Exit 0 means success. If stdout is one JSON object, Claude Code applies those fields. Exit 2 blocks events that can block, and stderr is the reason Claude sees. Any other exit code is a non-blocking error: the tool call still proceeds, unless that hook is configured so failure blocks. On PreToolUse, a valid JSON permissionDecision can deny the call even when the process exits 0.
Which hook runs before Claude edits a file?
PreToolUse, with a matcher for the Edit and Write tools. The script reads JSON on stdin. The target path is tool_input.file_path. Exit 2 to cancel the edit and print the reason on stderr. PostToolUse runs only after the tool has already succeeded, so it is the right place to format, not to prevent the write.
Can a hook run Prettier or Black?
Yes. A PostToolUse command hook can read tool_input.file_path and run Prettier on JavaScript and TypeScript files or Black on Python files. The hook’s stdout is not shown to Claude on exit 0. If the formatter fails and you exit 2, Claude sees stderr even though the edit already landed.
Do Stop and UserPromptSubmit accept a matcher?
No. Stop and UserPromptSubmit fire on every occurrence. Matchers apply to tool events such as PreToolUse and PostToolUse, where the pattern is matched against the tool name, and to Notification, where it is matched against the notification type. SubagentStop matches the agent type.
How this guide was verified
Written by hand from the official documentation, every command re-checked, and reviewed a second time before publishing. Last reviewed on .
Official sources read for this article

