CoreMesh

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.

json
{
  "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:

json
{
  "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:

javascript
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 PreToolUse this does not approve the tool call. The normal permission flow still applies. For UserPromptSubmit, UserPromptExpansion, SessionStart, and PostModelSwitch, 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 error notice with the first line of stderr, prefixed Failed 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:

bash
#!/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:

json
{
  "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.

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:

json
{
  "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:

bash
#!/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.

  1. Check /hooks first. Confirm the hook shows under the event you expect. If it's missing, the settings file or JSON structure is the problem.
  2. Wrong exit code for the job. Exit 1 is non-blocking. Use exit 2 to block.
  3. Exit 2 on an event that can't block. On Notification, exit code and stderr are ignored. On PostToolUse, the tool already ran.
  4. Mixing styles. Choose exit 2 with stderr, or exit 0 with JSON, not both.
  5. 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.
  6. Misplaced additionalContext. For UserPromptSubmit, it must be nested inside hookSpecificOutput. At the top level of the JSON, Claude Code silently ignores it.
  7. Timeouts don't gate anything by default. A timed-out command hook on PreToolUse doesn't block the call; it continues through the normal permission flow.
  8. 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 &).
  9. macOS notifications. osascript goes through Script Editor. If Script Editor lacks notification permission, the command fails silently. Run osascript -e 'display notification "test"' once, then enable Script Editor under System Settings > Notifications.
  10. Linux notifications. notify-send needs 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.

json
{
  "type": "command",
  "command": "node",
  "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check-command.js"],
  "onFailure": "block"
}

Quick reference checklist#

  • Register under hooks in ~/.claude/settings.json or .claude/settings.json, then confirm with /hooks.
  • Read stdin with cat and parse it with jq.
  • Block with exit 2 plus a stderr reason, on events that can block.
  • Exit 0 means no objection; on PreToolUse it doesn't approve the call.
  • Exit 1 or other codes are non-blocking errors.
  • Use exit 2 or JSON, not both.
  • Use a matcher such as Bash to scope tool hooks.
  • For policy hooks, consider onFailure: "block".
  • Debug with claude --debug or /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.

Indra Wijaya

Builds and runs AI agents, bots and content pipelines, and writes down what works and what broke.

About · GitHub · RSS

Keep reading

New notes, no spam.

One post at a time, only when there's something worth reading. Follow in any feed reader.

Subscribe via RSS