Hooks Overview
Event-driven shell hooks that fire on tool calls and prompt submission. The four event types and where each one is the right tool.
What it is#
A hook is a shell command Claude Code runs automatically in response to an event during the session. Hooks are configured in your settings (typically ~/.claude/settings.json or a project-scoped equivalent) and fire deterministically on every matching event. They are the primary mechanism for observing and gating what Claude Code does inside a session.
A hook can:
- Observe. Log what happened — a timestamp, the tool name, the inputs, an audit trail.
- Modify. Re-format inputs, redact secrets, inject extra context into prompts.
- Block. Refuse to let an action proceed by exiting non-zero with a message. The hook can stop a Bash command from running or a tool call from being made.
Hooks run as subprocess shell commands, not in-process callbacks, which means they can be written in any language, can call any binary, and have access to the project’s regular toolchain.
When to use it#
Reach for hooks when you need guaranteed behaviour around an event — something the model cannot forget or skip. Examples:
- Run a linter after every code edit. PostToolUse hook on Edit/Write that runs
pnpm lint:fix. The lint never gets skipped. - Audit every Bash command. PostToolUse hook on Bash that appends the command and result to a log file. Cheap insurance for incident review.
- Block dangerous commands. PreToolUse hook on Bash that exits non-zero if the command matches
rm -rf /orgit push --force origin main. The model never gets to run them, even with bypass permissions. - Redact secrets from user prompts. UserPromptSubmit hook that strips obvious API-key-shaped strings before they hit the model’s context.
- Inject context. UserPromptSubmit hook that appends “today is YYYY-MM-DD” so the model always has the current date.
- Cleanup at session end. Stop hook that runs
git statusso you see uncommitted changes when the session wraps up.
Don’t reach for hooks when:
- The behaviour only needs to happen sometimes. Hooks fire every time — they can’t read intent.
- The model could plausibly do it itself. A
pnpm lintafter edits the model would have run anyway is wasted ceremony; a hook is right when the model won’t reliably do it. - The action is slow. Every hook adds latency to the action it runs after. A 5-second hook on every tool call ruins the conversational feel.
How it works#
The four event types#
Claude Code dispatches four hook events. Each fires at a different point in the conversation loop:
| Event | When it fires | Common uses |
|---|---|---|
PreToolUse | After the model decides to call a tool, before the tool actually runs | Block dangerous commands; validate inputs; inject extra args |
PostToolUse | After a tool completes (success or error), before the result is shown to the model | Run linters; log to audit; transform results |
UserPromptSubmit | When the user submits a message, before it reaches the model | Redact secrets; append context; track usage |
Stop | When the session ends or the assistant yields the floor | Cleanup; summary; reminder of uncommitted state |
The lifecycle ordering matters: a PreToolUse hook can prevent a tool from running entirely (by exiting non-zero); a PostToolUse hook cannot — by the time it fires, the tool has already executed.
How a hook is invoked#
When a matching event fires, Claude Code spawns the hook’s command as a subprocess. The hook receives JSON on stdin describing the event:
{ "event": "PreToolUse", "tool": "Bash", "input": { "command": "rm -rf /tmp/test" }}The hook can read stdin, do its work, and exit. Exit code 0 means “proceed”; non-zero exit codes mean “block this action” — the hook’s stderr is shown to the user (and, for PreToolUse, returned to the model so it knows why the action was blocked).
For hooks that need to modify an input or output, the convention is to write modified JSON to stdout. Claude Code reads stdout and substitutes the original payload with the modified one before continuing. The exact stdin/stdout schemas are versioned in the Claude Code docs — check the docs for the current shape before authoring a new hook.
Matching and filtering#
Hook configs let you match by tool name, by event, and (for tools with structured inputs) by input pattern. A typical PostToolUse hook config:
{ "hooks": [ { "event": "PostToolUse", "matchers": [{ "tool": "Edit" }, { "tool": "Write" }], "command": "pnpm lint:fix" } ]}This fires only after Edit or Write tool calls, not after every tool. The matcher syntax lets you be more specific — only edits to certain paths, only Bash commands matching certain patterns. Specific matchers are strongly preferred over broad ones; a hook that runs on every PostToolUse will slow every step of every session.
Configuration#
Where hooks live#
Hooks are configured in your settings file — typically ~/.claude/settings.json for user-global hooks, with a project-scoped settings file for team-shared hooks. Schema example (truncated):
{ "hooks": [ { "event": "PreToolUse", "matchers": [{ "tool": "Bash" }], "command": "~/.claude/hooks/audit-bash.sh" }, { "event": "PostToolUse", "matchers": [{ "tool": "Edit" }], "command": "pnpm lint:fix" }, { "event": "UserPromptSubmit", "command": "~/.claude/hooks/redact-secrets.sh" } ]}The hook commands can be inline shell, paths to scripts, or any executable on $PATH. Project-scoped hooks should be committed alongside the project so the whole team gets the same behaviour.
Hook scripts#
A minimal PostToolUse hook script that logs every Bash command:
#!/usr/bin/env bashpayload="$(cat)"cmd="$(echo "$payload" | jq -r '.input.command')"echo "$(date -Iseconds) $cmd" >> ~/.claude/audit/bash.logA PreToolUse hook that blocks force-pushes to main:
#!/usr/bin/env bashpayload="$(cat)"cmd="$(echo "$payload" | jq -r '.input.command')"if echo "$cmd" | grep -qE 'git push.*--force.*\b(main|master)\b'; then echo "Blocked: force-push to main requires manual override" >&2 exit 1fiexit 0These are toy examples — production hooks should validate the JSON schema (don’t assume .input.command exists for every tool) and handle malformed input safely.
Examples#
Lint-after-edit#
A PostToolUse hook on Edit/Write that runs the project’s linter. Catches the common case of the model making a syntactically valid but stylistically off change.
{ "event": "PostToolUse", "matchers": [{ "tool": "Edit" }, { "tool": "Write" }], "command": "pnpm lint:fix"}Audit-and-block dangerous Bash#
A PreToolUse hook on Bash that both audits and blocks a curated denylist. The audit log gives you incident-review material; the denylist stops the model from running anything it shouldn’t.
Inject the date into every prompt#
A UserPromptSubmit hook that appends (Today is YYYY-MM-DD.) to the user’s message. Useful because the model otherwise has no way to know the date and may guess.
Session-end summary#
A Stop hook that runs git status --short and git log --oneline -5. When the session ends, you see uncommitted state and recent commits side by side — useful before closing the terminal.
Gotchas#
- Hook latency is per-event. A hook that takes 500ms to run adds 500ms to every event it matches. On a session that fires ten matching events per minute, that’s five seconds per minute of pure overhead. Keep hooks fast or scope matchers narrowly.
- Hooks fail closed by default. A non-zero exit code blocks the action. If your hook has a bug and crashes, the action it was supposed to gate stops working — silently, until you check. Test hooks before deploying.
- JSON parsing is your problem. Hooks receive structured input on stdin; if your script assumes a field exists and it doesn’t (different tool, different version), you’ll see a confusing error mid-session. Validate before reading.
- stderr is shown to the user. Anything your hook prints to stderr ends up in the conversation. For successful hooks, keep stderr empty; for blocking hooks, write a clear single-line reason.
- stdout substitutes the payload — sometimes by accident. If you
echodebug output to stdout in a hook that modifies inputs, Claude Code may substitute the original payload with your debug string and the action fails in an inscrutable way. Use stderr for logging, not stdout. - Project-scoped hooks need agreement. A hook committed to the project runs for every team member’s Claude Code sessions. Make sure the team agrees on each one before merging; surprising hooks are a real source of frustration.
- Hooks don’t see all model behaviour. A hook on Bash doesn’t fire if the model uses Edit. A hook on Edit doesn’t fire if the model uses Write. Cover the family of tools you care about, not just one.
- Hooks can’t see future actions. A PreToolUse hook sees the upcoming action but doesn’t know what comes next. Don’t write hooks that depend on planning ahead.
Related features#
- PreToolUse and PostToolUse Hooks
- UserPromptSubmit Hooks
- Building a Security Hook
- Permission Rules and Allowlists
- Settings and Configuration
Why hooks beat trusting the model
The model is well-aligned but not infallible. A lint-after-edit hook works every single time because it’s just a shell command running after every match. A “please remember to run lint after edits” instruction in CLAUDE.md works most of the time, fails some of the time, and you don’t know which session was which. Hooks are how you turn “the model usually does this” into “the model always does this” — at the cost of a little setup and per-event latency.