Originally published at heycc.cn. This is a mirrored copy — the canonical version is kept up to date at the source.
Claude Code Hooks: How to Use Them in 2026 (Complete Guide)
Most of what an AI coding agent does is probabilistic. You ask it to run the tests before committing, and usually it does — but "usually" is not a guarantee you can build a workflow on. Hooks are the part of Claude Code that is not probabilistic. They are user-defined handlers wired into specific points of Claude Code's lifecycle, and they fire every single time their event fires, regardless of what the model decided to do. That makes them the right tool for anything that must always happen: formatting after every edit, blocking writes to .env, logging every Bash command, or getting a desktop ping the moment Claude needs you.
What follows is the full picture: how hooks are configured, the complete lifecycle, the two channels a hook uses to talk back to Claude Code (exit codes vs. structured JSON), and a set of copy-paste examples that solve real problems. Each claim is grounded in the official docs, traceable through the sourcing rundown and Sources list at the end.
What a hook actually is
A hook is a handler attached to an event. When the event fires, Claude Code passes a JSON payload to your handler on stdin, your handler does its work, and it signals back through stdout, stderr, and its exit code. Because the handler is your code, hooks are deterministic: there's no "the model chose not to run it." If you register a PostToolUse formatter on Edit|Write, every edit gets formatted, full stop.
The most common handler is "type": "command" — a shell command. Four other handler types exist, covered further down: http (POST the event to a URL), mcp_tool (call a tool on a connected MCP server), prompt (a single-turn evaluation by a fast model — the model is configurable via an optional model field, and defaults to a fast model), and agent (a multi-turn subagent that can read files and run commands — still experimental).
Where hooks live, and who they apply to
Hooks go in a settings file under a top-level hooks object. Where you put that file decides the scope:
| Location | Scope | Committable? |
|---|---|---|
~/.claude/settings.json |
All your projects | No — local to your machine |
.claude/settings.json |
One project | Yes — commit it to share with the team |
.claude/settings.local.json |
One project | No — gitignored by default |
| Managed policy settings | Org-wide | Yes — admin-controlled |
Plugin hooks/hooks.json
|
When the plugin is enabled | Yes — bundled with the plugin |
This split is the whole game for teams: put shared guardrails in the committed .claude/settings.json so every teammate inherits them, and keep personal conveniences (like notifications) in ~/.claude/settings.json. To turn everything off at once, set "disableAllHooks": true. Run /hooks in Claude Code to open a read-only browser of everything configured, grouped by event — to actually add or change a hook you edit the JSON directly (or ask Claude to do it).
The structure of a hooks block
Every event key holds an array of groups. Each group has a matcher and a hooks array of handlers:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
The matcher filters which occurrences fire the group. For tool events it matches on the tool name: "Edit|Write" fires only on the Edit or Write tools, "Bash" only on Bash, and "mcp__github__.*" (a regex) on all tools from that MCP server. An empty or omitted matcher fires on every occurrence. The docs distinguish exact-string matchers (letters, digits, _, spaces, ,, |) from regex matchers (anything else). Note that the published matcher rules do not explicitly say whether exact matching is case-sensitive — but tool names ship capitalized (Bash, Edit), and in practice "bash" will not match Bash, so match the casing exactly to be safe. Different events match on different fields: SessionStart matches on startup/resume/clear/compact, Notification on the notification type, and several events (like UserPromptSubmit and Stop) take no matcher at all.
The hook lifecycle
Claude Code exposes a long list of events; these are the ones you'll reach for first:
| Event | Fires when | Can block? |
|---|---|---|
SessionStart |
A session begins or resumes | No (stdout adds context) |
UserPromptSubmit |
You submit a prompt, before Claude sees it | Yes |
PreToolUse |
Before a tool call executes | Yes |
PermissionRequest |
A permission dialog is about to appear | Yes (interactive only) |
PostToolUse |
After a tool call succeeds | No (already ran) |
Notification |
Claude Code sends a notification (waiting for input/permission) | No |
Stop |
Claude finishes responding | Yes (can force more work) |
SubagentStop |
A subagent finishes | Yes |
PreCompact |
Before context compaction | No |
The key mental model: PreToolUse is your gate — it runs before the tool and can stop it. PostToolUse is your reaction — it runs after success and cannot undo anything, since the tool already executed. If you need to prevent something, it has to be PreToolUse.
How a hook talks back: exit codes
The simplest way to control Claude Code is the exit code of your command:
-
Exit 0 — no objection; the action proceeds normally. For
PreToolUsethis does not auto-approve the tool — the normal permission flow still applies. ForUserPromptSubmitandSessionStart, whatever you write to stdout is injected into Claude's context. -
Exit 2 — block. Whatever you write to stderr is fed back to Claude as feedback so it can adjust. For
PreToolUsethis blocks the tool call. For un-blockable events likeSessionStartandNotification, exit 2 just shows stderr to you and execution continues. -
Any other exit code — the action proceeds anyway. The transcript shows a
<hook name> hook errornotice with the first line of stderr; the full stderr goes to the debug log.
Here's the canonical "block a dangerous Bash command" hook (a PreToolUse hook matched on Bash):
#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q "drop table"; then
echo "Blocked: dropping tables is not allowed" >&2 # stderr = Claude's feedback
exit 2 # exit 2 = block
fi
exit 0
How a hook talks back: structured JSON
Exit codes only let you block or stay silent. For finer control — approving, denying with a reason, or rewriting a tool's arguments — exit 0 and print a JSON object to stdout. Do not mix the two: Claude Code ignores your JSON if you exit 2.
A PreToolUse hook returns a hookSpecificOutput object with hookEventName: "PreToolUse" and a permissionDecision:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Use rg instead of grep for better performance"
}
}
There are four decision values, and they behave differently:
permissionDecision |
Effect |
|---|---|
allow |
Skips the interactive prompt for this hook — but does not grant final permission. Other hooks can still deny, and deny/ask rules from settings still apply. |
deny |
Cancels the tool call and sends the reason back to Claude. |
ask |
Shows the normal permission prompt to the user. |
defer |
Defers to the normal permission flow — identical to returning no decision at all. Use this when a hook inspects the call but has no opinion. |
When several matching hooks disagree, the safe-by-default principle holds: a single deny wins over the others, and allow is the weakest signal — it never overrides a deny from another hook or from settings rules. (The official docs note that all matching hooks run in parallel and that allow does not override a deny; community references summarize the effective ordering as deny > defer > ask > allow. Treat that ordering as a useful mental model rather than a guaranteed contract, and design hooks so that the most restrictive decision is the one you rely on.)
The asymmetry here is the most important security property of hooks. A PreToolUse hook returning deny blocks the tool even in bypassPermissions mode or under --dangerously-skip-permissions, because PreToolUse fires before any permission-mode check. But allow does not loosen things — if a deny rule in settings matches, the call is still blocked. In one sentence: hooks can tighten restrictions but never loosen them past what permission rules allow. That's exactly what you want for org-wide guardrails users can't opt out of.
Rewriting tool arguments before they run
A PreToolUse hook can also modify a tool's input by returning updatedInput inside hookSpecificOutput — for example, redirecting a raw rm to a trash wrapper, or adding --fix to a lint command:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": { "command": "trash ./build" }
}
}
The one caveat: the rewrite is silent to the model unless you tell it. Claude sees the result of the modified call, not your substitution, so if the change matters semantically, also return a permissionDecisionReason so the agent knows what happened.
The other handler types
Beyond command, the four remaining handler types let a hook reach outside the shell:
Handler type
|
What it does | Reach for it when |
|---|---|---|
http |
POSTs the event JSON to a URL and uses the response | You already run a policy/audit service and want hooks to call it |
mcp_tool |
Invokes a tool on a connected MCP server | The decision logic already lives behind an MCP server |
prompt |
Single-turn evaluation by a fast model (model configurable; defaults to a fast model) | You want a quick natural-language judgment ("is this command destructive?") without writing parsing code |
agent |
Multi-turn subagent with Read/Grep/Glob and command access — experimental | The check needs to investigate the repo before deciding |
The prompt and agent types are the newest and the most powerful, because they let a hook make a judgment instead of a regex match. The trade-off is latency and cost: every fire spends tokens. Keep command hooks for the hot path (formatting, simple blocks) and save model-backed hooks for the rare, high-stakes decisions where a regex would be too brittle.
A copy-paste starter set
Three hooks cover most of what teams actually want. Drop them in .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null || true" }
]
}
],
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": "jq -e '.tool_input.file_path | test(\"\\\\.env$\")' >/dev/null && { echo 'Refusing to write .env' >&2; exit 2; } || exit 0" }
]
}
],
"Notification": [
{
"hooks": [
{ "type": "command", "command": "osascript -e 'display notification \"Claude needs you\" with title \"Claude Code\"'" }
]
}
]
}
}
Top to bottom: auto-format every edit, hard-block any write to a .env file (exit 2 = deny), and ping you on macOS whenever Claude is waiting. The .env block is the one that earns its keep — it works even under --dangerously-skip-permissions, because PreToolUse runs first.
If you maintain shared agent configuration, link these from your team's internal AI tooling guide so new engineers inherit the same guardrails on day one.
Matching the event and handler to the job
| You want to... | Use | Why |
|---|---|---|
| Always run something after a tool, no opinion on outcome |
PostToolUse + command, exit 0 |
Fires every time; can't block, which is fine |
| Hard-block an action no matter the permission mode |
PreToolUse + exit 2 (or deny JSON) |
Runs before permission checks; survives skip-permissions |
| Block and let Claude retry intelligently |
PreToolUse + exit 2 with stderr |
stderr is fed back as feedback |
| Rewrite arguments instead of blocking |
PreToolUse + updatedInput JSON |
Fix the call rather than reject it |
| Inject context at session/prompt start |
SessionStart / UserPromptSubmit, stdout |
stdout is added to Claude's context |
| Make a nuanced natural-language judgment |
prompt or agent handler |
Model decides; no brittle regex |
The throughline: pick the earliest event that can do the job, and the simplest handler that expresses your logic. Reach for model-backed handlers only when a deterministic rule genuinely can't express the decision.
What the docs say, and where this guide reads between the lines
The official Hooks reference (checked 2026-06-28) is the source for the five handler types (command, http, mcp_tool, prompt, agent) and for the prompt handler being a single-turn evaluation that defaults to a fast model, configurable through an optional model field — the docs name no specific model, so neither does this guide. The same page gives permissionDecision its four values (allow, deny, ask, defer) and states both that matching hooks run in parallel and that allow never overrides a deny. What it does not publish is a numeric precedence table, so the deny > defer > ask > allow ordering above is offered as a working mental model rather than a quoted contract. Likewise, the matcher rules separate exact-string from regex matchers without asserting case-sensitivity, so the "match the casing exactly" advice is framed as observed behavior. The Settings reference backs the file-location and disableAllHooks details, and the Permissions page backs how hooks interact with permission modes.

Top comments (0)