DEV Community

Rulestack
Rulestack

Posted on

Does a Claude Code UserPromptSubmit hook keep a blocked prompt out of the transcript? 6 of 6 never reached the model, all 6 were still written to it

6 of 6 prompts that a UserPromptSubmit hook blocked never reached the model, yet all 6 were still written to the session transcript (4 of them twice), and claude -p exited 0 with "subtype": "success" every time. Context injection matched the hooks page: plain stdout and additionalContext arrived in 2 of 2 runs each, 10,000 characters arrived whole, 50,000 became a 1,949-character preview, and a hook that exited 1 or printed a bare JSON object delivered nothing in 4 of 4 runs.

UserPromptSubmit runs after you press Enter and before Claude sees what you typed. That position makes it the hook people reach for in two situations: adding context to every prompt (the current branch, today's date, a rule worth repeating) and refusing a prompt outright (a pasted key, a request that should never go out). Both jobs come down to one question. Of everything the hook prints, what goes in front of the model, what goes to the person or the script running Claude Code, and what goes to disk? The hooks reference answers most of that in prose, and I wanted the answer from the session files instead. So I built a lab with a single hook, ran it 20 times under claude -p across ten output shapes, two runs per shape, and read what every run left behind.

Everything below ran on 2026-09-30 with Claude Code 2.1.285 (claude --version) and claude-opus-5-5 (selected with --model opus). The documentation quotes come from https://code.claude.com/docs/en/hooks.md, fetched the same day with trafilatura.

What the hooks page says happens to the output

These are the sentences I tested, quoted as fetched:

  • On exit 0: "For most events, Claude Code writes stdout to the debug log and doesn't show it in the transcript. The exceptions are UserPromptSubmit, UserPromptExpansion, SessionStart, and PostModelSwitch, where Claude Code adds plain-text stdout as context that Claude can see and act on."
  • In the UserPromptSubmit section: "Plain stdout and the additionalContext value are each injected as a system reminder that starts with the hook's name; Claude reads both."
  • On blocking, in the decision table: "block" "prevents the prompt from being processed and erases it from context", and reason is "Shown to the user when decision is "block". Not added to context". In the exit code table, exit 2 on this event "Blocks prompt processing and erases the prompt".
  • On exit 2 specifically: "A hook that blocks by exiting 2 routes the same way as reason: the block message shows the stderr text to the user, and it isn't added to context."
  • suppressOriginalPrompt: "If true when decision is "block", omits the original prompt text from the block message shown to the user".
  • On other exit codes with plain-text stdout: "it's a non-blocking error for most hook events: the action proceeds".
  • On what counts as JSON: "Starts with { and ends with }: Claude Code parses it as JSON."
  • On size: plain stdout and additionalContext "are capped at 10,000 characters", and over the limit "Claude Code 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." Also: "Claude Code doesn't ask Claude to read the file, so keep anything Claude must always see within the cap."

The lab

The lab is a fresh directory: no git repository, no CLAUDE.md, one settings file and one script. .claude/settings.json registers a single command hook:

{
  "hooks": {
    "UserPromptSubmit": [
      { "hooks": [ { "type": "command", "command": "python3 <lab>/.claude/hooks/ups-probe.py" } ] }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

(In the real file both the interpreter and the script are absolute paths; <lab> stands for the lab directory throughout this article.) The script reads a mode from mode.txt, prints one output shape, and appends a line to a log of its own, so I could confirm it ran exactly once per run. It did: the log has 20 lines. Here it is with the logging removed:

import json, os, sys
LAB = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdin.read()
mode = open(os.path.join(LAB, "mode.txt")).read().strip()
out, err, code = "", "", 0

def long_text(n):
    line = "filler line: the quick brown fox jumps over the lazy dog, again and again.\n"
    chars = list((line * (n // len(line) + 2))[:n])
    marks = [(0, "UPS-LONG-AT0000\n"), (1900, "\nUPS-LONG-AT1900\n"),
             (2100, "\nUPS-LONG-AT2100\n"), (n // 2, "\nUPS-LONG-AT%05d\n" % (n // 2)),
             (n - 13, "\nUPS-LONG-END")]
    for at, text in marks:
        chars[at:at + len(text)] = list(text)
    return "".join(chars)

if mode == "plain":
    out = "Lab note from the prompt hook: the marker for this prompt is UPS-STDOUT-PLAIN-7K2.\n"
elif mode == "addctx":
    out = json.dumps({"hookSpecificOutput": {"hookEventName": "UserPromptSubmit",
        "additionalContext": "Lab note from the prompt hook: the marker for this prompt is UPS-ADDCTX-JSON-4Q9."}})
elif mode in ("block", "suppress"):
    specific = {"hookEventName": "UserPromptSubmit",
                "additionalContext": "Lab note from the prompt hook: UPS-BLOCK-ADDCTX-6T8."}
    reason = "Lab block reason: UPS-BLOCK-REASON-2M5."
    if mode == "suppress":
        specific = {"hookEventName": "UserPromptSubmit",
                    "additionalContext": "Lab note from the prompt hook: UPS-SUPPRESS-ADDCTX-7V3.",
                    "suppressOriginalPrompt": True}
        reason = "Lab block reason: UPS-SUPPRESS-REASON-5N1."
    out = json.dumps({"decision": "block", "reason": reason, "hookSpecificOutput": specific})
elif mode == "exit2":
    out = "Lab note from the prompt hook: UPS-EXIT2-STDOUT-1R6.\n"
    err = "UPS-EXIT2-STDERR-L1-8P3 first line of stderr\nUPS-EXIT2-STDERR-L2-5W1 second line of stderr\n"
    code = 2
elif mode == "exit1":
    out = "Lab note from the prompt hook: UPS-EXIT1-STDOUT-6D4.\n"
    err = "UPS-EXIT1-STDERR-L1-3H7 first line of stderr\nUPS-EXIT1-STDERR-L2-9B2 second line of stderr\n"
    code = 1
elif mode.startswith("long"):
    out = long_text(int(mode[4:]))
elif mode == "rawjson":
    out = json.dumps({"branch": "main", "open_issues": 3, "lab_marker": "UPS-RAWJSON-DATA-3C1"})

sys.stdout.write(out); sys.stderr.write(err); sys.exit(code)
Enter fullscreen mode Exit fullscreen mode

The control mode prints nothing and exits 0. Every marker starts with UPS-, and the prompt asks the model to list the markers it can see and nothing else:

Do not use any tools. Look at everything in your context for this turn, including any text
attached alongside this message. List every token that begins with the four characters UPS-
and continues with letters or digits, in the order they appear, separated by single spaces.
If there are none, reply NONE. Reply with that single line only.
Enter fullscreen mode Exit fullscreen mode

Each run was one invocation:

claude -p "$PROMPT" --setting-sources project,local --tools "" --strict-mcp-config \
  --max-turns 1 --model opus --output-format stream-json --verbose --include-hook-events \
  --session-id "$SID" --debug-file "runs/$NAME/debug.txt" < /dev/null
Enter fullscreen mode Exit fullscreen mode

--setting-sources project,local leaves my user settings out, so none of my own hooks could fire; the stream showed exactly one hook_started event per run, and it was this one. --tools "" removes every built-in tool, so the model can only report what is already in its context and cannot, for instance, open a file that a hook output points to. --strict-mcp-config with no config file means no MCP servers. --include-hook-events adds hook_started, hook_progress and hook_response events to the stream, and --session-id makes the transcript easy to find at ~/.claude/projects/<project>/<session-id>.jsonl. I also unset the environment variables that the Claude Code session I was working from exports (CLAUDECODE, CLAUDE_CODE_SESSION_ID, CLAUDE_EFFORT and a dozen more), so the child runs did not inherit them.

Every claim below rests on three sources per run. The first is the model's reply. The second is the transcript: each hook output that Claude Code keeps becomes an attachment record, and those records carry a rendered field holding the text Claude Code rendered for the model, or null when it rendered nothing. The third is the size of the first request, input_tokens + cache_creation_input_tokens + cache_read_input_tokens from the assistant message's usage, compared with the control hook, which came to 2,756 tokens in both control runs. A marker in the reply means it was in context, the rendered field says how it got there, and the token count says what it cost.

Twenty runs in one table

Hook output (2 runs each) Exit Model requests First request, tokens vs control Markers the model listed What the transcript kept
nothing (control) 0 1 2,756 — NONE no hook record
83-character plain stdout 0 1 2,811 +55 the stdout marker, 2 of 2 hook_success, sent
JSON additionalContext 0 1 2,813 +57 the context marker, 2 of 2 hook_additional_context, sent
10,000-character stdout 0 1 6,407 +3,651 5 of 5 markers, 2 of 2 hook_success, 10,068 characters sent
50,000-character stdout 0 1 3,708 / 3,711 +952 / +955 2 of 5 markers, 2 of 2 hook_success, path plus 1,949-character preview sent
stdout plus two stderr lines 1 1 2,756 0 NONE hook_non_blocking_error, rendered: null
a bare JSON object of data 0 1 2,756 0 NONE nothing at all
decision: "block" and reason 0 0 — — (no model call) the prompt, twice
two stderr lines (and a stdout line) 2 0 — — (no model call) the prompt, twice
decision: "block" and suppressOriginalPrompt 0 0 — — (no model call) the prompt, once

All 20 claude -p processes exited with code 0, including the six whose prompt never reached the model.

The two documented channels, and what they cost

Plain stdout on exit 0 did exactly what the page says. The transcript recorded a hook_success attachment whose rendered text was:

<system-reminder>
UserPromptSubmit hook success: Lab note from the prompt hook: the marker for this prompt is UPS-STDOUT-PLAIN-7K2.
</system-reminder>
Enter fullscreen mode Exit fullscreen mode

The JSON form produced a hook_additional_context attachment rendered the same way, with UserPromptSubmit hook additional context: in place of UserPromptSubmit hook success:. Both were sent with a system role, and in both runs of each the model listed the marker and nothing else. The "hook's name" at the start of the reminder was the event name. The plain-stdout record also stores the hook's full command line, its raw stdout, the exit code and the duration, but none of those went into the rendered text. The cost was 55 tokens for the plain form and 57 for the JSON form, for 83 characters of stdout (82 plus a newline that Claude Code trimmed) and an 81-character additionalContext, so the wrapper is a handful of tokens either way. On this evidence there is no reason to prefer JSON for context alone; the difference only matters when you also want another field in the same output.

The 10,000-character cap held, and it made the bigger output cheaper

The 10,000-character stdout went through whole. Its rendered attachment was 10,068 characters, the output plus the 68-character wrapper, and the model listed all five markers (at offsets 0, 1,901, 2,101, 5,001 and 9,988) in both runs. It added 3,651 tokens to the first request.

The 50,000-character stdout did not go through. Its attachment was rendered as a pointer and a preview:

UserPromptSubmit hook success: <persisted-output>
Output too large (48.8KB). Full output saved to: ~/.claude/projects/<project>/<session-id>/tool-results/hook-<id>-stdout.txt

Preview (first 2KB):
UPS-LONG-AT0000
 quick brown fox jumps over the lazy dog, again and again.
[25 filler lines omitted here]
UPS-LONG-AT1900
r the lazy dog, again and again.
...
</persisted-output>
Enter fullscreen mode Exit fullscreen mode

Apart from the bracketed line, which is mine, the redacted path and the <system-reminder> tags around it, that is the rendered text; the ... before the closing tag is Claude Code's. The preview held the first 1,949 characters, cut at the last line break before the 2,000th, so the marker at offset 1,901 made it in and the one at 2,101 did not. The model listed exactly those two markers in both runs and never mentioned the middle or the end. The saved file was all 50,000 bytes. The request grew by 952 and 955 tokens, about a quarter of what the 10,000-character output cost: five times the text, 3.8 times cheaper, because the model got a preview instead.

First-request input tokens across the runs: 2,756 with no output, 2,756 again when the hook exited 1 or printed a bare JSON object, 6,407 for a 10,000-character stdout, and 3,708 or 3,711 for a 50,000-character stdout that was replaced by a preview

All of this matches the documentation, including the part where Claude Code does not ask Claude to read the file; the rendered block gives a path and no instruction. The practical consequence is a cliff rather than a slope. A hook whose output sometimes lands at 9,000 characters and sometimes at 11,000 sends two very different things to the model, and on the long days everything past the last line break before character 2,000 is gone. If a hook's output can grow, cap it yourself below 10,000 or put what matters in the first 1,900 characters.

Exit 1 and a bare JSON object: printed, recorded, never sent

The exit 1 mode printed a context line to stdout and two lines to stderr, then exited 1. The model answered NONE in both runs, and the first request was 2,756 tokens both times, the same as the control. The transcript did keep a record, a hook_non_blocking_error attachment holding the stdout, both stderr lines behind a Failed with non-blocking status code: prefix, the exit code and the command, but its rendered field was null, so none of it was sent. The stream-json output carried no warning event for it either; the only trace was the hook_response event with "exit_code": 1 and "outcome": "error".

The documentation calls this a non-blocking error where "the action proceeds", and it only promises stdout as context on exit 0, so nothing here contradicts it. What the measurement adds is the consequence: whatever the hook printed before it failed is discarded, and from the model's side there is no sign that anything was ever printed. A bash hook with set -e that echoes the branch name and then runs a command that fails with status 1 is exactly this case.

The bare JSON object was quieter still. {"branch": "main", "open_issues": 3, "lab_marker": "UPS-RAWJSON-DATA-3C1"} starts with { and ends with }, so Claude Code parsed it as hook output. The debug log shows Successfully parsed and validated hook JSON output followed by Hook JSON output had unrecognized keys (ignored): branch, open_issues, lab_marker. There was no attachment in the transcript and no warning in the stream, and the request was 2,756 tokens in both runs. A hook that prints a JSON object straight to stdout (the output of gh pr view --json title,body, say, or a jq filter that emits an object), expecting the model to read it as data, adds nothing, and the only place that says so is a debug log that exists only when Claude Code runs with --debug or --debug-file. Put the data inside additionalContext, or print a line of text in front of it. The documentation treats a JSON array as plain text, so this is about objects; I did not run an array.

Blocking: the model never sees the prompt, the disk still does

Three modes blocked the prompt: decision: "block" with a reason, exit 2 with two lines of stderr, and decision: "block" with suppressOriginalPrompt. All six runs ended with num_turns: 0, total_cost_usd: 0 and no assistant message. The model never received the prompt, which is the "erases it from context" half of the documentation, and it held every time.

What claude -p reported is another matter. The stream carried a system event of subtype informational with "level": "warning" and "prevent_continuation": true, and then a result event with "subtype": "success" and "is_error": false, and the process exited 0. The result text was the block message. For the JSON block it read:

UserPromptSubmit operation blocked by hook:
Lab block reason: UPS-BLOCK-REASON-2M5.

Original prompt: Do not use any tools. Look at everything in your context for this turn, ...
Enter fullscreen mode Exit fullscreen mode

For exit 2 it read:

UserPromptSubmit operation blocked by hook:
[/Library/Frameworks/Python.framework/Versions/3.13/bin/python3 <lab>/.claude/hooks/ups-probe.py]: UPS-EXIT2-STDERR-L1-8P3 first line of stderr
UPS-EXIT2-STDERR-L2-5W1 second line of stderr

Original prompt: Do not use any tools. Look at everything in your context for this turn, ...
Enter fullscreen mode Exit fullscreen mode

Two details are not in the documentation. The exit 2 message carries the hook's whole command line in brackets, ahead of the stderr, and then both stderr lines. And the extra output I attached to each block went nowhere: the stdout line printed alongside exit 2 and the additionalContext inside the JSON block appear only in the stream's raw hook events, never in the transcript or the result.

The transcript is where "erases the prompt" stops being true. The first record in every blocked session's file was a queue-operation record with "operation": "enqueue" and the complete prompt text, timestamped before Claude Code parsed the hook's output (04:14:28.526 against 04:14:29.571 in the first JSON block run). Nothing later removed it. The block message, "Original prompt" line included, was stored again as a system record. There was no user record, so the conversation itself does not contain the prompt, but the file does, twice, in all 4 runs without suppressOriginalPrompt.

A blocked run: the hook_response event shows exit_code 2, zero model requests at zero cost, claude still exits 0 with subtype success, and the transcript keeps an enqueue record with the full prompt

suppressOriginalPrompt fixes the message and not the file. The table on the hooks page lists it without saying where it goes; I put it inside hookSpecificOutput, next to additionalContext. With it, the result text was just UserPromptSubmit operation blocked by hook: and the reason, with no prompt, in 2 of 2 runs. The enqueue record still held the complete prompt in 2 of 2 runs.

If your hook exists to stop a pasted secret from going out, these runs say the secret does not reach the model. They also say it lands in ~/.claude/projects/ regardless, and that without suppressOriginalPrompt it is printed back in the claude -p result, which in CI means a log line. A CI step that checks the exit code of claude -p will count the blocked run as a success.

What I would change in a UserPromptSubmit hook after these runs

For context hooks, the rule is exit 0, always. Wrap anything that can fail (git, gh, a network call) so the script still exits 0, because an exit 1 throws away everything the hook already printed, and in a headless run only the raw hook event says so. Print text, or JSON with additionalContext, but never a bare data object. Keep the output under 10,000 characters, and assume the model will only see the first 1,900 or so if it ever goes over.

For blocking hooks, exit 2 and decision: "block" both stopped the request in 2 of 2 runs each, so choose by what the user should see. Exit 2 shows your stderr with the hook's command line in front of it; the JSON form shows your reason without the command line. Add suppressOriginalPrompt whenever the prompt itself may be the problem, and treat the transcript as holding it anyway. When claude -p runs in a script, detect a block from the result text, from num_turns: 0, or from the informational warning event in the stream, not from the exit code.

What I did not measure

Every run was headless. I did not look at the interactive interface, where the documentation says the hook error notice shows the first line of stderr, and I did not check whether an interactive session writes the same enqueue record. I measured 10,000 and 50,000 characters, not 10,001, so the exact edge of the cap is the documentation's word, not mine. I did not send more than 10,000 characters through additionalContext, which the page says is capped separately. The CLI reference offers --no-session-persistence ("Disable session persistence so sessions are not saved to disk and cannot be resumed. Print mode only."); I did not test whether it keeps the blocked prompt off disk. With --tools "" the model could not have read the saved 50,000-character file, so I do not know whether it would have. I also left out exit 2 combined with JSON, continue: false, async hooks, timeouts, HTTP and prompt hooks, stderr on exit 0, other models, and Windows.

Numbers, for the record

Twenty claude -p runs on 2026-09-30 between 04:10 and 04:22 UTC, Claude Code 2.1.285 on macOS, claude-opus-5-5, a Python 3.13 hook script, two runs for each of ten modes. The fourteen runs that reached the model took 2.6 to 15.7 seconds each; the six blocked runs finished in 0.5 to 1.8 seconds with no API time. The reported cost for all twenty was $0.116. Replies matched the rendered attachments in all fourteen runs that made a request: every marker that was rendered was listed, and no marker that was not rendered was listed.


Rulestack writes hooks, skills and rules files for Claude Code and sells them at rulestack.gumroad.com. The lab here is one settings file, one script and a mode.txt, and all twenty runs cost $0.116, so it is cheap to rerun whenever a prompt hook's output changes shape.

If your Claude Code version keeps a blocked prompt somewhere else, or out of the transcript entirely, leave the version in the comments below, and follow @ai-shop.bsky.social on Bluesky for the next measurement.

Top comments (4)

Collapse
 
mateo_ruiz_6992b1fce47843 profile image
Mateo Ruiz •

The most important finding here is that “blocked” is not a single state. The model can be prevented from receiving the prompt while the prompt still exists in the session's persistence layer, and claude -p can still report a successful process exit.

That distinction matters a lot more than the hook mechanics themselves. This is the kind of execution-boundary issue we pay close attention to at IT Path Solutions when working with production AI workflows: you need to define separately what was prevented, what was persisted, what was exposed to the caller, and what the user was told happened.

The suppressOriginalPrompt result is particularly useful because it shows that presentation and persistence are independent controls. It removes the prompt from the block message, but doesn't remove it from the enqueue record. So a hook intended to protect secrets still needs to be evaluated against the entire data lifecycle, not just whether the model saw the input.

I also like the recommendation not to use the process exit code as the sole success signal. A blocked agent run that exits 0 is a classic example of why transport success, workflow success, and policy outcome should be separate states. That principle generalizes well beyond Claude Code: an API request can succeed, an agent can complete its turn, and the requested operation can still have been intentionally denied.

Collapse
 
rulestack profile image
Rulestack •

Yes, and the block message blurs that split a little: Claude Code also writes it into the transcript, so in these runs suppressOriginalPrompt kept a second copy of the prompt off disk too, while the enqueue copy stayed. The article's "fixes the message and not the file" put it too cleanly.

Collapse
 
swarmery profile image
Andrii Tretiak | Swarmery •

This matters a lot for anyone who uses the transcripts as a data source. My tool builds its view of every session from the JSONL files on disk, so a blocked prompt still landing there means it shows up in history, search and anything indexed from it, even though the model never saw it. I ended up redacting secrets at indexing time instead of trusting a hook to keep them out. Did you see any field in the transcript entry that marks the prompt as blocked, so a reader could filter it?

Collapse
 
rulestack profile image
Rulestack •

Not on the enqueue record itself: in our 20 runs on Claude Code 2.1.285 it held the operation and the prompt text beside the usual sessionId and timestamp, and nothing about the outcome. The block showed up as a separate system record with "preventContinuation": true and content starting "UserPromptSubmit operation blocked by hook:"; all 6 blocked sessions had one and no user record, and none of the 14 that reached the model had one. That's from headless claude -p runs with one prompt each, on one version and one hook script, and we didn't check interactive sessions, so redacting at indexing time, as you do, still looks like the safer layer.