Vibe Coding for Production
Getting work done

Hooks

Lifecycle hooks in Claude Code, run your own commands around prompts and tool use to log, gate, or customize behavior.

Hooks in Claude Code run automatically on lifecycle events. You attach matchers and one or more handlers. Current handler types are command, http, mcp_tool, prompt, and experimental agent hooks.

Plugin-defined hooks merge with user-defined hooks and can run in parallel, so teams can ship defaults in a plugin while individuals still add their own entries.

Hook events

Current event groups are summarized below. See the Hooks guide and Hooks reference for per-event schemas.

EventWhen it runs
SessionStart, Setup, SessionEndSession lifecycle and one-time setup.
UserPromptSubmit, UserPromptExpansionBefore a prompt or user command reaches the model.
PreToolUse, PermissionRequest, PermissionDeniedBefore execution and around permission decisions.
PostToolUse, PostToolUseFailure, PostToolBatchAfter successful, failed, or batched tool calls.
Notification, MessageDisplayNotifications and displayed assistant text.
SubagentStart, SubagentStopDelegated-agent lifecycle.
TaskCreated, TaskCompleted, TeammateIdleTasks and agent-team coordination.
InstructionsLoaded, ConfigChangeInstruction or configuration loading and changes.
CwdChanged, DirectoryAdded, FileChangedWorking-directory and watched-file changes.
WorktreeCreate, WorktreeRemoveWorktree lifecycle.
PreCompact, PostCompactBefore and after context compaction.
Elicitation, ElicitationResultMCP requests for user input and their results.
Stop, StopFailureNormal response completion or API-error termination.

Why teams use hooks

  • Policy and safety: Reject or flag tool invocations that match patterns your org forbids (destructive shell, paths outside the repo, disallowed hosts), in addition to permissions.deny.
  • Observability: Append a structured log line (who, which tool, which files) to your own pipeline for audit or debugging.
  • Consistency: Normalize prompts or attach metadata before the model sees the user message (UserPromptSubmit).
  • Integration: Run scripts on Stop / SessionEnd without manual copy-paste.

Hooks are not a full sandbox: they run your code with your privileges, so treat scripts like any other automation you would code-review.

Hook scripts: macOS, Linux, and Windows

Docs often show bash because it is easy to share. On Windows you can:

  • Run the same .sh file from Git Bash or WSL and point command at that bash path, or
  • Use pwsh -File / powershell -File with a .ps1 that reads stdin the same way (see the dotnet format example below).

Quote paths in settings.json if they contain spaces. Prefer PowerShell 7 (pwsh) for ConvertFrom-Json behavior that matches the examples here.

Configuration shape

User or project settings (.claude/settings.json)

Settings files use a top-level "hooks" object. Each event key maps to an array of matcher blocks.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Validate file write safety before allowing the edit.",
            "timeout": 30
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash ./scripts/hooks/on-stop.sh",
            "timeout": 45
          }
        ]
      }
    ]
  }
}

Use project .claude/settings.json for team rules; use user settings for personal automation.

Plugins (hooks/hooks.json)

Plugins use hooks/hooks.json with an optional description and a required hooks object whose keys are the event names (for example PreToolUse, PostToolUse). Prefer ${CLAUDE_PLUGIN_ROOT} in command paths so the plugin works wherever it is installed.

{
  "description": "Example plugin hooks",
  "hooks": {
    "SessionStart": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

Hook types

typeBehavior
commandRuns an executable. stdin receives a JSON payload. Use for deterministic checks (lint, secret scan, allowlist).
httpPOSTs event data to a URL.
mcp_toolCalls a tool on an already-connected MCP server.
promptRuns an LLM evaluation with the text you supply. Use when judgment is inherently fuzzy (safety of a Bash line, compliance wording).
agentRuns a multi-turn verifier with tool access; currently experimental.

Handlers support an optional timeout (seconds).

stdin: common JSON fields

Command hooks read JSON from stdin. All hooks share a common baseline; tool events add tool_name and tool_input.

{
  "session_id": "…",
  "transcript_path": "/path/to/transcript.txt",
  "cwd": "/current/working/dir",
  "permission_mode": "default|acceptEdits|plan|auto|dontAsk|bypassPermissions",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": { "command": "…" }
}

The tool_input shape depends on the tool (for Edit / Write, expect fields like file path and content in the payload your version sends).

Controlling PreToolUse outcomes

JSON on stdout (command hooks)

For PreToolUse, a command hook can print JSON to stdout to allow, deny, ask, or rewrite arguments:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow|deny|ask",
    "permissionDecisionReason": "Why this decision was made",
    "updatedInput": { "field": "modified_value" }
  },
  "systemMessage": "Short explanation for Claude (optional)."
}
  • permissionDecision: allow, deny, or ask (send the user a confirmation path).
  • permissionDecisionReason: Explain an allow, deny, or ask result.
  • updatedInput: Optional map of tool argument overrides before execution.
  • systemMessage: Optional extra context for the model.

Exit codes (command hooks)

Semantics used by Claude Code for command hooks:

Exit codeTypical meaning
0No objection by exit code. For PreToolUse, the normal permission flow still applies unless valid decision JSON is printed.
2Blocking failure, often used to stop a PreToolUse tool call; stderr can surface to Claude so the model sees the reason.
Other non-zeroUsually a hook error; valid structured JSON can still determine the result for most events. Event-specific exceptions are documented in the reference.

Use either exit 2 with stderr or exit 0 with structured JSON for a decision, not both. Exact blocking behavior varies by event.

Mental model (quick reference)

IdeaMeaning
EventWhich lifecycle moment (see table above).
MatcherEvent-specific filter, often a regex matched against the tool name. Leave it empty (or use .* where supported) for all; keep hot-event matchers narrow.
Inner hooks arrayOne or more handler definitions for that matcher.
TimeoutCap how long a hook may run, avoid hanging the session.

If a hook hangs or crashes, you can break or slow the whole session. Keep scripts short, time-bounded, and defensive.

Patterns that work well

Pre-tool gate (command) Parse tool_name / tool_input from stdin; return permissionDecision: "deny" or exit 2 with a clear stderr message when a rule fires.

Pre-tool gate (prompt) For Bash, use a prompt hook that asks the model to reject destructive or non-idempotent commands when deterministic regex is not enough.

Post-tool automation PostToolUse with a matcher like Write|Edit for formatters, test runners, or audit logs.

Prompt hygiene UserPromptSubmit (which does not support a matcher) to add security reminders when the user mentions auth, permissions, or APIs.

Stop / subagent stop Stop and SubagentStop for response-completion checks, config validation, notifications, or verifying a task is complete. Use SessionEnd for actual session shutdown.

Example: dotnet format with .editorconfig

A PostToolUse hook is a good fit when you want the workspace to stay aligned with .editorconfig and analyzer style rules after the agent edits C#. The .NET CLI dotnet format command applies those rules using the same settings your IDE would use for the solution or project.

Use a matcher such as Write|Edit (or your tool names for file edits) so you do not format the tree on every tool call.

The script lives at scripts/hooks/dotnet-format-after-edit.sh (or dotnet-format-after-edit.ps1 on Windows). stdin uses the common tool_name / tool_input fields; adjust tool_input keys if your build uses different property names for the file path.

Bash, requires jq on PATH.

#!/usr/bin/env bash
set -euo pipefail

root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
[[ -n "$root" ]] || exit 0
cd "$root"

payload="$(cat)"
tool_name="$(echo "$payload" | jq -r '.tool_name // empty')"
[[ "$tool_name" == Write || "$tool_name" == Edit ]] || exit 0

file="$(echo "$payload" | jq -r '.tool_input.file_path // .tool_input.path // empty')"
[[ -n "$file" ]] || exit 0
[[ "$file" == *.cs ]] || exit 0

rel="$file"
case "$file" in
  "$root"/*) rel="${file#"$root"/}" ;;
esac

sln="$(ls -1 *.sln 2>/dev/null | head -n1 || true)"
if [[ -n "$sln" ]]; then
  dotnet format "$sln" --include "$rel" --verbosity quiet
else
  dotnet format --include "$rel" --verbosity quiet
fi

Bash, requires jq on PATH.

#!/usr/bin/env bash
set -euo pipefail

root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
[[ -n "$root" ]] || exit 0
cd "$root"

payload="$(cat)"
tool_name="$(echo "$payload" | jq -r '.tool_name // empty')"
[[ "$tool_name" == Write || "$tool_name" == Edit ]] || exit 0

file="$(echo "$payload" | jq -r '.tool_input.file_path // .tool_input.path // empty')"
[[ -n "$file" ]] || exit 0
[[ "$file" == *.cs ]] || exit 0

rel="$file"
case "$file" in
  "$root"/*) rel="${file#"$root"/}" ;;
esac

sln="$(ls -1 *.sln 2>/dev/null | head -n1 || true)"
if [[ -n "$sln" ]]; then
  dotnet format "$sln" --include "$rel" --verbosity quiet
else
  dotnet format --include "$rel" --verbosity quiet
fi

PowerShell 7+, no jq; uses ConvertFrom-Json. Run with pwsh if that is what your hook command invokes.

# scripts/hooks/dotnet-format-after-edit.ps1
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$root = git rev-parse --show-toplevel 2>$null
if (-not $root) { exit 0 }
Set-Location $root

$payload = [Console]::In.ReadToEnd() | ConvertFrom-Json
if ($payload.tool_name -ne 'Write' -and $payload.tool_name -ne 'Edit') { exit 0 }

$file = $payload.tool_input.file_path
if (-not $file) { $file = $payload.tool_input.path }
if (-not $file -or $file -notlike '*.cs') { exit 0 }

$rel = $file
$rootNorm = $root.TrimEnd('/', '\')
if ($file.StartsWith($rootNorm)) {
  $rel = $file.Substring($rootNorm.Length).TrimStart('/', '\')
}

$sln = Get-ChildItem -Path . -Filter *.sln -File | Select-Object -First 1
if ($sln) {
  dotnet format $sln.FullName --include $rel --verbosity quiet
} else {
  dotnet format --include $rel --verbosity quiet
}

Why this shape: --include scopes work to the touched file; --verbosity quiet keeps transcripts readable; dotnet format picks up .editorconfig automatically. Ensure a compatible SDK for the repository is on PATH; this workshop uses .NET 10, but dotnet format is not a .NET-10-only command. On Windows you can instead run the Bash script inside Git Bash or WSL if you already use jq there.

Pitfalls

  • Secrets in logs: Never print environment variables or file contents from hook debug prints.
  • Double enforcement: Hooks plus permissions plus manual review can fight each other; pick one source of truth for “hard no” rules.
  • Latency: Network calls inside hooks run often; batch, cache, or sample when possible.
  • Portability: Use ${CLAUDE_PLUGIN_ROOT} in plugins; use repo-relative paths in shared command scripts; avoid macOS-only paths on Linux CI.
  • Matcher overly broad: .* on hot events (for example PostToolUse) can make large repos unusable, tighten the regex.

On this page