What Are Hooks?
Claude Code hooks are shell commands wired to lifecycle events in your .claude/settings.json. When a matching event fires, Claude runs your command synchronously and reads the output — so if your test suite fails after an edit, Claude sees the failure and fixes it automatically.
Basic Structure
Hooks live in .claude/settings.json at your project root (or in ~/.claude/settings.json for global hooks). Each event takes an array of matcher/hooks pairs:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit", // regex matched against tool name
"hooks": [
{
"type": "command",
"command": "npm test -- --passWithNoTests 2>&1 | tail -20"
}
]
}
]
}
}
The matcher is a regex tested against the tool name (e.g., Bash, Write, Edit, Read, WebSearch). Use ".*" to match all tools.
Hook Recipes
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "npm test 2>&1 | tail -30" }]
}
]
}
}
With this hook, Claude enters a write→test→fix loop automatically. You don't need to say "run the tests" — it just happens.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "npx eslint --fix $(git diff --name-only HEAD | grep '\\.js$' | tr '\n' ' ') 2>&1" }]
}
]
}
}
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "npx tsc --noEmit 2>&1 | head -40" }]
}
]
}
}
# macOS { "hooks": { "Stop": [ { "matcher": ".*", "hooks": [{ "type": "command", "command": "osascript -e 'display notification \"Claude Code finished\" with title \"Claude Code\"'" }] } ] } } # Linux (notify-send) { "hooks": { "Stop": [ { "matcher": ".*", "hooks": [{ "type": "command", "command": "notify-send 'Claude Code' 'Task finished'" }] } ] } }
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash -c 'echo \"$CLAUDE_TOOL_INPUT\" | python3 -c \"\nimport sys, json, os\ninput = json.load(sys.stdin)\ncmd = input.get(\\\"command\\\", \\\"\\\")\nif any(bad in cmd for bad in [\\\"rm -rf /\\\", \\\"DROP TABLE\\\", \\\"format c:\\\"]):\n print(\\\"BLOCKED: dangerous command detected\\\", file=sys.stderr)\n sys.exit(1)\n\"'"
}
]
}
]
}
}
When a PreToolUse hook exits with non-zero, Claude is told the tool was blocked and the reason. A simpler approach: use permission mode settings in CLAUDE.md.
{
"hooks": {
"PostToolUse": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "echo \"[$(date -u +%FT%TZ)] $CLAUDE_TOOL_NAME\" >> .claude/audit.log"
}
]
}
]
}
}
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "git diff --name-only HEAD | grep '\\.py$' | xargs black 2>&1"
}
]
}
]
}
}
Environment Variables in Hooks
Claude Code injects these variables into hook commands so you can act on context:
CLAUDE_TOOL_NAME— name of the tool that fired the event (e.g.,Write,Bash)CLAUDE_TOOL_INPUT— JSON-encoded input passed to the toolCLAUDE_TOOL_OUTPUT— JSON-encoded output of the tool (PostToolUse only)CLAUDE_SESSION_ID— unique identifier for the current conversation
# Example: log only Write tool calls with file path
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"hooks": [{
"type": "command",
"command": "python3 -c \"import json,os; inp=json.loads(os.environ['CLAUDE_TOOL_INPUT']); print('[write]', inp.get('file_path','?'))\" >> .claude/writes.log"
}]
}
]
}
}
Global vs Project Hooks
Hooks can be defined at two scopes:
- Project hooks —
.claude/settings.jsonin your repo root. Committed to git, shared with your team. - User hooks —
~/.claude/settings.jsonon your machine. Personal workflows (notifications, personal linters) that shouldn't be shared.
Both files are merged — user hooks run alongside project hooks. If a key conflicts, project settings win.
Combining Hooks with CLAUDE.md
Hooks handle when to run; CLAUDE.md handles what Claude should do. The power combination:
CLAUDE.md: "Always run tests before committing"settings.jsonhook: PostToolUse onBashmatchinggit commit→ runnpm test
Together they create self-enforcing workflows. See the CLAUDE.md guide for how to structure the instructions side.
Frequently Asked Questions
Do hooks block Claude from continuing?
PostToolUse hooks run synchronously — Claude waits for them to finish. If the hook exits with 0, Claude reads the output and continues. If exit code is non-zero in a PreToolUse hook, the tool call is blocked and Claude is notified. Keep hooks fast (<5s) or Claude's response latency will increase noticeably.
Can I use hooks to inject additional context into Claude's conversation?
Yes — anything your hook prints to stdout is fed back as tool output context that Claude reads. This means your hook can emit structured information (JSON, markdown tables) that Claude incorporates into its next response. For example, a PostToolUse hook could run git diff --stat and Claude will see a summary of changed files.
How do I debug a hook that isn't firing?
Run Claude with --debug to see hook evaluation logs. Check: (1) the matcher regex is correct — test it with echo "Write" | grep -P "your-matcher", (2) the settings.json is valid JSON — run cat .claude/settings.json | python3 -m json.tool, (3) the command is executable in a non-interactive shell — test it directly in a clean terminal.
Can hooks access the internet or call APIs?
Yes — hooks run as normal shell commands with your full environment, so they can make HTTP calls (curl), run Python scripts, send webhooks, post to Slack, etc. This is useful for notifications (post to Slack on task completion) or quality gates (call an external linting API).
Are hooks available in Claude Code's VSCode extension?
Yes. The VSCode extension uses the same CLI and reads the same .claude/settings.json. Hooks fire the same way whether you're using the terminal or the IDE panel.