Hooks in Claude Code: Deterministic Control Over Every Action
Use Claude Code's lifecycle hooks to run formatters, block dangerous operations, and enforce team conventions — every time, without relying on Claude to remember.
This lesson is original educational writing based on this video by Anthropic (published May 7, 2026). All credit for the original content goes to the creators.
1. Why hooks exist
If you have spent any time with Claude Code you have probably added a line to your CLAUDE.md
that says something like “always run prettier after editing TypeScript files”. It works — until
it doesn’t. Claude is probabilistic. When a session is long, the context window is full, or
the model simply miscalibrates its confidence, it may skip a step you thought was a permanent
rule. This is not a bug; it is the nature of large language models.
Hooks are the solution. They are shell commands that Claude Code executes at specific points in its lifecycle, regardless of what Claude thinks is appropriate. The distinction matters: a CLAUDE.md instruction is advice Claude reads and interprets; a hook is a mechanism the host process invokes directly. Claude has no say in whether the hook runs.
This creates a clean division of responsibility. Put preferences and conventions in CLAUDE.md — things where Claude’s judgment adds value (“prefer functional components over class components”, “write tests before implementation”). Put non-negotiable side effects in hooks — things where 100% consistency is what matters (“format every file Claude edits”, “block commits to main”).
2. The hook lifecycle
Claude Code exposes five lifecycle events. Understanding when each fires — and what information your hook script receives — is the foundation of everything else.
Each event in detail:
user-prompt-submit fires when you press Enter to send a message, before Claude has
processed it at all. This is useful for validating or transforming the prompt itself — for
example, automatically appending project context or enforcing a prompt policy.
pre-tool-use fires immediately before Claude would execute any tool: a file edit, a bash
command, a search. Your hook receives the tool name and full tool input as JSON on stdin. If
your hook exits with code 2, the tool call is blocked and Claude receives your stderr as
feedback explaining why. This is the hook for preventing dangerous operations.
post-tool-use fires after a tool call completes successfully. This is the most commonly
used event. It receives the tool name and its result. Use it for any side effect that should
accompany a tool — formatting a file Claude just edited, logging activity, updating an index.
notification fires when Claude sends a notification, typically when it is waiting for
input or has completed a long-running task and wants to alert you.
stop fires when Claude finishes its response entirely. A good place for summary reporting,
cleaning up temp files, or sending a system notification when a long agentic run completes.
3. Configuring hooks
Hooks are defined in .claude/settings.json. If you commit that file to your repository,
everyone on your team automatically runs the same hooks. Here is the structure:
{
"hooks": {
"post-tool-use": [
{
"matcher": "edit|multi-edit",
"command": "bash .claude/hooks/format.sh"
}
],
"pre-tool-use": [
{
"command": "bash .claude/hooks/guard.sh"
}
]
}
}
Each entry under an event key is an object with two fields. The command is a shell command
string that will be executed. The optional matcher is a substring or pipe-separated list that
is matched against the tool name — "edit|multi-edit" fires only when Claude uses the edit
or multi-edit tools, so your formatter never runs unnecessarily after a read or search.
When you need the hook script to be portable across machines — for instance, a shared script
stored in the project rather than on every developer’s PATH — use the $CLAUDE_PROJECT_DIR
environment variable. Claude Code sets this to the project root, so
$CLAUDE_PROJECT_DIR/.claude/hooks/format.sh always resolves correctly regardless of the
current working directory.
The exit-code protocol for pre-tool-use
The exit code of a pre-tool-use hook carries specific meaning:
- Exit 0 — proceed normally. The tool call runs as intended.
- Exit 2 — block the tool call. Whatever your hook wrote to stderr is sent to Claude as a feedback message, so Claude understands why it was stopped and can adapt.
- Any other exit code — the hook failed for an unexpected reason; Claude Code treats it as a warning and proceeds with the tool call anyway.
This two-way communication is what makes pre-tool-use hooks powerful. The hook is not just a hard wall — it’s a conversation. If your guard script blocks a commit to main and writes “Direct commits to main are not allowed. Use a feature branch.” to stderr, Claude will see that message and create a branch instead of retrying the same blocked operation.
Check your understanding
4 questions · your answers are saved in this browser only
-
1. A hook defined in `.claude/settings.json` differs from a CLAUDE.md instruction because:
-
2. You want to run a formatter only when Claude edits or multi-edits a file. Which configuration field narrows the hook to those tool calls?
-
3. Your pre-tool-use hook exits with code 2 and writes a message to stderr. What happens?
-
4. Which environment variable should you use in hook commands to reference a script stored inside the project, regardless of Claude's working directory?
4. Practical patterns
Auto-formatting on every edit
The canonical first hook. The formatter runs after every file Claude touches, so the codebase
is always in a formatted state even mid-session. You never need to remember to run prettier
before committing because it already ran at the moment each file was changed.
The hook script uses the file path from the tool result to pick the right formatter. Claude Code passes the full tool result as JSON on stdin, so a small jq expression extracts the path:
#!/usr/bin/env bash
FILE=$(echo "$STDIN_CONTENT" | jq -r '.path // empty')
[ -z "$FILE" ] && exit 0
case "$FILE" in
*.ts|*.tsx|*.js|*.jsx)
prettier --write "$FILE" ;;
*.py)
ruff format "$FILE" ;;
*.go)
gofmt -w "$FILE" ;;
esac
The settings.json entry that wires this up:
{
"hooks": {
"post-tool-use": [
{
"matcher": "edit|multi-edit",
"command": "bash $CLAUDE_PROJECT_DIR/.claude/hooks/format.sh"
}
]
}
}
Blocking commits to main
A pre-tool-use guard that inspects every bash command before it runs. If Claude is about to
git commit while on the main branch, the hook blocks it and explains why.
#!/usr/bin/env bash
TOOL=$(echo "$STDIN_CONTENT" | jq -r '.tool_name // empty')
COMMAND=$(echo "$STDIN_CONTENT" | jq -r '.tool_input.command // empty')
if [[ "$TOOL" == "bash" && "$COMMAND" == *"git commit"* ]]; then
BRANCH=$(git -C "$CLAUDE_PROJECT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null)
if [[ "$BRANCH" == "main" || "$BRANCH" == "master" ]]; then
echo "Direct commits to $BRANCH are not allowed. Create a feature branch first." >&2
exit 2
fi
fi
exit 0
When this guard blocks a commit, Claude reads the stderr message and typically responds by creating a feature branch — which is exactly the behavior you wanted.
Build it yourself
Follow these exact steps to reproduce it yourself · estimated time: ~15 minutes
Prerequisites
- Claude Code installed and authenticated
- A code repository with at least one TypeScript, Python, or Go file
- prettier, ruff, or gofmt installed (depending on your language)
Step 1 — Create the hooks directory
mkdir -p .claude/hooksBoth hook scripts and settings live inside .claude/. Creating the directory first keeps
things tidy.
Step 2 — Write the auto-formatter hook
Create .claude/hooks/format.sh:
#!/usr/bin/env bash
FILE=$(echo "$STDIN_CONTENT" | jq -r '.path // empty')
[ -z "$FILE" ] && exit 0
case "$FILE" in
*.ts|*.tsx|*.js|*.jsx)
prettier --write "$FILE" ;;
*.py)
ruff format "$FILE" ;;
*.go)
gofmt -w "$FILE" ;;
esacMake it executable:
chmod +x .claude/hooks/format.shStep 3 — Write the main-branch guard hook
Create .claude/hooks/guard.sh:
#!/usr/bin/env bash
TOOL=$(echo "$STDIN_CONTENT" | jq -r '.tool_name // empty')
COMMAND=$(echo "$STDIN_CONTENT" | jq -r '.tool_input.command // empty')
if [[ "$TOOL" == "bash" && "$COMMAND" == *"git commit"* ]]; then
BRANCH=$(git -C "$CLAUDE_PROJECT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null)
if [[ "$BRANCH" == "main" || "$BRANCH" == "master" ]]; then
echo "Direct commits to $BRANCH are not allowed. Create a feature branch first." >&2
exit 2
fi
fi
exit 0chmod +x .claude/hooks/guard.shStep 4 — Register both hooks in settings.json
Create or update .claude/settings.json:
{
"hooks": {
"post-tool-use": [
{
"matcher": "edit|multi-edit",
"command": "bash $CLAUDE_PROJECT_DIR/.claude/hooks/format.sh"
}
],
"pre-tool-use": [
{
"command": "bash $CLAUDE_PROJECT_DIR/.claude/hooks/guard.sh"
}
]
}
}Step 5 — Test the formatter hook
Start a Claude Code session and ask it to make a trivial edit to one of your files — add a comment, rename a variable. Watch the terminal: after the edit tool call completes you should see the formatter run. Open the file and confirm it is formatted.
Step 6 — Test the guard hook
Still in the session, ask Claude to commit the current changes. If you are on main or master, the hook should block the commit and you will see Claude acknowledge the guard message and offer to create a branch instead.
Expected result: Two hooks are running reliably on every matching operation. If a hook is
not firing, verify that chmod +x was applied and that jq is installed (which jq). If the
formatter exits with an error, check that the relevant tool (prettier, ruff, gofmt) is
on your PATH.
Step 7 — Commit and share
git add .claude/settings.json .claude/hooks/
git commit -m "Add formatting and branch-guard hooks"Every teammate who pulls this commit will have the same hooks active automatically, with no per-developer configuration required.
Where to go next
- Watch the original video for a live demo of hooks being configured.
- Read the Claude Code hooks documentation for the full event and field reference.
- Combine hooks with headless mode to build fully automated pipelines where hooks enforce consistency even in CI.