Key takeaways
- A hook is a hooks block in a settings file that runs a command when an event such as PreToolUse, PostToolUse or Notification fires.
- Exit code 2 blocks an action and sends stderr to Claude as feedback, while exit 1 and other codes do not block.
- A PreToolUse hook that denies a call blocks it in every permission mode, including bypassPermissions.
Claude Code hooks are shell commands that Claude Code runs at fixed points in its lifecycle, so an action happens every time instead of only when the model remembers to do it. To use hooks in Claude Code, you add a hooks block to a settings file, pick an event such as PostToolUse, and point it at a command. This guide follows Anthropic's hooks guide and the hooks reference, and shows how to set up your first hook, block edits to protected files and fix a hook that does nothing.
If you only want fewer permission prompts, hooks are one route and allow rules are another. Our guide to allowing all commands in Claude Code covers rules and permission modes, and the section on permissions below explains how hooks fit with them.
What you need first
You need Claude Code installed and a settings file you can edit. Many of the docs' examples use jq to read JSON, so install it with brew install jq on macOS or apt-get install jq on Debian and Ubuntu. A hook that runs a script also needs that script to be executable.
How a hook is configured
A hook has three levels: an event, a matcher group and one or more handlers. This example runs Prettier after Claude edits or writes a file:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
When an event fires, Claude Code sends the event's data to your command as JSON on stdin. For a PreToolUse hook on a Bash call, that includes hook_event_name, tool_name and tool_input, where tool_input.command holds the shell command. Your script reads it, then tells Claude Code what to do through its exit code and output.
How to set up your first hook
The docs' first walkthrough is a desktop notification for when Claude is waiting on you.
- Open
~/.claude/settings.json, creating it if needed, and add aNotificationhook. On Linux the command isnotify-send 'Claude Code' 'Claude Code needs your attention'. On macOS it is anosascriptcall, and on Windows a PowerShell command.
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "notify-send 'Claude Code' 'Claude Code needs your attention'"
}
]
}
]
}
}
- If the file already has a
hookskey, addNotificationnext to the existing event names. Each event is a key inside the singlehooksobject. - Type
/hooksin Claude Code. The hooks browser lists the new hook underNotification. - Press
Shift+Tabuntil the status bar showsmanual mode on, ask Claude to do something that needs permission, and switch to another window. A notification should appear.
The empty matcher fires on every notification type. To limit it, set the matcher to a value such as permission_prompt or idle_prompt. According to the docs, idle_prompt fires when Claude finished about 60 seconds ago and you haven't typed since.
On Linux, notify-send needs a desktop notification daemon, which headless servers, SSH sessions and most containers lack. If the command is missing, the docs say to install the libnotify-bin package on Debian and Ubuntu.
How to block edits to protected files
A PreToolUse hook runs before a tool call and can stop it. The docs use this to keep Claude away from .env, package-lock.json and .git/.
- Save this to
.claude/hooks/protect-files.sh:
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
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
- Make it executable with
chmod +x .claude/hooks/protect-files.sh. - Register it in
.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
}
]
}
}
- Ask Claude to add a comment to your
.envfile. Claude Code blocks the edit and passes theBlocked:message back to Claude, so it can try another approach.
We ran the script's logic in a Linux shell with sample JSON. A file_path of /app/.env printed the Blocked message and exited with code 2, and /app/index.js exited with code 0. We did not run it inside a live Claude Code session.
What exit codes and JSON do
Exit codes are how a hook answers.
| Result | What it does |
|---|---|
| Exit 0 | No objection. On PreToolUse, the normal permission flow still applies, because exit 0 does not approve the call. |
| Exit 2 | Blocks the action on events that can block. Stderr is the reason, and Claude receives it as feedback on most events. |
| Any other code | A non-blocking error. The action proceeds and a hook error notice appears. |
Exit 1 does not block. Use exit 2 for anything that must be enforced. For finer control, exit 0 and print a JSON object instead. This PreToolUse output denies a call and tells Claude why:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Use rg instead of grep for better performance"
}
}
The docs say to pick one approach per hook, exit 2 or JSON, and not mix them. Fields like permissionDecision and additionalContext must sit inside hookSpecificOutput. Placed at the top level, they are ignored without an error.
Which event to use
Anthropic lists more than 30 events. The ones most people need are SessionStart, UserPromptSubmit, PreToolUse (before a tool call, can block it), PermissionRequest, PostToolUse (after a call succeeds), Stop (Claude finishes responding), Notification and ConfigChange.
Matchers filter what a hook reacts to. For tool events the matcher is a tool name such as Bash or Edit|Write. A pattern like mcp__github__.* targets every tool from one MCP server. For SessionStart the matcher is how the session started: startup, resume, clear, compact or fork. Matchers are case-sensitive.
To narrow a tool hook by arguments too, add an if field using permission rule syntax, for example "if": "Bash(git *)". It works only on tool events, and the docs call it best-effort, so use permission rules rather than a hook for a hard allow or deny.
Where to put a hook
| Location | Scope | Shareable |
|---|---|---|
~/.claude/settings.json |
All your projects | No |
.claude/settings.json |
One project | Yes, commit it |
.claude/settings.local.json |
One project | No, gitignored |
| Managed policy settings | Organization | Yes, admin-controlled |
Plugin hooks/hooks.json |
When the plugin is enabled | Yes |
Skill and subagent frontmatter can also hold hooks. When several hooks match one event, all of them run to completion. For PreToolUse decisions the most restrictive answer wins, in the order deny, defer, ask, allow.
Other hook types
Most hooks use "type": "command". Anthropic's guide describes four more: http posts the event JSON to a URL, mcp_tool calls a tool on an MCP server, prompt asks a Claude model for a single-turn yes or no decision, and agent starts a subagent that can read files and run commands to verify something. The docs mark agent hooks experimental.
Hooks and permissions
A hook cannot loosen your rules. A hook that returns allow does not override deny rules from your settings. A hook that returns deny blocks the tool in every permission mode, including bypassPermissions and --dangerously-skip-permissions, because PreToolUse hooks run before the permission-mode check. That makes hooks useful as guardrails even when you run with prompts turned off.
To auto-approve one prompt, use a PermissionRequest hook with a narrow matcher. The docs' example approves only ExitPlanMode. They warn that an empty or .* matcher would approve every permission prompt, including file writes and shell commands.
Fixes when a hook doesn't work
- The hook never runs. Run
/hooksand check it sits under the right event. Check that the matcher matches the tool name exactly, and that you are using the right event, sincePreToolUsefires before a tool andPostToolUseafter. /hooksshows nothing. Check the JSON is valid, because trailing commas and comments are not allowed. Confirm the file is.claude/settings.jsonfor a project or~/.claude/settings.jsonfor global. Restart the session if the file watcher missed the change.- "hook error" in the transcript. Test the script by hand with
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.shand checkecho $?. Use absolute paths or${CLAUDE_PROJECT_DIR}for "command not found", installjqif it is missing, and runchmod +xif the script isn't executable. - JSON output has no effect. A line printed by your shell profile can reach stdout ahead of the JSON, so the output no longer starts with
{. Wrap anyechoin~/.bashrcor~/.zshrcso it runs only in interactive shells. Start Claude Code withclaude --debugand search the log forHook output does not start with {. - A Stop hook keeps Claude going. Claude Code overrides a Stop hook after eight blocks in a row. Read
stop_hook_activefrom the input and exit 0 when it istrue. - Other limits.
PostToolUsehooks cannot undo a tool call that already ran. Command hooks time out after 10 minutes by default, prompt hooks after 30 seconds and agent hooks after 60.
To see what a hook did, press Ctrl+O for the transcript view. For full detail, start with claude --debug-file /tmp/claude.log and tail the file.
Where to go next
Anthropic's hooks reference has every event's input schema, the output format and async hooks. Review its security considerations before deploying hooks in a shared or production setup.
Frequently asked questions
Where do I put Claude Code hooks?
Add a hooks block to ~/.claude/settings.json for all your projects, .claude/settings.json for one shared project, or .claude/settings.local.json for one project that stays out of git. Plugins and skill or subagent frontmatter can also hold hooks.
How do I block a tool call with a Claude Code hook?
Have a PreToolUse hook exit with code 2 and write the reason to stderr, or exit 0 and print JSON with permissionDecision set to deny. Claude receives the reason as feedback.
Why is my Claude Code hook not firing?
Run /hooks to confirm it is listed under the right event, check that the matcher matches the tool name exactly because matchers are case-sensitive, and make sure the JSON is valid and the script is executable.
Do hooks work with --dangerously-skip-permissions?
Yes for denials. A PreToolUse hook that returns deny blocks the tool even in bypassPermissions mode, because PreToolUse hooks run before the permission-mode check.
Sources
- Automate actions with hooksAnthropic
- Hooks referenceAnthropic