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_TYPEis 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_DIRcontains/.claude/agents/CLAUDE_AGENT_TYPEis 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:
BashEditWriteReadTaskNotebookEditSkillSlashCommand
- 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-sessioncommand: 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/claudedirectory
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, PreCompactmatcher- Pattern to match (use"*"for all tools, or specific tool names) - only relevant for tool-based eventstype- 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_DIRto override default~/.config/claudepath
7. Testing
- Test hooks manually with sample input
- Use
go testfor unit tests - Verify hooks work in actual Claude Code sessions
Troubleshooting
Hook Not Running
Check:
- Is hook binary in PATH?
which bun run ~/.config/claude/hooks/initialize-session.ts - Is path correct in settings.json? Use exact command name
- Is settings.json valid JSON?
jq . ~/.config/claude/settings.json - 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:
- Ensure
os.Exit(0)is always reached - Add timeouts to all blocking operations
- 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:
- Does
~/.config/claude/history/directory exist? - Are hooks actually running? (Check terminal stderr output)
- 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
- Zero runtime dependencies - Compiled to native binary, no Bun/Node.js required
- Faster execution - Native code vs interpreted JavaScript
- Easier distribution - Single binary per hook
- Cross-compilation - Build for any architecture from any platform
- Nix integration - Proper package management and reproducible builds
- Type safety - Compile-time checks without runtime overhead
- Standard library - Excellent built-in support for JSON, file I/O, paths
- Smaller memory footprint - TypeScript hooks use less memory than Node.js processes
Related Documentation
- 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)