main

Hook System

Event-Driven Automation Infrastructure

Location: ~/.config/claude/hooks/ (source: dots/config/claude/hooks/) Configuration: ~/.config/claude/settings.json Status: Active - TypeScript/Bun implementation


Overview

The hook system is an event-driven automation infrastructure built on Claude Code’s native hook support. Hooks are TypeScript scripts (run via bun) that execute automatically in response to specific events during Claude Code sessions.

Core Capabilities:

  • Unified AI Storage - All sessions, logs write to ~/.local/share/ai/ (shared with pi, opencode)
  • Session Management - Set terminal titles, log session starts
  • Tool Output Capture - Automatic logging of tool executions to JSONL files
  • Session Prompts - Prompt to save session summaries on exit
  • Git Safety - Block dangerous git operations (push without refspec, add -A)
  • Subagent Detection - Skip hooks for subagent sessions

Key Principle: Hooks run asynchronously and fail gracefully. They enhance the user experience but never block Claude Code’s core functionality.


Architecture

dots/config/claude/hooks/          # Source (in homelab repo)
  → ~/.config/claude/hooks/        # Symlinked at runtime
├── lib.ts                         # Shared utilities (paths, dates, slugs)
├── initialize-session.ts          # SessionStart hook
├── save-session.ts                # SessionEnd hook
├── capture-tool-output.ts         # PostToolUse hook
├── update-terminal-title.ts       # PostToolUse hook
├── validate-git-push.ts           # PreToolUse (Bash) hook
└── package.json                   # Dependencies

Implementation: TypeScript scripts executed via bun run. Shared utilities in lib.ts. Storage: Sessions and logs go to ~/.local/share/ai/ (unified with pi ai-storage extension).


Available Hook Types (Configured)

You currently have 3 hooks configured. Claude Code supports 8 hook event types total, but only these 3 are in use.

1. SessionStart

When: Claude Code session begins (new conversation) Command: bun run ~/.config/claude/hooks/initialize-session.ts

Current Configuration:

{
  "SessionStart": [
    {
      "hooks": [
        {
          "type": "command",
          "command": "bun run ~/.config/claude/hooks/initialize-session.ts"
        }
      ]
    }
  ]
}

What It Does:

  • Detects subagent sessions: Skips if CLAUDE_AGENT_TYPE is set or path contains /.claude/agents/
  • Sets terminal tab title: Sets tab title to “Claude Ready” using ANSI escape codes
  • Logs session start: Appends to ~/.local/share/ai/sessions/YYYY-MM/YYYY-MM-DD_session-log.txt
  • Implements debouncing: 2-second window to prevent duplicate triggers

Implementation Details:

// From initialize-session/main.go:88-113
func main() {
    // Check if this is a subagent session
    if isSubagentSession() {
        fmt.Fprintln(os.Stderr, "🤖 Subagent session detected - skipping session initialization")
        os.Exit(0)
    }

    // Check debounce to prevent duplicate notifications
    if shouldDebounce() {
        fmt.Fprintln(os.Stderr, "🔇 Debouncing duplicate SessionStart event")
        os.Exit(0)
    }

    // Set initial tab title
    tabTitle := "Claude Ready"
    setTerminalTitle(tabTitle)
    fmt.Fprintf(os.Stderr, "📍 Session initialized: \"%s\"\n", tabTitle)

    // Log session start to history (silent failure)
    if err := logSessionStart(); err != nil {
        // Don't break session start for logging issues
        fmt.Fprintf(os.Stderr, "[initialize-session] Warning: Could not log session start: %v\n", err)
    }

    os.Exit(0)
}

Subagent Detection: Checks environment variables to determine if this is a subagent session:

  • CLAUDE_PROJECT_DIR contains /.claude/agents/
  • CLAUDE_AGENT_TYPE is set

Debouncing: Uses a lockfile in /tmp/claude-session-start.lock with timestamps to prevent duplicate notifications within 2 seconds.


2. PostToolUse

When: After Claude executes any tool Command: bun run ~/.config/claude/hooks/capture-tool-output.ts

Current Configuration:

{
  "PostToolUse": [
    {
      "hooks": [
        {
          "type": "command",
          "command": "bun run ~/.config/claude/hooks/capture-tool-output.ts"
        }
      ]
    }
  ]
}

What It Does:

  • Captures tool outputs: Logs selected tool executions to JSONL files
  • Filters by tool type: Only captures these tools:
    • Bash
    • Edit
    • Write
    • Read
    • Task
    • NotebookEdit
    • Skill
    • SlashCommand
  • Logs to daily files: ~/.config/claude/history/tool-outputs/YYYY-MM/YYYY-MM-DD_tool-outputs.jsonl
  • Silent failure: Doesn’t disrupt workflow if logging fails
  • Auto-creates directories: Creates year-month subdirectories as needed

JSONL Format:

{
  "timestamp": "2024-01-15T10:30:00Z",
  "tool": "Bash",
  "input": {"command": "ls -la"},
  "output": {"stdout": "..."},
  "session": "conversation-id-abc123"
}

Implementation Details:

// From capture-tool-output/main.go:32-42
var interestingTools = map[string]bool{
    "Bash":         true,
    "Edit":         true,
    "Write":        true,
    "Read":         true,
    "Task":         true,
    "NotebookEdit": true,
    "Skill":        true,
    "SlashCommand": true,
}

// Only capture interesting tools
if !interestingTools[data.ToolName] {
    os.Exit(0)
}

Hook Input (stdin): Claude Code sends JSON data on stdin:

{
  "tool_name": "Bash",
  "tool_input": {"command": "ls -la"},
  "tool_response": {"stdout": "..."},
  "conversation_id": "session-id",
  "timestamp": "2024-01-15T10:30:00Z"
}

3. SessionEnd

When: Claude Code session terminates (conversation ends) Command: bun run ~/.config/claude/hooks/save-session.ts

Current Configuration:

{
  "SessionEnd": [
    {
      "hooks": [
        {
          "type": "command",
          "command": "bun run ~/.config/claude/hooks/save-session.ts"
        }
      ]
    }
  ]
}

What It Does:

  • Prompts user to save session: Displays message asking if session should be saved
  • Skips subagent sessions: Silent exit for subagent sessions
  • Works with /save-session command: Integrates with session-manager plugin
  • Documents session: Creates summary in ~/.local/share/ai/sessions/YYYY-MM/YYYY-MM-DD-HHMMSS_SESSION_description.md

Prompt Output:

---

**Session ending**. Would you like me to save a summary of this session to your history?

I can create a session entry in `~/.local/share/ai/sessions/` documenting:
- What was accomplished
- Decisions made
- Next steps
- Related notes

Implementation Details:

// From save-session/main.go:20-42
func main() {
    // Check if this is a subagent session
    if isSubagentSession() {
        // Silent exit for subagent sessions
        os.Exit(0)
    }

    // Output prompt for Claude to save the session
    fmt.Println("")
    fmt.Println("---")
    fmt.Println("")
    fmt.Println("**Session ending**. Would you like me to save a summary of this session to your history?")
    // ... rest of prompt
}

Other Hook Events (Not Configured)

Claude Code supports these additional hook events that are not currently configured in your setup:

UserPromptSubmit

When: User submits a new prompt to Claude Use Cases: Update UI indicators, pre-process user input, capture prompts

Stop

When: Main agent completes a response Use Cases: Voice notifications, capture work summaries, update terminal tabs

SubagentStop

When: Subagent (Task tool) completes execution Use Cases: Agent-specific notifications, capture agent outputs

PreToolUse

When: Before Claude executes any tool Use Cases: Tool usage analytics, pre-execution validation

PreCompact

When: Before Claude compacts context (long conversations) Use Cases: Preserve important context, log compaction events

To configure these events, add them to ~/.config/claude/settings.json in the "hooks" section with a corresponding TypeScript hook in ~/.config/claude/hooks/.


Configuration

Location

File: /home/vincent/.config/claude/settings.json Section: "hooks": { ... }

Environment Variables

Hooks have access to environment variables:

Claude Code Variables:

  • CLAUDE_PROJECT_DIR - Project directory (used for subagent detection)
  • CLAUDE_AGENT_TYPE - Agent type for subagents (used for detection)

Custom Variables (optional):

  • CLAUDE_DIR - Override default ~/.config/claude directory

Hook Configuration Structure

{
  "hooks": {
    "HookEventName": [
      {
        "matcher": "pattern",  // Optional: filter which tools/events trigger hook
        "hooks": [
          {
            "type": "command",
            "command": "command-to-execute"
          }
        ]
      }
    ]
  }
}

Fields:

  • HookEventName - One of: SessionStart, SessionEnd, UserPromptSubmit, Stop, SubagentStop, PreToolUse, PostToolUse, PreCompact
  • matcher - Pattern to match (use "*" for all tools, or specific tool names) - only relevant for tool-based events
  • type - Always "command" (executes external command)
  • command - Name of executable command (must be in PATH)

Hook Input (stdin)

All hooks receive JSON data on stdin. The format varies by event type.

Example for PostToolUse:

{
  "tool_name": "Bash",
  "tool_input": {"command": "ls -la"},
  "tool_response": {"stdout": "..."},
  "conversation_id": "session-id",
  "timestamp": "2024-01-15T10:30:00Z"
}

Common Patterns

1. Silent Failure

Pattern: Wrap everything in error handling → Log errors → Always exit successfully

func main() {
    // Read stdin
    input, err := io.ReadAll(os.Stdin)
    if err != nil {
        fmt.Fprintf(os.Stderr, "[hook-name] Error reading stdin: %v\n", err)
        os.Exit(0) // Exit 0 - don't break Claude Code
    }

    // Hook logic here
    if err := doWork(); err != nil {
        fmt.Fprintf(os.Stderr, "[hook-name] Error: %v\n", err)
        os.Exit(0) // Always exit 0
    }

    os.Exit(0)
}

Why: If hooks crash (exit 1), Claude Code may freeze. Always exit cleanly with code 0.


2. Subagent Detection

Pattern: Check environment variables → Skip hook if subagent

func isSubagentSession() bool {
    claudeProjectDir := os.Getenv("CLAUDE_PROJECT_DIR")
    if strings.Contains(claudeProjectDir, "/.claude/agents/") {
        return true
    }
    if os.Getenv("CLAUDE_AGENT_TYPE") != "" {
        return true
    }
    return false
}

func main() {
    if isSubagentSession() {
        fmt.Fprintln(os.Stderr, "🤖 Subagent session - skipping")
        os.Exit(0)
    }
    // Main hook logic
}

Why: Subagent sessions (Task tool) often don’t need the same initialization or prompts as main sessions.


3. History Capture

Pattern: Parse data → Save to appropriate history directory

File Naming Convention:

YYYY-MM-DD-HHMMSS_TYPE_description.md

Directory Structure:

~/.local/share/ai/
├── sessions/
│   └── 2024-01/
│       ├── 2024-01-15_session-log.txt
│       └── 2024-01-15-103000_SESSION_kubernetes-skill.md
└── tool-outputs/
    └── 2024-01/
        └── 2024-01-15_tool-outputs.jsonl

Example:

yearMonth := time.Now().Format("2006-01")
dateDir := filepath.Join(paths.HistoryDir(), "sessions", yearMonth)
os.MkdirAll(dateDir, 0755)

logFile := filepath.Join(dateDir, fmt.Sprintf("%s_session-log.txt", today))
f, _ := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
f.WriteString(logEntry)

4. JSONL Logging

Pattern: Append JSON entries to daily log files

entry := CaptureEntry{
    Timestamp: time.Now().Format(time.RFC3339),
    Tool:      "Bash",
    Input:     toolInput,
    Output:    toolOutput,
    Session:   sessionID,
}

jsonData, _ := json.Marshal(entry)
f.WriteString(string(jsonData) + "\n")

Why: JSONL (JSON Lines) format allows streaming appends and easy parsing with tools like jq.

Query Examples:

# Count tool uses by type
jq -r '.tool' ~/.config/claude/history/tool-outputs/2024-01/2024-01-15_tool-outputs.jsonl | sort | uniq -c

# Extract all Bash commands
jq -r 'select(.tool=="Bash") | .input.command' ~/.config/claude/history/tool-outputs/2024-01/*.jsonl

# Find tools from specific session
jq -r 'select(.session=="abc123")' ~/.config/claude/history/tool-outputs/2024-01/*.jsonl

5. Terminal Title Setting

Pattern: Use ANSI escape codes to update terminal tab title

func setTerminalTitle(title string) {
    fmt.Fprintf(os.Stderr, "\x1b]0;%s\x07", title)  // Standard
    fmt.Fprintf(os.Stderr, "\x1b]2;%s\x07", title)  // iTerm2
    fmt.Fprintf(os.Stderr, "\x1b]30;%s\x07", title) // Kitty
}

Why: Helps identify Claude Code sessions in terminal multiplexers (tmux, kitty, etc.)

ANSI Escape Codes:

  • \x1b]0;...\x07 - OSC 0 (Icon name and window title)
  • \x1b]2;...\x07 - OSC 2 (Window title)
  • \x1b]30;...\x07 - OSC 30 (Kitty tab title)

6. Debouncing

Pattern: Use lockfiles with timestamps to prevent duplicate triggers

const debounceDuration = 2 * time.Second

func shouldDebounce() bool {
    lockfile := filepath.Join(os.TempDir(), "claude-session-start.lock")

    data, err := os.ReadFile(lockfile)
    if err == nil {
        lockTime, _ := strconv.ParseInt(string(data), 10, 64)
        now := time.Now().UnixMilli()
        if now-lockTime < debounceDuration.Milliseconds() {
            return true // Within debounce window
        }
    }

    // Update lockfile
    now := time.Now().UnixMilli()
    os.WriteFile(lockfile, []byte(fmt.Sprintf("%d", now)), 0644)
    return false
}

Why: Claude Code may trigger hooks multiple times in rapid succession. Debouncing prevents duplicate notifications.


Installation and Setup

Source Location

Hooks are TypeScript files in ~/src/home/dots/config/claude/hooks/, symlinked to ~/.config/claude/hooks/ via make -C dots.

Verify Installation

# Check hooks are in place
ls ~/.config/claude/hooks/*.ts

# Test hooks manually
bun run ~/.config/claude/hooks/initialize-session.ts

# Test with input
echo '{"tool_name":"Bash","tool_input":{},"tool_response":{},"conversation_id":"test"}' | \
  bun run ~/.config/claude/hooks/capture-tool-output.ts

Configuration

Edit ~/.config/claude/settings.json to enable hooks (should already be configured):

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bun run ~/.config/claude/hooks/initialize-session.ts"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bun run ~/.config/claude/hooks/capture-tool-output.ts"
          }
        ]
      }
    ],
    "SessionEnd": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bun run ~/.config/claude/hooks/save-session.ts"
          }
        ]
      }
    ]
  }
}

Restart Claude Code after editing settings.json.


Creating Custom Hooks

Step 1: Choose Hook Event

Decide which event should trigger your hook (SessionStart, PostToolUse, etc.)

Step 2: Create Hook Script

Create a TypeScript file in ~/src/home/dots/config/claude/hooks/:

// my-hook.ts
import { readInput, getSessionDir } from "./lib";

const input = await readInput();
// input has: tool_name, tool_input, tool_response, conversation_id

// Your hook logic here

// For PreToolUse: output JSON to stdout to block
// { "decision": "block", "reason": "..." }
// Or exit 0 silently to allow

Shared utilities are in lib.ts (paths, dates, slugs, debouncing).

Step 3: Add to settings.json

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "my-custom-hook"
          }
        ]
      }
    ]
  }
}

Step 5: Test

# Test hook directly
echo '{"conversation_id":"test"}' | my-custom-hook

# Check output in terminal

Step 6: Restart Claude Code

Hooks are loaded at startup. Restart to apply changes.


Hook Development Best Practices

1. Fast Execution

  • Hooks should complete in < 500ms
  • For slow work, write to file and exit immediately
  • Never wait for external services unless they respond quickly

2. Graceful Failure

  • Always wrap in error handling
  • Log errors to stderr (visible in terminal)
  • Always os.Exit(0) - never exit(1) or panic

3. Non-Blocking

  • Never wait for external services
  • Fail silently if optional operations fail
  • Don’t disrupt Claude Code’s core functionality

4. Stdin Reading

  • Handle empty input gracefully
  • Parse JSON with error handling
  • Don’t assume stdin will always have data
input, err := io.ReadAll(os.Stdin)
if err != nil || len(input) == 0 {
    os.Exit(0)
}

5. File I/O

  • Check file existence before reading
  • Create directories with os.MkdirAll(..., 0755)
  • Use append mode for log files
dir := filepath.Join(os.Getenv("HOME"), ".config", ".claude", "history")
os.MkdirAll(dir, 0755)
f, _ := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)

6. Environment Access

  • Read environment variables with os.Getenv()
  • Provide defaults for missing variables
  • Use CLAUDE_DIR to override default ~/.config/claude path

7. Testing

  • Test hooks manually with sample input
  • Use go test for unit tests
  • Verify hooks work in actual Claude Code sessions

Troubleshooting

Hook Not Running

Check:

  1. Is hook binary in PATH? which bun run ~/.config/claude/hooks/initialize-session.ts
  2. Is path correct in settings.json? Use exact command name
  3. Is settings.json valid JSON? jq . ~/.config/claude/settings.json
  4. Did you restart Claude Code after editing settings.json?

Debug:

# Test hook directly
bun run ~/.config/claude/hooks/initialize-session.ts

# Test with input
echo '{"conversation_id":"test"}' | bun run ~/.config/claude/hooks/capture-tool-output.ts

# Check stderr output in terminal (hooks log there)

Hook Hangs/Freezes Claude Code

Cause: Hook not exiting (infinite loop, waiting for input, blocking operation)

Fix:

  1. Ensure os.Exit(0) is always reached
  2. Add timeouts to all blocking operations
  3. Check stdin reading doesn’t hang

Prevention:

// Add timeout
go func() {
    time.Sleep(5 * time.Second)
    fmt.Fprintln(os.Stderr, "[hook] Timeout - exiting")
    os.Exit(0)
}()

// Main logic here

Permission Errors

Check:

# Ensure directories exist
mkdir -p ~/.config/claude/history/sessions
mkdir -p ~/.config/claude/history/tool-outputs

# Check write permissions
ls -la ~/.local/share/ai/

# Check binary permissions
ls -la $(which bun run ~/.config/claude/hooks/initialize-session.ts)

No Logs Generated

Check:

  1. Does ~/.config/claude/history/ directory exist?
  2. Are hooks actually running? (Check terminal stderr output)
  3. File permissions? ls -la ~/.config/claude/history/

Debug:

# Check recent logs
ls -lt ~/.local/share/ai/sessions/$(date +%Y-%m)/ | head -10
ls -lt ~/.config/claude/history/tool-outputs/$(date +%Y-%m)/ | head -10

# Check if tool-outputs JSONL has entries
tail ~/.config/claude/history/tool-outputs/$(date +%Y-%m)/$(date +%Y-%m-%d)_tool-outputs.jsonl

TypeScript Hook Errors

Check:

# Verify bun is available
bun --version

# Test a hook directly
echo '{}' | bun run ~/.config/claude/hooks/initialize-session.ts

# Check for syntax errors
bun run --check ~/.config/claude/hooks/lib.ts

Advantages of TypeScript Implementation

  1. Zero runtime dependencies - Compiled to native binary, no Bun/Node.js required
  2. Faster execution - Native code vs interpreted JavaScript
  3. Easier distribution - Single binary per hook
  4. Cross-compilation - Build for any architecture from any platform
  5. Nix integration - Proper package management and reproducible builds
  6. Type safety - Compile-time checks without runtime overhead
  7. Standard library - Excellent built-in support for JSON, file I/O, paths
  8. Smaller memory footprint - TypeScript hooks use less memory than Node.js processes

  • Hook Source Code: ~/.config/claude/hooks/ (source: dots/config/claude/hooks/)
  • Nix Package: ~/.config/claude/hooks/ (source: dots/config/claude/hooks/)default.nix
  • Configuration: /home/vincent/.config/claude/settings.json
  • History Directory: ~/.config/claude/history/

Quick Reference Card

HOOK LIFECYCLE:
1. Event occurs (SessionStart, PostToolUse, SessionEnd)
2. Claude Code executes hook command
3. Hook receives JSON data on stdin
4. Hook performs actions (log, set title, etc.)
5. Hook exits 0 (always succeeds)
6. Claude Code continues

KEY FILES:
/home/vincent/.config/claude/settings.json        Hook configuration
~/.config/claude/hooks/ (source: dots/config/claude/hooks/)        Hook source code
~/.local/share/ai/sessions/                       Session logs
~/.config/claude/history/tool-outputs/                   Tool output JSONL logs

CONFIGURED HOOKS:
bun run ~/.config/claude/hooks/initialize-session.ts    SessionStart - Set title, log session
bun run ~/.config/claude/hooks/capture-tool-output.ts   PostToolUse - Log tool executions
bun run ~/.config/claude/hooks/save-session.ts          SessionEnd - Prompt to save summary

VERIFY HOOKS:
which bun run ~/.config/claude/hooks/initialize-session.ts
jq '.hooks' ~/.config/claude/settings.json
ls -la ~/.config/claude/history/tool-outputs/

DEBUGGING:
# Test hooks manually
bun run ~/.config/claude/hooks/initialize-session.ts
echo '{"tool_name":"Bash","tool_input":{}}' | bun run ~/.config/claude/hooks/capture-tool-output.ts

# Check logs
tail ~/.config/claude/history/tool-outputs/$(date +%Y-%m)/$(date +%Y-%m-%d)_tool-outputs.jsonl
cat ~/.local/share/ai/sessions/$(date +%Y-%m)/$(date +%Y-%m-%d)_session-log.txt

Last Updated: 2024-12-04 Implementation: Go-based hooks (migrated from TypeScript/Bun) Status: Active - 3 hooks configured (SessionStart, PostToolUse, SessionEnd)