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
/hooksdocs), 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 resumesSessionEnd— session terminates
3.2 Per-turn
UserPromptSubmit— user submits a prompt (before Claude processes)UserPromptExpansion— slash command expands to promptStop— Claude finishes respondingStopFailure— turn ends due to API error
3.3 Agentic loop (most common category)
PreToolUse— before a tool call (can block)PermissionRequest— permission dialog appearsPermissionDenied— auto-mode classifier deniedPostToolUse— tool call succeededPostToolUseFailure— tool call failedPostToolBatch— after full parallel batch resolvesSubagentStart/SubagentStop— subagent spawned/finished
3.4 Context & config
InstructionsLoaded— CLAUDE.md or.claude/rules/*.mdloadedConfigChange— config file changedCwdChanged— working directory changedFileChanged— watched file changed on disk
3.5 Compaction & worktree
PreCompact/PostCompact— before/after/compactWorktreeCreate/WorktreeRemove— worktree created/removed
3.6 Other
TeammateIdle— agent-team teammate about to idleNotification— Claude Code sends a notificationTaskCreated/TaskCompleted— task created/completedElicitation/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
- type — command / 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_FILEis only available inSessionStart,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 onlyrm *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: falsestops Claude entirely.suppressOutput: truekeeps 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
- Exit 1 doesn't block — use exit 2 or JSON
decision: "block". - Failed stdin JSON parse — can be silently ignored regardless of exit code. Always guard
jq -rresults. - Default timeouts — command 600s, prompt 30s, agent 60s. Long work →
async: true(background). allowManagedHooksOnly— a managed settings flag lets admins disable all user hooks.allowedEnvVarsin HTTP hooks — only explicitly listed env vars are interpolated into headers.- 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
promptoragenttype 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.jsonconstantly 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:
- CLAUDE.md authoring guide — the advisory layer that complements hooks.
- Slash commands complete reference — debugging commands beyond
/hooks. - Claude Code token & cache cost breakdown — hook-logged costs reveal the structure (coming soon).
References
- Official Hooks reference — full 25+ event schemas
- Official Hooks guide — step-by-step tutorial
- Official Memory docs
- Official Settings
This is post 4/15 in the "AI Coding CLI Entry Guide" series. last verified: 2026-04-25 (per official Claude Code Hooks reference).
댓글
댓글 쓰기