A Claude Code SessionStart hook is a shell script that runs every time a session starts, resumes, or clears, and it can hand Claude a block of text — additionalContext — before the first prompt is even read. The short version: you write a script that prints a small JSON object to stdout, point to it from .claude/settings.json, and Claude Code pastes that JSON’s text into the conversation as a system reminder. The rest of this guide builds one that injects the current git branch and the number of uncommitted files, plus the mistakes that break it.
What a SessionStart Hook Actually Does
A hook, in Claude Code’s terms, is a user-defined command that runs at a fixed point in the session lifecycle instead of waiting for the model to decide to run it. SessionStart is one of several lifecycle events — others fire before or after a tool call — and it specifically runs when a session begins. Five “matchers” control which kind of start triggers it: startup for a brand-new session, resume for --resume, --continue, or /resume, clear for /clear, compact for auto or manual compaction, and fork for a session branched off another one. Leaving the matcher blank, or setting it to "*", runs the hook on all five.
This is a narrower tool than CLAUDE.md, and that’s the point. CLAUDE.md is for context that doesn’t change between sessions — your stack, your conventions, the commands you always run. A SessionStart hook is for the handful of facts that are only true right now: which branch you’re on, how many files are dirty, whether a deploy is mid-flight. Anthropic’s own hooks documentation is explicit about this split, and treating the two as interchangeable is the first mistake most people make — they either stuff fast-changing state into CLAUDE.md, where it goes stale, or they write a hook to repeat something CLAUDE.md already says on every single session start.
Step 1: Decide What’s Actually Worth Injecting
Before writing any shell code, list the two or three facts that change often enough to need checking every session but aren’t worth asking Claude to run a command for. Branch name and dirty-file count are a reasonable starting pair: they’re one `git` call each, they’re almost always relevant to a coding session, and they cost nothing to compute. Resist the urge to dump a full `git log` or a directory listing in here — the hook’s job is a short factual nudge, not a briefing document.
There’s a hard ceiling worth knowing about before you design the payload: strings over 10,000 characters don’t get inserted directly. Claude Code writes them to a file instead and gives Claude only a 2,000-character preview of that file’s path — and Claude is never instructed to go open it. In practice that means anything you actually need Claude to act on has to fit in a couple of sentences, well under that limit, or it quietly becomes an optional file Claude usually won’t read.
It also matters how you phrase what you inject. Anthropic’s own guidance on additionalContext is to write it as a plain factual statement — “the current branch is X” — rather than as an instruction aimed at the model, because text that reads like a command can trip the same defenses Claude Code uses against prompt injection coming from untrusted tool output. That’s a second reason to keep the payload short and declarative instead of writing it as a to-do list for Claude to follow.
Step 2: Write the Script
The script below reads git state with two commands, formats it as a single sentence, and wraps that sentence in the exact JSON shape Claude Code expects. Save it as .claude/hooks/session-context.sh and make it executable with chmod +x .claude/hooks/session-context.sh.
#!/bin/bash
# .claude/hooks/session-context.sh
BRANCH=$(git -C "$CLAUDE_PROJECT_DIR" branch --show-current 2>/dev/null)
CHANGES=$(git -C "$CLAUDE_PROJECT_DIR" status --short 2>/dev/null | wc -l | tr -d ' ')
if [ -z "$BRANCH" ]; then
exit 0
fi
jq -n --arg ctx "The current git branch is $BRANCH with $CHANGES uncommitted changed files." '{
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: $ctx
}
}'
Every line here is doing a specific job, and skipping one is usually how this breaks in practice. git -C "$CLAUDE_PROJECT_DIR" runs git against the project root rather than whatever directory the hook happens to execute in — Claude Code sets that environment variable for you, so the script works even if it’s invoked from somewhere unexpected. The 2>/dev/null on both git calls swallows the “not a git repository” error so a non-repo folder gets an empty string instead of a crash. The early exit 0 when BRANCH is empty matters because printing nothing and exiting 0 is how a hook declines to add context — exiting 0 with no stdout is a deliberate no-op, not a failure. And the output is built with jq -n --arg rather than a hand-written string, because branch names can contain characters that would break naive string interpolation inside JSON; --arg escapes the value for you.
Step 3: Register the Hook
Point Claude Code at the script from .claude/settings.json. The matcher here covers both a fresh launch and a resumed one, which is the combination most people actually want — you usually don’t need fresh git state on every /clear inside a long session.
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/session-context.sh",
"args": [],
"timeout": 10
}
]
}
]
}
}
The timeout field is worth setting explicitly rather than trusting the default, because SessionStart hooks run in the background while you start typing, but Claude’s first response still waits for them to finish — a hook that hangs on a slow network filesystem delays every session, not just the one where it’s slow.
Step 4: Confirm It’s Actually Reaching Claude
Start a new session in the project and ask Claude directly what branch it thinks you’re on. If the hook fired correctly, the answer comes from the injected sentence, not from Claude running git branch itself — you can tell the difference because Claude answers instantly, before making any tool call. If you want to see the raw mechanics, run the script by hand first: .claude/hooks/session-context.sh should print a single JSON object to your terminal with no errors. If it prints nothing at all, you’re on an untracked path or $CLAUDE_PROJECT_DIR isn’t a git repo, which is the expected no-op case from Step 2.
It’s also worth knowing what Claude Code hands your script on a resumed session, since that’s the case this hook is arguably most useful for. On resume or fork — and only when there’s at least one prior response to resume from — the input JSON on stdin gains extra fields: seconds_since_last_response, context_tokens, and prompt_cache_likely_expired. None of those are used in the script above, but they’re sitting right there on stdin if you want a second version of this hook that warns you when you’re resuming a session old enough that its prompt cache has gone cold — a case where re-running the same prompt costs noticeably more than it would have five minutes earlier.
Errors You’ll Probably Hit, and the Actual Fix
Most SessionStart hook failures fall into a short, predictable list, and each one has a one-line fix once you know what Claude Code expects.
| Symptom | Likely cause | Fix |
|---|---|---|
| Hook never seems to run | Script isn’t executable | Run chmod +x on the file; Claude Code won’t set the bit for you |
| “command not found: jq” | jq isn’t installed on this machine | Install jq first — the hook has a hard dependency on it, it isn’t optional |
| Context never shows up in long sessions | additionalContext string exceeded 10,000 characters | Shorten the payload to a sentence or two; Claude only gets a path preview above that limit |
| Claude references stale branch info after you switch branches mid-session | SessionStart doesn’t rerun mid-session | Use /clear or start a new session — or pair it with a UserPromptSubmit hook if you need live updates |
| Hook silently fails and nothing is injected | A crash or non-zero, non-two exit code is treated as a non-blocking error | Check your settings.json for "onFailure": "block" if a missing context block should stop the session instead of continuing quietly |
Where This Fits Next to Your Other Hooks
A SessionStart hook answers “what does Claude need to know right now,” which is a different question from the one a PreToolUse hook answers — “should this specific command be allowed to run.” If you’re already using a PreToolUse hook to block destructive commands, or running Claude Code unattended with dontAsk permission mode in CI, a SessionStart hook is the natural next piece: it feeds the model accurate state instead of trusting it to infer the right branch or guess at uncommitted changes. The same instinct that makes you check an MCP server’s permissions before connecting it applies here too — a SessionStart script runs on every single session with no approval prompt, so keep it to read-only git commands and skip anything that writes.
If you work across several branches at once, the git status this hook reports gets more useful, not less, once each branch has its own working directory — see our walkthrough on running parallel Claude Code sessions with git worktrees for how that setup changes what “current branch” even means per session. And if your git calls ever need more than branch name and file count — say, the last commit message or ahead/behind counts against origin — the same jq -n --arg pattern scales to multiple fields; just add more --arg flags and build a longer sentence, keeping the 10,000-character ceiling in mind.
The full field reference for SessionStart — including the agent_type and session_title fields this guide didn’t need — is in Anthropic’s Claude Code hooks documentation. For the JSON construction itself, the jq manual covers the escaping rules behind --arg in more depth than this script needs, but it’s worth skimming once if you plan to extend the payload.
Photo: Laptop computer by KK IN HK, licensed under CC BY-SA 4.0, via Wikimedia Commons.



