ServicesAboutNotesContact Get in touch →
EN FR
Note

Claude Code Stop and Session Hooks

How Stop and SessionStart hooks complement per-tool hooks — running quality gates after Claude finishes responding and loading project context at session start

Planted
claude codedbtautomationdata quality

The Claude Code Hooks note covers PreToolUse and PostToolUse — hooks that fire before and after each individual tool action. These handle per-operation concerns: formatting a SQL file after an edit, blocking a dangerous command before it runs.

Two other hook lifecycle points serve different purposes. Stop fires when Claude finishes responding — after all the edits, commands, and reasoning are done for that turn. SessionStart fires once when a Claude Code session begins. Together, they handle end-of-turn quality validation and session initialization.

Stop Hooks: Quality Gates After Each Turn

The Stop hook runs after Claude’s entire response is complete. Think of it as a mini-CI that runs locally, immediately after every turn. This is the right place for checks that are too slow or too broad to run after every individual file edit but too important to skip entirely.

The most practical Stop hook for dbt work: compile modified models to catch syntax errors before Claude moves on.

Create .claude/hooks/dbt-quality-check.sh:

#!/usr/bin/env bash
set -euo pipefail
# Check if any SQL files were modified
modified_sql=$(git diff --name-only HEAD 2>/dev/null | grep "\.sql$" || true)
if [[ -n "$modified_sql" ]]; then
echo "Checking modified models..." >&2
# Compile to catch syntax errors
if ! dbt compile --select state:modified 2>&1; then
echo "Compilation failed — check the errors above" >&2
exit 2 # Block so Claude can see and fix the issue
fi
# Lint (warning only, don't block)
sqlfluff lint $modified_sql --dialect bigquery 2>&1 || true
echo "All checks passed" >&2
fi

Register it with an empty matcher, which means “fire on every stop, regardless of what tools were used”:

{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/dbt-quality-check.sh"
}
]
}
]
}
}

When the hook exits with code 2 (compilation failed), Claude sees the error immediately and can fix it in the next turn. When it exits with code 0 (all checks passed), Claude proceeds normally. The sqlfluff lint runs as a warning — it doesn’t block, but Claude sees any style issues in the output.

Why Stop Instead of PostToolUse?

You could run dbt compile as a PostToolUse hook after every file edit. The problem is speed. dbt compile --select state:modified takes a few seconds even on a small project. If Claude edits five SQL files in a single turn, that’s five compilation runs — each one adding latency to Claude’s workflow.

The Stop hook runs once after all edits are done. One compilation check covers all the changes from the entire turn. The tradeoff: you don’t catch errors between individual edits within the same turn. But Claude will see the errors at the end of the turn and fix them, which is usually good enough.

The rule of thumb: fast checks go in PostToolUse, slow checks go in Stop. Auto-formatting a single file with sqlfluff is fast — put it in PostToolUse. Compiling the dbt project is slower — put it in Stop.

Extending the Quality Gate

The basic compilation check is a starting point. You can layer additional checks:

Run dbt tests on modified models to catch data issues, not just syntax errors:

Terminal window
if [[ -n "$modified_sql" ]]; then
dbt test --select state:modified 2>&1 || true
fi

Keep this as a warning (not a block) since test failures on development data might be expected while iterating.

Check for missing documentation when new models are created:

Terminal window
new_models=$(git diff --name-only HEAD 2>/dev/null | grep "models/.*\.sql$" | while read f; do
model=$(basename "$f" .sql)
grep -rq "$model" models/ --include='*.yml' || echo "$f"
done)
if [[ -n "$new_models" ]]; then
echo "Warning: new models without YAML documentation:" >&2
echo "$new_models" >&2
fi

SessionStart Hooks: Loading Context

SessionStart hooks run once when you begin a Claude Code session. Their purpose is different from Stop hooks — they’re not enforcing quality, they’re providing context.

A SessionStart hook for dbt projects can show you where you left off:

#!/usr/bin/env bash
echo "" >&2
echo "dbt project context" >&2
echo "──────────────────────" >&2
# Current branch
branch=$(git branch --show-current 2>/dev/null || echo "unknown")
echo "Branch: $branch" >&2
# Modified models
modified=$(git diff --name-only HEAD 2>/dev/null | grep "models/.*\.sql$" | wc -l | tr -d ' ')
echo "Modified models: $modified" >&2
# Last run status
if [[ -f "target/run_results.json" ]]; then
failures=$(jq '[.results[] | select(.status == "error")] | length' target/run_results.json 2>/dev/null || echo "?")
echo "Last run failures: $failures" >&2
fi
echo "──────────────────────" >&2
echo "" >&2

The output goes to stderr, which Claude sees as context at the start of the conversation. It’s a small thing, but useful when switching between projects or picking up work the next morning.

Practical SessionStart Ideas

Show open PRs for the current branch:

Terminal window
pr_count=$(gh pr list --head "$(git branch --show-current)" --json number --jq 'length' 2>/dev/null || echo "?")
echo "Open PRs for this branch: $pr_count" >&2

Show which dbt packages are outdated:

Terminal window
if [[ -f "packages.yml" ]]; then
echo "Installed packages:" >&2
dbt deps --dry-run 2>&1 | grep -i "update" || echo "All packages up to date" >&2
fi

Show recent CI status:

Terminal window
last_run=$(gh run list --limit 1 --json status,conclusion --jq '.[0] | "\(.status): \(.conclusion // "in progress")"' 2>/dev/null || echo "unknown")
echo "Last CI run: $last_run" >&2

Keep SessionStart hooks fast. They run at the beginning of every session, and a slow hook means waiting several seconds before you can start working. If a check takes more than a second or two, it probably doesn’t belong in SessionStart.

Combining All Four Hook Types

A complete .claude/settings.json for a dbt project might use all four lifecycle hooks together:

{
"hooks": {
"SessionStart": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/dbt-context.sh"
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/dbt-safety.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/format-sql.sh"
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/dbt-quality-check.sh"
}
]
}
]
}
}

Each hook type handles a different concern:

HookWhenPurposeSpeed Requirement
SessionStartSession beginsLoad contextFast (runs once)
PreToolUseBefore each actionBlock dangerous operationsFast (runs per-tool)
PostToolUseAfter each actionAuto-format, lintFast (runs per-tool)
StopEnd of turnCompile, test, validateCan be slower (runs once per turn)

The progression is deliberate: SessionStart orients you, PreToolUse prevents mistakes, PostToolUse cleans up, and Stop validates the whole turn. Each layer catches what the others miss.