hook-developer

Complete Claude Code hooks reference - input/output schemas, registration, testing patterns

INSTALLATION
npx skills add https://github.com/parcadei/continuous-claude-v3 --skill hook-developer
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

Hook Developer

Complete reference for developing Claude Code hooks. Use this to write hooks with correct input/output schemas.

When to Use

  • Creating a new hook
  • Debugging hook input/output format
  • Understanding what fields are available
  • Setting up hook registration in settings.json
  • Learning what hooks can block vs inject context

Quick Reference

Hook

Fires When

Can Block?

Primary Use

PreToolUse

Before tool executes

YES

Block/modify tool calls

PostToolUse

After tool completes

Partial

React to tool results

UserPromptSubmit

User sends prompt

YES

Validate/inject context

PermissionRequest

Permission dialog shows

YES

Auto-approve/deny

SessionStart

Session begins

NO

Load context, set env vars

SessionEnd

Session ends

NO

Cleanup/save state

Stop

Agent finishes

YES

Force continuation

SubagentStart

Subagent spawns

NO

Pattern coordination

SubagentStop

Subagent finishes

YES

Force continuation

PreCompact

Before compaction

NO

Save state

Notification

Notification sent

NO

Custom alerts

Hook type options: type: "command" (bash) or type: "prompt" (LLM evaluation)

Hook Input/Output Schemas

PreToolUse

Purpose: Block or modify tool execution before it happens.

Input:

{

  "session_id": "string",

  "transcript_path": "string",

  "cwd": "string",

  "permission_mode": "default|plan|acceptEdits|bypassPermissions",

  "hook_event_name": "PreToolUse",

  "tool_name": "string",

  "tool_input": {

    "file_path": "string",

    "command": "string"

  },

  "tool_use_id": "string"

}

Output (JSON):

{

  "hookSpecificOutput": {

    "hookEventName": "PreToolUse",

    "permissionDecision": "allow|deny|ask",

    "permissionDecisionReason": "string",

    "updatedInput": {}

  },

  "continue": true,

  "stopReason": "string",

  "systemMessage": "string",

  "suppressOutput": true

}

Exit code 2: Blocks tool, stderr shown to Claude.

Common matchers: Bash, Edit|Write, Read, Task, mcp__.*

PostToolUse

Purpose: React to tool execution results, provide feedback to Claude.

Input:

{

  "session_id": "string",

  "transcript_path": "string",

  "cwd": "string",

  "permission_mode": "string",

  "hook_event_name": "PostToolUse",

  "tool_name": "string",

  "tool_input": {},

  "tool_response": {

    "filePath": "string",

    "success": true,

    "output": "string",

    "exitCode": 0

  },

  "tool_use_id": "string"

}

CRITICAL: The response field is tool_response, NOT tool_result.

Output (JSON):

{

  "decision": "block",

  "reason": "string",

  "hookSpecificOutput": {

    "hookEventName": "PostToolUse",

    "additionalContext": "string"

  },

  "continue": true,

  "stopReason": "string",

  "suppressOutput": true

}

Blocking: "decision": "block" with "reason" prompts Claude to address the issue.

Common matchers: Edit|Write, Bash

UserPromptSubmit

Purpose: Validate user prompts, inject context before Claude processes.

Input:

{

  "session_id": "string",

  "transcript_path": "string",

  "cwd": "string",

  "permission_mode": "string",

  "hook_event_name": "UserPromptSubmit",

  "prompt": "string"

}

Output (Plain text):

Any stdout text is added to context for Claude.

Output (JSON):

{

  "decision": "block",

  "reason": "string",

  "hookSpecificOutput": {

    "hookEventName": "UserPromptSubmit",

    "additionalContext": "string"

  }

}

Blocking: "decision": "block" erases prompt, shows "reason" to user only (not Claude).

Exit code 2: Blocks prompt, shows stderr to user only.

PermissionRequest

Purpose: Automate permission dialog decisions.

Input:

{

  "session_id": "string",

  "transcript_path": "string",

  "cwd": "string",

  "permission_mode": "string",

  "hook_event_name": "PermissionRequest",

  "tool_name": "string",

  "tool_input": {}

}

Output:

{

  "hookSpecificOutput": {

    "hookEventName": "PermissionRequest",

    "decision": {

      "behavior": "allow|deny",

      "updatedInput": {},

      "message": "string",

      "interrupt": false

    }

  }

}

SessionStart

Purpose: Initialize session, load context, set environment variables.

Input:

{

  "session_id": "string",

  "transcript_path": "string",

  "cwd": "string",

  "permission_mode": "string",

  "hook_event_name": "SessionStart",

  "source": "startup|resume|clear|compact"

}

Environment variable: CLAUDE_ENV_FILE - write export VAR=value to persist env vars.

Output (Plain text or JSON):

{

  "hookSpecificOutput": {

    "hookEventName": "SessionStart",

    "additionalContext": "string"

  },

  "suppressOutput": true

}

Plain text stdout is added as context.

SessionEnd

Purpose: Cleanup, save state, log session.

Input:

{

  "session_id": "string",

  "transcript_path": "string",

  "cwd": "string",

  "permission_mode": "string",

  "hook_event_name": "SessionEnd",

  "reason": "clear|logout|prompt_input_exit|other"

}

Output: Cannot affect session (already ending). Use for cleanup only.

Stop

Purpose: Control when Claude stops, force continuation.

Input:

{

  "session_id": "string",

  "transcript_path": "string",

  "cwd": "string",

  "permission_mode": "string",

  "hook_event_name": "Stop",

  "stop_hook_active": false

}

CRITICAL: Check stop_hook_active: true to prevent infinite loops!

Output:

{

  "decision": "block",

  "reason": "string"

}

Blocking: "decision": "block" forces Claude to continue with "reason" as prompt.

SubagentStart

Purpose: Run when a subagent (Task tool) is spawned.

Input:

{

  "session_id": "string",

  "transcript_path": "string",

  "cwd": "string",

  "permission_mode": "string",

  "hook_event_name": "SubagentStart",

  "agent_id": "string"

}

Output: Context injection only (cannot block).

SubagentStop

Purpose: Control when subagents (Task tool) stop.

Input:

{

  "session_id": "string",

  "transcript_path": "string",

  "cwd": "string",

  "permission_mode": "string",

  "hook_event_name": "SubagentStop",

  "stop_hook_active": false

}

Output: Same as Stop.

PreCompact

Purpose: Save state before context compaction.

Input:

{

  "session_id": "string",

  "transcript_path": "string",

  "cwd": "string",

  "permission_mode": "string",

  "hook_event_name": "PreCompact",

  "trigger": "manual|auto",

  "custom_instructions": "string"

}

Matchers: manual, auto

Output:

{

  "continue": true,

  "systemMessage": "string"

}

Notification

Purpose: Custom notification handling.

Input:

{

  "session_id": "string",

  "transcript_path": "string",

  "cwd": "string",

  "permission_mode": "string",

  "hook_event_name": "Notification",

  "message": "string",

  "notification_type": "permission_prompt|idle_prompt|auth_success|elicitation_dialog"

}

Matchers: permission_prompt, idle_prompt, auth_success, elicitation_dialog, *

Output:

{

  "continue": true,

  "suppressOutput": true,

  "systemMessage": "string"

}

Registration in settings.json

Standard Structure

{

  "hooks": {

    "EventName": [

      {

        "matcher": "ToolPattern",

        "hooks": [

          {

            "type": "command",

            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/my-hook.sh",

            "timeout": 60

          }

        ]

      }

    ]

  }

}

Matcher Patterns

Pattern

Matches

Bash

Exactly Bash tool

Edit|Write

Edit OR Write

Read.*

Regex: Read*

mcp__.*__write.*

MCP write tools

*

All tools

Case-sensitive: Bashbash

Events Requiring Matchers

  • PreToolUse - YES (required)
  • PostToolUse - YES (required)
  • PermissionRequest - YES (required)
  • Notification - YES (optional)
  • SessionStart - YES (startup|resume|clear|compact)
  • PreCompact - YES (manual|auto)

Events Without Matchers

{

  "hooks": {

    "UserPromptSubmit": [

      {

        "hooks": [{ "type": "command", "command": "/path/to/hook.sh" }]

      }

    ]

  }

}

Hook Types

Command Hooks (type: "command")

Default type. Executes bash commands or scripts.

{

  "type": "command",

  "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/my-hook.sh",

  "timeout": 60

}

Prompt-Based Hooks (type: "prompt")

Uses LLM (Haiku) for context-aware decisions. Best for Stop/SubagentStop.

{

  "type": "prompt",

  "prompt": "Evaluate if Claude should stop. Context: $ARGUMENTS. Check if all tasks are complete.",

  "timeout": 30

}

Response schema:

{

  "decision": "approve" | "block",

  "reason": "Explanation",

  "continue": false,

  "stopReason": "Message to user",

  "systemMessage": "Warning"

}

MCP Tool Naming

MCP tools use pattern mcp__<server>__<tool>:

Pattern

Matches

mcp__memory__.*

All memory server tools

mcp__.*__write.*

All MCP write tools

mcp__github__.*

All GitHub tools

Environment Variables

Available to All Hooks

Variable

Description

CLAUDE_PROJECT_DIR

Absolute path to project root

CLAUDE_CODE_REMOTE

"true" if remote/web, empty if local CLI

SessionStart Only

Variable

Description

CLAUDE_ENV_FILE

Path to write export VAR=value lines

Plugin Hooks Only

Variable

Description

CLAUDE_PLUGIN_ROOT

Absolute path to plugin directory

Exit Codes

Exit Code

Behavior

stdout

stderr

0

Success

JSON processed

Ignored

2

Blocking error

IGNORED

Error message

Other

Non-blocking error

Ignored

Verbose mode

Exit Code 2 by Hook

Hook

Effect

PreToolUse

Blocks tool, stderr to Claude

PostToolUse

stderr to Claude (tool already ran)

UserPromptSubmit

Blocks prompt, stderr to user only

Stop

Blocks stop, stderr to Claude

Shell Wrapper Pattern

#!/bin/bash

set -e

cd "$CLAUDE_PROJECT_DIR/.claude/hooks"

cat | npx tsx src/my-hook.ts

Or for bundled:

#!/bin/bash

set -e

cd "$HOME/.claude/hooks"

cat | node dist/my-hook.mjs

TypeScript Handler Pattern

import { readFileSync } from 'fs';

interface HookInput {

  session_id: string;

  hook_event_name: string;

  tool_name?: string;

  tool_input?: Record<string, unknown>;

  tool_response?: Record<string, unknown>;

  // ... other fields per hook type

}

function readStdin(): string {

  return readFileSync(0, 'utf-8');

}

async function main() {

  const input: HookInput = JSON.parse(readStdin());

  // Process input

  const output = {

    decision: 'block',  // or undefined to allow

    reason: 'Why blocking'

  };

  console.log(JSON.stringify(output));

}

main().catch(console.error);

Testing Hooks

Manual Test Commands

# PostToolUse (Write)

echo '{"tool_name":"Write","tool_input":{"file_path":"test.md"},"tool_response":{"success":true},"session_id":"test"}' | \

  .claude/hooks/my-hook.sh

# PreToolUse (Bash)

echo '{"tool_name":"Bash","tool_input":{"command":"ls"},"session_id":"test"}' | \

  .claude/hooks/my-hook.sh

# SessionStart

echo '{"hook_event_name":"SessionStart","source":"startup","session_id":"test"}' | \

  .claude/hooks/session-start.sh

# SessionEnd

echo '{"hook_event_name":"SessionEnd","reason":"clear","session_id":"test"}' | \

  .claude/hooks/session-end.sh

# UserPromptSubmit

echo '{"prompt":"test prompt","session_id":"test"}' | \

  .claude/hooks/prompt-submit.sh

Rebuild After TypeScript Edits

cd .claude/hooks

npx esbuild src/my-hook.ts \

  --bundle --platform=node --format=esm \

  --outfile=dist/my-hook.mjs

Common Patterns

Block Dangerous Files (PreToolUse)

#!/usr/bin/env python3

import json, sys

data = json.load(sys.stdin)

path = data.get('tool_input', {}).get('file_path', '')

BLOCKED = ['.env', 'secrets.json', '.git/']

if any(b in path for b in BLOCKED):

    print(json.dumps({

        "hookSpecificOutput": {

            "hookEventName": "PreToolUse",

            "permissionDecision": "deny",

            "permissionDecisionReason": f"Blocked: {path} is protected"

        }

    }))

else:

    print('{}')

Auto-Format Files (PostToolUse)

#!/bin/bash

INPUT=$(cat)

FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

if [[ "$FILE" == *.ts ]] || [[ "$FILE" == *.tsx ]]; then

  npx prettier --write "$FILE" 2>/dev/null

fi

echo '{}'

Inject Git Context (UserPromptSubmit)

#!/bin/bash

echo "Git status:"

git status --short 2>/dev/null || echo "(not a git repo)"

echo ""

echo "Recent commits:"

git log --oneline -5 2>/dev/null || echo "(no commits)"

Force Test Verification (Stop)

#!/usr/bin/env python3

import json, sys, subprocess

data = json.load(sys.stdin)

# Prevent infinite loops

if data.get('stop_hook_active'):

    print('{}')

    sys.exit(0)

# Check if tests pass

result = subprocess.run(['npm', 'test'], capture_output=True)

if result.returncode != 0:

    print(json.dumps({

        "decision": "block",

        "reason": "Tests are failing. Please fix before stopping."

    }))

else:

    print('{}')

Debugging Checklist

  • Hook registered in settings.json?
  • Shell script has +x permission?
  • Bundle rebuilt after TS changes?
  • Using tool_response not tool_result?
  • Output is valid JSON (or plain text)?
  • Checking stop_hook_active in Stop hooks?
  • Using $CLAUDE_PROJECT_DIR for paths?

Key Learnings from Past Sessions

  • Field names matter - tool_response not tool_result
  • Output format - decision: "block" + reason for blocking
  • Exit code 2 - stderr goes to Claude/user, stdout IGNORED
  • Rebuild bundles - TypeScript source edits don't auto-apply
  • Test manually - echo '{}' | ./hook.sh before relying on it
  • Check outputs first - ls .claude/cache/ before editing code
  • Detached spawn hides errors - add logging to debug

See Also

  • /debug-hooks - Systematic debugging workflow
  • .claude/rules/hooks.md - Hook development rules
BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card