9 min · +150 XP
Hooks: things that must always happen
Commands Claude Code runs by itself at fixed moments, whatever the model decides.
By the end of this lesson
You will be able to add a hook that formats files or blocks edits to a protected file, check that it fires, and avoid the exit-code mistake.
A rule in CLAUDE.md is a request: the model usually follows it, but it can forget. A hook is a command that Claude Code itself runs at a fixed moment, such as after every file edit or before every shell command. Use hooks for things that must happen every time.
Events you will use most
- 1PreToolUse: before a tool call runs. It can block the call.
- 2PostToolUse: after a tool call succeeded, for example after Edit or Write. Good for formatting.
- 3Stop: whenever Claude finishes responding, not only at the end of a whole task.
- 4Notification: when Claude Code sends a notification, for example when it waits for your input or permission.
- 5SessionStart: when a session begins or resumes. Its output can add context.
- 6UserPromptSubmit: when you send a prompt, before Claude processes it.
A first hook: format after every edit
“Add a hook to this project's .claude/settings.json that runs Prettier on every file you edit or write. Check that jq and Prettier are available, test the hook on one file, and show me the hook you added.”
Claude writes a hook like the one below and checks it. The rest of the lesson explains what it wrote, so you can review it.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}Claude Code passes details of the event to the command as JSON on standard input. Here jq takes the path of the edited file and Prettier reformats it. The matcher limits the hook to the Edit and Write tools; matchers are case-sensitive, and the example needs jq and Node.js installed.
Check that it works
- 1Type /hooks in Claude Code. The hook should appear under PostToolUse.
- 2Ask Claude to add a line with single-quoted strings to a JavaScript file.
- 3Open the file: with Prettier's default settings the quotes have become double. A successful hook prints nothing in the conversation.
- 4If the hook does not appear, ask Claude: "the formatting hook does not fire; find out why".
Blocking an action, and the exit-code trap
“Add a PreToolUse hook that blocks any edit to .env, package-lock.json and files in .git/. The script must exit with code 2 and explain the reason on stderr. Make it executable, test it with a sample JSON input, and show me the exit code.”
Claude writes a script like the one below, registers it and proves that it blocks.
#!/bin/bash
# .claude/hooks/protect-files.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
for pattern in ".env" "package-lock.json" ".git/"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
exit 2
fi
done
exit 0The script is registered as a PreToolUse hook with the matcher Edit|Write and the command "$CLAUDE_PROJECT_DIR"/.claude/hooks/protect-files.sh, and on macOS and Linux it must be executable (chmod +x); Claude does both when you ask. When Claude tries to edit .env, the edit is stopped before it runs and the Blocked: message reaches Claude, which can then choose another approach.
What the exit code means
- 10: no objection. For PreToolUse this is not an approval; the normal permission check still applies.
- 22: block. Write the reason to stderr; on events that can be blocked, Claude receives it as feedback.
- 3Any other non-zero code, such as 1: a non-blocking error. The action goes ahead and the transcript shows a hook error notice.
- 4Exception: a hook with "onFailure": "block" blocks the action when it fails.
A guard script that ends with exit 1 when it finds a problem does not stop anything: the edit goes through with an error notice. A blocking hook must exit 2. Test it by piping sample JSON into it: echo '{"tool_name":"Edit","tool_input":{"file_path":".env"}}' | ./protect-files.sh; echo $? should print 2.
~/.claude/settings.json applies to all your projects; .claude/settings.json belongs to one project and can be committed; .claude/settings.local.json is for one project and stays on your machine. Plugins, skills and subagents can carry hooks as well. "disableAllHooks": true switches them off.
Command hooks execute with your full user permissions and can read, change or delete anything your account can. Read every hook before you add it, including the hooks inside plugins. An interactive session holds settings hooks back until you trust the folder, but claude -p shows no trust dialog, so the hooks in a cloned repository's .claude/settings.json run. Review the .claude/ folder first, or use --bare.
Besides command hooks there are http, mcp_tool, prompt (a single-turn model check) and agent (experimental) types. A hook's deny holds in every permission mode, including bypassPermissions. When several hooks match, they run in parallel and the most restrictive decision wins: deny, then defer, ask, allow. Default timeouts are 10 minutes for command hooks and 30 seconds for UserPromptSubmit. PostToolUse cannot undo an action that already ran. /goal is itself a session-scoped prompt-based Stop hook.
Your PreToolUse guard finds a forbidden edit and ends with exit 1. What happens?
Which hook fits "run the formatter after every file Claude edits"?
Facts checked against the official Claude Code docs October 24, 2026 · source

Author
Evgenii Arsentev
PhD · Chief Executive Officer, digital health