Claude Code hooks
The SessionStart hook in Claude Code
SessionStart fires when a session begins or resumes, and can inject context before you type anything. Fields, the source matcher, and what it can return.
The comparison worth making is to UserPromptSubmit: that one runs on every message, while this one runs once per session or resume. Session-wide facts that will not change during the run belong here; anything that could be different by the next message belongs on the per-message event instead.
- When it fires
- When a session begins or resumes.
- Can it block?
- No. It is there to add context, not to stop anything.
- Matcher
- Yes, on
source - Matcher examples
startupresumeclearcompactfork
What it receives
The event arrives as JSON on standard input. These are the fields worth reading.
| Field | Type | What it is |
|---|---|---|
source | string | One of startup, resume, clear, compact, fork. Also what the matcher filters on. |
model | string | Optional. The model the session is running. |
agent_type | string | Optional. Set when the session is a subagent. |
session_title | string | Optional. |
session_id | string | Stable for the session. |
cwd | string | Working directory. |
transcript_path | string | Path to the transcript. |
Exit codes
The exit code is the decision. Anything the script writes to standard error on a blocking exit is what Claude gets told.
| Exit | What happens |
|---|---|
0 | Success. stdout is added as context for Claude. |
2 | Shown as a hook error notice. The session carries on regardless. |
other | Non-blocking error. First line of stderr goes in the transcript. |
What it can return
This event returns more than most. initialUserMessage starts the session off with a message as though you had typed it. watchPaths registers files whose changes should raise the FileChanged event. sessionTitle names the session, and reloadSkills picks up skills added since the last run.
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "string",
"initialUserMessage": "string",
"sessionTitle": "string",
"watchPaths": [
"absolute path"
],
"reloadSkills": true
},
"systemMessage": "string",
"continue": true
}
Starting every session with the state it needs
Match on startup so the hook runs for a fresh session but not on every resume, then print what Claude would otherwise have to go and find.
In settings.json
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{ "type": "command", "command": "node ~/.claude/hooks/load-context.mjs" }
]
}
]
}
}
The script
const fs = require('fs');
const path = require('path');
const home = process.env.HOME || process.env.USERPROFILE;
const file = path.join(home, '.claude', 'tasks.md');
const open = fs.existsSync(file)
? fs.readFileSync(file, 'utf8').split('\n').filter(l => l.startsWith('- [ ] ')).map(l => l.slice(6))
: [];
const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: open.length ? `Open tasks: ${open.join('; ')}` : 'No open tasks.',
// Absolute paths only. A relative one is silently ignored.
watchPaths: [path.join(cwd, '.env'), path.join(cwd, 'package.json')]
}
}));
process.exit(0);
Both fields have to sit inside hookSpecificOutput with the event name beside them. At the top level they are ignored, and the failure is quiet: additionalContext looks like it worked, because SessionStart adds plain stdout to the context anyway, so Claude ends up reading the raw JSON as text. Only the command and mcp_tool hook types work on this event. There is no prompt or agent type here.
The catch
Whatever this prints is in the context window for the whole session, so it is charged on every turn. A hook that dumps a large file into additionalContext makes every message in that session more expensive.