Claude Code hooks: events, settings.json and working examples
Claude Code hooks are shell commands, HTTP calls or prompts that run automatically at set points in a session, such as before a tool runs or when Claude finishes responding. You define them in a settings.json file under a hooks key: an event, a matcher, then a handler. A hook that exits with code 2 blocks the action, so hooks enforce rules that CLAUDE.md can only request.
What are Claude Code hooks?
A hook is a handler that Claude Code runs when something happens in a session: a prompt is submitted, a tool is about to run, a file edit finished, Claude stopped to wait for you. When the event fires and a matcher fits, Claude Code passes JSON about the event to your handler. For a command hook that JSON arrives on stdin. Your handler can then inspect it, take action, and optionally return a decision.
The reason to use them is determinism. The official docs are explicit that Claude treats CLAUDE.md as context, not enforced configuration, and recommend a PreToolUse hook when an action must be blocked regardless of what Claude decides. An instruction asks. A hook acts. If you want Prettier to run after every edit, or .env never to be touched, write a hook; if you want Claude to prefer one library over another, write it in CLAUDE.md.
| Mechanism | How reliable | Good for |
|---|---|---|
| CLAUDE.md | Context Claude tries to follow | Conventions, preferences, project facts |
| Skill | Instructions Claude applies with judgment | Repeatable procedures you invoke |
| Hook | Code that runs every time its event fires | Formatting, blocking, logging, notifications, checks |
See the guide to writing CLAUDE.md and the guide to Claude Code skills for the other two.
Which hook events are there?
The hooks reference lists more than thirty events. Most people use six or seven. Events fall into three rhythms: once per session, once per turn, and on every tool call inside the agent loop.
| Event | When it fires | Can block? |
|---|---|---|
SessionStart | A session begins or resumes | No |
UserPromptSubmit | A prompt is submitted, before Claude processes it | Yes |
PreToolUse | Before a tool call runs | Yes |
PermissionRequest | A tool call needs a permission decision | Through a decision object, not exit 2 |
PostToolUse | After a tool call succeeds | No, the tool already ran |
PostToolUseFailure | After a tool call fails | No |
Notification | Claude Code sends a notification, such as waiting for input | No |
Stop | Claude finishes responding | Yes, keeps Claude working |
SubagentStop | A subagent finishes | Yes |
PreCompact | Before context compaction | Yes |
SessionEnd | A session ends | No |
Others cover subagent start, task creation, config changes, a changed working directory, watched file changes, worktree creation, model switches and MCP elicitation. Use /hooks inside Claude Code to see what is configured.
One trap: Stop fires whenever Claude finishes responding, not only when the whole task is done, and it does not fire if you interrupt. Keep Stop hooks fast.
What is the settings.json format for hooks?
Hooks live in JSON settings files and have three levels of nesting. First, choose an event. Second, add a matcher group that filters when it fires. Third, define one or more handlers to run. Each event name is a key inside a single hooks object, so a new event is a sibling of the existing ones.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
],
"Notification": [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "notify-send 'Claude Code' 'Claude Code needs your attention'" }
]
}
]
}
}| Location | Scope | Shareable? |
|---|---|---|
~/.claude/settings.json | All your projects | No, local to your machine |
.claude/settings.json | One project | Yes, commit it to the repository |
.claude/settings.local.json | One project, just you | No, kept out of git |
Plugin hooks/hooks.json | While the plugin is enabled | Yes, bundled with the plugin |
| Skill or subagent frontmatter | While the skill or subagent is active | Yes, defined in the file |
Hook entries merge across levels rather than replacing each other. Use the project file for rules the whole team needs, such as formatting, and the user file for personal ones, such as notifications.
| Type | What it does |
|---|---|
command | Runs a shell command; input arrives on stdin; results come back through exit codes and stdout |
http | POSTs the event JSON to a URL and reads the decision from the response |
mcp_tool | Calls a tool on a configured MCP server |
prompt | Sends a prompt to a Claude model for a single-turn yes or no evaluation |
agent | Spawns a subagent that can read files to verify a condition. Experimental |
Common handler fields are type, timeout in seconds (default 600 for command hooks), statusMessage for a custom spinner line, and if, which narrows a hook with a single permission rule such as "Bash(git push *)". Handlers for one event run in parallel.
How do matchers work?
The matcher filters when a group fires. What it matches depends on the event: tool events match the tool name, SessionStart matches how the session started (startup, resume, clear, compact), and Notification matches the notification type.
| Matcher | Meaning | Example |
|---|---|---|
"*", "" or omitted | Match everything | Fire on every occurrence |
Letters, digits, _, -, |, , only | Exact name, or a list of exact names | Bash, Edit|Write |
| Anything else | A JavaScript regular expression, unanchored | ^Notebook, mcp__memory__.* |
Matchers are case-sensitive, and a miss is the most common reason a hook never fires. Wrap a regex in ^ and $ when you need a whole-string match: Edit.* also matches NotebookEdit.
How does a hook read input and block an action?
A command hook gets JSON on stdin. For a PreToolUse hook on a Bash command it looks like this, trimmed here to the useful fields:
{
"session_id": "abc123",
"cwd": "/home/user/my-project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "npm test" }
}Your script answers with its exit code. These are the three outcomes:
| Exit code | Meaning | What happens |
|---|---|---|
| 0 | Success | The action goes ahead. Any JSON you print is applied. For UserPromptSubmit, SessionStart and a few others, plain-text stdout is added to Claude's context |
| 2 | Blocking error | On events that can block, the action stops and your stderr becomes the reason Claude sees |
| Any other | Non-blocking error | The action goes ahead and a hook error notice shows the first line of stderr |
What exit 2 does depends on the event. On PreToolUse it blocks the tool call. On UserPromptSubmit it rejects the prompt. On Stop it prevents Claude from stopping. On PostToolUse the tool has already run, so exit 2 only shows stderr to Claude. For richer control, print JSON instead; for PreToolUse, the decision goes in hookSpecificOutput.permissionDecision, not at the top level. A misplaced field parses fine and is silently ignored.
What are some Claude Code hooks you can copy?
Each example below uses a documented event and field. The first four follow patterns from the official hooks guide. Several use jq to read the JSON, so install it first (brew install jq on macOS, apt-get install jq on Debian and Ubuntu).
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
}
]
}
]
}
}That is the macOS version. On Linux, swap the command for notify-send 'Claude Code' 'Claude Code needs your attention'. If nothing appears on macOS, Script Editor may lack notification permission; run osascript -e 'display notification "test"' once and enable it in System Settings, then Notifications.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}Ask Claude to add a line with single-quoted strings to a JavaScript file; with Prettier's defaults the hook rewrites them to double quotes. A successful hook shows nothing in the conversation, so check the file.
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
exit 2
fi
done
exit 0chmod +x .claude/hooks/protect-files.sh
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
}
]
}
}Claude receives the Blocked: message as feedback and can pick another approach. Hook scripts must be executable on macOS and Linux. Referencing the script through $CLAUDE_PROJECT_DIR keeps the path valid from any working directory.
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo 'Reminder: use pnpm, not npm. Run pnpm test before committing.'"
}
]
}
]
}
}Whatever the command prints to stdout is added to Claude's context. Replace the echo with something dynamic, such as git log --oneline -5, to show recent commits. For context you want in every session, use CLAUDE.md instead.
#!/bin/bash
COMMAND=$(jq -r '.tool_input.command' <<<"$(cat)")
if [[ "$COMMAND" == *"--force"* || "$COMMAND" == *" -f"* ]]; then
echo "Blocked: force pushes are not allowed in this project" >&2
exit 2
fi
exit 0
# .claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git push *)",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-force-push.sh"
}
]
}
]
}
}The if filter means the script runs only for git push commands, not for every Bash call. The script follows the docs' own rm example: read the command from tool_input.command, exit 2 with a message on stderr to block, and exit 0 to leave the normal permission flow alone. Note that the string match is blunt: it also blocks --force-with-lease. Loosen the pattern if your team uses that.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Evaluate if Claude should stop: $ARGUMENTS. Check if all tasks are complete."
}
]
}
]
}
}A prompt hook sends the event JSON to a Claude model, which returns a decision. It adds a model call each time it fires, so use it sparingly. If you write your own Stop hook in a script, check the stop_hook_active field and exit 0 when it is true; otherwise a hook that keeps blocking is overridden after eight consecutive continuations.
How do you test and debug a hook?
- Check it registered. Type
/hooks. Your hook should appear under its event. If it does not, validate the JSON: trailing commas and comments are not allowed. A restart forces a reload if the file watcher missed your edit. - Test the script by hand. Pipe sample JSON in and read the exit code:
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh; echo $?. - Read the outcome. Press Ctrl+O for the transcript view. A blocking error shows its message; a non-blocking one shows a
hook errornotice. For full detail, start withclaude --debug-file /tmp/claude.logand tail the file. - Fix the usual suspects.
jq: command not foundmeans installjq. A script that never runs probably needschmod +x. A matcher that never matches is usually a case error. - Check your shell profile. If your hook prints JSON but nothing happens, an unconditional
echoin~/.zshrcor~/.bashrcmay be printing before the JSON. Guard it with a check for an interactive shell.
A hook runs with your user's permissions, so treat it like any script. Read hooks in a repository you clone before you run Claude Code there, because a project's .claude/settings.json can define them. If you are experimenting, the Claude Code tutorial is a safe place to start, and the permissions guide explains the rules that sit beside hooks.
Hooks pay off most in repetitive workflows, such as changelog generation, test gating and release checks. If you want a project to practice on, browse the developer tools ideas on the board and pick one you can finish.
Frequently asked questions
What are Claude Code hooks?
Hooks are handlers that Claude Code runs automatically at specific points in a session, such as before a tool call, after a file edit, or when Claude finishes responding. They can be shell commands, HTTP requests, MCP tool calls, prompts or agents. You define them in a settings.json file.
Where do I configure Claude Code hooks?
Add a hooks block to a settings file. Use ~/.claude/settings.json for all your projects, .claude/settings.json for a project you want to share through git, or .claude/settings.local.json for personal project settings. Plugins and skills can also carry their own hooks.
How do I block an action with a hook?
Use a PreToolUse hook that reads the tool call from stdin and exits with code 2, writing the reason to stderr. Claude Code blocks the tool call and passes your message back to Claude. Exit 0 lets the normal permission flow continue. A denial holds even in bypassPermissions mode.
What is the difference between a hook and CLAUDE.md?
CLAUDE.md is context Claude tries to follow, so compliance is not guaranteed. A hook is code that runs every time its event fires, whatever Claude decides. Use CLAUDE.md for conventions and preferences, and hooks for rules that must always hold, like formatting or protecting files.
Why is my Claude Code hook not firing?
Run /hooks and confirm it appears under the right event. Check the matcher, which is case-sensitive and must match the tool name exactly. Make sure the JSON is valid, the script is executable, and you picked the right event: PreToolUse runs before a tool, PostToolUse after.
Can hooks undo what Claude already did?
No. A PostToolUse hook runs after the tool has finished, so it cannot undo the action. To prevent a change, use a PreToolUse hook. To reverse one, use /rewind or git. PostToolUse hooks can still show Claude an error message so it can correct itself.
Created with AI assistance and edited for this site. Examples are illustrative and do not represent customer research. About the publisher.