Hooks are commands Claude Code runs at defined points in its loop. Each receives event JSON on stdin. A PreToolUse hook that exits with code 2 blocks the tool call and returns its stderr to the model as the reason. Hooks are configured in the hooks block of a settings file, and also in skill and subagent frontmatter. They are the only deterministic control in Claude Code: a hook that denies a tool call blocks it even in bypassPermissions mode.
- Hooks are deterministic. Instructions are advisory; hooks are not.
- Event JSON arrives on stdin; you match on
tool_nameandtool_input. - Exit code 2 blocks a
PreToolUsecall and feeds stderr back to the model. - A hook deny beats
bypassPermissions. A hook allow does not beat a deny rule. - Five hook types now:
command,http,mcp_tool,prompt,agent.
The events worth wiring
Claude Code documents more than thirty hook events as of August 2026. Almost all real configurations use six or seven of them; the rest exist for enterprise auditing and editor integration.
The events people actually configure, and what each can do.
| Event | Fires | Can block? | Use for |
|---|---|---|---|
PreToolUse | Before a tool call executes | Yes | Guards, validation, policy, auto-approval |
PostToolUse | After a tool call succeeds | Feedback only | Formatting, linting, tests |
PostToolUseFailure | After a tool call fails | Feedback only | Turning a raw error into an instruction |
UserPromptSubmit | When you submit a prompt | Yes, erases it | Injecting context, redaction |
Stop | When Claude finishes responding | Yes, keeps it working | Completion checks, notifications |
SubagentStop | When a subagent finishes | Yes | Same, for delegated work |
SessionStart | Session begins or resumes | No | Environment checks, context injection |
Notification | Claude Code notifies you | No | Desktop alerts when it needs input |
PreCompact | Before context compaction | Yes | Saving state, blocking an unwanted compact |
SessionEnd | Session terminates | No | Cleanup, logging |
Configuration and the contract
The shape is the same everywhere: an event name maps to an array of matcher groups, each holding an array of handlers.
Add the block to a settings file
Project hooks go in .claude/settings.json and are committed. Personal hooks go in ~/.claude/settings.json.
Reference the script by an absolute path
Use "$CLAUDE_PROJECT_DIR" so the hook works whatever directory Claude Code is invoked from, and quote it, because project paths contain spaces.
Confirm it registered
Run /hooks. It lists every configured hook grouped by event, with the source file, matcher, and command. The menu is read only; edit the JSON to change anything.
Test it with a piped event before trusting it
Pipe a sample JSON payload into the script by hand and check the exit code. A hook that silently fails is worse than no hook, because you will believe the rule is enforced.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format.sh" }
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard.sh", "timeout": 5 }
]
}
]
}
}
Matcher syntax
How the matcher string is interpreted.
| Characters used | Evaluated as | Example |
|---|---|---|
*, empty string, or omitted | Match everything | Fires on every occurrence |
Letters, digits, _, -, spaces, ,, | | Exact string or list | Bash, Edit|Write |
| Anything else | JavaScript regex, unanchored | ^Notebook, mcp__memory__.* |
The stdin payload
Every event includes common fields, and each adds its own. A PreToolUse hook on Bash receives something like this:
{
"session_id": "3f2a...",
"transcript_path": "/Users/you/.claude/projects/.../3f2a.jsonl",
"cwd": "/Users/you/code/project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_use_id": "toolu_01...",
"tool_input": { "command": "rm -rf build" }
}
Exit codes are the API
Exit code semantics.
| Exit code | Meaning |
|---|---|
0 | Success. stdout is parsed for JSON output; JSON is only processed on exit 0. |
2 | Blocking error. stdout is ignored; stderr becomes the reason. What "block" means depends on the event. |
| anything else | Non-blocking error. The action proceeds and the first line of stderr appears in the transcript. |
The two hooks worth having
Auto-format after every edit
#!/usr/bin/env bash
# Format whatever the agent just wrote. Removes style from code review
# entirely, and stops the agent spending turns on formatting itself.
input=$(cat)
file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
[ -z "$file" ] && exit 0
[ ! -f "$file" ] && exit 0
case "$file" in
*.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.md) npx --no-install prettier --write "$file" 2>/dev/null ;;
*.py) ruff format "$file" 2>/dev/null ;;
*.go) gofmt -w "$file" ;;
*.rs) rustfmt "$file" 2>/dev/null ;;
esac
exit 0
Guard the things that must not happen
#!/usr/bin/env bash
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
block() { printf '%s\n' "$1" >&2; exit 2; }
case "$cmd" in
*"git push"*--force*|*"git push"*-f*)
block "Force-push is blocked by a repo hook. Push normally, or ask a human." ;;
*"rm -rf /"*|*"rm -rf ~"*)
block "Refusing a recursive delete outside the project." ;;
*".env"*|*"credentials"*|*"id_rsa"*)
block "That path holds secrets. Ask for what you need instead of reading it." ;;
*"git commit"*--no-verify*)
block "Pre-commit hooks exist deliberately. Do not skip them." ;;
esac
exit 0
printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git push --force"}}' \
| .claude/hooks/guard.sh; echo "exit=$?"
JSON output, and auto-approving a prompt
Exit codes cover blocking. For anything richer, exit 0 and print JSON to stdout. This is how you approve a permission prompt without dropping the whole session into a looser mode.
#!/usr/bin/env bash
input=$(cat)
path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
case "$path" in
*/docs/*)
jq -nc '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
permissionDecisionReason: "Documentation is always readable."
}
}'
;;
esac
exit 0
The output fields you will actually use.
| Field | Effect |
|---|---|
hookSpecificOutput.permissionDecision | allow, deny, ask, or defer on PreToolUse |
hookSpecificOutput.updatedInput | Rewrites the tool arguments before it runs |
hookSpecificOutput.additionalContext | Injects text the model reads, without blocking |
decision: "block" plus reason | The JSON equivalent of exit 2, on the events that support it |
continue: false plus stopReason | Stops Claude entirely |
systemMessage | Shows a warning to you, not to the model |
suppressOutput | Keeps hook stdout out of the transcript |
Five hook types, not one
Most hooks are type: "command". Four other types exist, and two of them are for decisions a shell script cannot make.
Hook types as of August 2026.
| Type | Runs | Default timeout |
|---|---|---|
command | A shell command with event JSON on stdin | 10 minutes |
http | POST of the same JSON to a URL; the response body is the output | 10 minutes |
mcp_tool | A tool on an already-connected MCP server | 10 minutes |
prompt | One LLM call, Haiku by default, returning {ok, reason} | 30 seconds |
agent | A subagent with tool access, up to 50 turns. Experimental. | 60 seconds |
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Check if all requested tasks are complete. If not, respond with {\"ok\": false, \"reason\": \"what remains\"}."
}
]
}
]
}
}
HTTP hooks are the one to know about for teams: a shared audit service can receive every tool-use event from every developer without shipping a script to every machine. Header values interpolate environment variables, but only those listed in allowedEnvVars, and an admin can restrict the URLs with allowedHttpHookUrls.
Where hooks can live, and being careful with them
Every place a hook can be configured.
| Location | Scope | Shareable |
|---|---|---|
~/.claude/settings.json | All your projects | No |
.claude/settings.json | One project | Yes, commit it |
.claude/settings.local.json | One project, just you | No |
| Managed policy settings | Whole organisation | Yes, admin controlled |
Plugin hooks/hooks.json | While the plugin is enabled | Yes |
| Skill or subagent frontmatter | While that component is active | Yes |
- Hooks run with your full permissions. A hook configured in a repository you cloned is arbitrary code execution. Read
.claude/settings.jsonin unfamiliar repositories before running an agent in them. - Keep them fast. A
PostToolUsehook runs after every matching call. Two seconds becomes very noticeable, and you can lower the ceiling per hook with thetimeoutfield in seconds. - Fail open unless you mean it. A formatter that exits non-zero when the tool is missing will nag on every edit forever.
- Quote everything. Paths contain spaces. Unquoted
$fileis a bug waiting for one, and so is an unquoted$CLAUDE_PROJECT_DIR. - Know the special timeouts.
UserPromptSubmitis capped at 30 seconds,MessageDisplayat 10, and everySessionEndhook shares a 1.5 second budget that a per-hooktimeoutcan raise to at most 60. Stophooks fire on every response, not only at task completion, and they do not fire on a user interrupt.
# inside a session: every configured hook, grouped by event, read only
/hooks
# kill switch, in a settings file
# { "disableAllHooks": true }
Questions people ask
Commands Claude Code runs at defined points in its loop, such as before or after a tool call. They receive event JSON on stdin and can block actions, which makes them the only deterministic control in the system.
Exit with code 2 from a PreToolUse hook. The call is blocked and whatever you wrote to stderr is returned to the model as the reason. The JSON equivalent is exit 0 with hookSpecificOutput.permissionDecision set to deny.
In the hooks block of .claude/settings.json for a project, ~/.claude/settings.json for all your work, or managed policy settings for an organisation. Hooks can also be scoped to a single skill or subagent through its frontmatter.
A hook can tighten but not loosen. A PreToolUse hook returning deny blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions. A hook returning allow does not override a deny rule in settings.
They run with your full permissions, so a hook configured in a repository you cloned is arbitrary code execution. Read the settings file before running an agent in an unfamiliar repo, and use disableAllHooks if you want a kill switch.
Auto-formatting after every edit, which removes style from review entirely. The second is a PreToolUse guard blocking force-pushes, credential reads, and --no-verify commits.
A PostToolUse hook runs after every matching tool call, so anything slow is felt constantly. Keep them under a few hundred milliseconds and set a per-hook timeout in seconds rather than relying on the ten-minute default.
They all run in parallel and all run to completion before the results are merged. For a permission decision the most restrictive answer wins, in the order deny, defer, ask, allow. One hook returning deny does not suppress side effects in another.
Sources
Every figure above was read from these pages on August 2026. Vendors reprice without notice; if you find a stale number, tell us.