Claude Code Session Hooks
Four lifecycle hooks for Claude Code. Run init steps on demand, inject project context at every start, back up transcripts, and log cleanup on exit.
Problem: Every new session starts blind. You re-explain the branch you're on, the task queue, and the env vars your scripts need. When the session ends, cleanup that should happen never does.
Quick Win: Paste this into settings.json. Git context lands in the chat on every start:
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "echo '## Git' && git branch --show-current && git status --short | head -10"
}
]
}
]
}
}Every session now opens with context. Zero manual setup.
The Four Session Lifecycle Hooks
Session behaviour is driven by four hook types:
| Hook | When It Fires | Can Block? | Use Case |
|---|---|---|---|
| Setup | With --init or --maintenance | NO | One-time setup, migrations |
| SessionStart | Every session start/resume | NO | Load context, set env vars |
| PreCompact | Before context compaction | NO | Backup transcripts |
| SessionEnd | Session terminates | NO | Cleanup, logging |
SessionStart: Load Context Every Time
SessionStart runs whenever a session begins or resumes. Reach for it when something should always be in Claude's head.
Basic Context Injection
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "echo '## Project State' && cat .claude/tasks/session-current.md 2>/dev/null || echo 'No active session'"
}
]
}
]
}
}With JSON Output
For structured context injection:
#!/usr/bin/env python3
import json
import sys
import subprocess
def get_project_context():
try:
branch = subprocess.check_output(
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
text=True, stderr=subprocess.DEVNULL
).strip()
status = subprocess.check_output(
['git', 'status', '--porcelain'],
text=True, stderr=subprocess.DEVNULL
).strip()
changes = len(status.split('\n')) if status else 0
except:
branch, changes = "unknown", 0
return f"""=== SESSION CONTEXT ===
Git Branch: {branch}
Uncommitted Changes: {changes}
=== END ===""".strip()
output = {
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": get_project_context()
}
}
print(json.dumps(output))
sys.exit(0)SessionStart Matchers
Target specific session events:
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [{ "type": "command", "command": "echo 'Fresh session'" }]
},
{
"matcher": "resume",
"hooks": [{ "type": "command", "command": "echo 'Resumed session'" }]
},
{
"matcher": "compact",
"hooks": [{ "type": "command", "command": "echo 'Post-compaction'" }]
}
]
}
}startup- New sessionresume- From--resume,--continue, or/resumeclear- After/clearcompact- After compaction
Persist Environment Variables
SessionStart has access to CLAUDE_ENV_FILE for setting session-wide environment variables:
#!/bin/bash
# Persist environment changes from nvm, pyenv, etc.
ENV_BEFORE=$(export -p | sort)
# Setup commands that modify environment
source ~/.nvm/nvm.sh
nvm use 20
if [ -n "$CLAUDE_ENV_FILE" ]; then
ENV_AFTER=$(export -p | sort)
comm -13 <(echo "$ENV_BEFORE") <(echo "$ENV_AFTER") >> "$CLAUDE_ENV_FILE"
fi
exit 0Anything written to CLAUDE_ENV_FILE shows up in every bash command Claude runs after that.
Setup: One-Time Operations
Setup hooks only run when you explicitly invoke --init, --init-only, or --maintenance. Good for work you don't want fired on every new session.
When to Use Setup vs SessionStart
| Operation | Use Setup | Use SessionStart |
|---|---|---|
| Install dependencies | Yes | No |
| Run database migrations | Yes | No |
| Load git status | No | Yes |
| Set environment variables | Yes | Yes |
| Inject project context | No | Yes |
| Cleanup temp files | Yes (maintenance) | No |
Setup Configuration
{
"hooks": {
"Setup": [
{
"matcher": "init",
"hooks": [
{
"type": "command",
"command": "npm install && npm run db:migrate"
}
]
},
{
"matcher": "maintenance",
"hooks": [
{
"type": "command",
"command": "npm prune && npm dedupe && rm -rf .cache"
}
]
}
]
}
}Invoke with:
claude --init # Runs 'init' matcher
claude --init-only # Runs 'init' matcher, then exits
claude --maintenance # Runs 'maintenance' matcherSetup hooks can also write to CLAUDE_ENV_FILE for persisting environment variables.
PreCompact: Before Context Loss
PreCompact fires just before compaction, whether the user triggered it with /compact or it kicked off automatically as the window filled up.
Backup Transcripts
#!/usr/bin/env python3
import json
import sys
import shutil
from pathlib import Path
from datetime import datetime
input_data = json.load(sys.stdin)
transcript_path = input_data.get('transcript_path', '')
trigger = input_data.get('trigger', 'unknown')
if transcript_path and Path(transcript_path).exists():
backup_dir = Path('.claude/backups')
backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_name = f"transcript_{trigger}_{timestamp}.jsonl"
shutil.copy2(transcript_path, backup_dir / backup_name)
# Keep only last 10 backups
backups = sorted(backup_dir.glob('transcript_*.jsonl'))
for old_backup in backups[:-10]:
old_backup.unlink()
sys.exit(0)PreCompact Matchers
{
"hooks": {
"PreCompact": [
{
"matcher": "auto",
"hooks": [{ "type": "command", "command": "echo 'Auto-compacting...'" }]
},
{
"matcher": "manual",
"hooks": [{ "type": "command", "command": "echo 'Manual /compact'" }]
}
]
}
}auto- Context window filled, automatic compactionmanual- User ran/compact
Create Recovery Markers
Pair PreCompact with SessionStart to rebuild context after a compaction event. A working pattern: a shared backup-core module, a statusline monitor that fires threshold-based triggers, and a PreCompact handler, all coordinating through one state file so nothing gets dropped between sessions. See the Context Recovery Hook guide for the full walkthrough.
SessionEnd: Cleanup
SessionEnd runs when a session is on its way out. It can't block the shutdown, but it can do cleanup.
Log Session Stats
#!/usr/bin/env python3
import json
import sys
from pathlib import Path
from datetime import datetime
input_data = json.load(sys.stdin)
session_id = input_data.get('session_id', 'unknown')
reason = input_data.get('reason', 'unknown')
log_dir = Path('.claude/logs')
log_dir.mkdir(parents=True, exist_ok=True)
log_entry = {
"session_id": session_id,
"ended_at": datetime.now().isoformat(),
"reason": reason
}
with open(log_dir / 'session-history.jsonl', 'a') as f:
f.write(json.dumps(log_entry) + '\n')
sys.exit(0)SessionEnd Reasons
The reason field tells you why the session ended:
clear- User ran/clearlogout- User logged outprompt_input_exit- User exited while prompt was visibleother- Other exit reasons
Complete Lifecycle Example
A full lifecycle configuration wired end-to-end:
{
"hooks": {
"Setup": [
{
"matcher": "init",
"hooks": [
{
"type": "command",
"command": "npm install && echo 'Dependencies installed'"
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "echo '## Context' && git status --short && echo '## Tasks' && cat .claude/tasks/session-current.md 2>/dev/null | head -20"
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "cp \"$CLAUDE_TRANSCRIPT_PATH\" .claude/backups/last-transcript.jsonl 2>/dev/null || true"
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "echo \"Session ended: $(date)\" >> .claude/logs/sessions.log"
}
]
}
]
}
}Input Payloads
SessionStart Input
{
"session_id": "abc123",
"hook_event_name": "SessionStart",
"source": "startup",
"model": "claude-sonnet-4-20250514",
"cwd": "/path/to/project"
}Setup Input
{
"session_id": "abc123",
"hook_event_name": "Setup",
"trigger": "init",
"cwd": "/path/to/project"
}PreCompact Input
{
"session_id": "abc123",
"hook_event_name": "PreCompact",
"transcript_path": "~/.claude/projects/.../transcript.jsonl",
"trigger": "auto",
"custom_instructions": ""
}SessionEnd Input
{
"session_id": "abc123",
"hook_event_name": "SessionEnd",
"reason": "clear",
"cwd": "/path/to/project"
}Best Practices
-
Keep SessionStart fast - It runs on every session. Push heavy work into Setup.
-
Use Setup for one-time work - Dependency installs, migrations, first-time project bootstrap.
-
Back up before compaction - PreCompact is your last chance to grab the transcript.
-
Log session ends - SessionEnd is handy for analytics and debugging.
-
Match carefully - Different behaviour for
startupvsresumevscompactavoids surprises.
Next Steps
- Set up the main Hooks Guide for all 12 hooks
- Configure Context Recovery for compaction survival
- Use Stop Hooks for task enforcement
- Explore Skill Activation for automatic skill loading
Stop configuring. Start building.
Self-Validating Claude Code Agents
Wire PostToolUse hooks, Stop hooks, and read-only reviewer agents into agent definitions so bad output never reaches you.
Context Backup Hooks for Claude Code
A StatusLine-driven backup system that writes structured snapshots every 10K tokens so auto-compaction cannot eat your session detail.