jared hebb ~ %

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
startup resume clear compact fork

What it receives

The event arrives as JSON on standard input. These are the fields worth reading.

FieldTypeWhat it is
sourcestringOne of startup, resume, clear, compact, fork. Also what the matcher filters on.
modelstringOptional. The model the session is running.
agent_typestringOptional. Set when the session is a subagent.
session_titlestringOptional.
session_idstringStable for the session.
cwdstringWorking directory.
transcript_pathstringPath 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.

ExitWhat happens
0Success. stdout is added as context for Claude.
2Shown as a hook error notice. The session carries on regardless.
otherNon-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.