Skip to main content

Hooks

Hooks are user-defined shell commands, HTTP requests, or LLM prompts that execute automatically at specific points in Claude Code's lifecycle. Unlike skills (which are AI-driven and flexible), hooks provide deterministic control: they always run the same way, every time, without relying on the LLM to decide whether to execute them.

Why hooks matter

Claude Code is powerful, but some tasks should never depend on AI judgment:

  • Formatting should happen on every file write, not just when Claude remembers to run Prettier
  • Protected files (.env, lock files) should be blocked from edits unconditionally
  • Linting should run after every code change, not when prompted
  • Audit logging should capture every command, not just the ones Claude considers important
  • Notifications should fire when Claude needs input, so you do not have to watch the terminal

Hooks solve this by letting you define rules that execute automatically at the right moment in Claude Code's lifecycle.

Hook types

Claude Code supports four types of hooks:

TypeWhat it doesBest for
commandRuns a shell commandFile operations, linting, formatting, scripts
httpSends a POST request to a URLExternal integrations, logging services, webhooks
promptEvaluates a single LLM promptJudgment-based checks (is this code safe?)
agentSpawns a subagent with tool accessComplex verification (run tests, check coverage)

Lifecycle events

Hooks attach to lifecycle events. Each event fires at a specific moment during a Claude Code session:

EventWhen it firesSupports all types?
SessionStartSession begins or resumesCommand only
UserPromptSubmitUser submits a prompt, before Claude processes itAll
PreToolUseBefore a tool call executes (can block it)All
PermissionRequestWhen a permission dialog appearsAll
PostToolUseAfter a tool call succeedsAll
PostToolUseFailureAfter a tool call failsAll
NotificationWhen Claude Code sends a notificationCommand only
SubagentStartA subagent spawnsCommand only
SubagentStopA subagent finishesAll
StopClaude finishes responding (not on user interrupt)All
TaskCompletedA task is marked as completedAll
PreCompactBefore context compactionCommand only
ConfigChangeA config file changes during the sessionCommand only
WorktreeCreateA git worktree is createdCommand only
WorktreeRemoveA git worktree is removedCommand only
TeammateIdleAn agent team teammate is about to go idleCommand only
SessionEndSession terminatesCommand only

Configuration

Where hooks live

Hooks can be defined at different scopes:

LocationScopeShareable with team?
~/.claude/settings.jsonAll your projects (user-level)No
.claude/settings.jsonCurrent projectYes (commit it)
.claude/settings.local.jsonCurrent project, local onlyNo (gitignored)
Skill or agent frontmatterActive while that component runsYes

Basic structure

Hooks are defined in the hooks key of a settings file:

{
"hooks": {
"EventName": [
{
"matcher": "optional_pattern",
"hooks": [
{
"type": "command",
"command": "your-shell-command"
}
]
}
]
}
}

Matchers

Matchers filter when a hook fires. Without a matcher, the hook fires on every occurrence of its event.

Tool-based events (PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest) match on tool names:

{ "matcher": "Bash" }
{ "matcher": "Edit|Write" }
{ "matcher": "mcp__github__.*" }

SessionStart matches on how the session started: startup, resume, clear, compact

Notification matches on notification type: permission_prompt, idle_prompt, auth_success

SubagentStart/SubagentStop match on agent type: Explore, Plan, or custom agent names

Matchers are case-sensitive and support regex syntax. Use | to match multiple patterns.

Practical examples

Auto-format code after every edit

Run Prettier automatically whenever Claude writes or edits a file:

{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null"
}
]
}
]
}
}

Block edits to protected files

Prevent Claude from modifying sensitive files like .env, lock files, or the .git/ directory:

#!/bin/bash
# .claude/hooks/protect-files.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

PROTECTED=(".env" "package-lock.json" ".git/" "yarn.lock")

for pattern in "${PROTECTED[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: cannot modify $FILE_PATH (matches protected pattern '$pattern')" >&2
exit 2
fi
done
exit 0

Register it in .claude/settings.json:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
}
]
}
}

Desktop notifications when Claude needs input

Stop watching the terminal. Get a system notification when Claude needs your attention:

Linux:

{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [
{
"type": "command",
"command": "notify-send 'Claude Code' 'Permission needed'"
}
]
}
]
}
}

macOS:

{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Permission needed\" with title \"Claude Code\"'"
}
]
}
]
}
}

Log all Bash commands for auditing

Keep a record of every shell command Claude runs:

{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.command' >> ~/.claude/command-log.txt"
}
]
}
]
}
}

Re-inject context after compaction

When Claude's context fills up and gets compacted, important instructions can be lost. Use a SessionStart hook with the compact matcher to re-inject critical context:

{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo 'Reminder: Use Bun, not npm. Run bun test before committing. Current sprint: auth refactor.'"
}
]
}
]
}
}

Enforce quality gates before stopping

Use an agent-based hook to verify tests pass before Claude considers its work done:

{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "agent",
"prompt": "Run the test suite and verify all tests pass. If tests fail, report what needs fixing. $ARGUMENTS",
"timeout": 120
}
]
}
]
}
}

Send events to an external service

POST tool usage data to a monitoring endpoint via HTTP:

{
"hooks": {
"PostToolUse": [
{
"hooks": [
{
"type": "http",
"url": "https://monitoring.example.com/api/claude-events",
"headers": {
"Authorization": "Bearer $AUTH_TOKEN",
"Content-Type": "application/json"
},
"allowedEnvVars": ["AUTH_TOKEN"]
}
]
}
]
}
}

Run tests asynchronously after file changes

Start the test suite in the background so Claude keeps working while tests run:

{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-tests.sh",
"async": true,
"timeout": 300
}
]
}
]
}
}

How hooks communicate

Hook scripts receive JSON input via stdin and communicate results through exit codes and stdout/stderr.

Input data

Every hook receives common fields:

{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "PreToolUse"
}

Tool-related events add tool_name, tool_input, and (for PostToolUse) tool_output.

Parse input in your scripts with jq:

#!/bin/bash
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

Exit codes

Exit codeEffect
0Action proceeds. Stdout is added to Claude's context.
2Action blocked. Stderr is sent to Claude as feedback.
OtherAction proceeds. Output is logged but not shown to Claude.

Structured JSON output

For finer control, return JSON on stdout (with exit 0):

{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Use rg instead of grep for better performance"
}
}

Valid permissionDecision values for PreToolUse:

  • "allow": bypass the permission prompt and let the tool run
  • "deny": block the tool call and send the reason to Claude
  • "ask": show the normal permission prompt to the user

Hooks in skills and agents

Hooks can be scoped to specific skills or agents by adding them to the frontmatter:

# .claude/skills/deploy/SKILL.md
---
name: deploy
description: Deploy the application
hooks:
PreToolUse:
- matcher: "Bash"
hooks:
- type: "command"
command: "echo 'Deploy hook: validating command' >&2"
---

Deploy instructions here...

These hooks only run while that skill or agent is active.

Hooks vs. skills

HooksSkills
ExecutionAutomatic on lifecycle eventsClaude decides when to use (or user invokes with /)
DeterminismAlways runs the same wayAI-driven, flexible
Can block actionsYes (exit code 2 or permissionDecision: deny)No
Tool accessLimited to stdin/stdoutFull tool access
Best forFormatting, linting, protection, notifications, auditingInstructions, conventions, reusable tasks

Rule of thumb: if it should happen every time without exception, use a hook. If it requires judgment or flexibility, use a skill.

Common pitfalls

Shell profile interference. If your .bashrc or .zshrc contains unconditional echo statements, they prepend to hook output and break JSON parsing. Wrap them in an interactive-shell check:

if [[ $- == *i* ]]; then
echo "Only in interactive shells"
fi

Stop hook infinite loops. If a Stop hook blocks Claude from stopping, Claude will keep working, trigger the hook again, get blocked again, and loop forever. Always check the stop_hook_active field:

INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0 # Allow Claude to stop on second attempt
fi
# Your validation logic here

Case-sensitive matchers. bash will not match Bash. Tool names are capitalized.

Relative paths. Always use "$CLAUDE_PROJECT_DIR" or absolute paths. Relative paths may not resolve correctly from the hook's working directory.

Async hooks cannot control flow. Since they run in the background, fields like permissionDecision and decision are ignored. Use async hooks only for logging, notifications, or background tasks.

Exit 2 with JSON. When you exit with code 2, Claude Code ignores any JSON output. Use exit 0 with structured JSON for fine-grained control, or exit 2 with stderr for simple blocking.