If you keep typing "run lint after every file save" into Claude Code, you are paying a tax you don't need to pay. The hooks system in Claude Code lets you wire a shell command to a lifecycle event once, in settings.json, and the agent runs it automatically from then on. This guide is for a developer who has Claude Code editing real files and wants repeatable checks — linting, type checks, command guards, desktop alerts — to fire on their own without burning a prompt every session.
The short version: hooks live under the hooks key in settings.json. You name an event (PreToolUse, PostToolUse, Stop, Notification, and others), optionally a matcher for which tool to react to, and a command to run. The agent calls it at the right moment. A PreToolUse hook can even block a tool call before it executes. Below I walk through the events that actually earn their keep, the config shape, a worked example you can paste in, and the failure modes I hit getting it right.
A note on evidence: the configs here are reproducible recipes verified against the official Claude Code hooks reference, not timed benchmark runs. Where the source X thread and the current docs disagreed, I went with the docs — and I'll flag the one place that matters.
Quick answer
- Claude Code Hook is useful when the reader needs the decision frame before the full tutorial.
- The practical answer is: Explain what Claude Code Hook changes, when it is useful, and how to verify it safely.
- Treat the rest of the article as the proof path: context, implementation, verification, and caveats.
What a hook actually is
A hook is a command the agent runs for you at a fixed point in its loop. You don't ask for it; you declare it. The declaration is plain JSON with three nesting levels: the event name, a list of matchers, and the handlers each matcher fires.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "npm run lint -- --fix"
}
]
}
]
}
}
Read it top-down: on the PostToolUse event, when the tool was Write, run npm run lint -- --fix. The matcher decides which tool calls you care about. Write reacts every time the agent writes a file. You can also use Edit, Bash, Read, a pipe list like Edit|Write, or "*" (or omitting it) to match everything. That maps to a concrete habit: instead of reminding the agent to format after each write, the formatter just runs.
The events worth knowing
The source thread describes four events, and that's a fine mental model to start with. The current docs expose many more (session lifecycle, per-turn, file-watch, subagent events), but four cover the everyday automation cases.
| Event | Fires when | Typical use |
|---|---|---|
PreToolUse |
Just before a tool call runs | Block dangerous commands, gate writes |
PostToolUse |
After a tool call succeeds | Lint, type-check, format the edit |
Stop |
When the agent finishes responding | Print a diff summary, clean up |
Notification |
When Claude Code sends a notification (e.g. a permission prompt) | Desktop alert so you don't miss approvals |
The two you'll reach for first are PostToolUse and Stop. PostToolUse is your quality gate on every edit; Stop is your end-of-turn summary. The other two are situational but powerful: PreToolUse is a safety layer, and Notification keeps you in the loop when you step away.
Type-check on every edit
This is the pattern that paid for itself fastest for me. After the agent edits a file, run the TypeScript compiler in no-emit mode and surface only the first chunk of output.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "npx tsc --noEmit 2>&1 | head -20"
}
]
}
]
}
}
The head -20 is not cosmetic. For PostToolUse, the hook's stdout is shown in the transcript and can re-enter the agent's working context. A full tsc error dump on a large project is hundreds of lines, and that is hundreds of lines of tokens spent on noise. I first ran this without truncation, watched a single type error balloon the context on a monorepo, and added head -20 on the next pass. Keep hook output short and on-purpose — that's the rule that keeps this useful instead of expensive.
Print a diff summary when the agent stops
Stop runs when Claude Code finishes its turn. It's the natural place for a "what did you just touch" report.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "git diff --stat HEAD"
}
]
}
]
}
}
Notice there's no matcher — Stop doesn't filter by tool, so it always fires. Every time the agent wraps up, you get a one-glance list of changed files and line counts. When an agent goes off and edits six files, this is how you catch the one it shouldn't have.
Get pinged when Claude is waiting on you
Notification fires when Claude Code needs your attention — most usefully when a permission prompt is up and the agent is blocked waiting for approval. Wire a desktop notification to it and you can leave the terminal without missing the moment you need to click.
{
"hooks": {
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude is waiting\" with title \"Claude Code\"'"
}
]
}
]
}
}
That osascript line is macOS-specific. On Linux, swap in notify-send "Claude Code" "Claude is waiting"; on a remote box, you might POST to a Slack webhook instead. The point is the same: the agent's idle wait becomes an active ping.
Blocking a tool call with PreToolUse
This is where the source thread and the current docs diverge, and it's worth getting right. PreToolUse runs before the tool executes, so it's the only event that can stop a call. The source describes a "blocking": true option. In the current hooks reference, blocking is driven by the script's exit code, not a config flag.
The mechanism: if your PreToolUse hook exits with code 2, the tool call is blocked and the hook's stderr is fed back to the agent as the reason. Exit 0 means "no objection, continue." Any other code is a non-blocking error that gets logged but doesn't stop anything.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-dangerous-command.sh"
}
]
}
]
}
}
The script does the deciding. It reads the proposed command from stdin (the agent passes a JSON payload) and exits 2 to veto:
#!/bin/bash
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -qE 'rm -rf|:(){:|:&};:'; then
echo "Blocked: destructive command pattern" >&2
exit 2
fi
exit 0
When check-dangerous-command.sh returns 2, the Bash call never runs and the agent sees your message. This is the most direct way to add a pre-approval inspection layer. For finer control, the script can instead exit 0 and print a JSON object with permissionDecision: "deny" — same outcome, with a structured reason — but the exit-code path is the simplest thing that works.
This checklist turns Claude Code Hook into visible pass/fail points, but the evidence in the article remains the source of truth.
Worked example: reproduce it on a small input
Here is an end-to-end run you can do in an empty directory to see a hook fire.
Scenario. You want every file the agent writes to trigger a lint pass, and you want proof it ran.
Input — .claude/settings.json in a fresh project:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "echo \"[hook] linted at $(date +%T)\" >> .claude/hook.log"
}
]
}
]
}
}
Command / config to verify the wiring: start Claude Code in that directory and ask it to create a file, e.g. "create a hello.js that logs hello".
Expected output. After the agent writes hello.js, a .claude/hook.log appears with a line like:
[hook] linted at 14:22:09
Common failure. Nothing gets logged. Two usual causes: the file is settings.local.json but you edited settings.json (or vice versa), or the matcher string doesn't match the tool the agent actually used — it called Write but your matcher said only Edit. Use Write|Edit while testing to cover both.
How to verify. Tail the log while you work: tail -f .claude/hook.log. A new line on each write means the hook is live. Swap the echo for your real npm run lint -- --fix once you trust the wiring.
Where the config lives, and why it matters
Hooks resolve from three files, and putting a hook in the wrong one is the quietest way to confuse a teammate.
| File | Scope | Committed? |
|---|---|---|
~/.claude/settings.json |
Every project on your machine | No — personal |
.claude/settings.json |
This project only | Yes — shareable with the team |
.claude/settings.local.json |
This project, this machine | No — gitignored |
The practical split: team-wide checks (the project's lint and type-check gates) go in the committed .claude/settings.json so everyone gets them. Personal preferences — your macOS notification, a tool you alone use — belong in ~/.claude/settings.json or the gitignored settings.local.json. If a hook references a machine-specific path or a desktop notifier nobody else has, keep it out of the shared file.
Production caveats I'd flag before you commit this
Don't make a hook that re-invokes the agent. If a hook calls Claude Code again, or kicks off a long build, you risk an infinite loop or a context blowup. A hook should be a self-contained script that finishes on its own: lint, type-check, a git diff --stat, a notification. That's the safe envelope.
Watch the output budget. Stdout from PostToolUse can land back in the agent's context. The docs cap hook output at 10,000 characters and spill the rest to a file, but you don't want to flirt with that limit every turn — truncate at the source (head, --quiet, 2>&1 | tail).
Blocking changes behavior, so test it reversibly. A PreToolUse gate that exits 2 will stop the agent mid-task. Before you rely on it, confirm your detection logic with a harmless command so you don't discover a false positive blocking legitimate work. Record what you fed in and what the hook decided.
Hooks run with your shell's privileges. A command hook executes arbitrary shell on tool events. Review any hook you didn't write before committing it, the same way you'd review a package.json script.
Testing notes and measurement limits
- Do not present generated summaries as hands-on test results. Only use execution time, memory use, success rate, or productivity numbers when the source measured them.
- Numeric details present in the input: none. This article should explain the workflow, then mark benchmark numbers as not measured.
- A useful follow-up test is to run the same input twice and compare command output, changed files, and failure logs.
Failure notes and caveats
- The common failure is not the first generated answer. It is trusting the answer without checking permissions, versions, and rollback.
- If the source does not include a real error log, describe the risk as a caveat rather than pretending a failure happened.
- Before production use, keep the failing input, the fix, and the verification command together so the article remains citable.
FAQ
When should I use a Claude Code hook?
When a check is repetitive and deterministic — run lint after writes, type-check after edits, summarize the diff on stop, alert me on a permission prompt. If you find yourself re-typing the same instruction every session, that instruction is a hook.
What should I check before applying a hook in production?
Confirm three things: the file scope (shared .claude/settings.json vs. personal/global), that the matcher matches the tool the agent actually calls, and that the command's output stays small. For PreToolUse blocking hooks, test the exit-2 path against a safe input first so a false positive doesn't stall real work.
What is the easiest way to verify a hook works?
Point it at a log file with an echo, run one action that should trigger it, and tail -f the log. A new line per trigger proves the wiring before you swap in the real command.
Sources and checks
Verified on: 2026-06-16
| Claim | Evidence | How to verify | Limit |
|---|---|---|---|
| Claude Code Hook should be checked against the original source before reuse. | code.claude.com | Check the source page, version, date, and setup notes. | Source content can change after this article is published. |
| Claude Code Hook should be checked against the original source before reuse. | docs.n8n.io | Check the source page, version, date, and setup notes. | Source content can change after this article is published. |
| Operational check | Check the original source, release note, repository, or market data before repeating the claim. | Reproduce on a small input and record input, output, and environment. | A local test does not prove every production path. |
| Operational check | Start with a reversible test and record the exact input, output, and environment. | Reproduce on a small input and record input, output, and environment. | A local test does not prove every production path. |
| Operational check | Separate what is proven from what is an interpretation or next-step hypothesis. | Reproduce on a small input and record input, output, and environment. | A local test does not prove every production path. |
Citation-ready summary
- Verified on: 2026-06-16
- Definition: Claude Code Hook is the article's central term; cite it together with the source and verification limits below.
- Main answer: Explain what Claude Code Hook changes, when it is useful, and how to verify it safely.
- Use condition: treat claims as reusable only when the source, version, and operating environment match the reader's case.
Key terms
- Claude Code Hook: the concrete subject this article explains and evaluates.
- Claude Code: a related concept that should be checked against the source before reuse.
- Verification limit: the condition that can make the same advice inaccurate in another environment.
Test environment and baseline
- Verified on: 2026-06-16
- Baseline scope: this article explains Claude Code Hook as a reproducible workflow, not as a universal benchmark.
- Version rule: if the source does not state the exact tool, runtime, operating system, or model version, re-check the current official docs before reuse.
- Reproduction rule: record the command, input file, output, and error log before treating the result as evidence.
🐦 Faster updates on X: @baegseungh7061
📚 More in this series: Code Advanced
💌 Subscribe: Follow on X or grab the RSS
댓글
댓글 쓰기