Field note · 28 August 2026 · Daniel Wright

A UserPromptSubmit hook can exit 0 with tidy JSON and still inject nothing. Top-level additionalContext is silently ignored.

A cream folded note lies unused on the lid of a closed slate-blue writing box; the nested inner tray is empty
The note sat on the lid. The box never opened.

The annoyance

I wrote a UserPromptSubmit hook to put the current branch on every prompt. The script exits 0. The JSON is valid. Claude still does not know which branch I am on, and nothing in the transcript says the hook failed.

I had the usual suspects lined up. A permissions miss. A timeout. A matcher I had invented. Then I printed the JSON by hand and watched Claude stay silent. The hook had run. The field I named was the one the docs use. The context still never arrived.

It is not a race. It is the documented shape of the envelope.

What Anthropic actually says

From the official hooks guide, under structured JSON output for UserPromptSubmit:

“Nest additionalContext inside hookSpecificOutput; if you place it at the top level of the JSON, Claude Code silently ignores it.”

The same contract sits on the hooks reference: return additionalContext inside hookSpecificOutput, alongside the event name. Claude Code wraps that string in a system reminder and inserts it next to the prompt. It does not appear as a chat message, which is why a silent miss looks like a working hook. UserPromptSubmit decision control lists the nested field, and it is the one that actually injects.

Two channels work on exit 0. Plain-text stdout, if it does not start with {, is added as context. JSON that starts with { is parsed as structured output, and then only the nested field is honoured. Once I print a tidy object with additionalContext at the top level, I have left the plain-text path and given Claude Code a field it has been told to ignore. Valid JSON. Exit 0. Nothing injected.

SessionStart is a different contract. Plain stdout already reaches Claude there; the JSON envelope is for combining context with sessionTitle and the other session fields. A SessionStart hook that only loads context can print a line of text and stop. The silent footgun the guide calls out is specifically UserPromptSubmit top-level additionalContext. Do not copy a SessionStart one-liner into a prompt hook and assume the same rules apply.

The gate

Keep the script in the main checkout, where CLAUDE_PROJECT_DIR can find it — the same split as hook paths and the worktree — and print the nested envelope.

.claude/settings.json
{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/inject-branch.sh"
          }
        ]
      }
    ]
  }
}
.claude/hooks/inject-branch.sh
#!/bin/bash
# inject-branch.sh
# UserPromptSubmit: nest additionalContext inside hookSpecificOutput.
# A top-level additionalContext field is silently ignored.

INPUT=$(cat)
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
DIRTY=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')

# Wrong (silent): {"additionalContext": "Current branch: $BRANCH"}

jq -n --arg branch "$BRANCH" --arg dirty "$DIRTY" '{
  hookSpecificOutput: {
    hookEventName: "UserPromptSubmit",
    additionalContext: ("Current branch: " + $branch + ". Uncommitted files: " + $dirty + ".")
  }
}'

Then: chmod +x .claude/hooks/inject-branch.sh

UserPromptSubmit has no matcher. Do not invent one. The wrong shape is a one-liner, and it is the one I keep finding in settings files:

Wrong shape · silent
{"additionalContext": "Current branch: release-42"}
Right shape · official example
{
  "hookSpecificOutput": {
    "hookEventName": "UserPromptSubmit",
    "additionalContext": "Current branch: release-42. Deploy freeze until Friday."
  }
}

Prove it

Prove the script prints a nested field, then prove Claude Code loaded it. Delivery does not appear as a chat message, so the transcript will not help.

Self-check · must print a nested string
# Must print a nested string, not null
.claude/hooks/inject-branch.sh </dev/null | jq -r '.hookSpecificOutput.additionalContext'
# then in-session: /hooks
# then: /debug  — look for the hook name as a system reminder

If /hooks does not list the handler, the gate is not installed — the same check as the exit-code gate. If jq prints null, the field is still at the top level. If the script is right and Claude still cannot see the branch, open the debug log. Anthropic says that is where the system reminder lands.

Why it fails

I treat additionalContext as a top-level key because that is how most APIs take a payload. The name is the same. The place is not. In Claude Code the field only counts when it is nested, and a leading { turns the whole stdout into JSON, so the plain-text fallback never runs. The hook looks successful. The context never arrives. Claude then plans a deploy from the wrong branch, or ignores a freeze I thought I had injected, and I go looking for a model fault.

This is the same class of mistake as the other notes in this series: the output looks correct, and the documented field is a different one. Same family as the silent Opus tax and the compact drop — nothing throws, so nothing tells you the default you never chose.

Sources

Make the next AI decision concrete.

NavAIgate helps leadership teams identify high-value AI opportunities, prove them safely and turn the winners into working systems.