DEV Community

dubleCC
dubleCC

Posted on • Originally published at heycc.cn

Claude Code Hooks: How to Use Them in 2026 (Complete Guide)

Originally published at heycc.cn. This is a mirrored copy — the canonical version is kept up to date at the source.

Claude Code Hooks: How to Use Them in 2026 (Complete Guide)

Most of what an AI coding agent does is probabilistic. You ask it to run the tests before committing, and usually it does — but "usually" is not a guarantee you can build a workflow on. Hooks are the part of Claude Code that is not probabilistic. They are user-defined handlers wired into specific points of Claude Code's lifecycle, and they fire every single time their event fires, regardless of what the model decided to do. That makes them the right tool for anything that must always happen: formatting after every edit, blocking writes to .env, logging every Bash command, or getting a desktop ping the moment Claude needs you.

What follows is the full picture: how hooks are configured, the complete lifecycle, the two channels a hook uses to talk back to Claude Code (exit codes vs. structured JSON), and a set of copy-paste examples that solve real problems. Each claim is grounded in the official docs, traceable through the sourcing rundown and Sources list at the end.

What a hook actually is

A hook is a handler attached to an event. When the event fires, Claude Code passes a JSON payload to your handler on stdin, your handler does its work, and it signals back through stdout, stderr, and its exit code. Because the handler is your code, hooks are deterministic: there's no "the model chose not to run it." If you register a PostToolUse formatter on Edit|Write, every edit gets formatted, full stop.

The most common handler is "type": "command" — a shell command. Four other handler types exist, covered further down: http (POST the event to a URL), mcp_tool (call a tool on a connected MCP server), prompt (a single-turn evaluation by a fast model — the model is configurable via an optional model field, and defaults to a fast model), and agent (a multi-turn subagent that can read files and run commands — still experimental).

Where hooks live, and who they apply to

Hooks go in a settings file under a top-level hooks object. Where you put that file decides the scope:

Location Scope Committable?
~/.claude/settings.json All your projects No — local to your machine
.claude/settings.json One project Yes — commit it to share with the team
.claude/settings.local.json One project No — gitignored by default
Managed policy settings Org-wide Yes — admin-controlled
Plugin hooks/hooks.json When the plugin is enabled Yes — bundled with the plugin

This split is the whole game for teams: put shared guardrails in the committed .claude/settings.json so every teammate inherits them, and keep personal conveniences (like notifications) in ~/.claude/settings.json. To turn everything off at once, set "disableAllHooks": true. Run /hooks in Claude Code to open a read-only browser of everything configured, grouped by event — to actually add or change a hook you edit the JSON directly (or ask Claude to do it).

The structure of a hooks block

Every event key holds an array of groups. Each group has a matcher and a hooks array of handlers:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

The matcher filters which occurrences fire the group. For tool events it matches on the tool name: "Edit|Write" fires only on the Edit or Write tools, "Bash" only on Bash, and "mcp__github__.*" (a regex) on all tools from that MCP server. An empty or omitted matcher fires on every occurrence. The docs distinguish exact-string matchers (letters, digits, _, spaces, ,, |) from regex matchers (anything else). Note that the published matcher rules do not explicitly say whether exact matching is case-sensitive — but tool names ship capitalized (Bash, Edit), and in practice "bash" will not match Bash, so match the casing exactly to be safe. Different events match on different fields: SessionStart matches on startup/resume/clear/compact, Notification on the notification type, and several events (like UserPromptSubmit and Stop) take no matcher at all.

The hook lifecycle

Claude Code exposes a long list of events; these are the ones you'll reach for first:

Event Fires when Can block?
SessionStart A session begins or resumes No (stdout adds context)
UserPromptSubmit You submit a prompt, before Claude sees it Yes
PreToolUse Before a tool call executes Yes
PermissionRequest A permission dialog is about to appear Yes (interactive only)
PostToolUse After a tool call succeeds No (already ran)
Notification Claude Code sends a notification (waiting for input/permission) No
Stop Claude finishes responding Yes (can force more work)
SubagentStop A subagent finishes Yes
PreCompact Before context compaction No

The key mental model: PreToolUse is your gate — it runs before the tool and can stop it. PostToolUse is your reaction — it runs after success and cannot undo anything, since the tool already executed. If you need to prevent something, it has to be PreToolUse.

How a hook talks back: exit codes

The simplest way to control Claude Code is the exit code of your command:

  • Exit 0 — no objection; the action proceeds normally. For PreToolUse this does not auto-approve the tool — the normal permission flow still applies. For UserPromptSubmit and SessionStart, whatever you write to stdout is injected into Claude's context.
  • Exit 2 — block. Whatever you write to stderr is fed back to Claude as feedback so it can adjust. For PreToolUse this blocks the tool call. For un-blockable events like SessionStart and Notification, exit 2 just shows stderr to you and execution continues.
  • Any other exit code — the action proceeds anyway. The transcript shows a <hook name> hook error notice with the first line of stderr; the full stderr goes to the debug log.

Here's the canonical "block a dangerous Bash command" hook (a PreToolUse hook matched on Bash):

#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')

if echo "$COMMAND" | grep -q "drop table"; then
  echo "Blocked: dropping tables is not allowed" >&2  # stderr = Claude's feedback
  exit 2                                               # exit 2 = block
fi

exit 0
Enter fullscreen mode Exit fullscreen mode

How a hook talks back: structured JSON

Exit codes only let you block or stay silent. For finer control — approving, denying with a reason, or rewriting a tool's arguments — exit 0 and print a JSON object to stdout. Do not mix the two: Claude Code ignores your JSON if you exit 2.

A PreToolUse hook returns a hookSpecificOutput object with hookEventName: "PreToolUse" and a permissionDecision:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Use rg instead of grep for better performance"
  }
}
Enter fullscreen mode Exit fullscreen mode

There are four decision values, and they behave differently:

permissionDecision Effect
allow Skips the interactive prompt for this hook — but does not grant final permission. Other hooks can still deny, and deny/ask rules from settings still apply.
deny Cancels the tool call and sends the reason back to Claude.
ask Shows the normal permission prompt to the user.
defer Defers to the normal permission flow — identical to returning no decision at all. Use this when a hook inspects the call but has no opinion.

When several matching hooks disagree, the safe-by-default principle holds: a single deny wins over the others, and allow is the weakest signal — it never overrides a deny from another hook or from settings rules. (The official docs note that all matching hooks run in parallel and that allow does not override a deny; community references summarize the effective ordering as deny > defer > ask > allow. Treat that ordering as a useful mental model rather than a guaranteed contract, and design hooks so that the most restrictive decision is the one you rely on.)

The asymmetry here is the most important security property of hooks. A PreToolUse hook returning deny blocks the tool even in bypassPermissions mode or under --dangerously-skip-permissions, because PreToolUse fires before any permission-mode check. But allow does not loosen things — if a deny rule in settings matches, the call is still blocked. In one sentence: hooks can tighten restrictions but never loosen them past what permission rules allow. That's exactly what you want for org-wide guardrails users can't opt out of.

Rewriting tool arguments before they run

A PreToolUse hook can also modify a tool's input by returning updatedInput inside hookSpecificOutput — for example, redirecting a raw rm to a trash wrapper, or adding --fix to a lint command:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "updatedInput": { "command": "trash ./build" }
  }
}
Enter fullscreen mode Exit fullscreen mode

The one caveat: the rewrite is silent to the model unless you tell it. Claude sees the result of the modified call, not your substitution, so if the change matters semantically, also return a permissionDecisionReason so the agent knows what happened.

The other handler types

Beyond command, the four remaining handler types let a hook reach outside the shell:

Handler type What it does Reach for it when
http POSTs the event JSON to a URL and uses the response You already run a policy/audit service and want hooks to call it
mcp_tool Invokes a tool on a connected MCP server The decision logic already lives behind an MCP server
prompt Single-turn evaluation by a fast model (model configurable; defaults to a fast model) You want a quick natural-language judgment ("is this command destructive?") without writing parsing code
agent Multi-turn subagent with Read/Grep/Glob and command access — experimental The check needs to investigate the repo before deciding

The prompt and agent types are the newest and the most powerful, because they let a hook make a judgment instead of a regex match. The trade-off is latency and cost: every fire spends tokens. Keep command hooks for the hot path (formatting, simple blocks) and save model-backed hooks for the rare, high-stakes decisions where a regex would be too brittle.

A copy-paste starter set

Three hooks cover most of what teams actually want. Drop them in .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null || true" }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          { "type": "command", "command": "jq -e '.tool_input.file_path | test(\"\\\\.env$\")' >/dev/null && { echo 'Refusing to write .env' >&2; exit 2; } || exit 0" }
        ]
      }
    ],
    "Notification": [
      {
        "hooks": [
          { "type": "command", "command": "osascript -e 'display notification \"Claude needs you\" with title \"Claude Code\"'" }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Top to bottom: auto-format every edit, hard-block any write to a .env file (exit 2 = deny), and ping you on macOS whenever Claude is waiting. The .env block is the one that earns its keep — it works even under --dangerously-skip-permissions, because PreToolUse runs first.

If you maintain shared agent configuration, link these from your team's internal AI tooling guide so new engineers inherit the same guardrails on day one.

Claude Code hooks lifecycle and decision flow

Matching the event and handler to the job

You want to... Use Why
Always run something after a tool, no opinion on outcome PostToolUse + command, exit 0 Fires every time; can't block, which is fine
Hard-block an action no matter the permission mode PreToolUse + exit 2 (or deny JSON) Runs before permission checks; survives skip-permissions
Block and let Claude retry intelligently PreToolUse + exit 2 with stderr stderr is fed back as feedback
Rewrite arguments instead of blocking PreToolUse + updatedInput JSON Fix the call rather than reject it
Inject context at session/prompt start SessionStart / UserPromptSubmit, stdout stdout is added to Claude's context
Make a nuanced natural-language judgment prompt or agent handler Model decides; no brittle regex

The throughline: pick the earliest event that can do the job, and the simplest handler that expresses your logic. Reach for model-backed handlers only when a deterministic rule genuinely can't express the decision.

What the docs say, and where this guide reads between the lines

The official Hooks reference (checked 2026-06-28) is the source for the five handler types (command, http, mcp_tool, prompt, agent) and for the prompt handler being a single-turn evaluation that defaults to a fast model, configurable through an optional model field — the docs name no specific model, so neither does this guide. The same page gives permissionDecision its four values (allow, deny, ask, defer) and states both that matching hooks run in parallel and that allow never overrides a deny. What it does not publish is a numeric precedence table, so the deny > defer > ask > allow ordering above is offered as a working mental model rather than a quoted contract. Likewise, the matcher rules separate exact-string from regex matchers without asserting case-sensitivity, so the "match the casing exactly" advice is framed as observed behavior. The Settings reference backs the file-location and disableAllHooks details, and the Permissions page backs how hooks interact with permission modes.

Sources

Top comments (0)