AI Coding Tool Setup and Reference (3/7) — Claude Code Hooks Complete Guide

If CLAUDE.md is advisory, hooks are enforced. SessionStart, PreToolUse, PostToolUse, PreCompact in practice


핵심 요약

  • Audience: Claude Code users who've heard about hooks but never wrote one.
  • What you'll get: The 25+ lifecycle events structure (per official /hooks docs), 4 config locations, the 4 most-used events demonstrated with 3 working scripts, the exit-code gotcha, matcher syntax, and debugging tips.
  • Prerequisite: Claude Code installed (install guide), CLAUDE.md basics (authoring guide).

1. How hooks differ from CLAUDE.md

CLAUDE.md Hook
Nature Advisory Deterministic (enforced)
Injected as User message after system prompt Shell command executed by Claude Code CLI
Adherence High but not guaranteed Exit 2 = hard block
Good for Coding style, architecture notes "Never touch this path", "always format after edit" — rules that must not fail

You can write "never delete X" in CLAUDE.md, and Claude will usually obey — but not always. Hooks can't be disobeyed. Hooks run outside the LLM loop, so they bypass model judgment.


2. Where to define hooks

In settings.json or in skill/agent/plugin frontmatter.

Location Scope Shareable
~/.claude/settings.json All projects (personal)
.claude/settings.json This project ✅ (commit to git)
.claude/settings.local.json This project (personal) ❌ (.gitignore)
Managed policy settings Org-wide ✅ (IT deploys)
Plugin hooks/hooks.json When plugin enabled
Skill/Agent frontmatter While skill/agent active

Disable all: "disableAllHooks": true. Note: managed hooks can't be disabled by user settings (policy guarantee).


3. The 25+ lifecycle events

Official reference organizes them into six buckets.

3.1 Session-level

  • SessionStart — session begins or resumes
  • SessionEnd — session terminates

3.2 Per-turn

  • UserPromptSubmit — user submits a prompt (before Claude processes)
  • UserPromptExpansion — slash command expands to prompt
  • Stop — Claude finishes responding
  • StopFailure — turn ends due to API error

3.3 Agentic loop (most common category)

  • PreToolUse — before a tool call (can block)
  • PermissionRequest — permission dialog appears
  • PermissionDenied — auto-mode classifier denied
  • PostToolUse — tool call succeeded
  • PostToolUseFailure — tool call failed
  • PostToolBatch — after full parallel batch resolves
  • SubagentStart / SubagentStop — subagent spawned/finished

3.4 Context & config

  • InstructionsLoaded — CLAUDE.md or .claude/rules/*.md loaded
  • ConfigChange — config file changed
  • CwdChanged — working directory changed
  • FileChanged — watched file changed on disk

3.5 Compaction & worktree

  • PreCompact / PostCompact — before/after /compact
  • WorktreeCreate / WorktreeRemove — worktree created/removed

3.6 Other

  • TeammateIdle — agent-team teammate about to idle
  • Notification — Claude Code sends a notification
  • TaskCreated / TaskCompleted — task created/completed
  • Elicitation / ElicitationResult — MCP server requests user input

This post focuses on the four most used: SessionStart, PreToolUse, PostToolUse, PreCompact.


4. Basic structure

Skeleton in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pre-bash.sh",
            "timeout": 600
          }
        ]
      }
    ]
  }
}

Components: - Event key (PreToolUse, SessionStart, etc.) — multiple allowed - matcher — when the hook fires within that event - hooks array — handlers that run - typecommand / http / mcp_tool / prompt / agent

4.1 Matcher syntax

Value Interpretation Example
"*", "", omitted Match all Fires on every event
Only letters, digits, _, | Exact or pipe list Bash or Edit\|Write
Contains other characters JavaScript regex ^Notebook or mcp__memory__.*

Match criteria differ per event:

Event Matches on
PreToolUse / PostToolUse / PermissionRequest / PermissionDenied Tool name (Bash, Edit|Write, mcp__.*)
SessionStart startup / resume / clear / compact
SessionEnd clear / resume / logout / other
SubagentStart/SubagentStop Agent type (Explore, Plan, Bash…)
PreCompact/PostCompact manual / auto
InstructionsLoaded session_start / nested_traversal / path_glob_match
UserPromptSubmit/Stop etc. No matcher (always fires)

4.2 type — 5 handler kinds

Type Purpose
command Run a shell command (default)
http HTTP POST request
mcp_tool Call an MCP server tool
prompt Delegate decision to Claude prompt
agent Delegate to subagent (experimental)

This post covers command only (see /hooks reference for others).


5. The four in practice

5.1 SessionStart — inject environment variables

On session start, export env vars into the session by writing to CLAUDE_ENV_FILE.

.claude/settings.json:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-start.sh"
          }
        ]
      }
    ]
  }
}

.claude/hooks/session-start.sh:

#!/bin/bash

if [ -n "$CLAUDE_ENV_FILE" ]; then
  echo 'export NODE_ENV=development' >> "$CLAUDE_ENV_FILE"
  echo 'export PATH="$PATH:./node_modules/.bin"' >> "$CLAUDE_ENV_FILE"
fi

RECENT_CHANGES=$(git log --oneline -5 2>/dev/null || echo "no git")
cat <<EOF
{
  "hookSpecificOutput": {
    "hookEventName": "SessionStart",
    "additionalContext": "Recent commits:\n$RECENT_CHANGES"
  }
}
EOF

exit 0

Make it executable:

chmod +x .claude/hooks/session-start.sh

additionalContext is injected as extra context at session start. Cap: 10,000 characters.

CLAUDE_ENV_FILE is only available in SessionStart, CwdChanged, FileChanged.

5.2 PreToolUse — block dangerous commands

Straight from the official reference — block Bash commands containing rm -rf.

.claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm.sh"
          }
        ]
      }
    ]
  }
}

.claude/hooks/block-rm.sh:

#!/bin/bash

INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')

if echo "$COMMAND" | grep -qE 'rm\s+-rf'; then
  jq -n '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "rm -rf blocked by hook. Use targeted deletes instead."
    }
  }'
  exit 0
fi

exit 0

Flow: 1. Hook receives JSON on stdin (contains tool_input.command) 2. If it matches rm -rf, print permissionDecision: "deny" + reason to stdout 3. Claude Code blocks the tool call

Pre-filter with if: {"if": "Bash(rm *)"} narrows the hook to only rm * invocations within Bash, saving script launches.

5.3 PostToolUse — auto-format

Run a formatter after every edit. Matches Write or Edit.

.claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-format.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

.claude/hooks/auto-format.sh:

#!/bin/bash

INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path')

case "$FILE" in
  *.ts|*.tsx|*.js|*.jsx)
    prettier --write "$FILE" 2>/dev/null || true
    ;;
  *.py)
    ruff format "$FILE" 2>/dev/null || true
    ;;
  *.rs)
    rustfmt "$FILE" 2>/dev/null || true
    ;;
esac

exit 0

Format failures (exit 0 kept) don't stop the loop. To force Claude to acknowledge a lint failure, output JSON with decision: "block" + reason:

if ! prettier --check "$FILE" > /tmp/lint.out 2>&1; then
  jq -n --arg reason "$(cat /tmp/lint.out)" '{
    decision: "block",
    reason: $reason,
    hookSpecificOutput: {
      hookEventName: "PostToolUse",
      additionalContext: "Lint failed. Fix and retry."
    }
  }'
  exit 0
fi

5.4 PreCompact — auto-generate handoff docs

Drop a handoff skeleton just before /compact so context survives across compaction.

.claude/settings.json:

{
  "hooks": {
    "PreCompact": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pre-compact.sh"
          }
        ]
      }
    ]
  }
}

.claude/hooks/pre-compact.sh:

#!/bin/bash

TIMESTAMP=$(date +%Y%m%d-%H%M%S)
HANDOFF_DIR="$CLAUDE_PROJECT_DIR/tasks/handoffs"
mkdir -p "$HANDOFF_DIR"

cat > "$HANDOFF_DIR/handoff-$TIMESTAMP.md" <<EOF

## Progress
- (write progress right before compact)

## Decisions
- (key decisions)

## Next Steps
- (what to continue next session)

## Blockers
- (what's stuck)
EOF

cat <<EOF
{
  "hookSpecificOutput": {
    "hookEventName": "PreCompact",
    "additionalContext": "Please fill in $HANDOFF_DIR/handoff-$TIMESTAMP.md before compacting."
  }
}
EOF

exit 0

Tip: If you maintain per-session snapshots (tasks/sessions/), write them here to survive /compact.


6. Input JSON — what hooks receive

Every hook gets JSON on stdin. Common fields:

{
  "session_id": "abc123",
  "transcript_path": "/path/to/transcript.jsonl",
  "cwd": "/current/working/dir",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "agent_id": null,
  "agent_type": null
}

Event-specific additions: - PreToolUse / PostToolUse: tool_name, tool_input (full arguments) - SessionStart: source (startup/resume/clear/compact), model - UserPromptSubmit: prompt (the user's input) - PreCompact: trigger (manual/auto)

Each tool's tool_input schema is documented in the middle of /hooks (Bash/Write/Edit/Read/Glob/Grep/WebFetch/WebSearch, etc.).


7. Output JSON — what hooks return

Print JSON to stdout and Claude Code parses it. No output = pass through.

7.1 Universal fields

{
  "continue": true,
  "stopReason": "message",
  "suppressOutput": false,
  "systemMessage": "warning shown to user"
}
  • continue: false stops Claude entirely.
  • suppressOutput: true keeps stdout out of the debug log.

7.2 PreToolUse has richer control

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "permissionDecisionReason": "Shown to user",
    "updatedInput": {
      "command": "npm run lint"
    },
    "additionalContext": "Extra context for Claude"
  }
}

permissionDecision options: - allow — skip permission prompt - deny — block the tool call - ask — prompt user - defer — end turn, resume later (SDK use)

Precedence: deny > defer > ask > allow.

updatedInput can rewrite the invocation. Example: intercept git commit and force -s.


8. The exit-code gotcha (most common mistake)

Exit code Behavior
0 Parse stdout as JSON (normal path)
2 Block — stderr becomes the error message
Other Non-blocking error — stderr shown in transcript, continues

Against Unix convention, exit 1 does NOT block. To enforce policy: use exit 2 or exit 0 + JSON decision: "block".

Exit 2 behavior varies by event:

Event Exit 2 effect
PreToolUse Block tool call
UserPromptSubmit Block + erase prompt
Stop / SubagentStop Prevent stop, keep going
PreCompact Block compaction
PostToolUse Cannot block (passed as message to Claude)
PermissionDenied / StopFailure Ignored
SessionStart / Notification Shown to user only

Exit 2 on a non-blockable event does nothing. Always check the official table.


9. In practice — minimal useful set

Combining the three above into one .claude/settings.json:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-start.sh"}
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(rm *)",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-format.sh", "timeout": 30}
        ]
      }
    ],
    "PreCompact": [
      {
        "hooks": [
          {"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pre-compact.sh"}
        ]
      }
    ]
  }
}

Directory layout:

.claude/
├── settings.json
└── hooks/
    ├── session-start.sh
    ├── block-rm.sh
    ├── auto-format.sh
    └── pre-compact.sh

chmod +x on all scripts.


10. Debugging — when hooks don't fire

10.1 /hooks command

Inside a session, /hooks lists every loaded hook. If yours isn't listed, check settings file path and JSON parse errors.

10.2 --debug hooks

claude --debug "hooks"

Live hook execution logs. Filter with e.g. --debug "api,hooks".

10.3 Ask Claude to write hooks

Claude is good at authoring hook scripts. Try:

Write a hook that runs eslint after every file edit.
Write a hook that blocks writes to the migrations/ folder.

Then open .claude/settings.json, verify with /hooks.

10.4 Deduplication

Identical command strings / HTTP URLs run only once per event. If you registered the same hook twice but see one run, that's deduplication at work.


11. Security & gotchas

  1. Exit 1 doesn't block — use exit 2 or JSON decision: "block".
  2. Failed stdin JSON parse — can be silently ignored regardless of exit code. Always guard jq -r results.
  3. Default timeouts — command 600s, prompt 30s, agent 60s. Long work → async: true (background).
  4. allowManagedHooksOnly — a managed settings flag lets admins disable all user hooks.
  5. allowedEnvVars in HTTP hooks — only explicitly listed env vars are interpolated into headers.
  6. MCP server must be connected — MCP-tool hooks don't trigger OAuth; they just fail.

12. Counter-scenarios — when NOT to use hooks

  • When judgment is needed → use prompt or agent type hooks, or leave it in CLAUDE.md. If the decision isn't formalizable as a rule, let the LLM decide.
  • Frequently changing rules → editing settings.json constantly causes git conflicts in teams. CLAUDE.md is more flexible.
  • When a skill or plugin already fits — SessionStart context injection can often be done with a skill. Use hooks only for "must happen, no exceptions".
  • Solo beginner on a personal project → a misbehaving hook can block everything. Start with a small set (e.g. danger-command blocker) and expand.

13. What's next

With hooks working:

  1. CLAUDE.md authoring guide — the advisory layer that complements hooks.
  2. Slash commands complete reference — debugging commands beyond /hooks.
  3. Claude Code token & cache cost breakdown — hook-logged costs reveal the structure (coming soon).

References


This is post 4/15 in the "AI Coding CLI Entry Guide" series. last verified: 2026-04-25 (per official Claude Code Hooks reference).

댓글

이 블로그의 인기 게시물

Agent Memory Engine (2/10) — Building an AI Agent Memory System with SQLite Alone

"ML Foundations (9/9) — PyTorch vs TensorFlow, and the Road to Local LLMs"

"RAG Core Study (14/26) — Evaluation Sets with RAGAS & DeepEval"

"ML Foundations (8/9) — Deep Learning Architectures: CNN, RNN, Attention"

"ML Foundations (7/9) — Deep Learning Training: Optimizers, Regularization, Initialization"

OpenClaw to Hermes Migration (2/13) — What to Preserve, Partially Port, or Discard

AI Agents I Built (5/7) — Building an Automated Blogger API Publishing System