AI Engineering Curriculum
Phase 1: Claude Code Mastery·8 min read

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

EventWhen it firesCan block?
SessionStartSession begins or resumes
UserPromptSubmitYou submit a prompt, before Claude processes it
PreToolUseBefore a tool executes
PermissionRequestWhen a permission dialog appears
PostToolUseAfter a tool succeeds
PostToolUseFailureAfter a tool fails
NotificationWhen Claude sends a notification
SubagentStartWhen a subagent is spawned
SubagentStopWhen a subagent finishes
StopWhen Claude finishes responding
TeammateIdleWhen an agent teammate goes idle
TaskCompletedWhen a task is marked completed
ConfigChangeWhen a config file changes during a session
WorktreeCreateWhen a worktree is being created
WorktreeRemoveWhen a worktree is being removed
PreCompactBefore context compaction
SessionEndWhen a session ends

Where Config Lives

FileScopeCommittable?
~/.claude/settings.jsonAll your projectsNo
.claude/settings.jsonSingle projectYes
.claude/settings.local.jsonSingle projectNo (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

JSON
{ "hooks": { "PostToolUse": [ { "matcher": "Write|Edit", "hooks": [ { "type": "command", "command": "npm test", "timeout": 60, "async": false } ] } ] } }

Handler fields (all types):

FieldRequiredDescription
typeYes"command", "prompt", or "agent"
timeoutNoSeconds before canceling. Defaults: 600 (command), 30 (prompt), 60 (agent)
statusMessageNoCustom spinner message shown while hook runs

Command-specific fields:

FieldRequiredDescription
commandYesShell command to execute
asyncNoIf 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:

JSON
{ "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

CodeEffect
0Allow. Claude continues. Claude Code parses stdout for JSON output.
2Blocking error. stderr fed back to Claude as an error. JSON on stdout is ignored.
OtherNon-blocking error. stderr shown in verbose mode. Claude continues anyway.

The effect of exit code 2 depends on the event:

EventWhat exit 2 does
PreToolUseBlocks the tool call
UserPromptSubmitBlocks and erases the prompt
PermissionRequestDenies the permission
Stop / SubagentStopPrevents Claude from stopping
TeammateIdlePrevents teammate going idle
TaskCompletedPrevents task being marked complete
PostToolUse / PostToolUseFailureCan't block (already happened) - shows stderr to Claude
Notification / SessionStart / SessionEndCan'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):

FieldDescription
continue: falseStop Claude entirely after the hook runs
stopReasonMessage shown to user when continue is false
suppressOutputIf true, hides stdout from verbose mode
systemMessageWarning message shown to the user

PreToolUse — use hookSpecificOutput:

JSON
{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Database writes not allowed", "updatedInput": { "command": "npm run lint" }, "additionalContext": "Running in production environment" } }
permissionDecision valueEffect
"allow"Bypasses permission system, proceeds
"deny"Blocks the tool call, reason shown to Claude
"ask"Prompts the user to confirm

PermissionRequest — use hookSpecificOutput:

JSON
{ "hookSpecificOutput": { "hookEventName": "PermissionRequest", "decision": { "behavior": "allow", "updatedInput": { "command": "npm run lint" } } } }

PostToolUse, Stop, SubagentStop, UserPromptSubmit — top-level decision:

JSON
{ "decision": "block", "reason": "Tests must pass before proceeding" }

SessionStart / SubagentStart — inject context:

JSON
{ "hookSpecificOutput": { "hookEventName": "SessionStart", "additionalContext": "Current branch: feature/auth. Open PRs: 3." } }

Prompt Hook — LLM Evaluates Yes/No

JSON
{ "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:

JSON
{ "ok": true } // or { "ok": false, "reason": "Still has failing tests" }

Agent Hook — Subagent Investigates

JSON
{ "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.

JSON
{ "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)

Bash
#!/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 fi

2. Auto-run tests after file edits, async (PostToolUse)

Bash
#!/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\"}" fi

3. Desktop notification on task finish (Stop)

Bash
#!/bin/bash powershell.exe -Command " Add-Type -AssemblyName System.Windows.Forms [System.Windows.Forms.MessageBox]::Show('Claude finished your task.') " exit 0

4. Log every tool call (PostToolUse)

Bash
#!/bin/bash echo "[$(date)] $(cat | jq -r '.tool_name')" >> ~/.claude/tool-log.txt exit 0

5. Inject git context at session start (SessionStart)

Bash
#!/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:

JSON
"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:

Bash
#!/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 0

Variables written here are available in all Bash commands Claude runs during the session.


Power User Tips

  • /hooks menu - type /hooks in 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 the memory server; mcp__.*__write.* matches any write-type tool across all servers
  • stop_hook_active - the Stop event includes this field; check it to prevent infinite loops (if it's true, 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), not settings.json

Sources