AI Learning
beginner ⏱️ 11 min read · 🎬 ~10 min video

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.

#claude-code #productivity
Video thumbnail: Hooks in Claude Code: Deterministic Control Over Every Action
Original video — all credit to the creators. Watch the original on YouTube ↗

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.

You submita promptpre-tool-usebefore any tool callTool executesedit / bash / search …post-tool-useafter tool completesstopClaude finishesnotificationClaude sends an alertuser-prompt-submitbefore Claude processesexit 2 = blocked
The five Claude Code lifecycle events. Hooks attach to any event; pre-tool-use hooks can block the action before it runs.

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. 1. A hook defined in `.claude/settings.json` differs from a CLAUDE.md instruction because:

  2. 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. 3. Your pre-tool-use hook exits with code 2 and writes a message to stderr. What happens?

  4. 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/hooks

Both 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" ;;
esac

Make it executable:

chmod +x .claude/hooks/format.sh

Step 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 0
chmod +x .claude/hooks/guard.sh

Step 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

Related lessons

beginner 🎬 Anthropic · ~3 min

How Anthropic's Marketing Team Uses Claude

There's an opportunity for marketing teams that adopt Claude Code to spend less time on repetitive execution and more time on what matters. Austin Lau, growth marketer, shows how marketing teams reduce execution overhead with Claude.

#marketing #productivity #claude-code
intermediate 🎬 Anthropic · ~3 min

How Anthropic's Product Engineers Use Claude

Product engineers lose hours toggling between tools and tackling subtasks one at a time. Software engineer Chuma Kabaghe shows how she uses Claude Code to onboard onto unfamiliar codebases in minutes, then stay in the flow throughout development.

#claude-code #productivity #best-practices
intermediate 🎬 Anthropic · ~25 min

Claude Code Best Practices: The Field Guide

Cal Rueb's field-tested playbook for getting consistently great results from Claude Code: context curation, permission strategy, planning, parallel sessions and knowing when to course-correct.

#claude-code #agentic-coding #productivity #workflows