SessionStart fires on every session start, resume, /clear, and post-compaction; Setup fires only when you explicitly run claude --init-only or claude -p --init / claude -p --maintenance. Pick SessionStart for fast per-session context loading, and Setup for slow one-time work you trigger from CI.
The decision in one table
| Concern | SessionStart | Setup |
|---|---|---|
| When it fires | Every new session, every resume, after /clear, after compaction | Only on claude --init-only, -p --init, or -p --maintenance |
| Frequency in a typical workday | 5 to 50 times per developer | Once per CI run, or scheduled |
| Can it block Claude from starting? | No | No |
| Input payload distinguisher | source: "startup" | "resume" | "clear" | "compact" | trigger: "init" | "maintenance" |
| Right for | Loading branch context, rotating env vars, injecting recent issues | npm install, building a sandbox, scheduled cache cleanup |
| Wrong for | Anything slower than ~500ms (you pay it 5 to 50 times) | Anything you want to happen on every session (it will not) |
If you want both, write both. They are independent.
SessionStart: fast context, every session
The minimal SessionStart hook in .claude/settings.json:
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/load-context.sh"
}
]
}
]
}
}
The matcher is a |-separated list of source values (startup, resume, clear, compact). Omit the matcher to fire on all four.
The script gets a JSON payload on stdin:
{
"session_id": "abc123",
"transcript_path": "/Users/you/.claude/projects/.../transcript.jsonl",
"cwd": "/Users/you/work/repo",
"hook_event_name": "SessionStart",
"source": "startup",
"model": "claude-sonnet-4-6"
}
Inject context by writing JSON to stdout:
#!/bin/bash
BRANCH=$(git rev-parse --abbrev-ref HEAD)
RECENT_PRS=$(gh pr list --limit 5 --json number,title --jq '.[] | "#\(.number) \(.title)"' | tr '\n' ';')
cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "Branch: $BRANCH\nRecent PRs: $RECENT_PRS"
}
}
EOF
Claude appends additionalContext to its system prompt for the new session. Keep this script under ~500ms; the user feels every millisecond on resume and after /clear.
Setup: heavy work, only when asked
The minimal Setup hook:
{
"hooks": {
"Setup": [
{
"matcher": "init",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/install-deps.sh"
}
]
}
]
}
}
Setup matchers are init and maintenance. Use init for first-run setup (deps, schema migration), maintenance for scheduled cleanup.
Trigger Setup from CI without starting an interactive session:
claude --init-only # runs Setup with trigger=init, then exits
claude -p --init "deploy-prep" # print-mode prompt + Setup init
claude -p --maintenance "cleanup" # print-mode prompt + Setup maintenance
The script gets the same shape of JSON, with hook_event_name: "Setup" and trigger: "init" (or "maintenance") instead of source.
A typical install-deps script:
#!/bin/bash
set -e
if [ ! -d "node_modules" ]; then
npm ci
fi
if [ ! -f ".env" ] && [ -f ".env.example" ]; then
cp .env.example .env
fi
exit 0
Run this once per fresh checkout in CI; do not put it in SessionStart, where it would re-check on every /clear.
A working pattern: Setup in CI, SessionStart for context
A repo that wants both has a .claude/settings.json like this:
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear|compact",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/load-context.sh"
}
]
}
],
"Setup": [
{
"matcher": "init",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/install-deps.sh"
}
]
},
{
"matcher": "maintenance",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/cleanup-cache.sh"
}
]
}
]
}
}
CI calls claude --init-only once after checkout to run install-deps. A nightly cron calls claude -p --maintenance to run cleanup-cache. Developers never run either; SessionStart silently loads branch context every time they open Claude Code.
Footguns
SessionStart fires four times more often than you think. Every /clear re-runs it. Every compaction (which can fire mid-session) re-runs it with source: "compact". A 2-second context-loading script becomes an 8-second tax on a single afternoon of heavy use. Why this matters: if your hook calls gh api or runs a build command, profile it under your real /clear rhythm, not just on cold start. If it is over ~500ms, move the slow part to Setup.
Setup does not fire on normal startup. Putting npm install in Setup and expecting it to run when developers claude into the project is a common mistake. Setup only fires on the explicit flags. Why this matters: a CI job that does not call claude --init-only will silently skip Setup, and so will every developer’s interactive launch. Document the entry point: either CI runs it, or SessionStart does (for cheap operations), or you ship a make setup target that wraps claude --init-only.
Neither hook can block Claude from starting. Setup with exit code 2 shows stderr to the user; any other non-zero exit code shows stderr only with --verbose. In all cases Claude proceeds. SessionStart has no decision control at all; it cannot block or modify instruction loading. Why this matters: do not write a “credential check” SessionStart hook expecting Claude to refuse to start. The hook will print a warning and Claude will keep going. For hard preconditions, gate the claude binary itself with a shell wrapper that exits non-zero before calling Claude.
source vs. trigger is easy to mix up. SessionStart’s input has a source field (startup / resume / clear / compact). Setup’s input has a trigger field (init / maintenance). Why this matters: a script that reads .source from a Setup payload silently gets null and behaves as if every Setup were the same trigger. Read hook_event_name first to decide which field to use, or write two separate scripts.
Compaction re-runs SessionStart with the old transcript already loaded. When source: "compact" fires, Claude already replaced the conversation with a summary; the SessionStart hook runs after, on top of that summary. Why this matters: if your hook injects “you are starting fresh” prose into additionalContext, it lies after compaction; the model is not starting fresh. Branch on source and inject different context for compact (for example, “context was compacted; the prior conversation summary is in the transcript above”).
When NOT to use these hooks
- You want to verify a precondition before Claude runs. Neither hook blocks startup. Use a shell wrapper around
claudethat exits early; for repo-shared cases, ship abin/claudescript in the repo and have developers add it to PATH. - Your work is per-tool, not per-session. Use
PreToolUse/PostToolUseif you want to validate or annotate a specific tool call. SessionStart fires once per session; it cannot react to aBashinvocation later. - You want it to run on every prompt. That is
UserPromptSubmit, not SessionStart. SessionStart fires once and stays out of the way until the next session boundary. - You are tempted to run a slow build in SessionStart “just to be safe”. Move it to Setup, expose a CI command that runs
claude --init-only, and treat SessionStart as the no-cost layer it is supposed to be. - You want the same hook in both events. Write two entries, do not symlink one script. SessionStart and Setup payloads differ (
sourcevs.trigger), and conflating them makes the script harder to reason about than the dozen lines you saved.