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:
| EVENT | FIRES | USE FOR |
|---|---|---|
PreToolUse | Before a tool call executes | Block dangerous edits, gate destructive commands, require confirmation |
PostToolUse | After a tool call returns | Format on save, lint, run affected tests, update indexes |
UserPromptSubmit | When you press enter on a prompt | Inject context, tag messages, warn about scope |
Stop | When the assistant finishes a turn | Play a chime, log the turn, post to Slack |
SubagentStop | When a spawned subagent finishes | Aggregate results, notify on completion |
The flow
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.
>&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.
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:
| LAYER | PATH | SCOPE |
|---|---|---|
| User | ~/.claude/settings.json | Every project you open |
| Project | <repo>/.claude/settings.json | This repo, shared with teammates |
| Local | <repo>/.claude/settings.local.json | This 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 assumejqis on the PATH. If teammates run intocommand 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_DIRexplicitly, 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 pipefailat 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.
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.
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.