obsidian/wiki/agent-sdk/hooks-guide.md
2026-04-17 12:40:31 +01:00

8.6 KiB

title aliases tags sources created updated
Claude Code Hooks — Automate Workflows
hooks-guide
claude-hooks
lifecycle-hooks
claude-code
hooks
automation
lifecycle
shell
workflow
raw/Automate workflows with hooks.md
2026-04-17 2026-04-17

Overview

Hooks are user-defined shell commands that execute at specific points in Claude Code's lifecycle. They provide deterministic control — certain actions always happen, regardless of what the LLM decides to do.

Four hook types exist:

  • "type": "command" — run a shell command (most common)
  • "type": "http" — POST event data to an HTTP endpoint
  • "type": "prompt" — single-turn LLM evaluation (Haiku by default)
  • "type": "agent" — multi-turn subagent with tool access (60s timeout, 50 tool turns)

Hook Events Reference

Event When it fires
SessionStart Session begins or resumes
UserPromptSubmit Prompt submitted, before Claude processes it
PreToolUse Before a tool call — can block it
PostToolUse After a tool call succeeds
PostToolUseFailure After a tool call fails
PermissionRequest Permission dialog is about to appear
PermissionDenied Tool call denied by auto-mode classifier
Notification Claude is waiting for input
Stop Claude finishes responding
StopFailure Turn ended due to API error
SubagentStart / SubagentStop Subagent spawned / finished
TaskCreated / TaskCompleted Task lifecycle events
ConfigChange Config file changed during session
CwdChanged Working directory changed
FileChanged Watched file changed on disk
PreCompact / PostCompact Before/after context compaction
InstructionsLoaded CLAUDE.md or rules file loaded
WorktreeCreate / WorktreeRemove Worktree lifecycle
Elicitation / ElicitationResult MCP server requesting user input
TeammateIdle Agent team member going idle
SessionEnd Session terminates

Exit Codes & Output

Exit code Meaning
0 Allow. stdout added to Claude's context (for SessionStart, UserPromptSubmit)
2 Block. Write reason to stderr — Claude receives it as feedback
Other Allow, but log error. First line of stderr shown in transcript

Structured JSON output (exit 0 + JSON to stdout) for fine-grained control:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Use rg instead of grep"
  }
}

permissionDecision values for PreToolUse: "allow" | "deny" | "ask" | "defer" (non-interactive only)

"allow" skips the interactive prompt but does not override deny rules from settings.


Matchers

Without a matcher, a hook fires on every occurrence of its event. Add "matcher" to narrow scope.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "prettier --write ..." }]
      }
    ]
  }
}
Event Matched field Example values
PreToolUse, PostToolUse, PermissionRequest tool name Bash, Edit|Write, mcp__.*
SessionStart start source startup, resume, clear, compact
SessionEnd end reason clear, resume, logout, other
ConfigChange config source user_settings, project_settings, skills
FileChanged literal filenames .envrc|.env
SubagentStart/Stop agent type Bash, Explore, Plan

if field (v2.1.85+): filter by tool name + arguments, using permission rule syntax:

{
  "type": "command",
  "if": "Bash(git *)",
  "command": "./.claude/hooks/check-git-policy.sh"
}

Only works on tool events (PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied).


Common Recipes

Desktop notification when Claude needs input

{
  "hooks": {
    "Notification": [{
      "matcher": "",
      "hooks": [{
        "type": "command",
        "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
      }]
    }]
  }
}

Auto-format with Prettier after file edits

{
  "hooks": {
    "PostToolUse": [{
      "matcher": "Edit|Write",
      "hooks": [{
        "type": "command",
        "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
      }]
    }]
  }
}

Re-inject context after compaction

{
  "hooks": {
    "SessionStart": [{
      "matcher": "compact",
      "hooks": [{
        "type": "command",
        "command": "echo 'Reminder: use Bun, not npm. Run bun test before committing.'"
      }]
    }]
  }
}

Reload direnv when directory changes

{
  "hooks": {
    "CwdChanged": [{
      "hooks": [{
        "type": "command",
        "command": "direnv export bash >> \"$CLAUDE_ENV_FILE\""
      }]
    }]
  }
}

Auto-approve ExitPlanMode permission

{
  "hooks": {
    "PermissionRequest": [{
      "matcher": "ExitPlanMode",
      "hooks": [{
        "type": "command",
        "command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PermissionRequest\", \"decision\": {\"behavior\": \"allow\"}}}'"
      }]
    }]
  }
}

Hook Scopes / Configuration Files

File Scope Shareable
~/.claude/settings.json All projects No (local machine)
.claude/settings.json Single project Yes (commit to repo)
.claude/settings.local.json Single project No (gitignored)
Managed policy settings Organization-wide Yes (admin-controlled)
Plugin hooks/hooks.json When plugin enabled Yes
Skill/agent frontmatter While active Yes

Disable all hooks: set "disableAllHooks": true in any settings file.


Prompt-Based & Agent-Based Hooks

Prompt hook — LLM evaluates a condition and returns {"ok": true} or {"ok": false, "reason": "..."}:

{
  "hooks": {
    "Stop": [{
      "hooks": [{
        "type": "prompt",
        "prompt": "Check if all tasks are complete. If not, respond with {\"ok\": false, \"reason\": \"what remains\"}."
      }]
    }]
  }
}

Agent hook — spawns a subagent that can read files and run tools before deciding:

{
  "hooks": {
    "Stop": [{
      "hooks": [{
        "type": "agent",
        "prompt": "Verify that all unit tests pass. Run the test suite. $ARGUMENTS",
        "timeout": 120
      }]
    }]
  }
}

Use prompt hooks when the event data alone is enough. Use agent hooks when you need to inspect actual codebase state.


Troubleshooting

Problem Fix
Hook not firing Check /hooks menu, verify matcher case-sensitivity, confirm correct event type
"hook error" in transcript Test manually: echo '{"tool_name":"Bash",...}' | ./hook.sh; echo $?
/hooks shows nothing Validate JSON (no trailing commas/comments), check file location
Stop hook infinite loop Parse stop_hook_active from stdin, exit 0 if true
JSON validation failed Shell profile echo statements pollute stdout — wrap in if [[ $- == *i* ]]
Debug Run claude --debug-file /tmp/claude.log, then tail -f /tmp/claude.log

Permission interaction: PreToolUse hooks fire before permission-mode checks. A hook returning deny blocks even in bypassPermissions mode. A hook returning allow cannot override deny rules from settings — hooks tighten but cannot loosen.


Key Takeaways

  • Hooks run deterministically at lifecycle events — LLM cannot skip them
  • Use PreToolUse + exit 2 to block commands; use PostToolUse to react after
  • matcher filters by tool name (regex); if field filters by tool name + arguments together
  • Multiple hooks for the same event run in parallel; decisions resolve to most restrictive
  • PostToolUse cannot undo already-executed actions
  • PermissionRequest hooks don't fire in non-interactive (-p) mode — use PreToolUse instead
  • Hook stdout goes to Claude's context; stderr goes to Claude as error feedback (on exit 2)
  • Three LLM-powered types: prompt (single call), agent (multi-turn with tools), http (external service)


Sources