Module 1.2
Hooks System
What Hooks Are
Hooks are shell commands (or LLM prompts, or agents) that Claude Code runs automatically at specific points in its lifecycle. Define them once in your settings and they fire silently in the background.
Two roles they play:
- Observers - log activity, send notifications, run tests after edits
- Gatekeepers - block Claude from taking an action before it happens
The 17 Hook Events
| Event | When it fires | Can block? |
|---|---|---|
SessionStart | Session begins or resumes | |
UserPromptSubmit | You submit a prompt, before Claude processes it | ✓ |
PreToolUse | Before a tool executes | ✓ |
PermissionRequest | When a permission dialog appears | ✓ |
PostToolUse | After a tool succeeds | |
PostToolUseFailure | After a tool fails | |
Notification | When Claude sends a notification | |
SubagentStart | When a subagent is spawned | |
SubagentStop | When a subagent finishes | ✓ |
Stop | When Claude finishes responding | ✓ |
TeammateIdle | When an agent teammate goes idle | ✓ |
TaskCompleted | When a task is marked completed | ✓ |
ConfigChange | When a config file changes during a session | |
WorktreeCreate | When a worktree is being created | |
WorktreeRemove | When a worktree is being removed | |
PreCompact | Before context compaction | |
SessionEnd | When a session ends |
Where Config Lives
| File | Scope | Committable? |
|---|---|---|
~/.claude/settings.json | All your projects | No |
.claude/settings.json | Single project | Yes |
.claude/settings.local.json | Single project | No (gitignored) |
Three Handler Types
1. Command (type: "command") - run a shell script. Most common.
2. Prompt (type: "prompt") - send a prompt to a Claude model (Haiku by default) for a yes/no decision. No shell needed. Good for simple evaluation logic.
3. Agent (type: "agent") - spawn a subagent that can use tools (Read, Grep, Glob) to investigate before deciding. Most powerful - use when verification requires inspecting actual files or test output.
Configuration Structure
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "npm test",
"timeout": 60,
"async": false
}
]
}
]
}
}Handler fields (all types):
| Field | Required | Description |
|---|---|---|
type | Yes | "command", "prompt", or "agent" |
timeout | No | Seconds before canceling. Defaults: 600 (command), 30 (prompt), 60 (agent) |
statusMessage | No | Custom spinner message shown while hook runs |
Command-specific fields:
| Field | Required | Description |
|---|---|---|
command | Yes | Shell command to execute |
async | No | If true, runs in background without blocking Claude |
matcher is a regex string:
"Bash"- only Bash tool calls"Write\|Edit"- Write or Edit"mcp__memory__.*"- all tools from the memory MCP server"mcp__.*"- all MCP tools- Omit entirely to match every occurrence of that event
What Hooks Receive (stdin JSON)
All hooks receive these common fields:
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../transcript.jsonl",
"cwd": "/your/project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /tmp/build"
}
}tool_name and tool_input are event-specific fields added on top. The tool_input schema varies by tool - for Bash it has command, for Write it has file_path and content, for Edit it has file_path, old_string, new_string, etc.
Exit Codes
| Code | Effect |
|---|---|
0 | Allow. Claude continues. Claude Code parses stdout for JSON output. |
2 | Blocking error. stderr fed back to Claude as an error. JSON on stdout is ignored. |
| Other | Non-blocking error. stderr shown in verbose mode. Claude continues anyway. |
The effect of exit code 2 depends on the event:
| Event | What exit 2 does |
|---|---|
PreToolUse | Blocks the tool call |
UserPromptSubmit | Blocks and erases the prompt |
PermissionRequest | Denies the permission |
Stop / SubagentStop | Prevents Claude from stopping |
TeammateIdle | Prevents teammate going idle |
TaskCompleted | Prevents task being marked complete |
PostToolUse / PostToolUseFailure | Can't block (already happened) - shows stderr to Claude |
Notification / SessionStart / SessionEnd | Can't block - shows stderr to user only |
JSON Output (Finer-Grained Control)
Instead of exit codes alone, exit 0 and print JSON to stdout. Claude Code reads specific fields from it.
Universal fields (work on all events):
| Field | Description |
|---|---|
continue: false | Stop Claude entirely after the hook runs |
stopReason | Message shown to user when continue is false |
suppressOutput | If true, hides stdout from verbose mode |
systemMessage | Warning message shown to the user |
PreToolUse — use hookSpecificOutput:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Database writes not allowed",
"updatedInput": { "command": "npm run lint" },
"additionalContext": "Running in production environment"
}
}permissionDecision value | Effect |
|---|---|
"allow" | Bypasses permission system, proceeds |
"deny" | Blocks the tool call, reason shown to Claude |
"ask" | Prompts the user to confirm |
PermissionRequest — use hookSpecificOutput:
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow",
"updatedInput": { "command": "npm run lint" }
}
}
}PostToolUse, Stop, SubagentStop, UserPromptSubmit — top-level decision:
{
"decision": "block",
"reason": "Tests must pass before proceeding"
}SessionStart / SubagentStart — inject context:
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "Current branch: feature/auth. Open PRs: 3."
}
}Prompt Hook — LLM Evaluates Yes/No
{
"hooks": {
"Stop": [{
"hooks": [{
"type": "prompt",
"prompt": "Are all tasks complete? $ARGUMENTS. Reply {\"ok\": true} or {\"ok\": false, \"reason\": \"...\"}",
"timeout": 30
}]
}]
}
}The model (Haiku by default) must respond with:
{ "ok": true }
// or
{ "ok": false, "reason": "Still has failing tests" }Agent Hook — Subagent Investigates
{
"hooks": {
"Stop": [{
"hooks": [{
"type": "agent",
"prompt": "Verify all unit tests pass before Claude stops. Run tests and check output. $ARGUMENTS",
"timeout": 120
}]
}]
}
}The subagent can use Read, Grep, Glob to inspect actual files - not just evaluate the hook input. Same { "ok": true/false } response format as prompt hooks.
Async Hooks
Set "async": true on a command hook to run it in the background. Claude continues working immediately while the hook runs.
{
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit",
"hooks": [{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-tests.sh",
"async": true,
"timeout": 300
}]
}]
}
}When the async hook finishes, if it outputs JSON with systemMessage or additionalContext, that gets delivered to Claude on the next conversation turn.
Limitations of async hooks:
- Only works with
type: "command"- not prompt or agent hooks - Cannot block tool calls (action already proceeded)
- Output delivered on next turn, not immediately
Real Examples
1. Block rm -rf commands (PreToolUse)
#!/bin/bash
COMMAND=$(cat | jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q 'rm -rf'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Destructive command blocked by hook"
}
}'
else
exit 0
fi2. Auto-run tests after file edits, async (PostToolUse)
#!/bin/bash
# .claude/hooks/run-tests.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
RESULT=$(npm test 2>&1)
if [ $? -eq 0 ]; then
echo "{\"systemMessage\": \"Tests passed after editing $FILE_PATH\"}"
else
echo "{\"systemMessage\": \"Tests FAILED after editing $FILE_PATH: $RESULT\"}"
fi3. Desktop notification on task finish (Stop)
#!/bin/bash
powershell.exe -Command "
Add-Type -AssemblyName System.Windows.Forms
[System.Windows.Forms.MessageBox]::Show('Claude finished your task.')
"
exit 04. Log every tool call (PostToolUse)
#!/bin/bash
echo "[$(date)] $(cat | jq -r '.tool_name')" >> ~/.claude/tool-log.txt
exit 05. Inject git context at session start (SessionStart)
#!/bin/bash
BRANCH=$(git branch --show-current 2>/dev/null)
RECENT=$(git log --oneline -5 2>/dev/null)
jq -n --arg ctx "Current branch: $BRANCH. Recent commits: $RECENT" \
'{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $ctx}}'File Structure
~/.claude/
├── settings.json ← global hook config
└── hooks/
├── guard.sh ← blocks dangerous commands
├── notify.sh ← desktop notifications
└── logger.sh ← tool call logging
.claude/ ← inside your project repo
├── settings.json ← project hooks (committable)
├── settings.local.json ← project hooks (gitignored)
└── hooks/
├── run-tests.sh
└── lint-check.sh
Always use $CLAUDE_PROJECT_DIR to reference project scripts:
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/my-script.sh"SessionStart: Persist Environment Variables
SessionStart hooks have access to $CLAUDE_ENV_FILE - a special file where you can write export statements that persist for the entire session:
#!/bin/bash
if [ -n "$CLAUDE_ENV_FILE" ]; then
echo 'export NODE_ENV=production' >> "$CLAUDE_ENV_FILE"
echo 'export DEBUG_LOG=true' >> "$CLAUDE_ENV_FILE"
fi
exit 0Variables written here are available in all Bash commands Claude runs during the session.
Power User Tips
/hooksmenu - type/hooksin Claude Code to view, add, and delete hooks without editing JSON. Changes don't take effect until reviewed here (security feature).- Hooks in skills/agents - define hooks in YAML frontmatter of a skill or agent file; they're scoped to that component's lifetime only
- MCP tool matching -
mcp__memory__.*matches all tools from thememoryserver;mcp__.*__write.*matches any write-type tool across all servers stop_hook_active- theStopevent includes this field; check it to prevent infinite loops (if it'strue, your Stop hook is already running)claude --debug- shows which hooks matched, their exit codes, and stdout/stderr output- Identical handlers deduplicated - if multiple matchers trigger the same command, it only runs once
Security Note
Hooks run with your full user permissions. Always:
- Quote shell variables:
"$VAR"not$VAR - Validate and sanitize stdin input before using it
- Use absolute paths for scripts
- Keep sensitive hooks in
settings.local.json(gitignored), notsettings.json
Sources
- Claude Code Hooks Reference (official docs)
- Hooks Quickstart Guide (official docs)