Skip to main content

Claude Code Hooks: A Complete Guide to Automating Your AI Coding Workflow

· 8 min read
Scott Havird
Engineer

Claude Code Hooks: A Complete Guide to Automating Your AI Coding Workflow

If you've been using Claude Code for more than a week, you've probably noticed a pattern: you keep telling it the same things. "Run prettier after editing." "Don't touch the .env file." "Run the tests before you stop." These aren't complex instructions — they're rules. And rules shouldn't depend on an LLM remembering to follow them.

That's exactly what Claude Code hooks solve. They're deterministic automation that runs at specific points in Claude Code's lifecycle, executed by the harness itself — not by Claude. If you configure a hook to format code after every edit, it will format code after every edit. No exceptions. No "I forgot."

What Are Hooks, Really?

Hooks are shell commands, HTTP endpoints, or LLM prompts that fire automatically when specific events happen in Claude Code. Think of them like git hooks, but for your AI coding assistant.

The key distinction is reliability:

  • CLAUDE.md instructions = Claude should do this (but might forget)
  • Hooks = The system will do this (deterministic, every time)

Hooks are configured in settings.json files at three scopes:

  • Project (.claude/settings.json) — shared with your team via git
  • Local (.claude/settings.local.json) — personal, gitignored
  • Global (~/.claude/settings.json) — applies to all projects

The Hook Lifecycle

Claude Code exposes 27 lifecycle events. Here are the ones that matter most for day-to-day development:

EventWhen It FiresBest Use Case
PreToolUseBefore a tool runsBlock dangerous commands, validate file edits
PostToolUseAfter a tool succeedsAuto-format code, log activity
PermissionRequestPermission dialog appearsAuto-approve safe operations
StopClaude finishes a responseRun tests, type checking
NotificationClaude needs attentionDesktop notifications, Slack alerts
SessionStartSession begins or resumesLoad environment, re-inject context
CwdChangedDirectory changesReload env vars with direnv
FileChangedWatched file changesReact to .env or config changes

Each event can be filtered with a matcher — a regex pattern that narrows which specific tools or sources trigger the hook.

Configuration Format

Here's the basic structure in .claude/settings.json:

{
"hooks": {
"EventName": [
{
"matcher": "regex_pattern",
"hooks": [
{
"type": "command",
"command": "your-script.sh",
"timeout": 30
}
]
}
]
}
}

Hooks receive JSON context on stdin (tool name, arguments, file paths) and communicate back through exit codes:

  • Exit 0 = success, allow the action
  • Exit 2 = block the action, with feedback to Claude via stderr

Practical Patterns That Actually Work

I've been running hooks across my projects for months now. Here are the patterns that have stuck.

Pattern 1: Auto-Format After Every Edit

This is the single most impactful hook. Claude writes code, and it's immediately formatted to your project's standards. No more "can you run prettier on that?"

{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null",
"timeout": 30
}
]
}
]
}
}

The hook reads the file path from the tool's JSON input and runs prettier on it. Works with any formatter — swap in black for Python, gofmt for Go, rustfmt for Rust.

Pattern 2: Protect Sensitive Files

Some files should never be edited by an AI assistant. Period.

.claude/hooks/protect-files.sh:

#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

PROTECTED=(".env" ".env.local" "secrets.yml" "credentials.json")

for pattern in "${PROTECTED[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: $FILE_PATH is a protected file" >&2
exit 2
fi
done
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/protect-files.sh"
}
]
}
]
}
}

Exit code 2 blocks the action and sends the stderr message back to Claude as feedback, so it knows why it was blocked and can adjust.

Pattern 3: Desktop Notifications

When Claude finishes a task or needs input, you want to know — especially if you've tabbed away to something else.

macOS:

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

Linux:

{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "notify-send 'Claude Code' 'Claude needs your attention'"
}
]
}
]
}
}

Pattern 4: Quality Gates Before Stopping

This is where hooks get powerful. Instead of hoping Claude remembers to run tests, you can require it:

{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "agent",
"prompt": "Run the project's test suite and type checker. If any tests fail or type errors exist, respond with {\"ok\": false, \"reason\": \"description of failures\"}. If everything passes, respond with {\"ok\": true}.",
"timeout": 120
}
]
}
]
}
}

The agent hook type spins up a subagent with full tool access. If it returns ok: false, Claude continues working to fix the issues instead of stopping. This is a game-changer for code quality.

Important: Check the stop_hook_active field in the Stop event input to prevent infinite loops — if your Stop hook triggers Claude to do more work, which triggers another Stop, you'd loop forever.

Pattern 5: Re-inject Context After Compaction

Long sessions hit the context window limit, and Claude compacts (summarizes) the conversation. Critical context can get lost. Fix it:

{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "cat .claude/critical-context.md"
}
]
}
]
}
}

Whatever your hook outputs to stdout gets injected into Claude's context. Keep a .claude/critical-context.md file with must-remember information, and it survives compaction automatically.

Pattern 6: Auto-Approve Safe Operations

Tired of clicking "Allow" for read-only operations? Auto-approve them:

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

Pattern 7: Environment Management with direnv

If your project uses direnv for environment variables, hooks keep Claude in sync:

{
"hooks": {
"CwdChanged": [
{
"hooks": [
{
"type": "command",
"command": "direnv export bash >> \"$CLAUDE_ENV_FILE\""
}
]
}
],
"FileChanged": [
{
"matcher": ".envrc|.env",
"hooks": [
{
"type": "command",
"command": "direnv export bash >> \"$CLAUDE_ENV_FILE\""
}
]
}
]
}
}

The $CLAUDE_ENV_FILE variable points to a file where you can persist environment variables across the session. Writing to it with direnv export keeps Claude's environment aligned with your shell.

Hook Types: When to Use Each

Claude Code supports four hook types, each suited to different needs:

TypeExecutionBest For
commandShell scriptFormatting, file protection, notifications
httpHTTP endpointExternal service integration, webhooks
promptSingle LLM turnQuick yes/no decisions
agentMulti-turn subagentComplex verification (running tests, type checking)

command handles 90% of use cases. Use agent for Stop hooks that need to run commands and reason about results. Use http for integrating with external services like Slack or custom dashboards. Use prompt for lightweight LLM-based decisions.

Team Configuration: What to Share

Here's how I recommend structuring hooks for a team:

.claude/settings.json (committed to git):

{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null"
}
]
}
],
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/protect-files.sh"
}
]
}
]
}
}

These are your team's rules — formatting standards, file protection, security gates. Everyone gets them automatically.

.claude/settings.local.json (gitignored):

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

Personal preferences like notifications and auto-approvals stay local.

Debugging Hooks

When a hook isn't working:

  1. Check configuration: Run /hooks in Claude Code to see all registered hooks
  2. Enable verbose mode: Press Ctrl+O during a session to see hook output in the transcript
  3. Test manually: Pipe sample JSON into your script:
    echo '{"tool_name":"Edit","tool_input":{"file_path":"src/app.ts"}}' | .claude/hooks/protect-files.sh
    echo $?
  4. Check permissions: Ensure hook scripts are executable (chmod +x)
  5. Full debug mode: Run claude --debug for complete execution traces

Common Pitfalls

Shell profile noise: If your ~/.zshrc or ~/.bashrc prints output (welcome messages, fortune quotes), it can interfere with hook JSON parsing. Guard with [[ $- == *i* ]] checks.

Stop hook loops: A Stop hook that causes Claude to do more work will trigger another Stop event. Always check stop_hook_active in the input and return ok: true if it's already active.

Matcher case sensitivity: Tool names are case-sensitive. It's Bash, not bash. It's Edit, not edit.

Parallel hook conflicts: Multiple hooks on the same event run in parallel. If two PostToolUse hooks both modify the same file, only the last one to finish wins. Keep hooks independent.

What's Next

Hooks are one piece of the Claude Code customization puzzle. Combined with CLAUDE.md patterns, MCP server integrations, and custom slash commands, you can build a development environment where AI assistance is deeply integrated with your team's specific workflow.

The teams I've seen get the most out of AI coding tools aren't the ones with the best prompts — they're the ones who've automated the repetitive parts so they can focus on the creative work. Hooks are the foundation for that.