0%
#hooks#claude-code#automation#tutorial#guide

Claude Code Hooks Cookbook: 5 Recipes You Can Copy Today

Published 2026-04-2311 min read

[01]Why Hooks Matter

Every Claude Code session runs through the same silent gates: tool calls, responses, prompt submissions. Most teams treat these as untouchable. They are not. The hooks field in .claude/settings.json lets you inject shell scripts at eight lifecycle events — and those scripts can block, log, notify, or transform what Claude does next.

A hook is cheaper than a CI job, closer to the action than a pre-commit hook, and runs on your laptop so it actually gets run. If you are still manually typing npm run check after every Claude edit, stop. This is automation you wire up once and forget for good.

This cookbook walks through five recipes you can copy into .claude/settings.json today, the anatomy so you can write your own, and the four common mistakes that will burn you the first week.

[02]Hook Anatomy

Hooks live under the hooks key in .claude/settings.json. The structure is always the same:

{
  "hooks": {
    "<EventName>": [
      {
        "matcher": "<regex over tool name or empty>",
        "hooks": [
          { "type": "command", "command": "sh .claude/hooks/your-script.sh" }
        ]
      }
    ]
  }
}

Three things matter:

  • Matcher: a regex matched against the tool name (e.g. Edit|Write, Bash, or omit for all). Only applies to tool-related events.
  • Command: any shell command. It inherits your shell's PATH, runs from your repo root, and gets the project directory in $CLAUDE_PROJECT_DIR.
  • Exit code: 0 continues, 2 blocks with stderr shown back to Claude, anything else is treated as a soft error.

Claude sends the event payload as JSON on stdin. A hook can read it, decide, and print output on stderr (for Claude) or stdout (quiet).

[03]The Eight Hook Events

Every Claude Code lifecycle step is a hook point. Use the right event to minimize work:

EventWhen it firesBest for
PreToolUseBefore any tool callValidation, blocking dangerous commands
PostToolUseAfter a tool callAuto-format, regenerate derived files
UserPromptSubmitWhen you press enterPrompt audit logs, policy check
StopResponse finishedDesktop notification, session snapshot
SubagentStopA delegated sub-agent finishedAggregate multi-agent results
NotificationClaude shows a notificationRoute to Slack, phone, or log
PreCompactBefore auto-compact runsDump state, archive transcript
SessionStartSession beginsLoad env, print reminders

The five recipes below cover the three most-used: PreToolUse, PostToolUse, and Stop.

[04]Five Copy-Paste Recipes

Recipe 1: Gate Every Edit with a Type Check

Refuse any Edit/Write if the project does not typecheck. Forces Claude to fix compile errors before piling on.

// .claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "sh .claude/hooks/typecheck.sh" }
        ]
      }
    ]
  }
}
#!/bin/sh
# .claude/hooks/typecheck.sh
npm run check 1>&2 || exit 2

exit 2 blocks the edit and shows the typecheck errors to Claude so it can fix them on the next turn.

Recipe 2: Auto-Format After Every Write

Keep style drift to zero without asking Claude to remember Prettier rules.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "npx prettier --write --loglevel=warn $CLAUDE_PROJECT_DIR" }
        ]
      }
    ]
  }
}

Runs silently on success. If Prettier fails, the non-zero exit surfaces in Claude's next prompt context.

Recipe 3: Desktop Notification When Claude Stops

For long-running sessions where you alt-tab away. macOS version:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "osascript -e 'display notification \"Claude finished\" with title \"claude-code\"'" }
        ]
      }
    ]
  }
}

Linux: swap for notify-send "claude-code" "Claude finished". Windows PowerShell: New-BurntToastNotification -Text "Claude finished".

Recipe 4: Prompt Audit Log

Every prompt you submit gets timestamped and appended to a per-project log. Useful for incident retros and "what did I ask Claude last week?" queries.

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          { "type": "command", "command": "sh .claude/hooks/log-prompt.sh" }
        ]
      }
    ]
  }
}
#!/bin/sh
# .claude/hooks/log-prompt.sh
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
prompt=$(jq -r '.prompt' 2>/dev/null)
mkdir -p .claude/memory
printf '%s\t%s\n' "$ts" "$prompt" >> .claude/memory/prompts.log

The JSON on stdin carries the prompt text — jq -r '.prompt' extracts it. Log file stays out of git via .claude/memory in your gitignore.

Recipe 5: Block Dangerous Bash Commands

A safety net that prevents Claude from running anything destructive, even if a permission slips through. Applies to every Bash tool call:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "sh .claude/hooks/safety-check.sh" }
        ]
      }
    ]
  }
}
#!/bin/sh
# .claude/hooks/safety-check.sh
cmd=$(jq -r '.tool_input.command' 2>/dev/null)
case "$cmd" in
  *"rm -rf "*|*"git push --force"*|*"sudo "*|*"curl "*"| sh"*)
    echo "Blocked: $cmd" 1>&2
    exit 2 ;;
esac

Pattern-matches the actual command string, exits 2 if it hits a dangerous shape. Claude sees the rejection reason in the next turn.

[05]Writing Your Own Hook

Once you see the pattern, custom hooks take about ten minutes. Every hook script follows three steps:

  1. Read the event payload. Claude pipes JSON to stdin. Use jq or any JSON parser.
  2. Decide. Pattern-match, check env, call an API — whatever gate you need.
  3. Exit with intent. 0 continues silently. 2 blocks and shows your stderr to Claude. Other non-zero prints an error but usually allows continuation.

Useful env vars inside hooks:

  • $CLAUDE_PROJECT_DIR — absolute path to the repo root
  • $CLAUDE_SESSION_ID — current session ID (useful for correlating logs)

For debugging, have your hook write to .claude/memory/hook-debug.log until it behaves, then silence it. Never print to stdout during a tool-matching event — Claude can misread stdout as tool output.

[06]Four Common Mistakes

1. Hooks that hang the session

If your PreToolUse hook calls a network API that times out, Claude waits. Keep synchronous hooks under two seconds. Heavy work belongs in PostToolUse (async-friendly) or in CI.

2. Hardcoding paths instead of reading stdin

A recipe that hardcodes client/src/auth.ts will break the moment Claude edits a different file. Always parse the tool input from stdin JSON — that is what the event payload is for.

3. Printing to stdout instead of stderr

During PreToolUse or PostToolUse, Claude interprets stdout as tool output in some event types. Print diagnostics to stderr (1>&2 or >&2) and keep stdout empty unless you are intentionally feeding data back.

4. Forgetting chmod +x

If your script is sh script.sh, no need. If it is ./script.sh or has a shebang and you invoke directly, chmod +x first. A silent "permission denied" is the most annoying bug in this system.

[07]Next Steps

You now have five hook recipes running. The next two pieces that compound this setup:

Got a hook recipe worth sharing? Start from the checker and drop it in a blog comment.

[08]Frequently Asked Questions

Can hooks be per-user only, or do they apply to everyone on the team?

Put team-wide hooks in .claude/settings.json (committed). Put personal hooks in .claude/settings.local.json (gitignored) or ~/.claude/settings.json (user scope). Local and user hooks merge with the team set — they do not replace it.

Do hooks work on Windows?

Yes, but the command runs through your shell. On Windows, use PowerShell or WSL-friendly syntax. The three cross-platform recipes above (Recipe 2, 4, 5) work as-is via WSL; Recipe 1 and 3 need platform-specific variants — see the notification recipe for the pattern.

How do I debug a hook that silently fails?

Add set -x at the top of the script and tee output to a log: exec 2> .claude/memory/hook-debug.log; set -x. You will see every command, exit code, and the stdin payload. Remove before committing.

Can a hook modify Claude's output or the tool input?

Partially. A PreToolUse hook can only allow or block — it cannot rewrite the tool input. If you exit 2 with a useful stderr message, Claude will typically retry with a corrected input. For transformation, use PostToolUse to alter files after the fact.

What is the difference between PreToolUse and UserPromptSubmit for validation?

UserPromptSubmit fires once per prompt — ideal for policy checks ("no production DB queries") before Claude plans. PreToolUse fires per tool call — ideal for granular gates like "every Edit must typecheck." Use both together for layered enforcement.

// COMMENTS

github_discussions.sh

Sign in with GitHub to leave a comment.

Ready to find your buddy?

CHECK YOUR BUDDY

Built by the community. Not affiliated with Anthropic.

All computation is local. No data is collected or transmitted.

> EOF