Writing Hooks: The Contract and Registration
Hooks are shell scripts that run on stdin/stdout. Learn the exact input/output contract, how to register hooks in settings, and how exit codes control behavior.
Learning objectives
- Understand the exact stdin JSON input and stdout JSON output contract for hooks
- Know how exit codes (0, 2, other) determine success vs. blocking vs. error
- Register hooks in `.claude/settings.json` with event matchers
- Write a complete, working hook script with input parsing and output formatting
ToolDix original visual
Frame
Name the outcome and constraints.
Build
Try one bounded workflow.
Review
Keep evidence, revise, and share.
A hook is a shell script that Claude Code invokes with JSON input on stdin and expects JSON output on stdout. The contract is simple: receive structured data about an event, decide what to do, and signal your decision via exit code and optionally JSON output. This lesson walks you through writing a real hook end-to-end.
The stdin/stdout contract
When Claude Code fires a hook, it:
- Spawns your script (shell command you registered)
- Sends JSON on stdin containing session metadata and event-specific fields (exact schema depends on the event type)
- Waits for your script to exit (with a configurable timeout, default 30 seconds)
- Reads stdout (if exit code is 0, expects valid JSON; otherwise, reads stderr for logging)
- Acts based on exit code and JSON output
Your hook script must:
- Read and parse the JSON input from stdin
- Perform validation, logging, or side effects
- Output a JSON object to stdout (if success, exit code 0)
- Exit with the appropriate code (0 = success, 2 = blocking error, other = non-blocking error)
Input contract: what your hook receives
Every hook receives at least these fields on stdin (full schema in the Hooks Reference):
{
"session_id": "sess_abc123",
"prompt_id": "uuid-string",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/home/user/project",
"permission_mode": "default|plan|acceptEdits|auto|dontAsk|bypassPermissions",
"effort": { "level": "low|medium|high|xhigh|max" },
"hook_event_name": "EventName",
"agent_id": "subagent-uuid-or-null",
"agent_type": "agent-type-name"
}
Plus event-specific fields (e.g., tool_name, file_path, command for PreToolUse).
Output contract: how your hook signals decisions
If your script exits with code 0, Claude Code parses stdout as JSON and looks for these optional decision fields:
{
"continue": true,
"stopReason": "Optional reason to stop processing if continue=false",
"suppressOutput": false,
"systemMessage": "Optional warning/info message for the user",
"decision": "allow|block|defer",
"reason": "Human-readable explanation of the decision"
}
Semantics:
continue(boolean, defaulttrue): Iffalse, stop processing this hook event immediately. Used rarely (e.g., SessionEnd hooks that do cleanup).decision(string): For action-gating hooks, set toblockto prevent the action;allowto force-allow;deferto leave to user (default behavior).systemMessage(string): A warning or info message displayed to the user in the terminal.reason(string): Explanation of your decision, logged for audit.
If you don't output JSON or output invalid JSON with exit code 0, Claude Code treats it as a successful no-op.
Exit codes: success vs. blocking vs. error
| Code | Behavior | Use Case | |------|----------|----------| | 0 | Success. Parse stdout as JSON and act on decision fields. | Hook ran successfully, may have a decision. | | 2 | Blocking error. stderr is shown to user; action is blocked. | Validation failed; user must fix (e.g., commit message invalid). | | Other (1, 127, etc.) | Non-blocking error. stderr logged; execution continues. | Hook crashed or returned unexpected error; don't halt flow. |
Example: A hook that validates a Git commit message:
- If message is valid: exit 0, output
{ "decision": "allow" } - If message is invalid (blocking): exit 2, write error to stderr
- If hook itself crashes: exit 1, write error to stderr (agent continues)
Registering hooks in settings
Hooks are registered in .claude/settings.json (project scope) or ~/.claude/settings.json (user scope). The structure is:
{
"hooks": [
{
"event": "EventName",
"matcher": "optional pattern",
"type": "command",
"command": "/absolute/path/to/hook.sh",
"args": ["optional", "args"],
"timeout": 30,
"continueOnError": true
}
]
}
Fields:
event(required, string): The hook event (e.g.,PreToolUse,PostToolUse,Stop,SessionStart).matcher(optional, string): Pattern to filter which events trigger this hook (e.g.,Bashto match only Bash tool calls, or.*for all). Syntax depends on event type.type(required, string): Currently always"command"for shell scripts (other types like"http"exist but aren't covered here).command(required if type iscommand, string): Absolute path to executable. Relative paths are not supported; use$HOMEor construct the full path.args(optional, array): Extra command-line arguments passed aftercommand.timeout(optional, integer, default 30): Seconds to wait for hook to complete. If exceeded, hook is killed.continueOnError(optional, boolean, defaulttrue): Iffalse, a non-zero exit code stops processing. Iftrue, non-zero codes are logged but don't halt.
Example settings registration
{
"hooks": [
{
"event": "PreToolUse",
"matcher": "Bash",
"type": "command",
"command": "/home/user/my-project/.claude/hooks/validate-bash.sh",
"timeout": 5,
"continueOnError": false
},
{
"event": "Stop",
"type": "command",
"command": "/home/user/my-project/.claude/hooks/notify-slack.sh",
"continueOnError": true
}
]
}
A complete worked example: validating Bash commands
Here's a real, production-grade hook that validates Bash commands before they run. It blocks any rm command in protected directories.
Setup:
- Create the hook script at
.claude/hooks/validate-bash.sh - Register it in
.claude/settings.json - Test it
Step 1: Write the hook script
#!/bin/bash
# validate-bash.sh
# Validates Bash commands before Claude Code executes them.
# Blocks 'rm' in protected directories; allows everything else.
# Exit 0 with JSON decision on success.
# Exit 2 to block (with error message on stderr).
set -o pipefail
# Read the hook input from stdin
input=$(cat)
# Extract the tool_name and tool_input
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
command=$(echo "$input" | jq -r '.tool_input.command // empty')
# Only validate Bash tool calls
if [[ "$tool_name" != "Bash" ]]; then
# Not a Bash call; allow
echo '{"decision": "allow", "reason": "Not a Bash tool call"}'
exit 0
fi
# Detect dangerous patterns: rm in protected directories
protected_dirs=(".git" ".venv" "node_modules" "/etc" "/var" "/usr")
for dir in "${protected_dirs[@]}"; do
if [[ "$command" =~ rm.*$dir ]]; then
# Dangerous pattern detected
echo "❌ Hook validation failed: attempted 'rm' in protected directory: $dir" >&2
exit 2
fi
done
# All checks passed
echo "{
\"decision\": \"allow\",
\"reason\": \"Bash command passed validation\"
}"
exit 0
Key details:
- Input parsing: Use
jqto extract fields from the JSON input on stdin. - Decision logic: Check the tool name and command arguments; decide to allow, block, or defer.
- Output: JSON on stdout if successful (exit 0). Error message on stderr if blocking (exit 2).
- Shebang: Always
#!/bin/bash(or your shell of choice). - Error handling: Use
set -o pipefailto catch jq errors.
Step 2: Register in settings
Create .claude/settings.json in your project root:
{
"hooks": [
{
"event": "PreToolUse",
"matcher": "Bash",
"type": "command",
"command": "/absolute/path/to/.claude/hooks/validate-bash.sh",
"timeout": 5,
"continueOnError": false
}
]
}
Important: Use an absolute path. If your project is at /home/alice/my-project, the command should be:
"/home/alice/my-project/.claude/hooks/validate-bash.sh"
Step 3: Test the hook
# Make the script executable
chmod +x .claude/hooks/validate-bash.sh
# Manually test with a valid Bash command
echo '{
"session_id": "test",
"prompt_id": "test-uuid",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {"command": "ls -la"}
}' | ./.claude/hooks/validate-bash.sh
# Expected output:
# {"decision": "allow", "reason": "Bash command passed validation"}
# Exit code: 0
# Test with a blocked command
echo '{
"session_id": "test",
"prompt_id": "test-uuid",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {"command": "rm -rf .git/config"}
}' | ./.claude/hooks/validate-bash.sh
# Expected stderr: ❌ Hook validation failed: attempted 'rm' in protected directory: .git
# Exit code: 2
Now start Claude Code in your project. When Claude tries to run rm -rf .git, the hook intercepts it:
User: "Clean up the git config"
Claude: "I'll remove the git config..."
Hook blocks:
❌ Hook validation failed: attempted 'rm' in protected directory: .git
Claude: (observes the block, doesn't attempt)
Input parsing best practices
When writing hooks, always:
- Use
jqfor JSON parsing — it's standard and safe. Avoid regex on JSON. - Provide defaults — not all events include all fields. Use
jq .field // "default". - Handle errors gracefully — if jq fails to parse, exit 1 (non-blocking error).
- Log to stderr — don't write debug output to stdout; it will interfere with the JSON decision.
- Make hooks idempotent — they may run multiple times for the same event; side effects should be safe to repeat.
Example defensive hook header:
#!/bin/bash
set -euo pipefail
# Read input, exit cleanly if invalid
input=$(cat 2>/dev/null) || {
echo "Failed to read hook input" >&2
exit 1
}
# Extract fields with defaults
session_id=$(echo "$input" | jq -r '.session_id // "unknown"' 2>/dev/null)
tool_name=$(echo "$input" | jq -r '.tool_name // empty' 2>/dev/null)
# Only proceed if we have critical fields
if [[ -z "$tool_name" ]]; then
echo '{"decision": "defer"}' # Let Claude Code decide
exit 0
fi
# ... rest of logic
Common patterns
Pattern 1: Logging every tool call
#!/bin/bash
input=$(cat)
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
tool_name=$(echo "$input" | jq -r '.tool_name // "unknown"')
session_id=$(echo "$input" | jq -r '.session_id // "unknown"')
echo "[${timestamp}] Tool: ${tool_name}, Session: ${session_id}" >> ~/.claude-hooks.log
echo '{"reason": "Logged"}'
exit 0
Pattern 2: Blocking based on environment
#!/bin/bash
input=$(cat)
env_type=$(echo "$input" | jq -r '.cwd // ""' | grep -o 'prod\|staging\|dev' | head -1)
if [[ "$env_type" == "prod" ]]; then
echo "❌ Claude Code cannot run in production directory" >&2
exit 2
fi
echo '{"decision": "allow"}'
exit 0
Pattern 3: Conditional approval based on time
#!/bin/bash
input=$(cat)
hour=$(date +%H)
if (( hour >= 22 || hour < 6 )); then
echo "Request for approval: Claude wants to run a command after hours" >&2
echo '{"decision": "defer"}'
exit 0
fi
echo '{"decision": "allow", "reason": "Within business hours"}'
exit 0
Common mistake
Assuming your hook will always run for an event if registered. Hooks only fire if the event matches. If you register a PreToolUse hook with matcher "Bash" and Claude calls the Edit tool, your hook won't fire. Always test your matcher pattern with actual usage, not just the event type name. Also, never rely on hooks for security-critical decisions; the permission boundary is the enforcement layer, hooks are the audit layer.
Sources and license context
These references informed the lesson. ToolDix adds its own explanation, workflow, and practice rather than reproducing source material. Every link below leaves ToolDix and opens the publisher's own site in a new tab.
- Hooks Guide (opens code.claude.com in a new tab)External · code.claude.com (Anthropic terms apply)
- Hooks Reference (opens code.claude.com in a new tab)External · code.claude.com (Anthropic terms apply)
- Settings (opens code.claude.com in a new tab)External · code.claude.com (Anthropic terms apply)
Keep going
Read these next on ToolDix.
Original lessons that build on what you just read.