A captured claude --print session against the demo app, with a SessionStart hook that injects recent git activity into the session’s system prompt. Claude used the commit SHA from the injected context to skip a redundant git log discovery step and produced a substantive coverage analysis in four tool calls; the events stream contains hook_started and hook_response events that prove the hook fired.
The setup
The demo had no hooks before this scenario. Three artifacts together make the hook work:
.claude/settings.json registers the hook:
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear|compact",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/inject-git-context.sh"
}
]
}
]
}
}
The matcher is a |-separated list of session-source values: the hook fires on cold start, on --resume/--continue, after /clear, and after compaction. The command path uses $CLAUDE_PROJECT_DIR so the script runs from any cwd within the project tree.
.claude/hooks/inject-git-context.sh is a shell script:
#!/usr/bin/env bash
set -euo pipefail
repo_root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
recent_log=$(git -C "$repo_root" log --oneline -5 2>/dev/null || echo "(no git history)")
status_short=$(git -C "$repo_root" status --short 2>/dev/null || echo "(no git status)")
uncommitted_lines=$(git -C "$repo_root" diff --shortstat 2>/dev/null | tr -d '\n' || echo "")
context=$(cat <<CTX
## Recent git activity (injected by SessionStart hook)
### Last 5 commits
${recent_log}
### Working tree
$([ -z "$status_short" ] && echo "(clean)" || echo "$status_short")
### Uncommitted changes
$([ -z "$uncommitted_lines" ] && echo "(none)" || echo "$uncommitted_lines")
CTX
)
escaped=$(printf '%s' "$context" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')
cat <<JSON
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": ${escaped}
}
}
JSON
The python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' line is doing real work: it escapes newlines, quotes, and backslashes so the resulting context survives JSON parsing. A pure-bash sed-and-tr escape is brittle around edge cases (a commit message with a literal \n in it would break a hand-rolled escape); the json.dumps path is correct by construction.
The wiring runs in two steps. Claude Code reads .claude/settings.json at session start and registers the hook. When SessionStart fires, Claude Code spawns the script as a Bash command, captures stdout, parses it as JSON, and appends the additionalContext string to the session’s system prompt. From there the text behaves like any other system-prompt content.
The prompt
What did the team change most recently? Without running git log yourself
(the SessionStart hook should have given you the recent activity already),
look at the most recent feature work and tell me whether the test coverage
is appropriate for what shipped. Name specific files and what's covered or
not. Keep it under 250 words.
The “without running git log yourself” clause is the experimental control. If Claude can answer the question, the hook context arrived. If Claude has to run git log anyway, the hook did not work or the context did not carry through.
What Claude actually did, in four tool calls
The events file shows:
Bash(ls src/notifications/)to confirm the directory layoutBash(git show --stat d6da4e6)to inspect the recent feature commit by SHARead(src/notifications/service.ts)to read the implementationRead(src/notifications/service.test.ts)to read the test file
Step 2 is the article. The SHA d6da4e6 came from the hook’s injected context (d6da4e6 feat(notifications): add retry mechanism with backoff and DLQ is the second-most-recent commit in the hook’s git log -5 output). Without the hook, Claude would have run git log --oneline -5 itself as step 1, then git show d6da4e6 as step 2, then the two Reads. The hook saved exactly one tool call, but the more important effect is that it pre-anchored Claude on the SHA before any work began.
What the events.jsonl recorded
Two system events confirm the hook fired:
{
"type": "system",
"subtype": "hook_started",
"hook_id": "b6d7c555-...",
"hook_name": "SessionStart:startup",
"hook_event": "SessionStart"
}
{
"type": "system",
"subtype": "hook_response",
"hook_id": "b6d7c555-...",
"hook_event": "SessionStart",
"output": "{ \"hookSpecificOutput\": { \"hookEventName\": \"SessionStart\", \"additionalContext\": \"## Recent git activity ...\" } }",
"exit_code": 0,
"outcome": "success"
}
The hook_response event embeds the full stdout of the hook. If the hook had failed (non-zero exit, malformed JSON, timeout), outcome would have been something other than success and the additionalContext would not have been appended. This is your debugging surface: a hook that does not fire leaves these events out of the stream entirely; a hook that fires but errors leaves them with outcome: "failure".
Claude’s verbatim final reply
## Most recent feature: notifications retry + DLQ (commit d6da4e6)
The team added a retry mechanism with exponential backoff and a dead-letter
queue. Touched: src/notifications/types.ts:1, src/notifications/service.ts:1
(rewritten with NotificationServiceOptions, separate dlq[], due(),
deadLetter()), plus 23 new tests in src/notifications/service.test.ts.
Coverage is appropriately thorough for what shipped:
[...]
Gaps worth noting:
- NotificationServiceOptions.maxAttempts and initialRetryDelayMs are
configurable but no test exercises non-default values
- The zero-arg new NotificationService() path is not directly tested
- Only channel: 'email' is exercised; SMS path is untouched
Net: appropriate coverage. The two configurability gaps are easy to add
and would close the only meaningful holes.
The reply names the commit SHA in the headline, lists specific test cases by number (1, 6, 9, 12, 5, 15, 16, 19-22, 23, which is 11 distinct case references), identifies three real coverage gaps, and concludes with a one-sentence verdict. That is the shape of a code review note worth pasting into a PR comment, not a generic “looks good”.
The hook context did not write that reply; Claude did. But the hook’s contribution is visible: the reply opens with the SHA from the hook’s commit list, the file paths come from the hook’s working-tree report, and Claude never needed to discover any of that. The hook compressed the “where did the team work recently?” question into a single Read of pre-injected text.
Footguns
The hook output goes into every session, not just the one where it is useful. A SessionStart hook fires on every --resume, every /clear, every compaction. If your hook produces 200 lines of git log, every one of those sessions starts with 200 extra lines in the system prompt. Why this matters: cap the hook output at something like 5-10 commits and a status summary, not “everything that might be useful”. The hook should be cheap on every turn because every turn pays for it.
A failed hook does not block the session, but it might run quietly. If the hook script crashes, returns non-JSON, or times out, Claude Code logs the failure as hook_response with outcome: "failure" and continues without the context. The session still works; the agent just has less to go on. Why this matters: if Claude is suddenly running git log in sessions where it used to skip, your hook is failing and you need to look at the events stream. Add a hook_response filter to your local debugging tooling.
$CLAUDE_PROJECT_DIR is the canonical way to locate the project. Manual testing of the script needs the variable set explicitly (CLAUDE_PROJECT_DIR=/path/to/project ./.claude/hooks/inject-git-context.sh); a script that uses $(pwd) works in production but produces confusing output during local dry-run. Why this matters: write hooks that prefer $CLAUDE_PROJECT_DIR and fall back to $(pwd) only when unset. The fallback is for ergonomics; the env var is for correctness.
JSON escaping is non-trivial in pure bash. The script uses python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' to escape the context string. A hand-rolled sed -e 's/"/\\"/g' -e 's/$/\\n/g' works for the easy cases and breaks the moment a commit message contains a backslash, a tab, a control character, or anything else JSON cares about. Why this matters: do not roll your own JSON escape in shell. Reach for python3 (almost always available), node, jq, or whatever is on the path. The hook’s correctness depends on it.
Hook output is text, not magic. It lands in the session’s system prompt as a string. Claude is free to ignore it, contradict it, or weight it less than other context. The hook does not pin Claude’s behavior; it adds information. Why this matters: do not rely on a SessionStart hook to enforce policy (“never push to main”). Use permissions or PreToolUse hooks for enforcement; use SessionStart for context.
Hook scripts inherit the user’s environment, not Claude Code’s. The script runs as a child of the Claude Code process with whatever environment the parent had. If the script depends on PATH finding git and python3, those need to be in the user’s PATH when Claude Code launches. Why this matters: a hook that works on your laptop but fails in CI is usually a PATH or shell-rc difference. Hardcode tool paths or add them to .claude/settings.json’s env block if you need reproducibility.
When SessionStart hooks pay off
- Recent activity context. This article’s case. Saves a discovery
git logand pre-anchors Claude on relevant commits. - Branch awareness. Inject the current branch name plus the upstream divergence so Claude knows whether the work-in-progress is fresh or stale.
- Open issue summary. A hook that calls
gh issue list --state open --limit 10and lists titles inadditionalContextputs the team’s backlog in front of every session. - CI status. A hook that calls
gh run list --limit 5 --json status,nameflags failing CI before the agent starts editing. - Test status. A hook that runs
npm test --silent --reporter=dotand emits a one-line summary lets Claude know whether the suite was already broken before the session began.
When NOT to use a SessionStart hook
- Static context. If the content does not change between sessions, put it in
CLAUDE.md. The hook adds overhead with no benefit. - Per-turn context. If you need fresh context every time the user types, use a
UserPromptSubmithook. SessionStart fires once at session boundary, not on every prompt. - Tool-call gating. If you want to validate or block specific tool uses, that is a
PreToolUsehook. SessionStart cannot block startup; it only injects. - Heavy operations. A hook that takes 30 seconds to run delays every session start by 30 seconds. Keep the script under a few hundred milliseconds; if it is heavy, cache its output or run it in a background process and read the cache from the hook.
- Cross-project policy. A SessionStart hook lives in one project’s
.claude/. For org-wide policy, use managed settings; for cross-project conventions, use a~/.claude/settings.jsonuser-scoped hook.