BlogAi

Claude Code hooks: settings.json, events & JSON schema

The complete Claude Code hooks reference: where hooks live in settings.json, every event, the JSON schema, permission decisions, exit codes, and copy-paste recipes.

Nicolás Torres

Claude Code ships a full hook system for the CLI agent loop. Hooks run small programs around lifecycle events: before a tool runs, after a file is edited, when a subagent spawns, when context compacts. They can observe, modify, or block what the agent does.

This guide is a standalone reference to Claude Code hooks: where the config lives, every event, the JSON schema shape, how permission decisions work, and recipes you can copy today.

If you came from Cursor, the model is the same but the JSON is different. The Cursor hooks.json guide covers the other side; a comparison table is at the end of this post.


Where hooks live

Claude Code hooks live in settings.json under a "hooks" key, not in a separate hooks.json file.

ScopeFileShared via git
Project.claude/settings.jsonYes
Project-local.claude/settings.local.jsonNo (gitignored)
User~/.claude/settings.jsonNo
EnterpriseManaged settings deploymentVia admin

Per the official guide, the /hooks slash command opens a read-only browser that lists configured hooks by event. To add, modify, or remove hooks, edit the settings JSON directly or ask Claude to make the change.

The schema is nested: event → matcher group → handler array. Cursor's is flatter. Same concept, different shape.


Events you can hook

Claude Code exposes hooks across the full agent lifecycle:

EventFires when
SessionStartSession begins, resumes, clears, or compacts
UserPromptSubmitBefore your prompt is processed
PreToolUse / PostToolUse / PostToolUseFailureAround tool execution
PermissionRequest / PermissionDeniedPermission dialog flow
NotificationClaude needs your attention
SubagentStart / SubagentStopSubagent lifecycle
Stop / StopFailureTurn completion or API error
PreCompact / PostCompactContext compaction
ConfigChangeSettings file changes mid-session
CwdChanged / FileChangedDirectory or watched file changes

Use the narrowest event for your goal. For shell command policy, PreToolUse with matcher Bash beats a broad UserPromptSubmit hook.


The settings.json schema

Hooks are nested three levels deep: event → matcher group → handler array.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/guard.sh"
          }
        ]
      }
    ]
  }
}

Handler types

TypeWhat it does
commandRuns a shell script
httpPOSTs the event payload to a URL
mcp_toolCalls a connected MCP tool
promptSingle-turn LLM evaluation of the event
agentMulti-turn verification, experimental

Matchers

Matchers filter which events reach a handler. PreToolUse matchers target tool names (Bash, Edit, Write, Read, Task). You can also add an if filter on arguments for finer control.


Permission decisions and exit codes

Two events make permission decisions, and they don't speak the same language. PreToolUse runs before every matching tool call and can block with an exit code or JSON. PermissionRequest runs only when Claude Code is about to show a permission dialog, and it only listens to JSON.

PreToolUse

Exit code 2 blocks:

#!/bin/bash
echo "Blocked: use a safer alternative" >&2
exit 2

Exit 0 with JSON decides: permissionDecision is allow, deny, ask, or defer.

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Use a safer alternative."
  }
}

Use "ask" to force the confirmation prompt (the reason is shown to the user) or "allow" to skip it. Deny and ask rules in your settings are still evaluated either way.

PermissionRequest

Exit code 2 does nothing here. The permission flow proceeds unchanged and stderr is discarded. To answer the dialog, exit 0 with a decision object:

{
  "hookSpecificOutput": {
    "hookEventName": "PermissionRequest",
    "decision": {
      "behavior": "deny",
      "message": "Network installs need manual approval."
    }
  }
}

Allow, optionally rewriting the input:

{
  "hookSpecificOutput": {
    "hookEventName": "PermissionRequest",
    "decision": {
      "behavior": "allow",
      "updatedInput": { "command": "npm run lint" }
    }
  }
}

updatedInput and updatedPermissions work only with allow; message and interrupt work only with deny. A matching deny rule still beats a hook's allow.

Key rules:

  • Pick one approach per hook. If you exit 2, the call is blocked no matter what JSON you print, even "permissionDecision": "allow". To return a decision, exit 0.
  • Other non-zero exit codes are non-blocking errors. Policy hooks should use exit 2 or JSON, not exit 1.
  • When multiple hooks match the same event, Claude Code runs them in parallel and merges results.
  • For PreToolUse permission decisions, the most restrictive answer wins: deny beats defer beats ask beats allow.
  • A PreToolUse deny blocks the tool even in bypassPermissions mode.
  • PostToolUse cannot block anything. The tool already ran. Use PreToolUse for blocking.

Recipes you can copy today

1. Block dangerous shell commands

.claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/block-dangerous.sh"
          }
        ]
      }
    ]
  }
}

.claude/hooks/block-dangerous.sh:

#!/bin/bash
input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // empty')
 
if echo "$command" | grep -qE 'rm -rf|curl.*\|.*bash|wget.*\|.*sh'; then
  echo "Blocked: destructive command pattern" >&2
  exit 2
fi
 
exit 0

2. Run tests after every edit

.claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/run-tests.sh"
          }
        ]
      }
    ]
  }
}

.claude/hooks/run-tests.sh:

#!/bin/bash
# PostToolUse is fail-open: log test results, never block the agent
if [ -f "pnpm-lock.yaml" ]; then
  pnpm test 2>&1 | tail -40
else
  npm test 2>&1 | tail -40
fi
exit 0

Keep test hooks fail-open. A failing test should be visible, not session-breaking. To gate merges on green tests, enforce in CI instead.

The Cursor equivalent uses afterFileEdit or postToolUse with a Write matcher; see Cursor hooks: run tests after every file edit for the payload details and monorepo variants.

3. Desktop notification when Claude needs you

.claude/settings.json:

{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude needs input\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}

macOS only. Add to .claude/settings.json, run /hooks to verify it registered.

4. HTTP hook to your own endpoint

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "http",
            "url": "https://your-api.example.com/hooks/prompt-audit"
          }
        ]
      }
    ]
  }
}

The event payload is POSTed as JSON. Useful for audit trails, PII scanning, or prompt policy enforcement without writing shell scripts.


Enterprise controls

Admins can lock down where hooks come from:

  • allowManagedHooksOnly: blocks user and project hooks entirely
  • strictPluginOnlyCustomization: restricts customization to plugins
  • Managed settings deployment: pushes hook config to every machine

Cursor vs Claude Code hooks

AspectCursorClaude Code
Config file.cursor/hooks.jsonsettings.json inline
Create via slash command/create-hook (scaffolds hooks)No create command
Inspect via slash commandCustomize > Hooks tab/hooks (read-only browser)
Documented events~20 agent + Tab + workspaceFull lifecycle, ~18 events
SchemaFlat per hook entryNested matcher groups
MatchersSingle regex per entryMatcher + optional if filter
failClosedPer-hook booleanFail-open default; exit 2 blocks
Plugin hooksVia Cursor pluginsVia plugin hooks/hooks.json
Third-party importReads Claude hooks when enabledN/A
HTTP / MCP / agent hook typesCommand + prompt onlycommand, http, mcp_tool, prompt, agent

Cursor is stronger on IDE integration: Tab hooks, workspaceOpen, cloud agent pickup from repo-level config. Claude Code goes deeper on the CLI loop: notifications, compaction hooks, permission events, and HTTP/MCP hook types. Same idea, different runtimes.


Debugging

  • Run /hooks to see what's configured. It is read-only.
  • Hook stderr lands in the Claude Code output. Log liberally inside your scripts.
  • Missing shebang or execute bit: scripts need #!/bin/bash and chmod +x.
  • jq must be on $PATH in the hook environment. Don't assume.
  • Circular invocations: a hook that shells out to claude can recurse. Use loop_limit.
  • Windows: shebang scripts won't work; use .bat/.cmd or explicit interpreters.

Sources

Frequently asked questions

Where do Claude Code hooks live?

Claude Code hooks live in settings.json under a "hooks" key, not in a separate hooks.json file. Project hooks: .claude/settings.json. User hooks: ~/.claude/settings.json. Project-local hooks: .claude/settings.local.json (gitignored). Enterprise admins can deploy managed settings.

What events can Claude Code hooks listen to?

The full lifecycle: SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied, Notification, SubagentStart, SubagentStop, Stop, StopFailure, PreCompact, PostCompact, ConfigChange, CwdChanged, and FileChanged.

How do I block a tool call in Claude Code?

Use a PreToolUse hook. Either exit with code 2 and a stderr message, or exit 0 with structured JSON: hookSpecificOutput.permissionDecision set to "deny". Pick one: if you exit 2 the call is blocked whatever JSON you print. Note that PermissionRequest hooks ignore exit code 2 and must return a decision object. A PreToolUse deny blocks the tool even in bypassPermissions mode.

What hook handler types does Claude Code support?

Five types: command (shell script), http (POST to a URL), mcp_tool (call a connected MCP tool), prompt (single-turn LLM evaluation), and agent (multi-turn verification, experimental). Cursor only supports command and prompt hooks.

How do I run tests after every edit in Claude Code?

Use a PostToolUse hook with matcher Edit\|Write that runs your test command. Keep it fail-open so failing tests don't break the session. Claude Code runs matching hooks in parallel and merges results.