Claude Code hooks: how dem dey work
Claude Code hooks dey run even if model no gree. Learn where dem dey live, event triggers, how exit code 2 blocks tool calls, and the security cost.
Wetin Claude Code hook be
Claude Code hooks na shell commands wey Claude Code dey run by itself for fixed points inside its own lifecycle. Na this be the main difference between hook and rules file. Instruction inside CLAUDE.md na advice, and the model dey weigh am against every other thing for im context. Hook na code, and e go run whether model agree or not. If your agent dey continue to skip formatter wey you don tell am about twice, you no need stronger instruction. You need hook.
The mechanism small. You register command inside settings file under event name. When that event happen, Claude Code go run your command and write event data to im standard input (stdin) as JSON (JavaScript object notation). Your command go read the data, do im work, then answer with exit status. Exit 2 from PreToolUse hook go cancel the tool call before e run, and anything wey your script write to standard error (stderr) go return to the model as the reason.
Event names and field names for here come from Claude Code hooks reference, wey dem check for August 2026 against release 2.1.232. This surface dey change quickly, so check the reference for your own version before you copy JSON from any blog post, including this one. Print your own with claude --version.
Where hook configuration dey live
A hook na JSON block wey dey inside settings file. Six places fit hold one, and the file scope na the hook scope.
~/.claude/settings.json: every project for your machine, but nobody else own..claude/settings.json: one project, committed to the repository, so everybody wey clone am go get the hook..claude/settings.local.json: one project, na your machine only.- Managed policy settings: e cover the whole organisation, and administrator dey set am.
hooks/hooks.jsoninside plugin, e dey active as long as that plugin dey enabled.- Skill or subagent frontmatter, e dey active as long as that component dey active.
Hook entries from these files dey merge; dem no dey override each other. Project settings file adds its hooks to the ones for your user settings instead of replacing dem. So one event fit get several hooks from different files. Setting "disableAllHooks": true switches dem off, except for one case: hooks from managed policy settings continue to run unless you apply that setting inside managed settings too.
Run /hooks inside a session to list every hook wey currently register, grouped by event, with the source file and matcher for each one. The menu na read-only, so you go change hook by editing the settings file. File watcher normally go detect the edit without restart.
Claude Code hook event dem dey exist
Release 2.1.232 list thirty one events, from SessionStart reach SessionEnd. Dem cover compaction, subagents, worktrees, and configuration files. Server work dey use small number of dem.
PreToolUse: before tool call execute. Na this one fit block the call.PostToolUse: after tool call succeed.PostToolUseFailurego fire when e fail instead. So, if hook must see every outcome, e need both.PermissionRequest: when tool call need permission decision. Na this time approval prompt for show.UserPromptSubmit: when you submit prompt, before Claude process am. Anything wey this hook print to stdout go join the model context.SessionStartandSessionEnd: for each end of session.SessionStartgo also fire after compaction, with matcher valuecompact.Stop: when Claude finish response. E happen once per turn, no be once per completed task.
Every group get matcher wey decide which occurrences go run the hook. For tool events, e filter by tool name. So "Edit|Write" go fire for file edits and nothing else. Matchers dey case sensitive. Empty matcher go fire for every occurrence. Tools from MCP (model context protocol) server dey use names like mcp__<server>__<tool>. So matcher "mcp__github__.*" go catch tools from one server and leave other servers alone.
Stop hooks get one trap wey you need know before you write one. If Stop hook block, e go send the model back to work. Claude Code go override the hook after eight blocks happen one after another. Read the stop_hook_active field from the hook input, then exit 0 when the value dey true. If you no do am, the hook go loop until e reach that limit.
Wetín hook dey receive for stdin
When Claude wan run npm test, a PreToolUse hook for Bash go read this from stdin:
{
"session_id": "abc123",
"cwd": "/home/deploy/myproject",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test"
}
}Every event carry session_id, cwd, permission_mode, transcript_path and hook_event_name. The tool events add tool_name, tool_input and tool_use_id. Other events carry their own fields: UserPromptSubmit get the prompt text, while SessionStart get a source of startup, resume, clear, compact or fork.
jq na the usual way to read this inside shell script, but minimal server image no get am. Install am first with sudo apt install -y jq for Ubuntu and Debian.
Wetin exit status do to the tool call wey dey happen
There be three possible results.
- Exit 0 mean say your hook no object. For
PreToolUse, this no mean approval, and the normal permission flow still run. ForUserPromptSubmitandSessionStart, stdout dey add to the model context. - Exit 2 block the action for events wey fit block, including
PreToolUse, and stderr become the reason wey dem show the model. For events wey no fit block, likePostToolUse, the block dey ignored, but stderr still reach the model as feedback. - Any other exit code na error wey no block. The action continue. The transcript show hook error notice wey carry the first line of stderr after the text
Failed with non-blocking status code:.
If you need anything apart from block or keep quiet, use exit 0 and print JSON object to stdout instead. A PreToolUse hook dey decide with permissionDecision:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Database drops go through a migration, not through the agent."
}
}"allow" skip the interactive prompt, "deny" cancel the call and send the reason to the model, and "ask" show the prompt normally. Choose one style for each hook. If you mix exit 2 with JSON decision for stdout, you go get result wey you need look up.
When several hooks match one event, dem run in parallel and every one of dem run reach completion. A deny from one hook no stop the other hooks, so logging hook still write its line while guardrail hook deny the same call. Claude Code then merge the answers and keep the most restrictive one, for this order: deny, defer, ask, allow.
Example 1: block a destructive command before e run
Save this as .claude/hooks/block-destructive.sh for your project:
#!/bin/bash
# Deny a Bash tool call whose command matches a banned pattern.
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
for pattern in 'rm -rf /' 'mkfs' 'dd if=' 'DROP TABLE'; do
if printf '%s' "$COMMAND" | grep -qiF -- "$pattern"; then
echo "Blocked by policy: the command matches '$pattern'. A human runs this one." >&2
exit 2
fi
done
exit 0Make am executable, then register am for PreToolUse inside .claude/settings.json:
chmod +x .claude/hooks/block-destructive.sh{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-destructive.sh",
"timeout": 10,
"statusMessage": "Checking the command against policy"
}
]
}
]
}
}Test the script by hand before you trust am, because hook wey crash on its own input fit fail open:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /var/lib/postgresql"}}' \
| .claude/hooks/block-destructive.sh
echo $?You suppose see the Blocked by policy: line for stderr and exit code of 2. Feed am harmless command like ls -la and you suppose see no output and exit code of 0. For a session, the denied call go show for transcript with your message as the reason, and the model go read the message and adjust.
One property make this useful: PreToolUse hooks dey fire before permission-mode check, for every permission mode, so deny still hold even under bypassPermissions. Na this make hook useful alongside Claude Code auto mode and e permission settings, where prompts dey turned down but hook still dey fire.
Make we clear about wetin this be. Pattern matching for command string na guardrail against agent carelessness, but e no be boundary against agent wey clever, because the same command fit dey written for form wey your grep no go see. Hard rules belong for permission system and for the account wey process dey run under.
Example 2: format and lint after every edit
PostToolUse wey get one Edit|Write matcher dey run after any file-editing tool. Save dis as .claude/hooks/after-edit.sh:
#!/bin/bash
# Format the edited file, then report lint failures back to the model.
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
[ -z "$FILE" ] && exit 0
case "$FILE" in
*.py)
ruff format "$FILE" >/dev/null 2>&1
if ! ruff check "$FILE" >&2; then
exit 2
fi
;;
*.sh)
if ! shellcheck "$FILE" >&2; then
exit 2
fi
;;
esac
exit 0{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/after-edit.sh",
"timeout": 60
}
]
}
]
}
}Ask Claude make e add one function wey indentation no correct to one Python file, then open the file. E go come back formatted. Dis na your check say the hook run, because successful hook no dey show anything for the conversation.
The exit 2 for here no dey undo anything. PostToolUse dey fire after the tool don already execute, so the edit dey disk either way. Wetin exit 2 dey give you be say the ruff check output go reach the model as feedback, so e go fix the error wey e just introduce instead of moving on. Na dis be the difference between lint failure wey you discover when you commit and one wey the agent repair for the same turn.
Two matcher limits matter for here. Edit|Write no dey see files wey shell command change, and Claude dey write files through Bash often enough make dis gap real. For coverage on every call, match Bash too and make the script list changed files with git status --porcelain. For coverage once for every turn, put the scan inside one Stop hook instead.
Example 3: log every tool call for audit
Empty matcher for PostToolUse dey fire for every tool. If you send the record go system journal instead of file inside home directory, the agent own shell no go fit reach am easily:
{
"hooks": {
"PostToolUse": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "jq -c '{time: now|todate, session: .session_id, cwd: .cwd, tool: .tool_name, input: .tool_input}' | logger -t claude-code -p local0.info"
}
]
}
]
}
}Use journalctl -t claude-code -o cat | tail -n 5 read am back. You suppose see one JSON line for each tool call, with the newest one last. If nothing show, e mean the hook no run, and the troubleshooting section below explain how to check am.
Add the same block under PostToolUseFailure to capture calls wey fail, because PostToolUse dey fire only when command succeed, while failed command na usually the one wey matter pass. The reason for using logger instead of appending to file inside your home directory na ownership: hook dey run with the same user account as the agent shell, so anything wey that user fit append to, the same user fit also truncate. systemd-journald dey write the journal with its own account.
How long hook fit run
The data behind this chart
[
{
"label": "command, http or mcp_tool hook",
"default_timeout_seconds": 600
},
{
"label": "agent hook",
"default_timeout_seconds": 60
},
{
"label": "prompt hook",
"default_timeout_seconds": 30
},
{
"label": "command hook on UserPromptSubmit",
"default_timeout_seconds": 30
},
{
"label": "command hook on MessageDisplay",
"default_timeout_seconds": 10
},
{
"label": "any hook on SessionEnd",
"default_timeout_seconds": 1.5
}
]A command hook get 600 seconds by default, wey be ten minutes. Some events fit cut this time down well well. SessionEnd hooks share 1.5 seconds budget between all of dem, so end-of-session cleanup need quick, even though setting longer timeout for the hook go raise that shared budget to match, up to 60 seconds.
If hook reach timeout, system go cancel am and e no go return any decision. For PreToolUse guardrail, this mean say e no block anything: the tool call go continue into the normal permission flow. So make guardrail scripts small. For slow work wey nobody dey wait for, like sending log go another place, set "async": true and the hook go run for background without delaying the tool call.
Hooks, rules files, skills and MCP servers
Four things dey confuse people because all of dem fit change wetin agent dey do. Na only one of dem fit stop being suggestion.
A rules file (CLAUDE.md, or file wey dey under .claude/rules/) na text wey model load into e context. E dey shape behaviour, but e no enforce anything. For long conversation, big diff and fresh user request, one line from am fit get lost. Na the normal reason behind agents dey ignore the instructions wey you write down.
A skill na folder of instructions and scripts wey model load when e judge say the skill relevant. That judgement na the main point of skill, and na also the limit: model still dey decide. You fit see both sides for skill like Ponytail, wey dey push agent towards the smallest change wey go work, because e dey shape how model dey approach the whole task in way wey no hook fit do, and na only while model choose to load am.
An MCP (model context protocol) server dey give model new tools wey e fit call. E dey expand wetin agent fit reach. E no make agent reach for anything, and e na separate process wey you gats operate. That one na work by itself: see how to run MCP servers for VPS.
Hook na the only one among the four wey dey run without model choosing am. Use rules file for preference, and use skill for procedure wey model suppose follow when e apply. Use hook for step wey must happen every time, or for thing wey must never happen. The deeper comparison, including when skill better pass rules file, dey for comparison of skills, MCP and rules files.
Plugin na packaging, no be fifth mechanism. E dey bundle hooks together with skills into one installable unit. Na so team fit ship the same guardrail to every machine: see how Claude Code plugins dey work.
Security decision wey concern shared VPS
A hook na code wey agent dey trigger, and e dey run as the user wey start Claude Code. E inherit that user environment and file permissions. For laptop, na workflow question be that. But for VPS wey agent dey run unattended, na security question with four practical parts.
A hook for repository na code wey you no write. .claude/settings.json dey committed, so if you clone repository and start session inside am, e fit register hooks wey come with the repository. Claude Code dey put project hooks behind workspace trust dialog for that folder. This mean say accepting trust na the moment wey you decide to run dem. Read the hooks block first.
A hook dey see the complete tool input. An audit hook wey dey log tool_input go write every argument of every command into file, including any token wey happen to dey for command line. That log then need the same protection wey the secret get. This na part of the bigger problem of keeping secrets away from AI agent reach.
A hook fit write enter model context. Anything wey SessionStart or UserPromptSubmit hook print to stdout go add to the conversation. Hook wey pipe text from outside, issue tracker, or log file dey hand untrusted text give the model as if na you type am yourself. Treat that stdout as input, no be output.
Privilege na the real control. Run the agent as dedicated unprivileged user with only the sudo rules wey e need. A PreToolUse deny dey useful, and na best effort by design: the reference talk the same thing about the if filter and tell you make you use permission system when you need hard deny. The permission rules and the account wey process run under na the parts wey still hold when pressure come.
One property dey hold for every configuration. PreToolUse hooks fire before permission-mode check for every permission mode, so hook wey return deny go block the tool even under bypassPermissions. Hooks fit tighten wetin permission rules allow. Dem no fit loosen am.
Why my hook no dey fire?
Work through am for this order. Each step name the symptom wey you go actually see.
- Run
/hooksand check say the hook dey show under the event wey you expect. If hook no dey for the menu, e usually mean say the settings file get JSON syntax error, because trailing commas and comments no dey allowed, or the file no dey for one of the six locations above. - Compare the matcher with the tool name exactly. Matchers dey case sensitive, so
"bash"no go ever matchBashtool. - Run the script by hand with sample input, as e dey for example 1 above. If exit code no be the one wey you expect, na bug dey your script, and Claude Code go report am as hook error instead of decision.
- Notice wey read
jq: command not foundmean sayjqdey missing for that machine. Acommand not foundfor your own script mean say the path no resolve, so use${CLAUDE_PROJECT_DIR}or absolute path. If the script no run at all, e probably no executable. - The hook print valid JSON but nothing happen. Shell-form hook dey run through
sh -c, and if your shell profile print banner, dem go add that banner before your JSON. Stdout no longer start with{, so Claude Code read everything as plain text and ignore the decision. For exit 0, nothing dey reported anywhere except the debug log. Wrap anyechofor your profile so e only run for interactive shells. - If problem still dey, start the session with
claude --debug-file /tmp/claude.logand runtail -f /tmp/claude.logfor another terminal. The debug log record which hooks match, the exit code wey each one return, and everything wey dem write to stdout and stderr.
FAQ
Difference between Claude Code hook and CLAUDE.md instruction na wetin?
A CLAUDE.md instruction na text wey dey inside model context, so e dey compete for attention with the conversation and current request, and model fit weigh am against dem. Hook na shell command wey Claude Code dey run for fixed point for im lifecycle, so e go execute every time the event happen, no matter wetin model decide. Use instruction for preference. Use hook for step wey must always happen or action wey must never happen.
How I fit stop Claude Code from running one specific shell command?
Register PreToolUse hook with Bash matcher wey dey read command from .tool_input.command, write reason to stderr, then exit 2. Claude Code go cancel the call and show model your reason. This one happen before permission-mode check, so the deny still hold even for bypassPermissions mode. Pattern matching for command string na guardrail, no be security boundary, because person fit write the same command for format wey pattern no catch. So support am with permission rules and unprivileged account.
My hook dey print valid JSON but nothing dey happen. Why?
The most common cause na your shell profile. Hook wey no get args field dey run through sh -c, and some profiles dey print banner for every shell. This banner go enter stdout before your JSON. Because output no longer start with {, Claude Code dey treat everything as plain text and ignore the decision. When e exit 0, nothing dey show for transcript at all. Put any echo for your profile behind interactive-shell test. Then confirm the fix by reading debug log from claude --debug-file /tmp/claude.log.
E safe to run Claude Code hooks for shared server?
Hooks dey run as the user wey start Claude Code, with that user's file permissions. So hook fit do anything wey that account fit do. Two habits dey cover most of the risk: run the agent as dedicated unprivileged account with narrow sudo policy, and read the hooks block for any repository before you accept its workspace trust dialog, because project hooks dey ship inside .claude/settings.json. Set "disableAllHooks": true for your settings file when you no want any of dem to run.