Claude Code Hooks: Events, Input JSON, Exit Codes, Examples
How Claude Code hooks work: where to configure them, the JSON they receive, exit code 2 vs 0, matchers, working examples, and why a hook may not be running.
A Claude Code hook is a command you register against an event, such as a tool call about to run or Claude waiting for input. Claude Code runs your command when the event fires and passes event data as JSON on stdin. Your script answers through its exit code, stdout, and stderr. You configure hooks in a settings file, and /hooks shows what's registered.
The short version for blocking something: read the JSON, write a reason to stderr, and exit 2. The short version for everything else: exit 0, and optionally print a JSON object to stdout for finer control. The rest of this post covers the details and the places hooks quietly fail.
How Claude Code hooks work#
Each hook belongs to an event. When the event fires, Claude Code looks at the hook entries for that event, checks the matcher, and runs the matching commands. Your script gets JSON on stdin, does its work, and reports back.
Three things matter:
- Input arrives as JSON on stdin.
- Output is stdout, stderr, and the exit code.
- Effect depends on the event. Some events can be blocked (a tool call that hasn't happened yet). Others describe something already done, so a hook can only react.
The minimal config shape is the same for every event: event name, a list of matcher groups, and a list of hooks inside each group.
{
"hooks": {
"EventName": [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "your-command-here" }
]
}
]
}
}
An empty matcher matches everything for that event.
Where to put hooks (setup and configuration)#
Where you define a hook decides its scope.
| Location | Scope | Shareable |
|---|---|---|
~/.claude/settings.json |
All your projects | No, local to your machine |
.claude/settings.json |
Single project | Yes, can be committed |
.claude/settings.local.json |
Single project | No, gitignored when Claude Code saves a setting to it |
| Managed policy settings | Organization-wide | Yes, admin-controlled |
Plugin hooks/hooks.json |
When the plugin is enabled | Yes, bundled with the plugin |
| Skill frontmatter | The rest of the session once the skill is invoked | Yes, defined in the skill file |
| Subagent frontmatter | While that subagent is running | Yes, defined in the subagent file |
My advice: put personal conveniences like desktop notifications in ~/.claude/settings.json, and put team rules like command blocking in .claude/settings.json so they travel with the repo.
Hook entries merge across levels. User, project, and local settings add hooks without removing managed ones. If you edit a settings file while Claude Code is running, the file watcher normally picks up hook changes. Run /hooks afterward and confirm your hook appears under the right event.
To turn everything off, set "disableAllHooks": true. Note that Claude Code reads the value left after settings precedence applies, so a project's settings file can override yours.
The JSON a hook receives#
Every event includes common fields such as session_id and cwd, plus event-specific data. For a PreToolUse hook on a Bash command, the stdin looks like this:
{
"session_id": "abc123",
"cwd": "/Users/sarah/myproject",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test"
}
}
tool_input holds the arguments Claude passed to the tool. For Bash, its command field is the shell command. Other events differ: UserPromptSubmit hooks get the prompt text, and SessionStart hooks get a source of startup, resume, clear, compact, or fork. Hooks that fire inside a subagent also carry agent_id and agent_type.
You can test your parsing logic without Claude Code at all. This runnable snippet feeds a sample payload to a decision function that mirrors the rm rule used later in this post:
const samples = [
{ hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "npm test" } },
{ hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "rm -rf build" } },
];
function decide(input) {
const cmd = input.tool_input?.command ?? "";
if (cmd.startsWith("rm")) {
return { exit: 2, stderr: "Blocked: rm commands are not allowed" };
}
return { exit: 0, stderr: "" };
}
for (const s of samples) {
const r = decide(s);
console.log(s.tool_input.command, "->", "exit", r.exit, r.stderr);
}
Exit codes: what 0, 2, and everything else do#
This is where most hook bugs come from.
- Exit 0: your hook reports no objection. For
PreToolUsethis does not approve the tool call. The normal permission flow still applies. ForUserPromptSubmit,UserPromptExpansion,SessionStart, andPostModelSwitch, stdout that Claude Code treats as plain text is added to Claude's context. - Exit 2: Claude Code blocks the action, where the event allows it. Write the reason to stderr.
- Any other code: usually a non-blocking error. The action proceeds, and the transcript shows a
<hook name> hook errornotice with the first line of stderr, prefixedFailed with non-blocking status code:.
The warning in the docs is worth repeating: without valid JSON on stdout, exit code 1 is a non-blocking error, even though 1 is the conventional Unix failure code. If a hook enforces policy, use exit 2.
What exit 2 does depends on the event:
| Event | Can block? | On exit 2 |
|---|---|---|
PreToolUse |
Yes | Blocks the tool call |
UserPromptSubmit |
Yes | Blocks the prompt before it reaches Claude |
Stop |
Yes | Prevents Claude from stopping, so the conversation continues |
SubagentStop |
Yes | Prevents the subagent from stopping |
TaskCompleted |
Yes | Prevents the task from being marked completed |
PreCompact |
Yes | Blocks compaction |
PostToolUse |
No | Shows stderr to Claude; the tool already ran |
PermissionRequest |
No | Not honored; deny through the decision object instead |
Notification |
No | Exit code and stderr are ignored |
SessionStart |
No | Shows stderr to the user only |
SessionEnd |
No | Shows stderr to the user only |
That's a subset. The reference page lists every event with its exit 2 behavior, and it's the page to check when you add a hook for an event you haven't used.
Example: block dangerous Bash commands#
This is the smallest useful policy hook. Save it as .claude/hooks/no-rm.sh and make it executable:
#!/bin/bash
input=$(cat)
command=$(jq -r '.tool_input.command' <<<"$input")
if [[ "$command" == rm* ]]; then
echo "Blocked: rm commands are not allowed" >&2
exit 2
fi
exit 0
Register it in .claude/settings.json, matching only the Bash tool:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-rm.sh"
}
]
}
]
}
}
When it blocks, Claude receives the stderr as the tool's error, along the lines of PreToolUse:Bash hook error: [...]: Blocked: rm commands are not allowed. Claude can then pick another approach.
The JSON alternative#
If you want a structured decision, exit 0 and print JSON instead. Pick one approach per hook; don't mix exit 2 with JSON.
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Use rg instead of grep for better performance"
}
}
For PreToolUse, permissionDecision takes these values:
"allow"skips the interactive permission prompt. Deny and ask rules, including enterprise managed deny lists, still apply."deny"cancels the call and sends the reason to Claude."ask"shows the normal permission prompt to the user.
Other events use other shapes. PostToolUse and Stop use a top-level decision: "block", while PermissionRequest uses hookSpecificOutput.decision.behavior.
Example: desktop notification when Claude needs you#
The Notification event fires when Claude is waiting for input or permission. On macOS:
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
}
]
}
]
}
}
On Linux, swap the command for notify-send 'Claude Code' 'Claude Code needs your attention'. On Windows the docs use a powershell.exe command that opens a message box.
The empty matcher fires on every notification type. To narrow it, set the matcher to one of these:
| Matcher | Fires when |
|---|---|
permission_prompt |
Claude needs you to approve a tool use, and the prompt has waited about six seconds |
idle_prompt |
Claude finished responding about 60 seconds ago and you haven't typed since |
auth_success |
Authentication completes |
elicitation_dialog |
An MCP server opens an elicitation form and you haven't typed for about six seconds |
The docs list more matchers, including agent view and usage-limit ones. A few of those need newer Claude Code versions, so check the guide if a matcher doesn't fire.
Example: Stop hook that keeps Claude working#
A Stop hook runs when Claude is about to finish. Exit 2 prevents it from stopping and continues the conversation. A common use is running the tests first:
#!/bin/bash
if ! npm test 2>&1; then
echo "Tests not passing. Fix failing tests before completing." >&2
exit 2
fi
exit 0
There's a guard rail here. The guide mentions a cap of eight iterations on blocking stop hooks, and says you can raise it with the CLAUDE_CODE_STOP_HOOK_BLOCK_CAP environment variable if your hook legitimately needs more passes to converge. I'd make the hook's check cheap and deterministic so it converges well inside that.
Hooks vs skills#
They solve different problems. A hook is a command that Claude Code runs when an event fires, so it happens whether or not Claude "remembers" to do it. A skill is a packaged set of instructions Claude loads for a task. The two connect in one place: a skill's frontmatter can define hooks, which then apply for the rest of the session once the skill is invoked. Subagent frontmatter works the same way, scoped to while that subagent runs.
My rule of thumb: if the behavior must happen every time (block this, notify me, run that check), use a hook. If it's guidance for how to do a task, write a skill.
Hooks not working? Gotchas and debugging#
Most "hooks not running" reports come down to a short list.
- Check
/hooksfirst. Confirm the hook shows under the event you expect. If it's missing, the settings file or JSON structure is the problem. - Wrong exit code for the job. Exit 1 is non-blocking. Use exit 2 to block.
- Exit 2 on an event that can't block. On
Notification, exit code and stderr are ignored. OnPostToolUse, the tool already ran. - Mixing styles. Choose exit 2 with stderr, or exit 0 with JSON, not both.
- A mistyped or non-executable script path. The hook fails to start with something like
/bin/sh: /path/to/hook.sh: No such file or directory, which is a non-blocking error, so the action still happens. Watch for this notice on the first run. - Misplaced
additionalContext. ForUserPromptSubmit, it must be nested insidehookSpecificOutput. At the top level of the JSON, Claude Code silently ignores it. - Timeouts don't gate anything by default. A timed-out
commandhook onPreToolUsedoesn't block the call; it continues through the normal permission flow. - Background processes. If your hook decides in a background process and exits, a late deny doesn't block the call. Print the decision first, and redirect the background process's stdout and stderr (
>/dev/null 2>&1 &). - macOS notifications.
osascriptgoes through Script Editor. If Script Editor lacks notification permission, the command fails silently. Runosascript -e 'display notification "test"'once, then enable Script Editor under System Settings > Notifications. - Linux notifications.
notify-sendneeds a desktop notification daemon, which headless servers, SSH sessions, and most containers don't have.
To see full stderr instead of just its first line, run claude --debug or type /debug mid-session.
For policy hooks, you can make failure block the action. Set "onFailure": "block" on a command or http hook (default is "continue"; it needs Claude Code v2.1.295 or later). It has no effect on Stop, SubagentStop, TaskCompleted, or TeammateIdle, nor on background command hooks.
{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check-command.js"],
"onFailure": "block"
}
Quick reference checklist#
- Register under
hooksin~/.claude/settings.jsonor.claude/settings.json, then confirm with/hooks. - Read stdin with
catand parse it withjq. - Block with
exit 2plus a stderr reason, on events that can block. - Exit 0 means no objection; on
PreToolUseit doesn't approve the call. - Exit 1 or other codes are non-blocking errors.
- Use exit 2 or JSON, not both.
- Use a
matchersuch asBashto scope tool hooks. - For policy hooks, consider
onFailure: "block". - Debug with
claude --debugor/debug. - Test the script alone by piping a sample JSON payload into it before wiring it up.
The official hooks guide covers setup walkthroughs, and the hooks reference has every event's schema and exit code behavior.
Quick answers
Why is my Claude Code hook not running?
Run /hooks to confirm it's registered under the right event, then check the script path and permissions. A missing or non-executable script is a non-blocking error, so Claude carries on without it.
Why doesn't my hook block anything when it exits 1?
Without valid JSON on stdout, exit 1 is a non-blocking error. Use exit 2 with a reason on stderr to block, on an event that supports blocking.
Why is my Stop hook not working?
Exit 2 on Stop prevents Claude from stopping and continues the conversation, so check that your script actually reaches that exit. The guide also mentions a cap of eight iterations, which you can raise with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP.
How do I debug Claude Code hooks?
Run claude --debug or type /debug mid-session to capture full stderr, since the transcript only shows its first line. /hooks confirms what's registered.
Where do I add Claude Code hooks?
Add a hooks block to ~/.claude/settings.json for all your projects, or .claude/settings.json for one project you can commit. Plugins, skills, and subagents can define hooks too.
Next note