Hook Creator
This skill guides the creation of Claude Code hooks - deterministic shell commands or LLM prompts that execute at specific points in Claude's lifecycle.
What Are Hooks?
Observability
First: At the start of execution, display:
🔧 Skill "julien-dev-hook-creator" activated
Hooks provide deterministic control over Claude's behavior. Unlike skills (which Claude chooses to use), hooks always execute at their designated lifecycle event.
┌─────────────────────────────────────────────────────────────────┐
│ HOOKS vs SKILLS │
├─────────────────────────────────────────────────────────────────┤
│ HOOKS: Deterministic, always run at lifecycle events │
│ SKILLS: Model-invoked, Claude decides when to use │
└─────────────────────────────────────────────────────────────────┘
Available Hook Events
| Event | When It Runs | Common Use Cases |
|-------|--------------|------------------|
| SessionStart | Session begins/resumes | Load context, sync data, set env vars |
| SessionEnd | Session ends | Cleanup, save state, push changes |
| PreToolUse | Before tool execution | Validate, block, modify tool input |
| PostToolUse | After tool completes | Format output, log, trigger actions |
| PermissionRequest | Permission dialog shown | Auto-approve or deny permissions |
| UserPromptSubmit | User submits prompt | Add context, validate requests |
| Notification | Claude sends notification | Custom alerts |
| Stop | Claude finishes responding | Decide if Claude should continue |
| SubagentStop | Subagent completes | Evaluate task completion |
Hook Configuration
Hooks are configured in ~/.claude/settings.json (global) or .claude/settings.json (project).
Basic Structure
{
"hooks": {
"EventName": [
{
"matcher": "ToolPattern",
"hooks": [
{
"type": "command",
"command": "your-command-here",
"timeout": 60
}
]
}
]
}
}
Configuration Fields
| Field | Required | Description |
|-------|----------|-------------|
| matcher | For tool events | Pattern to match tool names (regex supported) |
| type | Yes | "command" (shell) or "prompt" (LLM) |
| command | For type:command | Shell command to execute |
| prompt | For type:prompt | LLM prompt for evaluation |
| timeout | No | Seconds before timeout (default: 60, max: 300) |
Matcher Patterns
"matcher": "Write" // Exact match
"matcher": "Edit|Write" // OR pattern (regex)
"matcher": "Notebook.*" // Wildcard pattern
"matcher": "*" // All tools (or omit matcher)
Hook Input (stdin)
Hooks receive JSON via stdin with context about the event:
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/current/working/directory",
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "/path/to/file.txt",
"content": "file content"
}
}
Hook Output (Exit Codes)
| Exit Code | Behavior |
|-----------|----------|
| 0 | Success - continue normally |
| 2 | Block - stderr fed to Claude, action blocked |
| Other | Non-blocking error (shown in verbose mode) |
Advanced JSON Output (exit 0)
{
"continue": true,
"stopReason": "message if continue=false",
"suppressOutput": true,
"systemMessage": "warning shown to user"
}
PreToolUse Decision Control
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow|deny|ask",
"permissionDecisionReason": "Reason here",
"updatedInput": {
"field": "modified value"
}
}
}
Creating a Hook - Step by Step
Step 1: Identify the Use Case
Ask:
- When should this run? (which event)
- What should it do? (validate, log, transform, block)
- Scope: Global (
~/.claude/settings.json) or project (.claude/settings.json)?
Step 2: Write the Script
Create script in ~/.claude/scripts/ or .claude/scripts/:
#!/bin/bash
# ~/.claude/scripts/my-hook.sh
# Read input from stdin
INPUT=$(cat)
# Parse with jq
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Your logic here
if [[ "$FILE_PATH" == *".env"* ]]; then
echo "Blocked: Cannot modify .env files" >&2
exit 2 # Block the action
fi
exit 0 # Allow the action
Important: Make executable with chmod +x
Step 3: Configure the Hook
Add to settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/scripts/my-hook.sh",
"timeout": 10
}
]
}
]
}
}
Step 4: Test
# Test script directly
echo '{"tool_name":"Write","tool_input":{"file_path":"/test/.env"}}' | bash ~/.claude/scripts/my-hook.sh
echo "Exit code: $?"
Real-World Example: Terminal Title Restoration
Problem: happy.cmd and claude.cmd contain title %COMSPEC% which overwrites terminal title to "C:\WINDOWS\system32\cmd.exe"
Solution: SessionStart hook that restores the title after launch
Script: ~/.claude/scripts/restore-terminal-title-on-start.ps1
# Restore terminal title on Claude Code SessionStart
# This runs AFTER Claude has potentially overwritten the title
try {
# Get current directory name
$dirName = if ($PWD.Path -eq $HOME) {
"~"
} else {
Split-Path $PWD -Leaf
}
# Restore title using multiple methods for maximum compatibility
# Method 1: PowerShell native
$Host.UI.RawUI.WindowTitle = $dirName
# Method 2: ANSI escape sequence (more reliable with Windows Terminal)
Write-Host "$([char]27)]0;$dirName$([char]7)" -NoNewline
# Exit with success
exit 0
} catch {
# Silent fail - don't break Claude startup
exit 0
}
Configuration: ~/.claude/settings.json (NOT repo .claude/settings.json)
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"%USERPROFILE%\\.claude\\scripts\\session-start-banner.js\"",
"timeout": 5
},
{
"type": "command",
"command": "powershell.exe -NoProfile -File \"%USERPROFILE%\\.claude\\scripts\\restore-terminal-title-on-start.ps1\"",
"timeout": 2
}
]
}
]
}
}
Timeline:
happy.cmdexecutes → title becomes "C:\WINDOWS\system32\cmd.exe"- Happy/Claude starts
- SessionStart hook:
session-start-banner.jsdisplays banner - SessionStart hook:
restore-terminal-title-on-start.ps1fixes the title
Result: Title restored to directory name despite npm CLI wrapper interference
Lesson: Hooks can fix issues caused by external tools (npm wrappers, shell scripts)!
Hook Languages: JavaScript vs Python vs PowerShell
JavaScript Hooks (Fastest Startup)
Pros:
- Node.js already loaded by Claude Code
- No interpreter startup cost
- Faster execution (~50-200ms faster than Python)
- Great async support
Cons:
- Limited system integration compared to PowerShell
- JSON parsing requires external library or built-in JSON
Examples:
session-start-banner.js- Fast banner displaytrack-skill-invocation.js- Performance-critical trackingfast-skill-router.js- Routing must be instant
When to use: Performance-critical hooks (SessionStart, UserPromptSubmit)
Python Hooks (Rich Ecosystem)
Pros:
- Rich libraries (json, pathlib, subprocess)
- Better for complex data processing
- Easier multiline string handling
- Great for ML/data tasks
Cons:
- Python interpreter startup cost (~100-300ms)
- May not be installed on all systems
Examples:
session-end-delete-reserved.py- Complex file operationssave-session-for-memory.py- Data processingcleanup-null-files.py- File system traversal
When to use: Complex logic, data processing, non-time-critical tasks
PowerShell Hooks (Windows Native)
Pros:
- Native Windows API access
- Can modify environment directly
- Better integration with Windows Terminal
- Access to .NET framework
Cons:
- Windows-only
- Slower than JavaScript (~50-150ms startup)
- CRLF line ending issues
Examples:
restore-terminal-title-on-start.ps1- Terminal manipulationcleanup-null-files.ps1- Windows file operationsset-terminal-title.ps1- Environment modification
When to use: Windows-specific tasks, terminal manipulation, .NET integration
Choosing the Right Language
Need speed? → JavaScript
Need Python libraries? → Python
Need Windows integration? → PowerShell
Need to modify terminal? → PowerShell
Need to call .NET APIs? → PowerShell
Need async operations? → JavaScript
Common Hook Patterns
1. File Protection (PreToolUse)
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
PROTECTED=(".env" "package-lock.json" ".git/" "credentials")
for pattern in "${PROTECTED[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Protected file: $pattern" >&2
exit 2
fi
done
exit 0
2. Auto-Format on Save (PostToolUse)
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "FILE=$(cat | jq -r '.tool_input.file_path') && npx prettier --write \"$FILE\" 2>/dev/null || true"
}
]
}
]
}
}
3. Command Logging (PostToolUse)
#!/bin/bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
DESC=$(echo "$INPUT" | jq -r '.tool_input.description // "No description"')
echo "$(date +%Y-%m-%d_%H:%M:%S) | $CMD | $DESC" >> ~/.claude/logs/bash-commands.log
exit 0
4. Session Sync (SessionStart/SessionEnd)
{
"hooks": {
"SessionStart": [
{
"hooks": [{
"type": "command",
"command": "bash ~/.claude/scripts/sync-marketplace.sh",
"timeout": 30
}]
}
],
"SessionEnd": [
{
"hooks": [{
"type": "command",
"command": "bash ~/.claude/scripts/push-marketplace.sh",
"timeout": 30
}]
}
]
}
}
5. Add Context to Prompts (UserPromptSubmit)
#!/bin/bash
# stdout is added as context to the prompt
echo "Current git branch: $(git branch --show-current 2>/dev/null || echo 'not a git repo')"
echo "Node version: $(node -v 2>/dev/null || echo 'not installed')"
exit 0
6. LLM-based Stop Decision (Stop)
{
"hooks": {
"Stop": [
{
"hooks": [{
"type": "prompt",
"prompt": "Review if all tasks are complete. Check: 1) All todos marked done 2) Tests passing 3) No pending questions. Respond with decision: approve (stop) or block (continue).",
"timeout": 30
}]
}
]
}
}
Best Practices
Do's
- ✅ Always quote shell variables:
"$VAR"not$VAR - ✅ Use absolute paths for scripts
- ✅ Handle errors gracefully (exit 0 if non-critical)
- ✅ Set appropriate timeouts
- ✅ Test scripts independently before configuring
- ✅ Use
tr -d '\r'for Windows CRLF compatibility
Don'ts
- ❌ Don't block critical operations without good reason
- ❌ Don't use long timeouts (blocks Claude)
- ❌ Don't trust input blindly - validate paths
- ❌ Don't expose secrets in logs
- ❌ Don't use interactive commands (no stdin available)
Debugging Hooks
# Run with debug output
bash -x ~/.claude/scripts/my-hook.sh
# Test with sample input
echo '{"tool_name":"Write","tool_input":{"file_path":"/test/file.txt"}}' | bash ~/.claude/scripts/my-hook.sh
# Check hook errors in Claude Code
# Look for "hook error" messages in the UI
For detailed troubleshooting of common errors (timeout, CRLF, jq not found, etc.), see references/troubleshooting.md.
Environment Variables
Available in hooks:
CLAUDE_PROJECT_DIR- Current project directoryCLAUDE_CODE_REMOTE- Remote mode indicatorCLAUDE_ENV_FILE- (SessionStart only) File path for persisting env vars
File Locations - CRITICAL INFORMATION
| Location | Scope | Usage |
|----------|-------|-------|
| ~/.claude/settings.json | Global (REAL FILE) | File USED by Claude Code |
| .claude/settings.json | Project (versioning) | Committed to repo, NOT used directly |
| .claude/settings.local.json | Local overrides | Not committed |
| ~/.claude/scripts/ | Global scripts | Used by hooks |
| .claude/scripts/ | Project scripts | Versioned with repo |
⚠️ CRITICAL WARNING
Claude Code uses ~/.claude/settings.json (home directory)
NOT the repo .claude/settings.json
These files are DIFFERENT and must be synchronized manually!
Best Practice:
- Modify
~/.claude/settings.jsonfirst (real file) - Copy changes to
.claude/settings.json(for versioning) - Commit repo version for documentation
Never assume the repo version is active!
Verification:
# Check what Claude Code actually uses
cat ~/.claude/settings.json | grep -A 5 "SessionStart"
# Compare with repo version
diff ~/.claude/settings.json .claude/settings.json
Quick Reference
Event Flow:
SessionStart → UserPromptSubmit → PreToolUse → [Tool] → PostToolUse → Stop → SessionEnd
Exit Codes:
0 = Success (continue)
2 = Block (stop action, feed stderr to Claude)
* = Non-blocking error
Matcher:
"Write" = exact match
"Edit|Write" = OR
"Notebook.*" = regex
"*" or omit = all tools
🔗 Skill Chaining
Skills Required Before
- Aucun (skill autonome)
- Optionnel: Connaissance de base de bash/shell scripting
Input Expected
- Use case description: Quel événement déclencher, quelle action effectuer
- Scope decision: Global (
~/.claude/settings.json) ou project (.claude/settings.json) - Prerequisites:
jqinstallé pour parsing JSON
Output Produced
- Format:
- Script bash dans
~/.claude/scripts/ou.claude/scripts/ - Configuration JSON dans
settings.json
- Script bash dans
- Side effects:
- Création/modification de fichiers scripts
- Modification de settings.json
- Hooks actifs au prochain événement
- Duration: 2-5 minutes pour un hook simple
Compatible Skills After
Recommandés:
- sync-personal-skills: Si le hook modifie des fichiers du marketplace
- skill-creator: Si création d'un skill qui intègre des hooks
Optionnels:
- Git workflow: Committer les scripts et settings
Called By
- Direct user invocation: "Crée un hook pour...", "Je veux automatiser..."
- Part of skill/workflow development
Tools Used
Read(lecture settings.json existant)Write(création scripts bash)Edit(modification settings.json)Bash(test du hook, chmod +x)
Visual Workflow
User: "Je veux protéger les fichiers .env"
↓
hook-creator (this skill)
├─► Step 1: Identify event (PreToolUse)
├─► Step 2: Write script (protect-files.sh)
├─► Step 3: chmod +x script
├─► Step 4: Configure settings.json
└─► Step 5: Test with sample input
↓
Hook active ✅
↓
[Next: Test in real session]
Usage Example
Scenario: Créer un hook de logging des commandes bash
Input: "Log toutes les commandes bash exécutées"
Process:
- Event identifié:
PostToolUseavec matcherBash - Script créé:
~/.claude/scripts/log-bash.sh - Settings.json mis à jour avec hook config
- Test avec sample JSON input
Result:
- Script logging actif
- Commandes loguées dans
~/.claude/logs/bash-commands.log
微信扫一扫