Claude Code fires shell hooks at named points in a session: session start, prompt submit, before and after every tool call, on stop and on notification. A guide to each event, the JSON it hands you on stdin, the exit codes that block a tool call, and the rule that keeps a broken hook from bricking your agent.
what a hook is
A Claude Code hook is a shell command the CLI runs for you at a named point in a session: before a tool call, after a tool call, when you submit a prompt, when the agent stops. Claude Code hands the command a JSON object describing what just happened on stdin. What the command does with that, and what it exits with, decides whether the session carries on normally, carries on with something extra injected, or stops.
The important thing is the direction of control. A hook is not a prompt. You are not asking the model to remember to run the formatter, you are running the formatter yourself, from outside the model, at a moment the CLI guarantees. Everything you move from the prompt into a hook becomes deterministic, and the prompt gets shorter at the same time. That trade is the whole reason hooks exist.
Three things people reach for hooks to do, and they are three different shapes of hook:
- Something that must happen every time. Run the formatter after every edit, write a line to an audit log after every command. These are
PostToolUsehooks and they cannot fail in a way that matters. - Something that must never happen. A command you refuse to let an agent run. This is a
PreToolUsehook, it is the only shape that can actually stop the CLI, and the section on exit codes below is entirely about getting it right. - Something you want to know about. A desktop notification when the agent finishes or needs you. These are
StopandNotificationhooks, and they are the ones that pay off most when you are running more than one agent at a time, because that is the situation where you stop noticing that one of them has been waiting.
/hooks inside a session and read what your own version lists.where hooks are configured
Hooks live under a hooks key in a settings file. There are three you will use, and the difference between them is who they follow around:
Organisations can also ship managed settings that take precedence over all three, and plugins can bundle their own hooks. If you are on a managed install and a hook you wrote is being ignored, that is the first thing to check. The precedence order and the managed settings path are both version-dependent enough that you should read them off your own install rather than off this page.
The shape is the same everywhere. An event name, a list of matchers, a list of commands:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format.sh",
"timeout": 30
}
]
}
]
}
}The nesting reads oddly the first time. The outer array is a list of matcher groups, and each group has its own inner hooks array of commands to run when that matcher matches. Use separate groups when different tools need different commands.
The matcher matches the tool name for PreToolUse and PostToolUse: Bash, Edit, Write, Read, and MCP tools under names like mcp__servername__toolname. Plain names separated by | are treated as alternatives. An empty matcher, or no matcher at all, matches everything. Other events interpret the matcher differently or ignore it; check /hooks for what yours does.
$CLAUDE_PROJECT_DIR is exported into the hook process and points at the project root, which is what lets a committed project hook reference a script by a path that works on everyone's machine. Use it. A hook whose command is a bare relative path is a hook that breaks the moment somebody runs the agent from a subdirectory.
The failure this prevents: a hook that works for you and silently does nothing for everyone else on the team, because the path resolved against your home directory.
the events, one by one
The events below are the ones we install ourselves and have watched fire against a real CLI. Recent versions carry considerably more than these, including events for subagents, worktree creation, file changes and config changes. Take this as the stable core, not as the complete list.
One finding worth carrying with you, because it saved us a day: a settings file naming an event the CLI does not know is ignored silently, not rejected. Probed on 2.1.220. That is convenient for forward compatibility, since listing an event a future version adds costs nothing today. It is also why a hook that does nothing gives you no error to search for. If an event name is not firing, suspect the spelling before you suspect the script.
the JSON handed to your hook on stdin
Every hook is spawned with a single JSON object on stdin. Read it with cat in shell, json.load(sys.stdin) in Python, or ignore it entirely if all you want is a side effect. There is no argument vector to parse; the payload is the interface.
These fields were confirmed present on a real CLI (2.1.212, macOS, probed 2026-07-26), on every event we watched:
{
"session_id": "…",
"prompt_id": "…",
"transcript_path": "/Users/you/.claude/projects/<dir>/<session-id>.jsonl",
"cwd": "/Users/you/code/myapp",
"permission_mode": "default",
"hook_event_name": "PreToolUse"
}Tool events add tool_name, tool_input and tool_use_id. tool_input is the tool's own argument object, so for Bash it has a command key, and for Edit and Write it has a file_path. Those two are the fields nearly every useful hook reads.
tool_output on 2.1.212, and the published reference has used tool_response. If your hook needs the result rather than the input, print the whole payload to a file once and read what actually arrived. Do not take either name on faith, including from us.Other events carry their own extras: prompt on UserPromptSubmit, a source string on SessionStart saying whether this was a startup, a resume or a clear, a message on Notification. The reliable way to learn the exact shape for the event you care about is four lines:
#!/bin/sh
# .claude/hooks/dump.sh - install on the event you are learning, then delete it.
cat >> /tmp/claude-hook-payloads.jsonl
echo >> /tmp/claude-hook-payloads.jsonl
exit 0Trigger the event once, read the file, then remove the hook. That takes about two minutes and it is authoritative for your version, which no document can be.
permission_mode deserves a note, because it has an asymmetry we walked into. The vocabulary the payload reports and the vocabulary the --permission-mode flag accepts are not identical: on 2.1.212 the flag value manual arrives in hook payloads as default, and default is not accepted as a flag value at all. If you are writing a hook whose behaviour depends on the mode, read it from the payload and test the mode you actually use.
exit codes, and which one blocks
Exit code 2 is the one that blocks. Everything else is a variation on "carry on". This is the single most misremembered fact about hooks, because in every other context a non-zero exit means failure and here it mostly does not.
Which events actually honour exit 2 is the part to be careful about, because a hook that exits 2 on an event that cannot block just fails silently in a way that looks like it worked. The ones we rely on:
PreToolUseblocks the tool call. This is the important one.UserPromptSubmitblocks the prompt before Claude sees it.Stoprefuses to let the turn end, which sends Claude back to work.PostToolUsedoes not block, because the tool already ran. Exit 2 there surfaces your stderr to Claude as a complaint about the result. That is genuinely useful (a type error from a post-edit check lands in front of the model that caused it) but it is not prevention.- Reporting events like
Notification,SessionStartandSessionEndblock nothing at all. Their exit code is essentially cosmetic.
The structured alternative to exit 2
Exiting 0 and printing JSON gives you more control than an exit code does. On PreToolUse, a decision object on stdout controls the call. We verified both shapes work on 2.1.212, which is why our own script emits the modern one and treats the legacy one as a free hedge against version skew:
# modern shape
{"hookSpecificOutput":{"hookEventName":"PreToolUse",
"permissionDecision":"deny",
"permissionDecisionReason":"migrations/ is append-only in this repo"}}
# legacy shape, also honoured on 2.1.212
{"decision":"block","reason":"…"}permissionDecision takes allow, deny or ask, so a hook can pre-approve a call as well as refuse one. Printing nothing and exiting 0 leaves the normal permission flow untouched, which is the graceful fallback every hook should collapse to when it is unsure. Other fields exist for injecting context and passing a message to the user, and the set varies by version.
If you are thinking about hooks as an authority mechanism, that is a bigger subject than a single exit code and we wrote it up separately in who may command whom.
three worked examples
1. A formatter on PostToolUse
The canonical hook, and the one most worth having. After Claude edits or writes a file, run the formatter on it. Not because the model cannot format, but because the model formatting costs tokens on every single edit and the formatter is free and correct.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format.sh",
"timeout": 30 }
]
}
]
}
}#!/bin/sh
# .claude/hooks/format.sh
set +e
payload=$(cat)
file=$(printf '%s' "$payload" | python3 -c \
'import json,sys; print(json.load(sys.stdin).get("tool_input",{}).get("file_path",""))' \
2>/dev/null)
[ -n "$file" ] || exit 0
[ -f "$file" ] || exit 0
case "$file" in
*.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.md)
npx --no-install prettier --write "$file" >/dev/null 2>&1
;;
*.py)
ruff format "$file" >/dev/null 2>&1
;;
esac
exit 0Note the shape as much as the content. Every step that could fail is followed by a guard that exits 0, --no-install stops npx pausing to download something the repository does not have, and nothing writes to stdout, so nothing can be misread as a decision object.
The failure this prevents: a diff where half the changes are formatting the model did by hand and inconsistently, and the reviewer cannot see the actual change through them.
2. A guard on PreToolUse
The one hook worth writing on day one. It refuses a class of command outright, before it runs, without asking you.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/guard.sh",
"timeout": 10 }
]
}
]
}
}#!/bin/sh
# .claude/hooks/guard.sh - exit 2 blocks the call, stderr tells Claude why.
set +e
payload=$(cat)
cmd=$(printf '%s' "$payload" | python3 -c \
'import json,sys; print(json.load(sys.stdin).get("tool_input",{}).get("command",""))' \
2>/dev/null)
# A parse failure means we do not know what the command is, so we allow it.
# See the fail-open section: a guard that blocks when confused blocks everything.
[ -n "$cmd" ] || exit 0
deny() {
echo "Blocked by repo policy: $1" >&2
exit 2
}
case "$cmd" in
*"git push --force"*|*"git push -f"*) deny "no force pushes from an agent" ;;
*"git reset --hard"*) deny "reset --hard discards work the human has not seen" ;;
*"rm -rf /"*) deny "absolute recursive delete" ;;
*"DROP TABLE"*|*"TRUNCATE "*) deny "destructive SQL" ;;
*" .env"*|*"cat .env"*) deny "environment files are not read by agents here" ;;
esac
exit 0Two honest caveats. The stderr message matters: Claude reads it and will usually adapt rather than retry, so "no force pushes from an agent" produces better behaviour than a bare "denied". And this is pattern matching on a string. It stops accidents, which is most of what goes wrong. It does not stop a determined caller, because the same command can be written through a variant flag, an alias or a script. Treat it as a guardrail, never as a security boundary. The real boundary is what the agent can reach at all.
The failure this prevents: the one command in a thousand that destroys work nobody has reviewed yet. Not the model being malicious, just an agent tidying up a branch state it misread.
3. A desktop notification on Stop
The hook that changes how a day feels, and the reason is boring: an agent that finished three minutes ago and is sitting silently is three minutes of your life spent looking at another window.
{
"hooks": {
"Stop": [
{ "hooks": [
{ "type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/notify.sh",
"timeout": 10 }
]}
],
"Notification": [
{ "hooks": [
{ "type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/notify.sh",
"timeout": 10 }
]}
]
}
}#!/bin/sh
# .claude/hooks/notify.sh - macOS. Use notify-send on Linux.
set +e
payload=$(cat)
dir=$(basename "$(pwd)")
case "$payload" in
*'"hook_event_name"'*'"Notification"'*) title="Claude needs you: $dir" ;;
*) title="Claude finished: $dir" ;;
esac
osascript -e "display notification \"$dir\" with title \"$title\"" >/dev/null 2>&1
exit 0The matcher is omitted on both events, so it fires on all of them, and the script tells them apart by reading hook_event_name out of the payload rather than by taking an argument. That is the pattern for one script registered against several events.
The failure this prevents: the specific waste of running two or three agents and discovering that one of them has been blocked on a permission prompt since before lunch. Notification hooks are how a multi-agent workspace stops needing you to poll it.
when a hook does not fire
Hooks fail quietly by design, which is right for production and infuriating while you are writing one. Work the list in this order, because it is roughly the order of how often each one is the answer.
- Run
/hooksin the session. It lists what this session actually loaded. If your hook is not in that list, nothing downstream matters and the problem is in the settings file, not the script. - Check the JSON is valid. A trailing comma invalidates the whole settings file, and what you lose is not just the hook, it is every other setting in it.
python3 -m json.tool ~/.claude/settings.jsonsettles it in one line. - Restart the session. Whether a running session picks up a mid-session settings edit is not something we have verified, and it may differ by version. Restarting removes the question.
- Check the event name and the matcher. An unknown event name is ignored silently (measured on 2.1.220), so a typo produces no error anywhere. Matchers are case sensitive, and the tool is
Bash, notbash. - Check the script runs at all.
chmod +xit, confirm the shebang is correct, then run it by hand with a payload piped in. - Then use
claude --debug. It prints each hook spawning, what it was given, what it returned and its exit code. This is where you find the answer when the first five did not.
The three that are not obvious
Your shell profile is polluting stdout. If your .bashrc or .zshrc unconditionally echoes anything, and the hook process sources it, that text lands on the hook's stdout ahead of your JSON and the whole payload is now unparseable. This produces the maddening symptom of a hook that works when you run it by hand and not when Claude runs it. Guard interactive output in your profile with a check for an interactive shell.
Something is holding stdout open. Claude Code reads a hook's stdout to EOF. If your hook backgrounds a child and that child inherits stdout, the pipe stays open until the child exits and the tool call waits. We hit this exactly: backgrounding our status POST was not enough, because the subshell still held the inherited descriptor. Redirect the subshell itself, not the command inside it: ( … ) </dev/null >/dev/null 2>&1 &. Symptom: everything works, everything is just slow, nothing is in any log.
The hook fires and its effect is invisible. A PostToolUse formatter that writes to a file leaves no trace in the transcript, so it looks identical to a hook that never ran. Log a line while developing, and take it out afterwards.
a hook is arbitrary code running as you
Every hook is a command executed with your user's full privileges, with no sandbox, no confirmation and no prompt, triggered by a decision an agent made. There is no permission dialog in front of it. A hook is not inspected, is not reviewed, and does not appear on screen when it runs. If a hook can delete your home directory, it will delete your home directory, and the agent that triggered it will report the tool call succeeded.
Which makes a short list non-negotiable:
- Never accept a settings.json you did not read. Not from a blog post, not from a gist, not from a plugin, not from this page. A hooks block is a remote code execution primitive wearing a config file. Read every
commandstring before it lands on your machine. - Quote every variable. A file path from
tool_inputis a string an agent chose, and an unquoted$filein a shell script is a command injection with extra steps."$file", always, everywhere. - Assume the payload is attacker-influenced. Prompt content, file contents and tool arguments all pass through a model that has read whatever is in your repository and possibly whatever was on a web page it fetched. Treat the JSON on stdin as untrusted input, because that is exactly what it is, and strip anything you interpolate down to a charset that cannot escape its context.
- Prefer a script file to an inline command. An inline command in settings.json cannot be reviewed in a diff or tested standalone. A script under
.claude/hooks/can be both. - Be careful what a committed project hook does. A hook in
.claude/settings.jsonruns on the machine of everyone who checks out the branch. That is a supply chain, and pull requests that touch it deserve the attention you would give a CI config change.
PreToolUse hook is a limit. That is the argument in setting a token budget applied to a different resource.the fail-open rule
Every hook path fails open. A broken hook must never block an agent. This is the rule we hold ourselves to, in writing, and it is the one thing on this page we would insist on if you took nothing else.
The reason is structural rather than stylistic. A hook sits in the critical path of an agent's work while being the least tested code in the system: a shell script somebody wrote in five minutes, running on every tool call, on a machine whose state it does not control. Sooner or later it meets a payload it cannot parse, a binary that is not installed, an endpoint that is down or a directory that has moved. If any of those exit non-zero on a blocking event, the agent stops, and not with a useful error: with a refused tool call and a reason nobody wrote.
Our own hook server returns HTTP 204 on every error path, including a bad token, malformed JSON and an oversized body. Failing open is not a fallback branch in it, it is the only branch, and a request that fails authentication is answered exactly like one that succeeds. The same rule shapes the script: every guard ends in || exit 0, anything slow is detached so a dead listener cannot stall a turn, and the branch that produces stdout prints either one complete JSON object or nothing at all, because printing nothing is a valid answer and printing half an object is not.
The three rules it collapses to, applied to any hook you write:
- Exit 0 when you do not know. A guard that cannot parse the command should allow the command. A guard that blocks whenever it is confused blocks everything the first time its parser meets an unusual payload, and you will diagnose that as the model behaving strangely rather than as your own script.
- Set a timeout, and make it shorter than your patience. Every hook accepts a
timeoutin seconds. Anything reaching the network gets a small one. Our status POST gets 5 seconds for a request that is already backgrounded, because we would rather lose a status update than delay a tool call. - Print nothing unless you mean it. Stdout on exit 0 is a control channel. A stray
echoleft in from debugging is a malformed decision object, and the CLI is within its rights to do something surprising with it.
There is one deliberate exception, and it is the point of the whole mechanism: a PreToolUse guard that has successfully identified a command it refuses should exit 2. That is not a failure, that is the hook working. Fail open applies to the hook not knowing, never to the hook knowing and deciding.
common questions
What are Claude Code hooks?
Claude Code hooks are shell commands the CLI runs automatically at named points in a session, such as before a tool call, after a tool call, when you submit a prompt, and when the agent stops. Each hook receives a JSON object describing the event on stdin, and its exit code and stdout can allow, block or annotate what happens next. They are configured in a settings.json file and run outside the model, which is what makes them deterministic where a prompt instruction is not.
Where do I put Claude Code hooks?
In the hooks key of a settings.json file. ~/.claude/settings.json applies to every project on your machine, .claude/settings.json in a repository applies to that project and can be committed, and .claude/settings.local.json is the per-machine, gitignored variant. Managed organisation settings and plugins can supply hooks too. Run /hooks inside a session to see which hooks are actually loaded, which is the only listing that reflects the merge of all of them.
Which exit code blocks a tool call in Claude Code?
Exit code 2. On PreToolUse, a hook exiting 2 blocks the tool call and its stderr is shown to Claude as the reason it did not run. Exit code 0 means success, and any other non-zero code is treated as a non-blocking error: the action proceeds and the message goes only to the debug log. Do not use exit 1 to try to block anything. For more control than an exit code gives you, exit 0 and print a decision object on stdout instead.
Can a Claude Code hook stop a dangerous command?
Yes, with a PreToolUse hook matched on Bash that inspects tool_input.command and exits 2 when it matches a pattern you refuse to allow. Treat it as a guardrail against accidents rather than a security boundary, because it is pattern matching on a string and the same command can usually be written another way. It reliably catches the careless case, which is the case that actually happens. Pair it with isolation, so that the worst a stopped command could have done was limited anyway.
Why is my Claude Code hook not firing?
The usual causes, in order of frequency: a settings file that is not valid JSON, an event name spelled wrong, a matcher that does not match the tool name, a script that is not executable, or a session started before the settings change. Run /hooks to see what the session actually loaded, then claude --debug to watch each hook spawn and report its exit code. An unrecognised event name is ignored silently rather than reported as an error, so a typo produces no message anywhere.
Are Claude Code hooks safe?
A hook is arbitrary code running with your shell privileges, triggered by an agent, with no confirmation prompt. It is exactly as safe as the script you wrote and the settings file you accepted. Never accept a settings.json from an untrusted source, quote every variable you interpolate from the payload, and read any hook that a template or a plugin installs on your behalf. A hooks block committed to a shared repository runs on every teammate's machine, so review changes to it the way you review CI configuration.
one implementation
We build a macOS app called Skribbl, and its agent status comes from this hook stream rather than from scraping terminal output, which is why the rules on this page are ones we hold ourselves to rather than ones we read. The managed script it installs is merged into your ~/.claude/settings.json alongside your own hooks rather than replacing them, every path in it exits 0, and the loopback server it POSTs to answers 204 on every error because a hook that can wedge an agent is worse than no hook. The event list in the section above is the one we install, and each entry in it was probed against a real CLI before it shipped.
None of that requires the app. Every example on this page is a settings file and a shell script and works with nothing installed but Claude Code. If you want the shape of what we built on top of it, the docs describe it, the download page has the build, and the comparison page is honest about the other tools in the space. If you are still choosing a CLI, the hook seam is a real differentiator and we compared the two we know best in Claude Code vs Codex CLI.
The next thing worth building with a hook, once you have a formatter and a guard, is a reviewer: a second model reading the diff the first one produced. That has its own post, because the interesting part is not the wiring, it is the prompt.