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.
| Event | When it runs |
|---|---|
SessionStart, Setup, SessionEnd | Session lifecycle and one-time setup. |
UserPromptSubmit, UserPromptExpansion | Before a prompt or user command reaches the model. |
PreToolUse, PermissionRequest, PermissionDenied | Before execution and around permission decisions. |
PostToolUse, PostToolUseFailure, PostToolBatch | After successful, failed, or batched tool calls. |
Notification, MessageDisplay | Notifications and displayed assistant text. |
SubagentStart, SubagentStop | Delegated-agent lifecycle. |
TaskCreated, TaskCompleted, TeammateIdle | Tasks and agent-team coordination. |
InstructionsLoaded, ConfigChange | Instruction or configuration loading and changes. |
CwdChanged, DirectoryAdded, FileChanged | Working-directory and watched-file changes. |
WorktreeCreate, WorktreeRemove | Worktree lifecycle. |
PreCompact, PostCompact | Before and after context compaction. |
Elicitation, ElicitationResult | MCP requests for user input and their results. |
Stop, StopFailure | Normal 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/SessionEndwithout 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
.shfile from Git Bash or WSL and pointcommandat thatbashpath, or - Use
pwsh -File/powershell -Filewith a.ps1that 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
type | Behavior |
|---|---|
command | Runs an executable. stdin receives a JSON payload. Use for deterministic checks (lint, secret scan, allowlist). |
http | POSTs event data to a URL. |
mcp_tool | Calls a tool on an already-connected MCP server. |
prompt | Runs an LLM evaluation with the text you supply. Use when judgment is inherently fuzzy (safety of a Bash line, compliance wording). |
agent | Runs 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, orask(send the user a confirmation path).permissionDecisionReason: Explain anallow,deny, oraskresult.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 code | Typical meaning |
|---|---|
0 | No objection by exit code. For PreToolUse, the normal permission flow still applies unless valid decision JSON is printed. |
2 | Blocking failure, often used to stop a PreToolUse tool call; stderr can surface to Claude so the model sees the reason. |
| Other non-zero | Usually 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)
| Idea | Meaning |
|---|---|
| Event | Which lifecycle moment (see table above). |
| Matcher | Event-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 array | One or more handler definitions for that matcher. |
| Timeout | Cap 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
fiBash, 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
fiPowerShell 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
permissionsplus 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 examplePostToolUse) can make large repos unusable, tighten the regex.
Browser and computer use with Claude
Verify web and native application behavior with Claude in Chrome, computer use, or structured browser automation.
Understanding Security with AI agents
Common vulnerability classes in AI-generated code, why agents can amplify them, and review habits that keep shipping safe.