Claude Code SessionStart Hook: Refreshing Context on Resume
Claude Code SessionStart Hook: Refreshing Context on Resume
Let me tell you about the kind of bug that doesn't crash anything but quietly wastes an afternoon. You close a Claude Code session, grab lunch, come back an hour later, and pick up where you left off. Two things have shifted under you in that hour, and neither one announced itself: the working tree, and the world. Files you edited in another editor sit on disk in a different state. The dev server you swore was running was killed by a reboot. The PR you opened has merged. The credentials that were valid yesterday have rotated. Claude resumes from the conversation transcript, but the transcript is a snapshot of beliefs, not facts, and beliefs go stale fast.
The SessionStart hook with the resume matcher is the seam where I fix this. It runs every time Claude Code restarts a previously paused conversation, and it lets me inject a fresh status report \u2014 a small bundle of "here is what is actually true right now" \u2014 before the model gets its first user turn. Done well, it prevents the most common class of resume-day bug: the model confidently acting on stale assumptions.
In this article I'll walk through what the SessionStart hook is, why resume is the matcher you almost certainly want, what to put inside the refresh payload, and the failure modes that bite people when they first wire one up.
Why resume needs a refresh at all
What changes about a Claude Code session while you're away from it? Strictly speaking, nothing in the transcript \u2014 and that's exactly the problem. You can quit the CLI, reboot your machine, come back in three days, and claude --resume will reconstitute the conversation: same messages, same tool calls, same intermediate state. From the model's point of view, no time has passed. That's exactly what you want for continuity of thought, and exactly what's dangerous for continuity of facts.
Picture a session that paused right after the model wrote a migration file and asked you to review it. Three days later you resume. In those three days:
- A teammate merged a conflicting migration.
- You bumped the database version in a docker-compose file.
- The schema the migration targeted gained two new columns.
- The test suite gained a new fixture that overlaps with the one the model intended to add.
Nothing in the conversation transcript reflects any of this. The model's next suggested action \u2014 "now let's run the migration" \u2014 was correct in the world it last saw, and wrong in the world that exists now. A SessionStart resume hook is the cheapest defense against this drift, because it runs exactly once per resume, and its output lands in context before the model can act.
What the SessionStart hook actually is
There are six lifecycle moments Claude Code will run a shell command for you, and exactly one of them sits at the seam this article is about. Hooks live in your ~/.claude/settings.json (user scope) or .claude/settings.json (project scope) and they fire deterministically at the moments Claude Code documents: PreToolUse, PostToolUse, UserPromptSubmit, Stop, SessionStart, and a handful of others. The full event matrix is published at the Claude Code hooks documentation.
SessionStart fires whenever a session begins. The hook configuration accepts a matcher that scopes which start events trigger the command. The three matchers most people use are:
startup: freshclaudeinvocation, no prior transcript.resume:claude --resumeor picking a saved session out of the picker.clear: the/clearslash command produced an effectively-new conversation.
You can register a separate command per matcher, or share a single command and branch inside it. Either pattern works; the per-matcher form is easier to reason about, because the shell script for "first launch of the day" usually has nothing in common with the shell script for "resume a three-day-old session". I'd keep them split unless they really do share logic.
A minimal resume-refresh hook
You don't have to commit to this layout on the first try \u2014 copy it, run it once on your next resume, and shape it as you see what your own drift actually looks like. Drop it into ~/.claude/settings.json and adapt the command to your environment.
{
"hooks": {
"SessionStart": [
{
"matcher": "resume",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/resume-refresh.sh"
}
]
}
]
}
}
The accompanying script is just a shell program that prints to stdout. Anything you write to stdout gets appended into Claude's context for that turn. There's no special protocol; your script is responsible for choosing what's worth saying.
#!/usr/bin/env bash
# ~/.claude/hooks/resume-refresh.sh
set -euo pipefail
echo "=== Resume refresh: $(date -u +%FT%TZ) ==="
echo
echo "## Git state"
git -C "$CLAUDE_PROJECT_DIR" status --short --branch 2>/dev/null || echo "(not a git repo)"
echo
echo "## Recent commits"
git -C "$CLAUDE_PROJECT_DIR" log --oneline -10 2>/dev/null || true
echo
echo "## Modified files since last commit"
git -C "$CLAUDE_PROJECT_DIR" diff --stat HEAD 2>/dev/null || true
That's it. The script does three things \u2014 date, git status, modified-file summary \u2014 and it does them on every resume. The model now opens the resumed session with a small, fresh ground-truth report at the top of its working context.
What is worth putting in the refresh payload
The trap most people fall into is dumping too much. A 4000-token resume preamble isn't a refresh; it's a refactor of the conversation. The model has to re-read it on every subsequent turn (it's part of context now), and most of it will be irrelevant within five exchanges. Aim for under 500 tokens of output. My triage rule: "would the model behave differently if it didn't know this?". If the answer is no, leave it out.
Categories that earn their token weight on resume:
- Git state: current branch, uncommitted changes, last few commits. This is the single most valuable item, because file-system drift is the most common form of stale state.
- Active process status: is the dev server up, is the database container running, is the tunnel alive. A line per process is enough.
- Open issue or PR titles: only if the session was actively working on one. Title plus number plus state is sufficient.
- Recent CI results on the current branch: pass or fail of the last run, plus the workflow name.
- Time delta: how long since the session was paused. The model treats "five minutes ago" and "five days ago" differently, and it should.
- External event triggers: did the on-call rotation change, did a dependency you were tracking publish a release.
What doesn't belong in a resume refresh: full file contents, environment variables, anything secret, log tails over a few lines, anything that doesn't change between resumes. If it's static, put it in CLAUDE.md instead. That file is loaded once and lives outside the hook system, exactly as documented in the project memory section of the Claude Code docs.
Patterns that work in practice
A few wiring patterns have proven durable across teams.
The diff-since-pause pattern. Stamp a timestamp into a file at Stop-hook time. On SessionStart with resume, diff the working tree against the snapshot taken at pause. The output is a literal answer to "what changed while I was away", and it's enormously useful for the model. This requires pairing the resume hook with a Stop hook that writes the snapshot; both events are part of the same documented hook matrix.
The capability probe pattern. Some sessions depend on long-running things: a Docker stack, a Postgres container, a websocket bridge to a remote agent. Probe each capability with a one-second timeout and report up or down. The model then knows which tool calls will succeed before it tries them, instead of discovering a connection refused mid-plan.
The branch fingerprint pattern. Record the HEAD SHA and branch name into a tag stored in the conversation's metadata, then compare on resume. If HEAD has moved more than ten or so commits, surface that loudly. Sessions paused across a feature-branch merge are the highest-drift case I've seen.
The on-pause TODO pattern. Many resume sessions exist because the operator paused mid-task to grab lunch or shift to another conversation. A short TODO snapshot captured from the assistant's last Stop message and re-shown at resume keeps the agent on the original task, instead of inferring a new one from the resumed prompt.
Pitfalls
The hook command runs in your shell environment, not the model's sandbox. That has three consequences worth internalizing.
First, anything your script reads \u2014 including the contents of git diff \u2014 becomes part of the model's context. If your repository contains an .env.local file with secrets that show up in a git status --short line, that file path is now visible to the assistant. Path leakage is usually harmless, but don't pipe file contents you wouldn't want logged. Scrub or scope aggressively.
Second, the script blocks session startup. If it takes eight seconds to run, every resume takes eight seconds. Aim for under one second total. Run probes in parallel with & and wait. Cache anything you can; the network call to GitHub for the latest CI status can be replaced by reading a locally-mirrored status file that a separate cron job keeps fresh.
Third, hook errors are surfaced to the operator but don't prevent the session from starting. Your refresh script crashing isn't a fatal condition; it just means the model resumes without the refresh. That's the right default, but it means a silently-broken hook can rot for weeks before you notice the model drifting again. Add a || echo "(resume refresh failed: $?)" at the bottom so failures show up as a visible breadcrumb in the resumed context rather than as silence.
A fourth, subtler pitfall: the resume hook runs before the model sees the user's next prompt. So you can't inspect what the user is about to ask and tailor the refresh to it. Treat the resume payload as a generic ground-truth dump, not a query response.
When to choose hooks over CLAUDE.md or slash commands
There's overlap between the three context-injection mechanisms Claude Code exposes. The clean partition is by frequency and freshness requirement.
CLAUDE.md is for static project knowledge: conventions, architectural rules, the build command list. Loaded once, rarely changes, lives in the repo.
Slash commands are for operator-initiated context: the user asks for something, the command produces a one-shot bundle, the model uses it for the current turn. Fresh, but on demand.
Hooks are for deterministic, event-driven context: every time event X fires, run script Y, append its output. The resume refresh is the canonical example. It has to run, it has to run on a precise event, and the operator shouldn't have to remember to invoke anything.
If you find yourself adding the same /status-refresh slash command to every resumed session, that's the signal that the work belongs in a SessionStart plus resume hook instead. Conversely, if the refresh you need is "the schema of the current ticket I'm about to discuss", that belongs in a slash command, because it depends on operator intent rather than time.
Conclusion
The resume gap is a small, regular, easily-fixed defect. A thirty-line shell script attached to the SessionStart hook with the resume matcher pays for itself the first time it prevents the model from running a stale migration or pushing to a branch that was force-pushed away under it. Keep the payload small, keep the runtime fast, scrub anything sensitive, and let the snippet above be the start of a refresh routine you grow as you discover the specific drifts your projects actually suffer. The hook isn't glamorous, but it's the difference between an assistant that resumes confidently in the world that exists, and one that resumes confidently in the world that used to.