Skip to content

[hooks] Keep session-start context within Claude Code's hook output cap - #343

Open
azank1 wants to merge 3 commits into
vercel:mainfrom
azank1:fix/session-start-output-cap
Open

azank1 wants to merge 3 commits into
vercel:mainfrom
azank1:fix/session-start-output-cap

Conversation

@azank1

@azank1 azank1 commented Oct 15, 2026 •

Copy link
Copy Markdown

Problem

Claude Code caps a hook's plain stdout at 10,000 characters. Over the cap, it saves the output to a file in the session directory and replaces it with the file path and a preview of up to the first 2,000 characters. From the hooks reference, "Output size limits": "Claude Code doesn't ask Claude to read the file, so keep anything Claude must always see within the cap."

inject-claude-md.mjs prints vercel-session.md and the knowledge-update skill body as one SessionStart output. On main that output is 10,425 characters in a Vercel project and 10,830 in an empty directory. The preview ends at the last line break before 2,000 characters, so in every activated session Claude sees the session context and the first 1,249 of the knowledge update's 9,852 characters. Everything after the "Vercel is a full compute platform" bullet is missing from context, including:

  • the "Integrations on Vercel" section, which tests/inject-claude-md.test.ts describes as the load-bearing contract every activated session gets
  • the Next.js section that points agents at node_modules/next/dist/docs/
  • in empty directories, the "Greenfield execution mode" block, which is appended last (the profiler's one-line greenfield hint still arrives)

The hook runs on startup, resume, clear and compact, so the same happens after every compaction.

Reproduction

Output size, with the hook run the way hooks.json runs it ($PLUGIN is a checkout of main or an installed 0.53.0 or later):

cd "$(mktemp -d)" && echo '{}' > vercel.json
echo '{"session_id":"repro","hook_event_name":"SessionStart","source":"startup"}' \
  | VERCEL_PLUGIN_TELEMETRY=off node "$PLUGIN/hooks/inject-claude-md.mjs" | wc -m
10425

In an empty directory (no vercel.json) the same command prints 10830.

What Claude receives: I ran Claude Code 2.1.300 in print mode with this repo loaded through --plugin-dir (with .mcp.json removed so no MCP connection is attempted), an isolated CLAUDE_CONFIG_DIR, and ANTHROPIC_BASE_URL pointed at a local server that records the Messages API request. The first user message of the recorded request contains:

<system-reminder>
SessionStart:startup hook success: <persisted-output>
Output too large (10.2KB). Full output saved to: <config>/projects/<project>/<session>/tool-results/hook-<id>-stdout.txt

Preview (first 2KB):
# Vercel Plugin Session Context

Use Vercel guidance only when the current repo, prompt, or tool call makes it relevant.
[18 lines omitted]
- **Vercel is a full compute platform**, not just a frontend/static site host. It runs full backend frameworks (Express, FastAPI, NestJS, Hono, etc.) natively with zero configuration.
...
</persisted-output>
</system-reminder>

"Integrations on Vercel", "hardcode a provider SDK" and node_modules/next/dist/docs/ do not appear anywhere in the request. In the empty-directory run, "Greenfield execution mode" does not appear either. With the same setup and the 0.50.0 hook (8,009 characters), the whole output is in the request.

Root cause

The output grew past the cap as content was added; no single change is at fault. Hook output size per release, measured with the command above:

Version Vercel project Empty directory
0.50.0, 0.50.1 8,009 8,414
0.51.0 8,852 9,257
0.52.0 9,928 10,333
0.53.0, 0.54.0 10,008 10,413
0.54.1, main 10,425 10,830

#282 added the Next.js section and took the empty-directory case over the cap, #290 took the Vercel-project case over, and #307 added another 417 characters. The existing tests check that the content is in the hook's stdout, which it is, so the size never failed a check.

Fix

  • hooks.json runs inject-claude-md.mjs a second time with --knowledge-update. That invocation prints only the knowledge update (9,852 characters). The existing invocation prints the session context (570 characters, or 976 with the greenfield guidance). Claude Code measures each hook's stdout on its own, "even when several hooks run for the same event" (same docs section), so both arrive in full. A flag on the same script keeps the activation rules in one place; both invocations use them unchanged.
  • buildInjectClaudeMdParts now puts the greenfield guidance before the knowledge update. OpenCode joins all parts into one context string, so with this order its context still equals the two Claude Code outputs joined in hooks.json order, which is what tests/opencode-plugin.test.ts asserts.
  • hooks/inject-claude-md.mjs is rebuilt with bun run build:hooks, and CLAUDE.md lists the new registration.

Trade-offs

  • The knowledge update alone is 9,852 characters, 148 under the cap. The new test fails any change that pushes a single output over 10,000 characters, so a later sync that grows the section past the cap will need a trim or a further split. That is intended: past the cap the content is dropped without an error anywhere. Two open PRs edit the knowledge update. With [skills] Sync knowledge-update with current docs #322 merged into this branch it is 9,908 characters and the tests pass. With draft Scope skill routing and setup to the selected architecture #333 it is 10,425 characters and the new test fails, so that change would need a trim.
  • One more node process at session start. It runs in parallel with the other SessionStart hooks and took 45 to 120 ms per run on this machine.
  • Claude Code adds the two outputs as separate SessionStart reminders, and their order is not fixed: in my empty-directory run the knowledge update came first. The two sections do not refer to each other.
  • Cursor gets two additional_context values from this hook instead of one. The session-start profiler already returns additional_context to Cursor whenever it has a message (for example when the Vercel CLI is missing), so more than one SessionStart hook supplying it is not new. I did not test in Cursor.

Not taken: trimming skills/knowledge-update/SKILL.md to fit one hook is a content decision for its owners, and the docs sync updates that file. Replacing the injection with a pointer to the knowledge-update skill would change the always-on behavior the tests describe.

Testing

  • New test each session-start output fits within Claude Code's hook output cap runs every inject-claude-md invocation registered in hooks.json, for a Vercel project and an empty directory. It checks that each output is at most 10,000 characters and that together they contain the session context, ## Integrations on Vercel and, for the empty directory, the greenfield guidance. On main it fails with Expected: <= 10000 / Received: 10425. On this branch it passes.
  • The content tests in tests/inject-claude-md.test.ts and the OpenCode session-context tests now run every registered invocation, since the knowledge update moved to the second one. The Cursor test also checks the second invocation's JSON.
  • bun test: 1079 pass, 0 fail on main (d874dac); 1080 pass, 0 fail on this branch, across 60 files. bun run typecheck and bun run build:from-skills:check pass on both, and bun run validate passes on both (on the branch with --coverage skip, which skips the vercel.com/llms.txt fetch). bun run build:all on the branch leaves the tree unchanged.
  • End to end with Claude Code 2.1.300 and the recording setup above: the request contains the session context, the full knowledge update and, in an empty directory, the greenfield guidance, each as its own SessionStart reminder, and no <persisted-output> block.

Environment

Linux Mint 22.3 (kernel 7.0.0-38-generic), Node 20.20.2, Claude Code 2.1.300, Bun 1.4.3 for the test suite. Plugin at main d874dac (0.54.1).


Update (@molebox)

Merged main. After the merge, the knowledge-update output was 10,191 characters, back over the cap (main added more content), and this PR's test failed. I replaced the fixed two-way split with a structural one so it keeps working as content grows:

  • One ordered list, packed into parts: the hook builds the full session-start context in order (session context, greenfield guidance, knowledge update) and packs it into parts of at most 9,000 characters. hooks.json runs it as --part 1/2 and --part 2/2, still two processes.
  • Where it splits: the knowledge update splits before ## headings. A section too long on its own splits between top-level bullets or paragraphs, never inside a code fence. Later parts repeat the title with "(continued)", because Claude Code runs matching hooks in parallel and may deliver outputs in any order.
  • Nothing is ever cut. If content outgrows the registered parts, the last part prints the rest and warns on stderr. That pushes it over 9,000, so the tests fail and tell you to add another --part entry.
  • OpenCode builds its context from the same parts, so it still equals the Claude Code outputs joined in hooks.json order.
  • Unit of measure: JS string length, which is what Claude Code checks (e.length <= 1e4 in the 2.1.220 binary).

Sizes on the merged tree:

Directory Part 1 Part 2
Vercel project 8,601 2,214
Empty directory 8,358 2,862

Tests run the real built hook as hooks.json invokes it. They check the size limit, that every ## section appears exactly once, ordering, and greenfield placement, plus growth, overflow and oversized-block scenarios. CLAUDE.md and vercel.md are updated. Checks: typecheck, validate, and bun run test (1124 pass).

Separate pre-existing bug, not fixed here: the hook exits silently when started through a symlinked path (e.g. /var → /private/var).

Claude Code caps a hook's stdout at 10,000 characters. Longer output is
saved to a file and replaced with the file path and a preview of up to
2,000 characters, and Claude is not asked to read the file. The
inject-claude-md output has been over the cap since 0.52.0 in empty
directories and since 0.53.0 in Vercel projects (10,830 and 10,425
characters on main), so sessions see only the start of it and lose the
rest of the knowledge update, including the Integrations on Vercel
section, the Next.js bundled docs pointer and, in empty directories, the
greenfield guidance.

hooks.json now runs inject-claude-md twice: once for the session context
and greenfield guidance (570 or 976 characters) and once with
--knowledge-update for the knowledge update (9,852 characters). The
greenfield guidance moves ahead of the knowledge update, so the OpenCode
context, which joins all parts into one string, still equals the two
Claude Code outputs joined in hooks.json order.

A new test runs every registered invocation and fails if any output is
longer than 10,000 characters.
@vercel
vercel Bot requested a review from Melkeydev October 15, 2026 14:02
After merging main, the --knowledge-update output alone is 10,191
characters, over Claude Code's 10,000-character cap (it compares the JS
string length of each hook output), so the knowledge update was again
replaced with a file path and a 2,000-character preview.

inject-claude-md now builds the session context, greenfield guidance and
knowledge update as one ordered list of blocks, splits the knowledge
update at `##` sections (and a section that is too long on its own at its
top-level list items and paragraphs, never inside a code fence), and packs
the blocks greedily into parts of at most 9,000 characters. A part that
continues the knowledge update repeats its title with "(continued)".
hooks.json runs the hook once per part with `--part <i>/<n>` (two parts).
If the content ever needs more parts than are registered, the last part
prints all of the rest and the hook warns on stderr, so nothing is
dropped, and the tests fail.

OpenCode builds its context from the same parts, so it still equals the
Claude Code outputs joined in hooks.json order.

Tests run the built hook the way hooks.json does, in a Vercel project and
an empty directory, and assert every output is at most 9,000 characters,
every `##` heading of the knowledge update appears exactly once, and every
line of the session context and knowledge update appears in order. Fixture
tests cover a knowledge update that grows past one part, content that
outgrows the registered parts, and a single block longer than the limit.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants