Skip to main content

SessionStart: systemMessage vs additionalContext

The harness audit produced two small jobs that wanted doing together: five allow-rules belonging to a different project had drifted up into user-level settings.json and were polluting global scope, and the Open work checklist in CLAUDE.local.md was invisible at session start because nothing surfaced it. /update-config handled both — and the under-documented part of the SessionStart fix turned out to be which JSON key the hook should emit. systemMessage shows the content in the UI to the human running the session. additionalContext injects it into the model’s context. For a checklist the human is meant to read first thing, those are not interchangeable.

What I ran

/update-config invoked twice in the same session:

  • Pass 1: relocate the five stranded allow-rules from ~/.claude/settings.json into the bot project’s own .claude/settings.local.json where they belonged.
  • Pass 2: write a new SessionStart hook script and register it in this project’s .claude/settings.json.

Inputs: the existing user-level settings.json (5 allow rules + 10 deny + 1 PreToolUse hook + 1 Notification hook), the bot project’s existing .claude/settings.local.json (55 allow rules, all gcloud), and the ## Open work section of CLAUDE.local.md.

What happened

Allow-rule relocation. The skill’s “READ before WRITE, MERGE not REPLACE” rule earned its keep on the first pass. Editing ~/.claude/settings.json blind would have wiped the deny array and the two hook entries. Instead the skill read the file, identified the 5 misplaced allow-rules (variants of Bash(gcloud run deploy:*) and similar), checked against the bot project’s existing 55 entries to confirm they weren’t exact duplicates, appended them there, and zeroed out the user-level allow array. Deny rules and hooks at user level stayed exactly where they were.

FileBeforeAfter
~/.claude/settings.json allow50
bot-project/.claude/settings.local.json allow5560
Project hooks count12

SessionStart hook. The script at .claude/hooks/session-start-checklist.mjs is 18 lines — reads CLAUDE.local.md, regex-extracts the ## Open work section, emits a single JSON line. Registered in .claude/settings.json next to the existing PostToolUse Skill activation log:

{
  "hooks": {
    "PostToolUse": [ /* existing Skill log hook */ ],
    "SessionStart": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/session-start-checklist.mjs"
          }
        ]
      }
    ]
  }
}

The script’s emitted JSON:

console.log(JSON.stringify({
  systemMessage: `## Open work\n\n${openWorkSection}`
}));

systemMessage is the right key for this job. Claude Code reads the hook’s stdout, parses the JSON, and surfaces the systemMessage value to the human in the session UI — the same channel system notifications already use. The model doesn’t see it as context; the human reads it as a banner.

additionalContext would have done the opposite: silently injected the open-work list into the model’s context window for the rest of the session. That’s the right choice for things like “the user’s current branch is X, the failing test is Y” — facts you want the model to act on without belaboring the human. It is the wrong choice for an open-work checklist that’s meant to be a reading prompt for the operator, not background knowledge for the model.

Both keys are accepted on the same SessionStart hook event. The hook docs cover the schema; the load-bearing distinction between the two output channels is less prominent than it deserves to be.

Where it drifted

The skill’s pipe-test step caught a real edge case before the hook landed. The first version of the regex was /## Open work\n([\s\S]+?)\n## / — worked on a synthetic test file with a trailing section after Open work, missed the trailing content in the actual CLAUDE.local.md because ## Open work is the last ## section and the regex anchor \n## never matched at end-of-file.

Tightened to /## Open work\n([\s\S]+?)(?=\n## |\n*$)/ — the lookahead matches either the next ## heading or end-of-file. Pipe-test exited 0 with valid JSON containing the full ~1.2 KB section.

That’s the receipt for the skill’s pipe-test discipline: had the regex shipped untested, the SessionStart hook would have silently emitted an empty checklist at every session start. No error, no warning, just a blank banner — the worst kind of bug because it pretends to work.

The other small thing worth knowing: when relocating allow-rules across files, exact-duplicate detection isn’t sufficient. The 5 user-level rules were variants of what was already in the bot project’s local settings (different gcloud subcommands hitting the same overall pattern). Removing them as duplicates would have lost coverage. The skill defaulted to “merge unique entries, leave near-duplicates alone” — which is the right call when the operator can audit afterward.

What I’d change

Two things for the next hook of this shape.

1. Treat the systemMessage / additionalContext choice as the first design decision, not the last. For SessionStart specifically, ask: who is the audience for this output? If it’s the operator who needs to see something before the session starts (“you have 3 unfinished todos,” “your last commit was 4 days ago”), systemMessage. If it’s the model that needs context to be helpful (“the user’s current focus is X, the test framework is pytest, the recent failure is Y”), additionalContext. The keys are not interchangeable and choosing wrong wastes the hook entirely.

2. Pipe-test every regex on real files, not synthetic ones. The end-of-file edge case wouldn’t have shown up on a hand-written test fixture where someone (me) would have naturally added a trailing section. It showed up on the actual CLAUDE.local.md because the file’s structure puts Open work last. Real input is where the bugs are. The minimum-viable pipe-test for any hook that parses an existing repo file is: run it against the actual file in the repo, before registering the hook.

The wider rule the two passes leave behind: user-level settings.json is for global preferences (universal allow rules, universal deny rules, universal hooks). Project-level settings.json and .claude/settings.local.json are where project-specific allow-rules belong. Drift between scopes is silent — nothing prompts when a per-project rule sneaks up to user level — so a periodic relocation audit is worth the five minutes.