Guides
Claude Code hooks tutorial: set up hooks, pick events and copy working examples
Hooks are user-defined shell commands that Claude Code runs at specific points in its lifecycle. Because the hook runs, not the model, the action always happens. This page is the how-to: where to put a hook, what a hook entry looks like, which events matter most, four examples you can paste, how exit codes block an action, and what to check when a hook does nothing. It follows Anthropic's Automate actions with hooks and the Hooks reference, checked against both pages on October 30, 2026.
- A hook is a
hooksblock in a settings file: pick an event, add a matcher to filter it, then define the handler that runs. Put it in~/.claude/settings.jsonfor all your projects or.claude/settings.jsonto share it with the repo. - Type
/hooksin Claude Code to confirm the hook is registered under the right event. - Exit 0 means success; exit 2 blocks the action on events that can block, and your stderr becomes the reason. Any other exit code is a non-blocking error.
- Command hooks execute shell commands with your full user permissions, so review and test every hook command before you add it.
What hooks are, and when to use one
Hooks are user-defined shell commands. Claude Code runs them at specific points in its lifecycle, which gives you deterministic control: certain actions always happen rather than relying on the LLM to choose to run them. Use hooks to enforce project rules, automate repetitive tasks, and integrate Claude Code with your existing tools.
Anthropic's examples include formatting files after edits, blocking commands before they execute, sending notifications when Claude needs input, and injecting context at session start. For decisions that require judgment rather than deterministic rules, the guide points to prompt-based and agent-based hooks that use a Claude model to evaluate conditions. For injecting context on every session start, the guide suggests CLAUDE.md instead. For giving Claude additional instructions and executable commands, see skills.
Security: command hooks execute shell commands with your full user permissions. They can modify, delete, or access any files your user account can access. Review and test all hook commands before adding them to your configuration.
Where hooks are configured
Hooks are defined in JSON settings files. Where you add a hook determines its scope:
| Location | Scope | Shareable |
|---|---|---|
~/.claude/settings.json | All your projects | No, local to your machine |
.claude/settings.json | Single project | Yes, can be committed to the repo |
.claude/settings.local.json | Single project | No, gitignored when Claude Code saves a setting to it |
| Managed policy settings | Organization-wide | Yes, admin-controlled |
Plugin hooks/hooks.json | When plugin is enabled | Yes, bundled with the plugin |
| Skill frontmatter | The rest of the session once the skill is invoked | Yes, defined in the skill file |
| Subagent frontmatter | While that subagent is running | Yes, defined in the subagent file |
Type /hooks in Claude Code to open a read-only browser for your configured hooks. The list labels each hook with where it comes from, such as user settings, project settings, local settings, a plugin, or the current session. To disable hooks, set "disableAllHooks": true in your settings file. If you edit settings files directly while Claude Code is running, the file watcher normally picks up hook changes automatically.
In an interactive session, Claude Code holds back hooks from every settings file, including your own ~/.claude/settings.json, until you accept the workspace trust dialog for the folder, or for a parent directory whose trust extends to it.
The structure of a hook entry
The configuration has three levels of nesting: choose a hook event to respond to, like PreToolUse or Stop; add a matcher group to filter when it fires, like "only for the Bash tool"; then define one or more hook handlers to run when matched. Each event name is a key inside the single hooks object. This is the first hook from Anthropic's guide, a desktop notification on macOS:
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
}
]
}
]
}
}
If your settings file already has a hooks key, add the new event as a sibling of the existing event keys rather than replacing the whole object.
Each object in the inner hooks array is a hook handler. Most hooks use "type": "command", which runs a shell command. Four other types are available: "type": "http", "type": "mcp_tool", "type": "prompt" and "type": "agent". Agent hooks are experimental and may change. Common handler fields:
| Field | Required | What it does |
|---|---|---|
type | yes | "command", "http", "mcp_tool", "prompt", or "agent" |
if | no | Permission rule syntax to filter when this hook runs, such as "Bash(git *)" or "Edit(*.ts)". Only evaluated on tool events: PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, and PermissionDenied. On other events, a hook with if set never runs |
timeout | no | Seconds before canceling. Defaults: 600 for command, http, and mcp_tool; 30 for prompt; 60 for agent |
statusMessage | no | Custom spinner message displayed while the hook runs |
Matchers
Without a matcher, a hook fires on every occurrence of its event. The matcher field filters when hooks fire, and how it is evaluated depends on the characters it contains:
| Matcher value | Evaluated as | Example |
|---|---|---|
"*", "", or omitted | Match all | fires on every occurrence of the event |
Only letters, digits, _, -, spaces, ,, and | | Exact string, or list of exact strings separated by | or , with optional surrounding whitespace | Bash matches only the Bash tool; Edit|Write and Edit, Write each match either tool exactly |
| Contains any other character | JavaScript regular expression, unanchored | ^Notebook matches any tool whose name starts with Notebook; mcp__memory__.* matches every tool from the memory server |
Each event type matches on a different field: for PreToolUse and PostToolUse it is the tool name, for SessionStart it is how the session started (startup, resume, clear, compact, fork), and for Notification it is the notification type. Matchers are case-sensitive. UserPromptSubmit, Stop and a few other events have no matcher support and always fire on every occurrence.
The if field goes further than matcher, which filters at the group level by tool name only. This runs a hook only when Claude uses git commands rather than all Bash commands:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git *)",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-git-policy.sh"
}
]
}
]
}
}
Because this filter is best-effort, the guide says to use the permission system rather than a hook to enforce a hard allow or deny.
The events you will use most
When an event fires, Claude Code runs the matching hooks in parallel. The guide lists many more events; these are the ones the examples on this page rely on, plus a few common ones.
| Event | When it fires | Can it block? |
|---|---|---|
SessionStart | When a session begins or resumes | No. Exit 2 shows stderr to the user only |
UserPromptSubmit | When a prompt is submitted, before Claude processes it | Yes, blocks the prompt |
PreToolUse | Before a tool call executes | Yes, blocks the tool call |
PermissionRequest | When a tool call needs a permission decision | No. Exit code 2 isn't honored; deny through the decision object instead |
PostToolUse | After a tool call succeeds | No. Shows stderr to Claude; the tool already ran |
Notification | When Claude Code sends a notification | No. Exit code and stderr are ignored |
Stop | When Claude finishes responding | Yes, prevents Claude from stopping and continues the conversation |
ConfigChange | When a configuration file changes during a session | Yes, blocks the change (except policy_settings) |
CwdChanged | When the working directory changes, for example when Claude executes a cd command | No. Shows stderr to user only |
PreCompact | Before context compaction | Yes, blocks compaction |
SessionEnd | When a session terminates | No. Shows stderr to user only |
Four examples to paste
Each one is a ready-to-use configuration block from Anthropic's guide. The Bash examples use jq for JSON parsing: install it with brew install jq on macOS, apt-get install jq on Debian and Ubuntu, or see the jq downloads page.
Get notified when Claude needs input
This hook uses the Notification event, which Claude Code fires when Claude is waiting for input or permission. The macOS version is in the section above; this is Linux, which uses notify-send. Add it to ~/.claude/settings.json:
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "notify-send 'Claude Code' 'Claude Code needs your attention'"
}
]
}
]
}
}
The empty matcher fires on all notification types. To fire only on specific events, set it to one of the notification types, for example permission_prompt (Claude needs you to approve a tool use) or idle_prompt (Claude finished responding about 60 seconds ago and you haven't typed since). If no notification appears on macOS, osascript routes notifications through the built-in Script Editor app, and if Script Editor doesn't have notification permission the command fails silently. On Linux, notify-send needs a desktop notification daemon, which headless servers, SSH sessions, and most containers don't have.
Auto-format code after edits
This hook uses the PostToolUse event with an Edit|Write matcher, so it runs only after file-editing tools. The command extracts the edited file path with jq and passes it to Prettier. Add it to .claude/settings.json in your project root:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
To test it, ask Claude to add a line with single-quoted strings to a JavaScript file, then open the file: with Prettier's default settings, the hook rewrites them to double quotes. When the hook succeeds, Claude Code shows nothing in the conversation, so check that the edited file is reformatted. A Bash command can also rewrite files; to reformat a specific file however it changes, use a FileChanged hook instead.
Block edits to protected files
This prevents Claude from modifying sensitive files like .env, package-lock.json, or anything in .git/. Claude receives feedback explaining why the edit was blocked, so it can adjust its approach. The hook calls a script that checks the target file path against a list of protected patterns and exits with code 2 to block the edit. Save the script to .claude/hooks/protect-files.sh:
#!/bin/bash
# protect-files.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Normalize Windows backslash separators so the patterns below match
FILE_PATH="${FILE_PATH//\\//}"
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 0
Hook scripts must be executable for Claude Code to run them. On macOS and Linux:
chmod +x .claude/hooks/protect-files.sh
Then add a PreToolUse hook to .claude/settings.json that runs the script before any Edit or Write tool call:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
}
]
}
}
To test it, ask Claude to add a comment to your .env file. Claude Code blocks the edit before it runs and passes the script's Blocked: message to Claude as feedback.
Re-inject context after compaction
When Claude's context window fills up, compaction summarizes the conversation to free space, which can lose important details. Use a SessionStart hook with a compact matcher to re-inject critical context after every compaction. Claude Code adds plain text your command writes to stdout to Claude's context. Add this to .claude/settings.json in your project root:
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo 'Reminder: use Bun, not npm. Run bun test before committing. Current sprint: auth refactor.'"
}
]
}
]
}
}
You can replace the echo with any command that produces dynamic output, like git log --oneline -5 to show recent commits.
Exit codes and how blocking works
Hooks communicate with Claude Code through stdin, stdout, stderr, and exit codes. When an event fires, Claude Code passes event-specific data as JSON to your script's stdin. For a Bash command, a PreToolUse hook receives fields such as session_id, cwd, hook_event_name, tool_name and tool_input, where tool_input.command holds the shell command. This script blocks a command:
#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q "drop table"; then
echo "Blocked: dropping tables is not allowed" >&2 # stderr becomes Claude's feedback
exit 2 # exit 2 = block the action
fi
exit 0 # exit 0 = no decision; the normal permission flow applies
- Exit 0 means success. If you exit 0 from a
PreToolUsehook, the tool call still goes through the normal permission flow, because exit 0 doesn't approve it. ForUserPromptSubmit,UserPromptExpansion,SessionStart, andPostModelSwitchhooks, Claude Code adds plain-text stdout to Claude's context. For most other events, stdout goes to the debug log and is not shown in the transcript. - Exit 2 blocks the action on events that can block. Write a reason to stderr: the message that comes with the block is your hook's stderr. Depending on the event, Claude receives it as feedback so it can adjust, the user sees it, or no message appears. Some events can't be blocked: for
SessionStartand others, exit 2 shows stderr to the user and execution continues. - Any other exit code is a non-blocking error: the action goes ahead, except for hooks whose failure blocks the action, for example a hook with
onFailureset to"block". On events such asPreToolUse, you see a<hook name> hook errornotice in the transcript.
Claude never sees stderr from a hook that exits 0. To surface a warning to Claude from a PostToolUse hook, exit 2 instead so Claude sees the stderr even though the tool already ran. A PostToolUse hook can't undo the action, since the tool has already executed.
Exit codes only let you block or stay silent. For more control, exit 0 and print a JSON object to stdout. Choose one approach per hook: exit 2 to block with a stderr message, or exit 0 with JSON for structured control. For example, a PreToolUse hook can deny a tool call and tell Claude why:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Use rg instead of grep for better performance"
}
}
With "deny", Claude Code cancels the tool call and feeds permissionDecisionReason back to Claude. A hook that returns permissionDecision: "deny" blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions. When several hooks match the same event, every hook's command runs to completion before Claude Code merges the results, so one hook returning deny doesn't stop sibling hooks from executing.
How to debug a hook
- Run
/hooksand confirm the hook appears under the correct event. - Test the script by piping sample JSON into it, then check the exit code (see the commands below).
- Press
Ctrl+Oto open the transcript view and look for the hook's outcome. A success shows nothing unless the hook's JSON surfaces something. A blocking error shows the message that came with the block. A non-blocking error shows a<hook name> hook errornotice with a short explanation. - For full execution details, including hook exit codes, stdout, and stderr, read the debug log. Start Claude Code with
claude --debug-file /tmp/claude.logto write to a known path, thentail -f /tmp/claude.login another terminal. If you started without that flag, run/debugmid-session to enable logging and find the log path.
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh
echo $? # Check the exit code
claude --debug-file /tmp/claude.log
tail -f /tmp/claude.log
Troubleshooting
| Symptom | What to check |
|---|---|
| The hook never fires | Run /hooks and confirm it appears under the correct event. Check that the matcher pattern matches the tool name exactly; matchers are case-sensitive. Verify you're triggering the right event type: PreToolUse fires before tool execution, PostToolUse fires after. |
/hooks shows no hooks | If they haven't appeared after a few seconds, restart your session to force a reload. Verify your JSON is valid: trailing commas and comments aren't allowed. Confirm the file is in the right place: .claude/settings.json for project hooks, ~/.claude/settings.json for global hooks. |
| "PreToolUse hook error: ..." in the transcript | Your script exited with a non-zero code unexpectedly. Test it manually by piping sample JSON. If the script isn't running at all, make it executable: chmod +x ./my-hook.sh. |
| "command not found" | Use absolute paths or ${CLAUDE_PROJECT_DIR} to reference scripts. |
| "jq: command not found" | Install jq or use Python/Node.js for JSON parsing. |
| Hook prints JSON but nothing happens | Something else may write to stdout first, usually an unconditional echo in your shell profile, so the output no longer starts with {. Wrap those echo statements so they run only in interactive shells. Or a field is at the wrong level: permissionDecision belongs inside hookSpecificOutput, not at the top level. Start Claude Code with claude --debug and search the debug log for Hook output does not start with { or Hook JSON output had unrecognized keys. |
| Claude keeps working and a warning says the Stop hook blocked too many times | Claude Code overrides a Stop hook after it blocks eight times in a row with no tool call from Claude in between. Parse the stop_hook_active field from the JSON input and exit early if it's true. |
The stop_hook_active check looks like this:
#!/bin/bash
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0 # Allow Claude to stop
fi
# ... rest of your hook logic
FAQ
When should I use a hook instead of a CLAUDE.md instruction?
Use a hook when an action must always happen. A hook is a shell command that Claude Code runs at a specific point, so it does not rely on the LLM choosing to run it. For injecting context on every session start, Anthropic's guide suggests CLAUDE.md instead; a SessionStart hook with a compact matcher is the example for re-injecting context after compaction.
Which settings file should the hook go in?
Use ~/.claude/settings.json for a hook that applies to all your projects and stays on your machine. Use .claude/settings.json for one project, which can be committed to the repo. Use .claude/settings.local.json for one project without sharing it. Hook entries merge across settings levels rather than replacing each other.
Can a hook block something even in bypass mode?
Yes. A PreToolUse hook that returns permissionDecision: "deny" blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions, because PreToolUse hooks fire before any permission-mode check. A hook returning "allow" does not bypass deny rules from settings.
Sources
- Claude Code: Automate actions with hooks (first hook, examples, matchers, troubleshooting; checked October 30, 2026)
- Claude Code: Hooks reference (hook locations, handler fields, exit codes, debugging, security considerations; checked October 30, 2026)