Unmarkdown
AI Tools

Claude Code Hooks: 5 Automations That Changed How I Ship Code

Updated Mar 17, 2026 · 12 min read

I've been using Claude Code hooks for about six weeks now. The short version: they turned Claude from a tool I supervised into a tool I trust to run unsupervised.

Hooks are shell commands that fire at specific points during Claude Code's execution. Before a tool runs, after a tool runs, when Claude finishes a task. You configure them in .claude/settings.json, and they execute locally on your machine. No API calls, no cloud services. Just shell scripts triggered at the right moment.

Anthropic's official hooks documentation explains the mechanics. This post covers the five Claude Code hooks I actually use every day, why each one exists, and the mistakes I made getting them right.

How Claude Code hooks work

A hook is a JSON object in your .claude/settings.json file. Each hook specifies:

  • type: When it fires. PreToolUse (before Claude runs a tool), PostToolUse (after), Stop (when Claude finishes).
  • matcher: Which tool or pattern triggers it. Bash for shell commands, Edit for file edits, * for everything.
  • command: The shell command to run.

For PreToolUse hooks, a non-zero exit code blocks the action. Claude sees the error output and adjusts. For PostToolUse and Stop hooks, the exit code doesn't block anything, but Claude still sees the output.

That's the entire model. Simple, but the composition is what makes it powerful.

Hook 1: Pre-commit lint gate

This was the first hook I added, and it's the one that catches the most problems.

The situation: Claude writes code, runs tests, everything passes, and then commits. But the linter wasn't part of that loop. So I'd pull down a commit and find unused imports, type errors that TypeScript caught but Claude hadn't checked, or formatting inconsistencies.

The fix is a PreToolUse hook that intercepts any git commit command and runs the linter and type checker first.

{
  "hooks": [
    {
      "type": "PreToolUse",
      "matcher": "Bash(git commit)",
      "command": "npm run lint && npx tsc --noEmit"
    }
  ]
}

When Claude tries to run git commit, this hook fires first. It runs ESLint and the TypeScript compiler. If either fails, the hook exits non-zero, the commit is blocked, and Claude sees the lint errors. Claude then fixes the issues and tries again.

The key detail: the matcher Bash(git commit) matches any Bash command that contains git commit. This covers git commit -m "...", git commit --amend, and any variation. You don't need to match the exact command string.

Why it matters. Before this hook, maybe one in ten commits had a lint issue. Not catastrophic, but annoying. After the hook, zero. Claude fixes lint errors before they're committed because the feedback loop is immediate.

Gotcha. Make sure your lint command is fast. My ESLint + TypeScript check takes about 4 seconds. If yours takes 30 seconds, Claude will be blocked for 30 seconds on every commit attempt. That adds up. If your lint is slow, consider running only the changed files: npx eslint $(git diff --cached --name-only --diff-filter=d -- '*.ts' '*.tsx').

Hook 2: Dangerous command blocker

This one is about safety. Claude Code runs shell commands on your machine. It's generally careful, but I want a hard gate on certain commands that should never run without explicit human approval.

{
  "hooks": [
    {
      "type": "PreToolUse",
      "matcher": "Bash",
      "command": "blocked_patterns=('git push --force' 'git reset --hard' 'rm -rf /' 'git clean -fd' 'DROP TABLE' 'DROP DATABASE'); input=$(cat); for pattern in \"${blocked_patterns[@]}\"; do if echo \"$input\" | grep -q \"$pattern\"; then echo \"BLOCKED: Command contains '$pattern'. This command requires manual execution.\" >&2; exit 1; fi; done; exit 0"
    }
  ]
}

This hook receives the command Claude is about to run via stdin. It checks against a list of patterns. If any match, the hook exits non-zero with a clear message explaining why it was blocked.

The patterns I block:

  • git push --force: Force-pushing can destroy remote history. If I need to force push, I'll do it myself.
  • git reset --hard: Discards uncommitted work. Too easy to lose changes.
  • rm -rf /: Obviously. But also catches partial matches that could be destructive.
  • git clean -fd: Removes untracked files. Fine sometimes, but I want to review what's being deleted.
  • DROP TABLE / DROP DATABASE: For projects with database access. Belt and suspenders.

Why it matters. I've never actually had Claude try to force push or drop a table unprompted. But hooks are cheap insurance. The cost of running a grep on every Bash command is negligible. The cost of recovering from a force push to main is not.

Gotcha. Be specific with your patterns. I originally had rm -rf as a pattern, which blocked legitimate commands like rm -rf node_modules or rm -rf .next during cleanup. I narrowed it to rm -rf / for the truly dangerous case and handle project-specific cleanup directories separately.

Hook 3: Auto-format on file edit

After Claude edits a file, I want Prettier to format it automatically. Not because Claude writes badly formatted code (it's actually quite good), but because consistency matters and Prettier is the source of truth for my project's style.

{
  "hooks": [
    {
      "type": "PostToolUse",
      "matcher": "Edit",
      "command": "filepath=$(cat | jq -r '.file_path // empty'); if [ -n \"$filepath\" ] && echo \"$filepath\" | grep -qE '\\.(ts|tsx|js|jsx|css|json)$'; then npx prettier --write \"$filepath\" 2>/dev/null; fi; exit 0"
    }
  ]
}

This hook fires after every Edit tool call. It extracts the file path from the tool's input (passed via stdin as JSON), checks if it's a formattable file type, and runs Prettier on it. The exit 0 at the end ensures the hook never blocks Claude's workflow, even if Prettier fails on a particular file.

Why it matters. Without this, Claude writes code that's 95% formatted correctly, and then I run Prettier later and get a diff full of whitespace changes. With the hook, every file Claude touches is Prettier-formatted immediately. My diffs show only real changes.

Gotcha. The 2>/dev/null on the Prettier command is important. Some files that Claude edits (like markdown or config files) might not have Prettier configs, and the error output would confuse Claude into thinking something went wrong. Swallow the errors, always exit 0.

Another subtlety: this hook fires on every edit, which means if Claude makes 15 edits to a file in one session, Prettier runs 15 times. For most projects this is fine (Prettier on a single file takes milliseconds). But if you're using a slower formatter, consider debouncing or only formatting on save.

Hook 4: Desktop notifications on completion

This is the quality-of-life hook. When Claude finishes a task, I want to know about it, especially when I've walked away from my desk.

{
  "hooks": [
    {
      "type": "Stop",
      "matcher": "*",
      "command": "osascript -e 'display notification \"Claude Code has finished the task\" with title \"Claude Code\" sound name \"Glass\"'"
    }
  ]
}

The Stop hook fires when Claude finishes its response and returns control to you. The osascript command triggers a native macOS notification with a sound.

Simple, but it changed how I work with Claude. Before this hook, I'd context-switch to another task while Claude worked, then forget to check back for 10 minutes. Now I hear the Glass sound and switch back immediately. The round-trip time between "Claude finishes" and "I review the output" went from minutes to seconds.

Linux alternative. Replace the osascript line with notify-send "Claude Code" "Task finished" if you're on Linux with libnotify installed.

Gotcha. If you're running multiple Claude Code sessions, you'll get a notification for each one. There's no built-in way to distinguish which session finished. I've considered adding the working directory to the notification message, but in practice it hasn't been confusing enough to justify the extra complexity.

Hook 5: Auto-changelog generation

After a successful git commit, this hook appends the commit message to a changelog file. It's the hook that saves me the most manual work.

{
  "hooks": [
    {
      "type": "PostToolUse",
      "matcher": "Bash(git commit)",
      "command": "last_msg=$(git log -1 --pretty=format:'%s'); last_date=$(git log -1 --pretty=format:'%Y-%m-%d'); echo \"- [$last_date] $last_msg\" >> CHANGELOG.md; exit 0"
    }
  ]
}

Every time Claude successfully commits, this hook grabs the commit message and date, formats them as a markdown list item, and appends them to CHANGELOG.md. The exit 0 ensures it never interferes with the commit itself.

After a day of work, my CHANGELOG.md looks like:

- [2026-03-17] Fix template preview overflow on mobile viewports
- [2026-03-17] Add rate limiting to AI editing endpoints
- [2026-03-17] Update pricing page copy for annual plan

It's not a polished, release-ready changelog. It's a raw log of what happened, which is exactly what I need when I sit down to write release notes. The raw material is already there.

Why it matters. I used to forget what I shipped. Seriously. After a long session with 8-10 commits, I'd go to write release notes and have to read through git log to reconstruct what changed. The changelog hook means the record is built in real time, in a file I can read without running git commands.

The Unmarkdown connection. This hook generates markdown. When it's time to share the changelog with your team, that markdown needs to look professional in Google Docs, Slack, or an email. That's exactly what Unmarkdown™ does. Paste the markdown, pick a template, copy to your destination. The changelog goes from a raw text file to a formatted document in about 10 seconds.

Gotcha. This hook fires on every commit, including amend commits. If Claude amends a commit, you'll get a duplicate entry in the changelog (the original message and the amended message). For my workflow this is fine since I review the changelog before publishing. If it bothers you, add a check: if ! grep -q "$last_msg" CHANGELOG.md; then echo "..." >> CHANGELOG.md; fi.

How hooks compose

Multiple Claude Code hooks can fire on the same event. My Bash(git commit) matcher triggers both the lint gate (PreToolUse) and the changelog hook (PostToolUse). The execution order is:

  1. Claude decides to run git commit
  2. PreToolUse hook fires: lint + type check
  3. If lint passes (exit 0), the commit runs
  4. PostToolUse hook fires: append to changelog

This composition is what makes hooks powerful. Each hook does one thing. Together, they create a workflow: code is linted before commit, and documented after commit, all without me doing anything.

You can also have multiple hooks on the same event type and matcher. They run in the order they appear in settings.json. If any PreToolUse hook exits non-zero, the action is blocked regardless of the other hooks.

Common Claude Code hook mistakes

After six weeks of using hooks, here are the patterns that cause problems.

Slow hooks block everything. A PreToolUse hook that takes 30 seconds means Claude waits 30 seconds before every tool call that matches. I had a hook that ran the full test suite before every commit. It took 45 seconds. Claude would sit there, unable to commit, while 875 tests ran. I moved the full test suite to a pre-push git hook instead and kept only the fast lint check as a Claude hook.

Side effects that confuse Claude. A PostToolUse hook that modifies files Claude just edited can create a confusing feedback loop. Claude edits a file, Prettier reformats it, Claude reads the file and sees it changed, and tries to "fix" the formatting back. This mostly doesn't happen with well-configured Prettier, but it can happen with aggressive linters that rewrite code. Test your hooks with a few real editing sessions before relying on them.

Not testing hooks before deploying them. Hooks run shell commands. Shell commands fail in surprising ways. A hook that works in your terminal might fail in Claude's execution context because of PATH differences or missing environment variables. Test each hook manually first: echo '{"tool": "Bash", "command": "git commit -m test"}' | your-hook-command.

Hooks that assume project structure. I once wrote a hook that ran cd src && npm run lint. It worked in my main project but broke when I used Claude in a different directory. Hooks should be defensive about paths and fail gracefully when assumptions don't hold.

Forgetting exit 0 on informational hooks. If your PostToolUse hook is purely informational (notifications, logging, changelog), always end with exit 0. A non-zero exit from a post-use hook won't block the action (it already happened), but it will show Claude an error message, which can derail its planning.

The hooks file

Here's my complete hooks configuration, all five hooks together:

{
  "hooks": [
    {
      "type": "PreToolUse",
      "matcher": "Bash(git commit)",
      "command": "npm run lint && npx tsc --noEmit"
    },
    {
      "type": "PreToolUse",
      "matcher": "Bash",
      "command": "blocked_patterns=('git push --force' 'git reset --hard' 'rm -rf /' 'git clean -fd' 'DROP TABLE' 'DROP DATABASE'); input=$(cat); for pattern in \"${blocked_patterns[@]}\"; do if echo \"$input\" | grep -q \"$pattern\"; then echo \"BLOCKED: Command contains '$pattern'. This command requires manual execution.\" >&2; exit 1; fi; done; exit 0"
    },
    {
      "type": "PostToolUse",
      "matcher": "Edit",
      "command": "filepath=$(cat | jq -r '.file_path // empty'); if [ -n \"$filepath\" ] && echo \"$filepath\" | grep -qE '\\.(ts|tsx|js|jsx|css|json)$'; then npx prettier --write \"$filepath\" 2>/dev/null; fi; exit 0"
    },
    {
      "type": "Stop",
      "matcher": "*",
      "command": "osascript -e 'display notification \"Claude Code has finished the task\" with title \"Claude Code\" sound name \"Glass\"'"
    },
    {
      "type": "PostToolUse",
      "matcher": "Bash(git commit)",
      "command": "last_msg=$(git log -1 --pretty=format:'%s'); last_date=$(git log -1 --pretty=format:'%Y-%m-%d'); echo \"- [$last_date] $last_msg\" >> CHANGELOG.md; exit 0"
    }
  ]
}

You don't need all five Claude Code hooks. Start with the one that addresses your biggest pain point. For most people, that's either the lint gate (Hook 1) or the dangerous command blocker (Hook 2). Add more as you identify friction in your workflow.

The bigger picture

Hooks are part of a broader pattern: making Claude Code's environment smarter so you don't have to babysit it. They pair well with a hub-and-spoke memory system that gives Claude persistent context across sessions, and with context engineering practices that structure the information Claude sees.

The combination matters. Memory gives Claude knowledge about your project. Hooks give Claude guardrails and automation around its actions. Together, they turn Claude from a powerful but unsupervised tool into something closer to a junior developer who knows your project, follows your conventions, and handles the mechanical parts of the workflow automatically.

If you're spending time reviewing Claude's commits for lint issues, or nervously watching every command it runs, or manually copying commit messages into changelogs, hooks eliminate that overhead. The setup takes 15 minutes. The time savings compound every session.

For more on keeping Claude's context intact during long sessions (which is when hooks matter most, since they maintain consistency even as Claude's memory drifts), see how to prevent Claude from compacting your context.

Your markdown deserves a beautiful home.

Start publishing for free. Upgrade when you need more.

View pricing