Skip to main content
Claude Code Tutorial: From First Command to Custom Workflows

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.

Advanced19 minBy ToolDix Editorial

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

Claude Code Tutorial practice loop
1

Frame

Name the outcome and constraints.

2

Build

Try one bounded workflow.

3

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.

ToolDix original diagram
Hooks: middleware around the agent loop
Agent decides
Time to call a tool
PreToolUse hook
Validate or block the tool call
Tool executes
If hook allowed it
PostToolUse hook
Log result, validate output, trigger side effects
Agent observes
And decides next step
Hooks add middleware at specific points without changing how the agent reasons.

The stdin/stdout contract

When Claude Code fires a hook, it:

  1. Spawns your script (shell command you registered)
  2. Sends JSON on stdin containing session metadata and event-specific fields (exact schema depends on the event type)
  3. Waits for your script to exit (with a configurable timeout, default 30 seconds)
  4. Reads stdout (if exit code is 0, expects valid JSON; otherwise, reads stderr for logging)
  5. Acts based on exit code and JSON output

Your hook script must:

  1. Read and parse the JSON input from stdin
  2. Perform validation, logging, or side effects
  3. Output a JSON object to stdout (if success, exit code 0)
  4. 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, default true): If false, stop processing this hook event immediately. Used rarely (e.g., SessionEnd hooks that do cleanup).
  • decision (string): For action-gating hooks, set to block to prevent the action; allow to force-allow; defer to 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., Bash to 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 is command, string): Absolute path to executable. Relative paths are not supported; use $HOME or construct the full path.
  • args (optional, array): Extra command-line arguments passed after command.
  • timeout (optional, integer, default 30): Seconds to wait for hook to complete. If exceeded, hook is killed.
  • continueOnError (optional, boolean, default true): If false, a non-zero exit code stops processing. If true, 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:

  1. Create the hook script at .claude/hooks/validate-bash.sh
  2. Register it in .claude/settings.json
  3. 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 jq to 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 pipefail to 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:

  1. Use jq for JSON parsing — it's standard and safe. Avoid regex on JSON.
  2. Provide defaults — not all events include all fields. Use jq .field // "default".
  3. Handle errors gracefully — if jq fails to parse, exit 1 (non-blocking error).
  4. Log to stderr — don't write debug output to stdout; it will interfere with the JSON decision.
  5. 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.

Keep going

Read these next on ToolDix.

Original lessons that build on what you just read.