{"slug": "my-claude-code-sessionstart-hook-sent-305k-characters-almost-none-of-it-reached", "title": "My Claude Code SessionStart hook sent 305k characters. Almost none of it reached the conversation (the cap is 10,000)", "summary": "A developer found that their Claude Code SessionStart hook was emitting roughly 305,000 characters of project \"pickup\" notes per session, far exceeding Claude Code's undocumented 10,000-character cap on hook output, so nearly all of it was being replaced by a file path and a 2,000-character preview. Using git history of their backed-up settings, they traced the payload from 14,559 characters on May 10 to 305,295 characters on November 12, and noted the cap has no setting or environment variable to raise it.", "body_md": "I run several projects in parallel with Claude Code, and for each one I keep a small file with \"what to do next\" (I call these files pickups). A SessionStart hook passed all of them to Claude at the start of every conversation.\n\nWhile cleaning up my PC, I measured how big the pickups had become: about 530 KB. I remembered that hooks have an output limit, so I looked into it.\n\nShort version: a hook can put at most 10,000 characters into the conversation, and my pickups were far beyond that, so almost none of them were reaching the start of the conversation. Here is how I checked it and how I fixed it.\n\nA SessionStart hook is a command that runs when a conversation starts. It prints JSON, and the string in `additionalContext` goes into the conversation's context.\n\nThe old hook simply concatenated the index and every pickup file (simplified):\n\n``` bash\n#!/usr/bin/env bash\nDIR=\"$HOME/.claude/memory\"\ncontent=$(cat \"$DIR/pickup-queue.md\")\nwhile IFS= read -r -d '' f; do\n  content=\"$content\"$'\\n\\n'\"$(cat \"$f\")\"\ndone < <(find \"$DIR/pickup-queue\" -maxdepth 1 -type f -name '*.md' -print0 | sort -z)\n\nprintf '%s' \"$content\" | jq -Rsc '{\n  hookSpecificOutput: {hookEventName: \"SessionStart\", additionalContext: .}\n}'\n```\n\nThe official hooks documentation says:\n\nA hook's `additionalContext`, `systemMessage`, and `initialUserMessage` strings, and its plain stdout, are capped at 10,000 characters\n\nClaude Code saves the output to a file in the session directory. It replaces the output with the file path and a preview of up to the first 2,000 characters.\n\nUnlike that Bash ceiling, this cap has no setting or environment variable to raise it.\n\nSo once a hook goes over 10,000 characters, the whole output is saved to a file, and the conversation only gets the file path and a preview of up to 2,000 characters. There is no setting to raise the cap.\n\nThe full text my old hook tried to send (the index plus 35 pickup files) was 305,295 characters, counted in UTF-16 units. That is about 30 times the cap. The index comes first, so even the preview would only cover the start of the index, never any project's next step.\n\nWhen I looked back at the start of the conversation where I was doing this investigation, I could not find anything from the pickups, not even a preview. That does not match the documentation (which says a preview is included), and I have not looked into why.\n\nTo see how long this had been going on, I used the history of the git repository where I back up my settings. For each day I took the last commit and added up the index and pickup files in UTF-16 units:\n\n```\ngit log --format='%h %ad' --date=short | awk '!seen[$2]++' | sort -k2 |\nwhile read h d; do\n  files=$(git ls-tree -r --name-only \"$h\" |\n          grep -E '^memory/pickup-queue\\.md$|^memory/pickup-queue/[^/]+\\.md$')\n  [ -z \"$files\" ] && continue\n  chars=$(for f in $files; do git show \"$h:$f\"; done |\n          jq -Rs 'explode | map(if . > 65535 then 2 else 1 end) | add')\n  echo \"$d $chars\"\ndone\n```\n\nOn the first day with a backup (May 10) it was already 14,559 characters. Backups before July 7 contain only the index and one pickup file, which is why the chart jumps in July. After that it grew as I added projects, reaching 305,295 characters on November 12.\n\nI have not checked since when Claude Code's cap has been 10,000 characters. So I can't say \"it never got through\"; I can only say \"it was over 10,000 characters for as long as I have records.\"\n\nI measured assuming the 10,000 is a JavaScript string length, that is, UTF-16 code units. The documentation does not state the unit, so this is my assumption. Emoji count as 2 in UTF-16, so the number is a bit larger than `jq`'s `length` (code points).\n\n```\nbash ~/.claude/hooks/inject-pickup-queue.sh |\n  jq '.hookSpecificOutput.additionalContext\n      | [length, (explode | map(if . > 65535 then 2 else 1 end) | add)]' -c\n```\n\nAt the time of writing, the old hook (30 pickup files) printed `[275902,276362]`: 275,902 code points, 276,362 UTF-16 units.\n\nSomething I learned along the way: Git Bash's `wc -m` counts UTF-16 units (`printf 'a😀b' | wc -m` gives 4). I first assumed it counted code points and had to redo the numbers.\n\nBeing under the cap does not prove the text actually gets in. I opened a fresh conversation with `claude -p` and asked whether it could see the heading that should be at the top (translated from the Japanese prompt I actually used):\n\n```\nclaude -p \"Is there a heading 'Pickup Queue' at the start of this conversation's context? \\\nIf so, copy the first 3 lines starting with '- ' right after it. If not, answer only 'no'. \\\nDo not use any tools.\" --max-turns 1\n```\n\nWith the fixed hook it answered yes and copied back the 3 lines the hook had printed. I did not run this check against the old hook.\n\nWhat the start of a conversation needs is not the full text, but \"which projects exist and what is next for each.\" So the hook now takes one line from each file.\n\nHow the line is chosen:\n\n`description`; failing that, the first body line\nLines are sorted by last modified time, newest first, so recently touched projects come first. The full text stays in each file, to be read when I actually work on that project.\n\nThe output looks like this (made-up examples):\n\n```\n# Pickup Queue (auto-injected summary)\nOne \"next\" line per project, newest first (lines are trimmed). ...\n\n- garden-sensor (11-12): ▶▶ Next: find out why the soil moisture reading jumps at night by swapping the wiring\n- recipe-site (11-10): ▶▶ Next: sort search results by rating instead of creation date\n- photo-backup (10-21): ⏳ Remaining: check that the first sync finished after replacing the external disk\n```\n\nThe fitting is done in `jq` (excerpt; `header`, `footer` and `$limit` are defined elsewhere). It tries per-line lengths from 400 characters downward and uses the first one at which the whole output fits the limit, counted in UTF-16. If it still does not fit, it drops the oldest lines and notes how many were dropped.\n\n``` python\ndef u16: explode | map(if . > 65535 then 2 else 1 end) | add // 0;\ndef cut($n): if length > $n then .[0:$n] + \"…\" else . end;\ndef render($items; $n): header + ($items | map(\"- \" + .s + \" (\" + .d + \"): \" + (.t | cut($n))) | join(\"\\n\")) + footer;\ndef omitted($k): if $k > 0 then \"\\n- (\" + ($k|tostring) + \" more omitted to fit the cap)\" else \"\" end;\ndef fit($items; $k): (render($items; 20) + omitted($k)) as $o\n  | if ($o | u16) <= $limit or ($items | length) == 0 then $o else fit($items[:-1]; $k + 1) end;\n\n(first((400, 320, 260, 220, 180, 150, 120, 90, 60, 40, 20) as $n\n       | render($items; $n) | select(u16 <= $limit))\n // fit($items; 0))\n```\n\nI set the limit to 9,500 to leave a margin below 10,000. With 30 pickup files the output is now 8,314 UTF-16 units. It went from 35 files to 30 because I archived 5 finished projects while doing this.\n\nMy first rewrite started `basename`, `date` (twice), `awk` and `tr` for every file. It took 8.5 seconds on my machine. When I had another Claude (Opus) review it, it pointed out that settings.json gives this hook a 10-second timeout. I have not tested what happens past the timeout, but that was cutting it close.\n\nTiming just that loop gave 7.1 seconds (median of 3 runs). That is 30 files × 5 commands = 150 process starts, about 47 ms each. The original \"cat everything\" version takes 2.6 seconds.\n\nSo I changed it so the number of processes does not grow with the number of files:\n\n`find -printf` gets the modification times and paths in one go`gawk` reads all files (`BEGINFILE` / `ENDFILE` / `nextfile`)` jq` formats and fits the result\n\n```\nmapfile -t META < <(find \"$SECTIONS_DIR\" -maxdepth 1 -type f -name '*.md' \\\n                      -printf '%T@\\t%Tm-%Td\\t%p\\n' | sort -t$'\\t' -k1,1nr -k3,3)\nFILES=(); for m in \"${META[@]}\"; do FILES+=(\"${m##*$'\\t'}\"); done\n\nprintf '%s\\n' \"${META[@]}\" | LC_ALL=C.UTF-8 gawk '\n  BEGINFILE {\n    if (ERRNO != \"\") { done = 1; nextfile }   # skip a file that vanished after find\n    ...\n  }\n  FILENAME == \"-\" { split($0, a, \"\\t\"); day[a[3]] = a[2]; next }   # dates arrive on stdin\n  ...\n' - \"${FILES[@]}\" | jq -Rsc '...'\n```\n\nThat brought it down to 0.46 seconds (median of 3 runs).\n\nThree things I tripped over:\n\n`%TM` is minutes, not month. Month is `%Tm`.` length` returns bytes (\"あいう\" gives 9).`find` and `ERRNO` and skipping fixes that.\nThe hook has 24 tests. Against the real pickups they check \"finishes within 3 seconds\", \"at most 9,500 UTF-16 units\" and \"one line per file\". The rest use planted inputs in a scratch directory:\n\n`---` rule is not mistaken for frontmatter / reads a folded `description: >`\n`_archive/` / sorts newest first`abc`, `10`, `-5`, empty) do not produce empty output\nI also checked that the cap tests are not vacuous: without the cap, the planted 150 files produce about 79k characters. The original version passes only 7 of the 24 tests, and removing just the line that skips a vanished file makes 1 test fail.\n\nIf a hook of yours feeds text into the conversation, measuring its output once tells you whether it is being cut off silently. I had never measured mine.", "url": "https://wpnews.pro/news/my-claude-code-sessionstart-hook-sent-305k-characters-almost-none-of-it-reached", "canonical_source": "https://dev.to/yasumorishima/my-claude-code-sessionstart-hook-sent-305k-characters-almost-none-of-it-reached-the-conversation-3202", "published_at": "2026-11-12 11:41:14+00:00", "updated_at": "2026-11-12 11:52:14.249970+00:00", "lang": "en", "topics": ["ai-tools", "ai-agents", "developer-tools", "ai-products"], "entities": ["Claude Code", "Anthropic"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/my-claude-code-sessionstart-hook-sent-305k-characters-almost-none-of-it-reached", "markdown": "https://wpnews.pro/news/my-claude-code-sessionstart-hook-sent-305k-characters-almost-none-of-it-reached.md", "text": "https://wpnews.pro/news/my-claude-code-sessionstart-hook-sent-305k-characters-almost-none-of-it-reached.txt", "jsonld": "https://wpnews.pro/news/my-claude-code-sessionstart-hook-sent-305k-characters-almost-none-of-it-reached.jsonld"}}