1:1 mentoring with Big Tech AI engineers
Back to blog
9 min read

Hooks in Claude Code — The Automation Layer Most People Skip

A ~30-line settings.json change that removes half the manual review from your dev loop.

hooksclaude-codeautomation

A ~30-line settings.json change that removes half the manual review from your dev loop.

Most people using Claude Code review each edit by eye, then remember to format, remember to lint, remember to check that .env wasn't touched. Every one of those is a shell command with a well-defined trigger. Hooks are that trigger.

A hook is a shell command Claude Code runs when a tool event fires. The command receives structured JSON on stdin, writes text to stderr, and returns an exit code. Exit 0 means proceed. Exit 2 means block the tool call and show the message to the model. Anything else is treated as a soft warning.

The event surface

Five events cover almost everything you'll want to automate:

EVENTFIRESUSE FOR
PreToolUseBefore a tool call executesBlock dangerous edits, gate destructive commands, require confirmation
PostToolUseAfter a tool call returnsFormat on save, lint, run affected tests, update indexes
UserPromptSubmitWhen you press enter on a promptInject context, tag messages, warn about scope
StopWhen the assistant finishes a turnPlay a chime, log the turn, post to Slack
SubagentStopWhen a spawned subagent finishesAggregate results, notify on completion

The flow

Hook lifecycle around a tool call
Hook lifecycle: user prompt → PreToolUse → tool → PostToolUse → response Claude wants to Edit PreToolUse block-env.sh exit 0 → proceed exit 2 → block Tool runs writes file PostToolUse eslint-fix.sh formats, then returns blocked → stderr shown to model tool call gate execute follow-up PreToolUse gates; PostToolUse follows through. Every tool call, every time.

The JSON contract

Every hook receives the same shape on stdin: a JSON object with the tool name, tool input, and Claude Code session context. You extract what you care about with jq and act on it. The most common field to read is .tool_input.file_path for edit-based tools, or .tool_input.command for Bash.

Exit code semantics matter more than the message:

  • Exit 0 — Proceed. Any stdout is shown as a quiet note to the user.
  • Exit 2 — Block. The tool call is aborted, and everything the hook wrote to stderr is fed back to the model as the reason. This is how you steer future attempts.
  • Any other exit — Non-blocking warning. The tool proceeds anyway.
WATCH OUT: stdout vs stderr matters. Writing to stdout means the model never sees your error message. If you want to teach Claude why the block happened, redirect to stderr with >&2.

Three worked examples

1. Block edits to .env files

Secrets should never be edited by an agent. Wire a PreToolUse hook that checks the file path and refuses. The rule is one line of jq plus a case statement on the basename. If Claude tries to edit .env.local or any dotted variant, the tool aborts and Claude sees the reason.

The value here isn't hypothetical. Every project with a .env file has secrets in it. Without the hook, one accidental accept-permission click can rewrite your Supabase keys or Razorpay secrets. With the hook, that click is silently harmless.

2. Auto-format on TypeScript edits

ESLint at build time catches errors too late. A PostToolUse hook that runs eslint --fix on the single file that just changed catches them at edit time — and often fixes them in place. Claude sees the reformatted file on its next read and adapts without you saying anything.

Two nuances make this reliable in a monorepo. First, filter by file path — you probably don't want to lint every .js file on the machine, just files under the app directory. Second, use the local node_modules/.bin/eslint, not global — versions drift.

WHY IT PAYS: Every ‘please run lint’ message is a full round-trip: you type, model reads, model calls Bash, Bash returns, model responds. That's easily 15 seconds and a paragraph of chatter. A hook is zero seconds and zero chatter.

3. Cost-limit warning on Stop

Long agent runs can silently burn through budget. A Stop hook that reads a cost total (from your own tracker or from a Claude Code plugin) and pushes a desktop notification when it crosses a threshold turns invisible spend into a visible signal. This is a warning, not a block — the hook exits 1, not 2 — so Claude keeps going. But you know.

Where hooks live

Hooks are configured in settings.json. Three layers, precedence low to high:

LAYERPATHSCOPE
User~/.claude/settings.jsonEvery project you open
Project<repo>/.claude/settings.jsonThis repo, shared with teammates
Local<repo>/.claude/settings.local.jsonThis repo, just you (gitignored)

Global hooks that should always apply — the .env block, the cost warning — belong in the user layer. Project-specific hooks — an ESLint fixer that only makes sense for a TypeScript repo — belong in the project layer. Local layer is for experiments you don't want to commit yet.

Common pitfalls

  • Missing jq. Hooks assume jq is on the PATH. If teammates run into command not found, install it as part of onboarding — or shell out to a Node one-liner instead.
  • Absolute paths in the command. Relative paths in a hook resolve against the shell's working directory, not the project root. Use $CLAUDE_PROJECT_DIR explicitly, or absolute paths.
  • Silent failures. A hook that errors out mid-script (unquoted variable, missing file) may exit non-zero and be treated as a soft warning. Add set -euo pipefail at the top and be intentional about what non-zero means.
  • Slow hooks. A PostToolUse hook runs on every edit. If it takes 3 seconds, every edit is now 3 seconds slower. Profile early — most linters have a fast single-file mode.

What hooks aren't

Hooks are not the right tool for injecting knowledge, running long analyses, or making decisions that need context. For those, reach for a subagent or a skill. Hooks are for deterministic, sub-second reactions to events — the kind of thing you'd write a git pre-commit hook for. If the shell script is longer than a screenful, you're probably writing the wrong thing in the wrong place.

Worked examples

Two use cases, one from an engineering workflow and one from a writing workflow, to show how the same primitive lands in very different domains.

Example 1 — PR auto-review gate (engineering)

A team uses Claude Code to draft PRs. Every commit needs to pass ESLint and TypeScript, but nobody wants to remember to run npm run typecheck before git commit. A PreToolUse hook watches for Bash commands that start with git commit, runs the checks first, and blocks the commit if either fails — feeding the errors back to the model as the reason.

Hook flow — git commit gets gated by ESLint + tsc
PR auto-review — git commit blocked when ESLint or tsc fails Claude runs git commit -m "..." PreToolUse matcher: Bash grep 'git commit' → run checks else exit 0 npm run typecheck strict tsc pass npm run lint eslint --max-warnings 0 both pass? exit code YES exit 0 commit runs NO exit 2 commit blocked errors → stderr → Claude tries again Two checks, one gate. Every commit, no exceptions, no forgotten typecheck.

The virtuous cycle is that Claude sees the ESLint output on stderr and self-corrects. After one or two failed commits, the model internalizes the rules and starts writing code that passes on the first try. The hook trains the model against your standards without you ever writing a prompt about it.

Example 2 — Word-count guard for a blog writer (non-technical)

A writer uses Claude Code to draft posts under content/drafts/*.md. Editorial policy says posts should land between 800 and 1500 words. A PostToolUse hook that fires on every Edit or Write to a Markdown file counts words in the current file and prints a note — green if in range, amber if too short, red if too long. Not a block, just a signal.

Word-count meter — a passive signal every time a draft is saved
Word-count guard meter — under, on-target, or over Edit content/drafts/first-post.md PostToolUse hook fires → counts words → prints signal to stdout wc -w < $FILE 500 under = thin 800 target start 1500 target end 2000 over = flabby 1,142 words < 500 or > 2000 ✗ ‘Post length out of policy’ 500-800 or 1500-2000 ! ‘Close to the edge’ 800 - 1500 ✓ ‘On target — 1,142 words’

Notice how the hook doesn't change what the writer writes — it makes the length visible in the workflow the writer is already in. Every save, the note appears; over time, the writer stops needing to think about length at all. The same shape works for reading grade level, headline character count, or ratio of subheads to paragraphs — anything countable, on every edit, for free.

KEY INSIGHT: Every ‘please do X after every edit’ instruction in your CLAUDE.md is a hook that hasn't been written yet. Prompts are how you ask once. Hooks are how you never have to ask again.

Enjoyed this post? The full curriculum has 87+ sections, system design problems, and AI-reviewed practice runs.

See the full guide