All articles
platform engineering · advanced ·

Hooks are Claude Code's extension architecture

The hooks system — nine lifecycle events, prompt-based and command-based hooks, a JSON I/O contract, and a self-referential loop pattern — turns Claude Code from a tool into a programmable platform.

automationclaude-codeextension-architecturehooksplugins

Most developer tools have extension points. VS Code has extensions. Git has hooks. Chrome has extensions. Claude Code’s answer is a system of lifecycle events — nine specific moments where your code can inspect, validate, modify, or block what the agent is about to do. Together they turn Claude Code from an application into a programmable platform.

The nine lifecycle events

Every meaningful moment in a Claude Code session has a hook event. Some fire before an action (validate, block, or modify), some after (react, log, or enforce):

Claude Code hook events
EventWhen it firesPrimary use
PreToolUseBefore any tool executesValidate, approve, deny, or modify tool calls
PostToolUseAfter a tool completesReact to results, provide feedback, log activity
StopWhen the main agent considers stoppingEnforce completion standards before exit
SubagentStopWhen a subagent considers stoppingEnsure subagents completed their task
SessionStartWhen a session beginsLoad project context, detect environment, set variables
SessionEndWhen a session endsCleanup, persist state, audit logging
UserPromptSubmitWhen the user submits a promptAdd context, validate input, block forbidden requests
PreCompactBefore context compactionPreserve critical information before context is truncated
NotificationWhen Claude sends a desktop notificationLog, route to external systems, trigger downstream workflows

The first four events (PreToolUse, PostToolUse, Stop, SubagentStop) operate on agent behavior directly — they can say yes or no. The remaining five operate on session lifecycle — they set up, tear down, and observe.

Two types of hooks: prompt vs. command

Hooks come in two forms, and the choice between them is a tradeoff between flexibility and determinism.

Prompt-based hooks

A prompt-based hook sends a natural language instruction to an LLM alongside the event context. The model decides whether to approve, deny, or warn:

{
  "type": "prompt",
  "prompt": "Command: $TOOL_INPUT. If the command contains 'rm', 'delete', 'drop', or other destructive operations, return 'ask' to confirm with the user. Otherwise return 'approve'.",
  "timeout": 30
}

The LLM reads the tool input, applies the criteria, and returns a structured decision. Prompt-based hooks handle ambiguity well — they recognize intent, not just pattern matches. A command that looks dangerous on regex (rm /tmp/cache) is clearly safe to a model that reads the path.

Supported events: PreToolUse, Stop, SubagentStop, UserPromptSubmit

Command hooks

A command hook executes a shell script with deterministic logic:

{
  "type": "command",
  "command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/validate.sh",
  "timeout": 10
}

The script receives event data as JSON on stdin and decides by exit code. Command hooks are fast, predictable, and auditable — they do exactly one thing and never hallucinate. Use them for checks where correctness matters more than flexibility: path traversal detection, file size limits, secret scanning.

Default timeouts: 60 seconds for command hooks, 30 seconds for prompt hooks. Set lower timeouts for hot-path checks (PreToolUse on every file write), higher for batch operations (Stop hook with transcript review).

The JSON I/O contract

Hooks communicate through a structured JSON interface. Input arrives on stdin. Output goes to stdout. Exit codes signal the decision.

Input format

Every hook receives a common envelope with event-specific fields appended:

{
  "session_id": "abc123",
  "transcript_path": "/path/to/transcript.txt",
  "cwd": "/current/working/dir",
  "permission_mode": "ask",
  "hook_event_name": "PreToolUse",
  "tool_name": "Write",
  "tool_input": {
    "file_path": "/src/auth.ts",
    "content": "..."
  }
}

Event-specific fields:

  • PreToolUse / PostToolUse: tool_name, tool_input (and tool_result for PostToolUse)
  • UserPromptSubmit: user_prompt
  • Stop / SubagentStop: reason

Inside prompt-based hooks, these are accessible as $TOOL_INPUT, $TOOL_RESULT, $USER_PROMPT. Command hooks parse them from stdin JSON with jq.

Output format

Hooks return a JSON decision:

{
  "continue": true,
  "suppressOutput": false,
  "systemMessage": "File write validated and approved"
}

For PreToolUse specifically, the output includes a permission decision:

{
  "hookSpecificOutput": {
    "permissionDecision": "allow",
    "updatedInput": {"file_path": "/corrected/path.ts"}
  },
  "systemMessage": "Path corrected to match project conventions"
}

The permissionDecision field takes three values: allow (proceed normally), deny (block silently with an error to Claude), or ask (surface to the user for manual approval). The updatedInput field lets hooks modify tool inputs before execution — correct a path, add a flag, strip dangerous arguments.

Exit codes

The exit code of a command hook is the primary signal:

  • 0 — Success. stdout is shown in the transcript.
  • 2 — Blocking error. stderr is fed back to Claude as feedback.
  • Any other — Non-blocking error. The hook failed but the operation continues.

Matchers: routing events to the right hook

Not every hook should fire on every event. The matcher field routes specific tools to specific hooks using regex patterns:

{
  "PreToolUse": [
    {
      "matcher": "Write|Edit",
      "hooks": [{
        "type": "prompt",
        "prompt": "Validate file write safety..."
      }]
    },
    {
      "matcher": "mcp__.*__delete.*",
      "hooks": [{
        "type": "prompt",
        "prompt": "Deletion via MCP detected — verify this is intentional..."
      }]
    },
    {
      "matcher": "Bash",
      "hooks": [{
        "type": "command",
        "command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/validate-bash.sh"
      }]
    }
  ]
}

Matchers support exact tool names ("Write"), alternation ("Read|Write|Edit"), wildcards ("*" for all tools), and full regex ("mcp__.*__delete.*" for all MCP delete operations).

Multiple hooks on the same matcher run in parallel. This is a design constraint, not a limitation — hooks cannot depend on each other’s output, which forces each hook to be self-contained. A PreToolUse with three hooks (check size, check path, check content) runs all three simultaneously and blocks only if any one returns deny.

The Ralph Wiggum pattern: self-referential agent loops

The most dramatic use of the Stop hook is the Ralph Wiggum pattern — named after the relentlessly persistent Simpsons character. A Stop hook that always blocks exit and feeds the same prompt back creates a self-referential loop where Claude iterates autonomously until work is complete:

#!/bin/bash
# ralph-loop.sh — The Stop hook that never stops
# Reads the session prompt file and blocks exit unless the completion promise appears

PROMPT_FILE="$CLAUDE_PROJECT_DIR/.ralph-prompt.txt"
COMPLETION_PROMISE="${RALPH_COMPLETION_PROMISE:-DONE}"

# Check if the completion promise is in the transcript
if grep -q "$COMPLETION_PROMISE" "$TRANSCRIPT_PATH" 2>/dev/null; then
  echo '{"decision": "approve"}'
  exit 0
fi

# Not done — block exit, feed the same prompt back
echo "{\"decision\": \"block\", \"reason\": \"Work not complete. Continue iterating.\"}" >&2
exit 2

The loop works like this:

  1. You write a prompt describing the task and a completion promise (“Output <promise>COMPLETE</promise> when done”)
  2. Claude works on the task
  3. Claude tries to exit
  4. The Stop hook inspects the transcript, finds no completion promise, and blocks exit (code 2)
  5. Claude receives the block as feedback and continues working
  6. Repeat until Claude outputs the completion promise or hits the iteration cap

The prompt never changes between iterations. Claude’s previous work persists in files on disk. Each iteration, Claude reads its own past work, sees what tests pass or fail, and improves. The loop is not a bash while true wrapper — it happens inside a single Claude Code session, with full continuity of file state and git history.

The key safeguards: always set --max-iterations as a ceiling, include a completion promise in the prompt so the loop has a defined exit condition, and design prompts for incremental progress (phases, checklists, TDD loops) rather than monolithic goals.

Hookify: rules as configuration, not code

Writing hooks typically means writing JSON configuration and shell scripts. The hookify plugin removes that friction: describe the behavior you want and it generates a markdown configuration file with YAML frontmatter. No code, no JSON, no shell scripts.

A rule is a single .local.md file:

---
name: block-dangerous-rm
enabled: true
event: bash
pattern: rm\s+-rf
action: block
---

🛑 **Dangerous rm command detected!**

This command could delete important files. Verify the path is correct
and consider using a safer approach.

action: block prevents the operation entirely (PreToolUse exits 2). action: warn (the default) shows the message but allows the operation to continue.

Advanced rules with conditions

Multi-condition rules check several fields simultaneously:

---
name: api-key-in-typescript
enabled: true
event: file
action: warn
conditions:
  - field: file_path
    operator: regex_match
    pattern: \.tsx?$
  - field: new_text
    operator: regex_match
    pattern: (API_KEY|SECRET|TOKEN)\s*=\s*["']
---

🔐 **Hardcoded credential in TypeScript!**

Use environment variables instead of hardcoded values.

All conditions must match for the rule to fire. This lets you write targeted rules: “warn about hardcoded credentials, but only in TypeScript files, and only when they appear in new content being written.”

The six operators

Hookify rule operators
OperatorDescriptionExample pattern
regex_matchFull regex matching`.env$
containsSubstring anywhereconsole.log(
equalsExact string matchproduction
not_containsAbsence check`npm test
starts_withPrefix matchDROP TABLE
ends_withSuffix match.env

The fields available vary by event: command for bash events, file_path and new_text for file events, user_prompt for prompt events. For Stop events, you typically check the transcript for evidence of completion (tests run, build succeeded).

Why the markdown format matters

The hookify format has zero dependencies — it parses YAML frontmatter with Python’s stdlib. Rules live in .claude/ as plain files you can grep, diff, and version. There is no registry, no build step, no server. Adding a rule is creating a file; removing a rule is deleting it.

The composition model: what you can build

Individually, each hook event solves a narrow problem. Composed, they form a programmable surface:

Layered validation. A SessionStart hook detects the project type and sets environment variables. PreToolUse hooks validate every file write (no path traversal, no secrets, correct project conventions). PostToolUse hooks run linters on changed files. A Stop hook blocks exit unless tests were run and the build succeeded.

Cross-event workflows. SessionStart initializes tracking counters. PostToolUse increments them when specific tools run. Stop reads the counters and blocks if required operations never happened.

External integration. A Notification hook routes every desktop notification to Slack. A SessionEnd hook writes an audit log entry. A PreToolUse hook on destructive MCP operations posts a confirmation request to a webhook.

Policy enforcement at scale. Enterprise-managed hooks deployed across an organization block specific behaviors regardless of user settings: no writes to /etc, no curl | bash, no hardcoded credentials, tests must pass before any session ends.

Hook events mapped to what they enable
EventArchitecture role
PreToolUseGuard — validate, block, or modify before action
PostToolUseReact — enforce quality, provide feedback after action
StopGate — prevent exit until standards are met
SessionStartBootstrap — configure environment before work begins
SessionEndCleanup — persist state, audit, notify on close
UserPromptSubmitIntercept — add context or block prompts before processing
PreCompactPreserve — save critical context before truncation
NotificationObserve — route events to external systems

A platform is defined by its extension points. Nine lifecycle events, two hook types, a structured I/O contract, and a composition model that lets you layer them is a credible claim to being one.

Takeaways

Nine events cover the full session lifecycle

PreToolUse, PostToolUse, Stop, and SubagentStop control agent behavior. SessionStart, SessionEnd, UserPromptSubmit, PreCompact, and Notification handle lifecycle.

Prompt hooks handle judgment; command hooks handle determinism

Use prompt hooks when the decision requires reading intent and context. Use command hooks when correctness matters more than flexibility.

The JSON contract is the extension protocol

Stdin JSON in, stdout JSON out, exit code 0 for allow and 2 for block. Every hook speaks the same protocol regardless of event type.

The Stop hook creates self-referential agent loops

Block exit, feed the same prompt back, let Claude iterate by reading its own file state. Works for well-defined tasks with automatic verification.

Hookify turns rules into configuration

Six operators, YAML frontmatter, markdown files with zero dependencies. Rules take effect immediately without restart — no code required.

Composition makes it a platform

Individual hooks solve narrow problems. Layered — SessionStart detection + PreToolUse validation + Stop gating — they form a programmable surface.