What are Claude Code hooks?
Claude Code hooks are handlers that Claude Code runs automatically at specific points in a session: before a tool runs, after a file is edited, when Claude needs your input, when it stops. A hook can be a shell command, an HTTP endpoint, an MCP tool call, an LLM prompt or a subagent. You define hooks in a settings file, and they fire every time their event happens.
That is the key difference from instructions. A line in CLAUDE.md like "never edit .env" is a request Claude usually follows. A PreToolUse hook that blocks the edit is enforcement. Use hooks for anything that must happen the same way every time: formatting, guardrails, notifications, test runs.
The copy-ready examples come first below, followed by the full list of 33 events, the five hook types and the reference details.
Where do Claude Code hooks go?
Hooks live in a hooks object inside a settings file. Where you put it decides who gets the hook.
| Location | Applies to | Shared? |
|---|---|---|
~/.claude/settings.json |
All your projects | No |
.claude/settings.json |
One project | Yes, commit it |
.claude/settings.local.json |
One project | No |
Plugin hooks/hooks.json |
Wherever the plugin is enabled | Yes |
| Subagent or skill frontmatter | While that subagent runs, or after the skill is invoked | Yes |
The shape is always the same: event name, then a list of matcher groups, then the handlers to run.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}
If your file already has a hooks key, add new events as siblings inside it rather than replacing the object. Settings files are strict JSON, so no comments and no trailing commas. After saving, type /hooks in Claude Code to confirm the hook is listed under the right event.
Six copy-ready Claude Code hook examples
The examples use jq to read the JSON input. Install it first (brew install jq on macOS).
1. Format every file Claude edits
Add to .claude/settings.json in your project:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
Swap Prettier for gofmt, black or rustfmt to suit your stack. Note that Claude can also change files through Bash; this hook only sees the Edit and Write tools.
2. Block edits to protected files
Save this as .claude/hooks/protect-files.sh and run chmod +x on it:
#!/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 0
Then register it:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
}
]
}
}
Ask Claude to add a comment to .env to test it. The edit is refused and Claude sees the Blocked: message.
3. Get a desktop notification when Claude needs you
Add to ~/.claude/settings.json so it works in every project. This is the macOS version:
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
}
]
}
]
}
}
On Linux, replace the command with notify-send 'Claude Code' 'Claude Code needs your attention'. Set the matcher to permission_prompt or idle_prompt if you only want one kind of alert.
4. Block dangerous shell commands
Save as .claude/hooks/block-rm-rf.sh, make it executable, and register it on PreToolUse with matcher Bash, the same way as example 2:
#!/bin/bash
COMMAND=$(jq -r '.tool_input.command // empty')
if echo "$COMMAND" | grep -qE 'rm -rf|git push --force'; then
echo "Blocked: destructive command. Ask the user to run it themselves." >&2
exit 2
fi
exit 0
A hook that denies a call holds in every permission mode, including bypass mode, which makes this a useful backstop.
5. Keep Claude working until tests pass
A Stop hook can refuse to let Claude finish. Exit 2 and Claude receives your stderr as the reason to continue. Check stop_hook_active so the hook does not loop forever:
#!/bin/bash
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
if ! OUTPUT=$(npm test 2>&1); then
echo "Tests are failing. Fix them before you finish:" >&2
echo "$OUTPUT" | tail -n 30 >&2
exit 2
fi
exit 0
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/require-tests.sh" }
]
}
]
}
}
Claude Code also caps this: after a Stop hook blocks eight times in a row with no tool call in between, it ends the turn anyway. For long suites, the docs show an alternative that runs tests in the background with "async": true on a PostToolUse hook and reports the result on the next turn.
6. Re-add key context after compaction
When a long session compacts, details can get lost. A SessionStart hook with the compact matcher adds text back, because stdout from SessionStart goes into Claude's context:
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo 'Reminder: use pnpm, not npm. Run pnpm test before committing.'"
}
]
}
]
}
}
Replace the echo with something dynamic like git log --oneline -5 if you want recent commits instead.
All Claude Code hook events
As of October 2026 the docs list 33 hook events. The last column says what exit code 2 does, which tells you whether a hook on that event can stop something.
| Event | When it fires | Exit 2 |
|---|---|---|
SessionStart |
A session begins or resumes | Does not block; plain stdout is added to Claude's context |
Setup |
You start with --init-only, or --init / --maintenance in -p mode |
Ignored |
InstructionsLoaded |
A CLAUDE.md or .claude/rules/*.md file loads |
Ignored |
UserPromptSubmit |
You submit a prompt, before Claude sees it | Blocks the prompt |
UserPromptExpansion |
A typed command expands into a prompt | Blocks the expansion |
MessageDisplay |
Assistant text is being displayed | Original text is shown |
PreToolUse |
Before a tool call runs | Blocks the tool call |
PermissionRequest |
Claude Code is about to ask you for permission | Not honored; use JSON to allow or deny |
PermissionDenied |
Auto mode denies a tool call | Ignored |
PostToolUse |
After a tool call succeeds | Stderr goes to Claude; the tool already ran |
PostToolUseFailure |
After a tool call fails | Stderr goes to Claude |
PostToolBatch |
After a batch of parallel tool calls, before the next model call | Stops the loop |
Notification |
Claude is waiting for input or permission | Ignored |
SubagentStart |
A subagent is spawned | Stderr shown to you only |
SubagentStop |
A subagent finishes | The subagent keeps working |
TaskCreated |
A task is being created | Rolls back the task |
TaskCompleted |
A task is being marked complete | Keeps it open |
Stop |
Claude finishes responding | Claude keeps working |
StopFailure |
The turn ends on an API error | Ignored |
TeammateIdle |
An agent team teammate is about to go idle | The teammate keeps working |
ConfigChange |
A settings or skills file changes mid-session | Blocks the change (except policy settings) |
CwdChanged |
The working directory changes | Stderr shown to you only |
DirectoryAdded |
A directory is added with /add-dir |
Logged only |
FileChanged |
A watched file changes on disk | Stderr shown to you only |
WorktreeCreate |
A worktree is being created | Any non-zero exit fails creation |
WorktreeRemove |
A hook-created worktree is being removed | Any non-zero exit fails removal |
PreCompact |
Before context compaction | Blocks compaction |
PostCompact |
After compaction completes | Stderr shown to you only |
PreModelSwitch |
Before a model switch you requested | Blocks the switch |
PostModelSwitch |
After the session's model changes | Stderr shown to you only |
Elicitation |
An MCP server asks for user input | Declines the request |
ElicitationResult |
After you answer an MCP elicitation | Blocks the response |
SessionEnd |
The session ends | Stderr shown to you only |
In practice most setups use five: PreToolUse for guardrails, PostToolUse for formatting and linting, Notification for alerts, Stop for "are you really done" checks, and SessionStart for injecting context. The hooks reference has the input schema for each event.
What are the five hook types?
The five hook types are command, http, mcp_tool, prompt and agent. Each handler has a type, and most hooks use command:
| Type | What it does | Default timeout |
|---|---|---|
command |
Runs a shell command that gets the event JSON on stdin | 10 minutes |
http |
POSTs the event JSON to a URL and reads the decision from the response body | 10 minutes |
mcp_tool |
Calls a tool on an MCP server you have configured | 10 minutes |
prompt |
Sends the event to a Claude model, which returns {"ok": true} or {"ok": false, "reason": "..."} |
30 seconds |
agent |
Spawns a subagent that can read files and run commands before deciding. Marked experimental | 60 seconds |
Override the timeout per hook with a timeout field in seconds. A few events use shorter defaults, such as 30 seconds for UserPromptSubmit. A prompt hook is handy when the decision needs judgment rather than a fixed rule:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Check if all tasks are complete. If not, respond with {\"ok\": false, \"reason\": \"what remains to be done\"}."
}
]
}
]
}
}
Command hooks can also run in the background with "async": true. They cannot block anything then, but their output reaches Claude on the next turn, which suits slow test suites.
Matchers
Without a matcher, a hook fires on every occurrence of its event. A matcher narrows it down. For tool events it matches the tool name:
Bashmatches only shell commands.Edit|Writematches either tool. A comma works the same way.mcp__github__.*matches every tool from an MCP server namedgithub.
Other events match other fields. SessionStart matches how the session started (startup, resume, clear, compact), Notification matches the notification type (permission_prompt, idle_prompt and others), and PreCompact matches manual or auto. Events like Stop and UserPromptSubmit ignore matchers. Matchers are case-sensitive.
For finer control on tool events, add an if field to a handler. It uses permission rule syntax, so "if": "Bash(git *)" only spawns the hook for git commands.
Input, output and exit codes
Each hook gets a JSON object on stdin. Every event includes fields like session_id, cwd and hook_event_name. Tool events add tool_name and tool_input:
{
"session_id": "abc123",
"cwd": "/Users/sarah/myproject",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "npm test" }
}
Your script tells Claude Code what to do through its exit code:
| Exit code | What happens |
|---|---|
0 |
Success. No objection. For PreToolUse this does not approve the call; the normal permission flow still applies |
2 |
Blocking error. On events that can block, the action stops and your stderr becomes the reason. On PostToolUse, Claude sees the stderr even though the tool already ran |
| Anything else | Non-blocking error. The action goes ahead and the transcript shows a hook error notice |
For more control, exit 0 and print one JSON object to stdout. A PreToolUse hook can deny a call with a reason Claude sees:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Use rg instead of grep for better performance"
}
}
permissionDecision also accepts ask and allow. Pick one approach per hook: exit 2 with stderr, or exit 0 with JSON.
Safety notes
- Hooks run as you. Command hooks have your full user permissions and can modify or delete anything your account can. Read every hook before adding it, especially from a repo you cloned.
- Workspace trust. In interactive sessions, Claude Code holds back settings-file hooks until you accept the trust dialog for the folder. In
claude -pand SDK runs there is no dialog, so committed hooks run. Review.claude/before scripting Claude over a repo you did not write, or pass--settings '{"disableAllHooks": true}'. - Quote variables and use absolute paths. Write
"$VAR", not$VAR, and reference scripts with"$CLAUDE_PROJECT_DIR". - Keep matchers narrow. An empty matcher on
PermissionRequestthat returnsallowwould approve every prompt. - Use permissions for hard rules too. The
iffilter is best effort. For a strict allow or deny list, pair hooks with permission rules.
To switch every hook off, set "disableAllHooks": true in a settings file.
Hooks, skills and subagents together
Hooks handle what must always happen. Skills handle how Claude should do a task, and subagents handle work that needs its own context. A common setup is a formatter hook, a protected-files hook, a review skill and a read-only reviewer subagent. The best practices guide covers how they fit into a daily workflow.
If you keep hook scripts, prompts and skills scattered across machines, Kipt can hold the prompts and skills in one private GitHub repo you own. Turn a prompt you reuse into a SKILL.md with Promote to skill, then install every skill on a new machine with one npx skills add command.
Source: Automate actions with hooks and the hooks reference.