Hooks — Lifecycle Events
If skills and subagents extend what Claude can do, hooks control how it does it — and they do so deterministically. A hook is an ordinary shell command (or HTTP request, MCP tool, or even an LLM call) that Claude Code runs automatically at specific moments in the session lifecycle. The model has no say in this decision: a hook always fires, regardless of what Claude thinks.
This is a fundamental difference from instructions in CLAUDE.md or skills. Writing "always run prettier after editing files" is a request that Claude may ignore deep in a long tool chain. A hook with the same rule is a guarantee.
Anatomy of a Hook
Hooks live under "hooks" inside any settings.json (user-level, project-level, or local). The structure has three levels:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format.sh",
"timeout": 30
}
]
}
]
}
}- Top-level key — the event name (
PostToolUse,PreToolUse,SessionStart, etc.) - Matcher (
matcher) — a filter specifying which tools or situations to apply the hook to."Edit|Write"targets only those two tools;"Bash(rm *)"targets only bash commands containingrm;"mcp__github__.*"targets all tools on the GitHub MCP server. hooksarray — the list of commands executed sequentially when the matcher fires.
Hooks can be committed to the repository (.claude/settings.json) so the whole team receives them alongside the code. Personal rules (paths to local scripts, tokens) go in .claude/settings.local.json, which is added to .gitignore.
flowchart TD
A[Session opens] --> B[[SessionStart]]
B --> C[User enters prompt]
C --> D[[UserPromptSubmit]]
D --> E{Agent loop}
E --> F[[PreToolUse]]
F --> G{Blocked?}
G -- no --> H[Tool executes]
G -- yes --> E
H --> I[[PostToolUse]]
I --> E
E --> J[[Stop]]
J --> K{Stop?}
K -- no --> E
K -- yes --> L[Response ready]
L --> C
C --> M[[SessionEnd]]Event Types
Events fall into three rhythms:
Session-level (once per session):
SessionStart— fires immediately after a session is opened or resumed. Useful for loading the environment and fetching up-to-date data.SessionEnd— fires on close. Good for logging and cleaning up temporary files.Setup— fires duringinitormaintenance.
Per-prompt (once per user request):
UserPromptSubmit— fires right after the prompt is submitted, before Claude processes it. Can block or modify the prompt.Stop— fires when Claude is about to finish its response. Can force it to continue.
Agent loop (on every tool call):
PreToolUse— fires before execution. The most powerful event: you can block the call, modify its arguments, or log it.PostToolUse— fires after execution. The tool has already run; you can inject additional context or trigger formatting.
How a Hook Communicates with Claude Code
The hook script receives a JSON context object on stdin. For PreToolUse it looks roughly like this:
{
"session_id": "abc123",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf ./dist"
},
"cwd": "/home/user/myproject",
"permission_mode": "default"
}The hook returns its decision via an exit code and JSON on stdout:
| Exit code | Meaning |
|---|---|
0 | Success. stdout is parsed as JSON containing instructions. |
2 | Blocking error. stderr is passed to Claude as context. |
| Any other | Non-blocking error; the session continues and a warning is written to the log. |
Critically: JSON on stdout is only processed when the exit code is 0. If the script exits with code 1, its output is ignored.
Blocking and Modifying
The most common PreToolUse scenario is blocking a dangerous call:
#!/bin/bash
# .claude/hooks/block-destructive.sh
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$COMMAND" | grep -qE 'rm -rf|DROP TABLE|truncate'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Destructive command blocked by project policy"
}
}'
else
exit 0
fiWhen exiting with exit 0 and no JSON output, Claude Code simply continues. A hook only needs to write to stdout when it wants to influence behavior.
The second option is not to block, but to modify the tool's arguments. The updatedInput field replaces what the tool actually receives:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": { "command": "npm run build -- --no-cache" }
}
}For PostToolUse, blocking is unavailable (the tool has already run), but you can inject additional context that Claude will see alongside the result:
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "This file is auto-generated. Edit src/schema.ts and run npm run generate."
}
}Three Practical Patterns
1. Auto-formatting after editing
The classic case: Claude writes code, and a hook immediately formats the file.
#!/bin/bash
# .claude/hooks/auto-format.sh
FILE=$(cat | jq -r '.tool_input.file_path // empty')
if [[ "$FILE" =~ \.(ts|tsx|js|jsx)$ ]]; then
prettier --write "$FILE" 2>/dev/null
fi
exit 0{
"hooks": {
"PostToolUse": [{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/auto-format.sh", "timeout": 15 }]
}]
}
}2. Lint gate: making Claude fix what it broke
PostToolUse cannot undo a tool that has already run, but exit code 2 passes the error back to Claude:
#!/bin/bash
# .claude/hooks/lint-gate.sh
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ "$FILE" =~ \.ts$ ]]; then
if ! npx tsc --noEmit --skipLibCheck "$FILE" 2>/dev/null; then
npx tsc --noEmit --skipLibCheck "$FILE" >&2
exit 2
fi
fi
exit 0With exit 2, Claude Code passes stderr back to the model. Claude sees the TypeScript errors and typically fixes them right away.
3. Audit: logging all agent actions
#!/bin/bash
# ~/.claude/hooks/audit.sh
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $(cat | jq -c '.')" \
>> ~/.claude/audit.jsonl
exit 0{
"hooks": {
"PreToolUse": [{
"hooks": [{
"type": "command",
"command": "${HOME}/.claude/hooks/audit.sh",
"async": true
}]
}]
}
}The "async": true field runs the script in the background without blocking the agent loop — ideal for logging where latency is not acceptable.
SessionStart: Preparing the Environment
SessionStart is the only event that can inject context before the first prompt. This makes it indispensable for loading up-to-date data:
#!/bin/bash
# .claude/hooks/session-start.sh
BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
UNCOMMITTED=$(git status --short 2>/dev/null | wc -l | tr -d ' ')
LAST_COMMIT=$(git log --oneline -1 2>/dev/null || echo "no commits")
jq -n \
--arg branch "$BRANCH" \
--arg uncommitted "$UNCOMMITTED" \
--arg last "$LAST_COMMIT" '{
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: ("Branch: " + $branch + "\nUncommitted files: " + $uncommitted + "\nLast commit: " + $last),
sessionTitle: $branch
}
}'Claude starts the session already aware of the repository context — no unnecessary questions or git status calls in the first message.
Beyond additionalContext, SessionStart can also:
- Set environment variables via
CLAUDE_ENV_FILE - Set the session title (
sessionTitle) - Reload skills (
reloadSkills: true) — useful when skills are stored in git and updated regularly
Hook Types Beyond command
The documentation describes five types; command is just the most common:
| Type | What it does |
|---|---|
command | A shell script. Reads from stdin, writes to stdout. |
http | Sends a POST request to an endpoint. The body is the same JSON context. |
mcp_tool | Calls a tool on a connected MCP server. |
prompt | A single-turn LLM request that returns a yes/no decision. |
agent | Launches a subagent with tool access for complex validation. |
The http hook is particularly useful for teams: a centralized server receives all events, logs them, and enforces policies — with no need to install scripts on every machine.
Viewing All Hooks
The /hooks command in Claude Code opens a read-only hook browser — displaying all configured rules along with their events, matchers, types, and sources (User / Project / Local / Plugin). It is invaluable for debugging: you can immediately see which hook will fire on which event and whether there are any conflicting rules from different levels of the hierarchy.
See also
- Skills — переносимые навыки — skills give Claude instructions; hooks enforce execution — together they cover the majority of customization scenarios
- Слэш-команды: встроенные и кастомные — lightweight prompt templates; hooks complement them with deterministic behavior
- Субагенты и контекстная изоляция — an
agent-type hook itself launches a subagent - Plugins и marketplace — a plugin bundles hooks together with commands and skills into a single installable unit
- Что выбрать: команда, навык, субагент, MCP или хук — the complete decision matrix for choosing an extension mechanism
- Модель разрешений, безопасность и доверие — hooks interact with the permissions system;
PreToolUsecan expand or restrict it - Настройки и иерархия конфигурации — hooks live in settings.json; priority is inherited through the same hierarchy
- Headless-режим и скриптинг через CLI — hooks work identically in headless mode, opening the door to full automation