AnswerQA

Setup or SessionStart hook for my install script?

Answer

SessionStart fires on every session start, resume, `/clear`, and post-compaction, so it has to be fast. Setup fires only on explicit `claude --init-only`, `claude -p --init`, or `claude -p --maintenance`, which makes it the right home for slow one-time work like dependency installs or scheduled cleanup. Neither hook can block Claude from starting; for hard preconditions, gate the `claude` binary, not the hook.

By Kalle Lamminpää Verified May 7, 2026

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

ConcernSessionStartSetup
When it firesEvery new session, every resume, after /clear, after compactionOnly on claude --init-only, -p --init, or -p --maintenance
Frequency in a typical workday5 to 50 times per developerOnce per CI run, or scheduled
Can it block Claude from starting?NoNo
Input payload distinguishersource: "startup" | "resume" | "clear" | "compact"trigger: "init" | "maintenance"
Right forLoading branch context, rotating env vars, injecting recent issuesnpm install, building a sandbox, scheduled cache cleanup
Wrong forAnything 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 claude that exits early; for repo-shared cases, ship a bin/claude script in the repo and have developers add it to PATH.
  • Your work is per-tool, not per-session. Use PreToolUse / PostToolUse if you want to validate or annotate a specific tool call. SessionStart fires once per session; it cannot react to a Bash invocation 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 (source vs. trigger), and conflating them makes the script harder to reason about than the dozen lines you saved.

Sources

  • Hooks reference
    Authoritative: full hook event list, exact firing conditions for SessionStart (startup/resume/clear/compact) and Setup (--init-only, -p --init, -p --maintenance), input payload fields (`source` for SessionStart, `trigger` for Setup), and the rule that neither hook can block startup.
  • CLI reference
    Documents the `--init-only`, `-p --init`, and `-p --maintenance` flags that trigger Setup hooks, and `-p` (print) non-interactive mode behavior.

Was this helpful?