CLAUDE CODE · HOOKS

Claude Code hooks
explained, with a real plugin.

Claude Code hooks are commands that Claude Code runs every time an event fires, such as before a tool call or when a session ends, set under hooks in settings.json. Exit code 2 or a JSON deny blocks the action; any other failure lets it go ahead.

Get started for free ↗

Free for 3 seats · No credit card

Zest lemon in its mech suit

01See your team’s AI stack

Winding / Teams Last 30 days

Zest

3 Members 1 Agent
Team overview
67%
AI Adoption▲ 34 pts
240
PRs Merged▲ 101
96.1h
Median AI Time▲ 9.5h
57.2h
Build Hours▲ 20.3h
This week
Member AI PRs/wk AI Task Plan Impl Last Standup AI Stack
22.8/wk28 min ago
15.8/wk12 min ago
4/wkAug 14
—2 hours ago
Current AI models & harnesses As of Sep 8
Claude CodeFable 5.1 (max) (with fallback)70.4 · Coding Agent Index
CodexGPT-6 Astra (max)67.0 · Coding Agent Index
Muse CodeMuse Spark 1.3 (xhigh)64.2 · Coding Agent Index
Grok BuildGrok 4.5 (high)64.1 · Coding Agent Index
Kimi Code CLIKimi K362.6 · Coding Agent Index
OpencodeGemini 3.8 Flash (high)61.2 · Coding Agent Index
Cursor CLIComposer 2.5 Fast38.3 · Coding Agent Index

Demo setups use current model families. The pairings above are the combinations benchmarked by Artificial Analysis; rankings measure performance, not popularity.

Intelligence Index

Illustrative daily trends · Other totals: last 30 days. Build Hours: this week, model-estimated activity including overlap; not engineer hours.

USED BY ENGINEERING TEAMS AT

FlypsCharmWildfiresGeoforgeMisaLabs

What are Claude Code hooks?

A hook is a handler that runs every time its event fires: before a tool call, after a file edit, when you submit a prompt, when the session ends. A CLAUDE.md instruction is advice Claude may follow; a hook always runs.

Most hooks are shell commands: Claude Code sends the event as JSON on stdin, and the command answers with an exit code and, optionally, JSON on stdout. Other handler types post the event to a URL (http), call an MCP tool, or ask a model or a subagent.

This .claude/settings.json blocks edits to protected files before they happen and runs Prettier on every file Claude writes. Both commands read the event with jq, so it has to be on your PATH:

.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.sh",
            "args": []
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
          }
        ]
      }
    ]
  }
}
.claude/hooks/protect-files.sh (chmod +x)
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

for pattern in ".env" "package-lock.json" ".git/"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
    exit 2
  fi
done
exit 0

Where hooks are configured

Hooks sit under the hooks key of a settings file, never in a file of their own: only plugins use a separate hooks/hooks.json. Hooks from every location run, so a project hook adds to yours instead of replacing it. /hooks lists every active hook and where it came from.

Command hooks run with your full user permissions. Settings-file hooks wait for the folder's trust dialog, but claude -p skips it, so read a repository's .claude/settings.json before you script Claude over code you didn't write.

Hook locations in Anthropic's hooks reference.
LocationApplies to
~/.claude/settings.jsonAll your projects
.claude/settings.jsonThis project, committed for the team
.claude/settings.local.jsonThis project, for you only
Managed policy settingsYour whole organization
A plugin's hooks/hooks.jsonWhile the plugin is enabled
A skill's frontmatterThe rest of the session, once the skill runs
A subagent's frontmatterWhile that subagent runs

Claude Code hook events and matchers

The hooks reference lists more than 30 events. A matcher picks which occurrences fire a group: the tool name for tool events, the source or reason for session events. Matchers are case-sensitive. One made only of letters, digits, _, -, | and , lists exact names, such as Edit|Write; any other character makes it an unanchored regex, so Edit.* also matches NotebookEdit. MCP tools are named mcp__server__tool, so mcp__github__.* covers one server.

On tool events, a handler's if field filters further with permission-rule syntax, such as Bash(git *) or Edit(*.ts), so the command doesn't even start for other calls.

The events most hooks use, from Anthropic's hooks reference. The last column is what exit code 2 does.
EventFiresMatcherExit 2
SessionStartA session starts or resumesstartup, resume, clear, compact, forkCan't block
UserPromptSubmitYou send a promptNoneRejects the prompt
PreToolUseBefore a tool callTool nameBlocks the call
PermissionRequestBefore a permission promptTool nameIgnored: deny with JSON
PostToolUseAfter a tool call succeedsTool nameCan't undo; stderr goes to Claude
StopClaude finishes respondingNoneClaude keeps working
NotificationClaude Code sends a notificationNotification typeIgnored
SessionEndThe session endsclear, resume, logout and moreCan't block

How a hook allows or blocks

A hook answers with its exit code (the table below) or, for finer control, exits 0 and prints JSON. A PreToolUse hook sets permissionDecision to allow, deny, ask or defer; when hooks disagree, deny wins over defer, ask and allow, and updatedInput replaces the tool's whole input. A deny holds even in bypassPermissions mode, but an allow can't override a deny rule in settings. This output denies the call:

stdout of a PreToolUse hook
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Database writes are not allowed"
  }
}
Command hook exit codes, from Anthropic's hooks reference.
Exit codeWhat happens
0Success. Stdout that starts with { is read as JSON; for UserPromptSubmit and SessionStart, plain text is added to Claude's context
2Blocks, on the events that can block. Stderr, or the JSON reason, tells Claude why, and a JSON allow can't override it
Any other, including 1A non-blocking error: the action goes ahead and the transcript shows stderr's first line

When a hook fails quietly

Because only exit code 2 blocks, a broken hook stops nothing. One of our repositories runs its formatter in a PostToolUse hook after every Edit and Write; on a laptop without pnpm it failed 124 times in two days (exit code 127, command not found), and Claude carried on each time, so the formatter never ran.

Run a hook's command in your own shell before you commit it, with a sample event on stdin. In a session, /hooks shows whether it loaded, and claude --debug writes each match, exit code and output to ~/.claude/debug/<session-id>.txt.

Test a hook outside Claude Code
echo '{"tool_name":"Edit","tool_input":{"file_path":".env"}}' \
  | .claude/hooks/protect-files.sh
echo $?   # 2: blocked
Common hook problems, from Anthropic's hooks guide and configuration-debugging docs.
SymptomUsual cause
Never firesA lowercase or array matcher, hooks in a file of their own, or hooks in ~/.claude.json instead of ~/.claude/settings.json
"hook error" in the transcriptThe script isn't executable (chmod +x), the path is wrong, or jq is missing
JSON decision ignoredYour shell profile prints text before the JSON, or permissionDecision sits outside hookSpecificOutput
A Stop hook loopsExit 0 when the input's stop_hook_active is true; Claude Code overrides a Stop hook after eight blocks in a row

Does Codex have hooks?

Yes, on by default. Codex reads them from a hooks.json or [hooks] tables in config.toml, in ~/.codex or the repository's .codex folder, and a Codex plugin can ship its own. The events mirror Claude Code's: SessionStart, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, Stop, the subagent and compaction events, and a few more. A hook you add yourself runs only after you review and trust it in /hooks.

Blocking works the same way: exit code 2 with a reason on stderr, or JSON with permissionDecision set to deny. An ask decision is parsed but not supported yet. Zest's Codex plugin uses two hooks, SessionStart and UserPromptSubmit, to keep its login fresh.

Does Cursor have hooks?

Yes. Cursor reads .cursor/hooks.json in the project and ~/.cursor/hooks.json, plus enterprise and team hooks that take precedence. Its events have their own names: beforeShellExecution, beforeMCPExecution, beforeReadFile, afterFileEdit, beforeSubmitPrompt, stop, and generic preToolUse and postToolUse.

Exit code 2 blocks here too, and other failures let the action go ahead unless the hook sets failClosed. Cursor also runs the hooks in Claude Code's settings files, mapping their events to its own, while its setting for third-party configs is on, which it is by default. A hook written for Claude Code can then fire in both tools, so test it in each.

Hook examples from a real plugin: Zest

Zest builds AI coding analytics from three hooks: each session's models, tokens, skills, subagents and MCP tools, next to the branch, commits and pull request it touched, shown as Claude Code usage per engineer.

This is the plugin's hooks/hooks.json. PostToolUse has no matcher, so it runs after every tool call. Each hook has a timeout, exits 0 and returns no decision: Zest records, and never blocks or changes what Claude does.

hooks/hooks.json in Zest's Claude Code plugin
{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PLUGIN_ROOT}/bin/zest hook session-start",
            "timeout": 30
          }
        ]
      }
    ],
    "SessionEnd": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PLUGIN_ROOT}/bin/zest hook session-end",
            "timeout": 30
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PLUGIN_ROOT}/bin/zest hook incremental",
            "timeout": 15
          }
        ]
      }
    ]
  }
}

TRY IT ON YOUR TEAM

See what your team ships with AI.

Starter is free (3 seats, no credit card). Pro is $49 a month with 5 seats and Max is $149 a month with 10 seats — priced per workspace, not per engineer, with extra seats at $19 a month.

A FEW THINGS TO KNOW

Questions

What is the difference between hooks and skills?+

A skill is instructions Claude chooses to use when a request matches it. A hook is code Claude Code runs every time its event fires, whatever Claude decides, so use a hook for anything that must always happen.

Why isn't my Claude Code hook running?+

Run /hooks: a hook that isn't listed didn't load, usually because it sits outside a settings file's hooks key or its matcher is an array. If it is listed, check that the matcher spells the tool name exactly (Bash, not bash), then start claude --debug and read the log.

Do hooks run inside subagents?+

Yes. Hooks from settings files, managed settings and plugins fire for a subagent's tool calls too, and the input carries agent_id and agent_type so a script can tell them apart.

Can I turn all hooks off?+

Yes. Set disableAllHooks to true in your settings, or pass --settings '{"disableAllHooks": true}' for one run; hooks from managed settings keep running. Administrators can go the other way with allowManagedHooksOnly, which blocks user, project, local and plugin hooks.