UserPromptSubmit Hooks
Hooks that fire when the user submits a prompt. Use cases: redaction, secret-scrubbing, injecting context, audit logging.
What it is#
A UserPromptSubmit hook fires the moment the user hits enter on a prompt — before the message reaches the model. It is the only hook event that intercepts user input, and it is the right place to do anything that should happen to every prompt regardless of what the model decides to do next.
Like the other hook events, UserPromptSubmit is a subprocess shell command that receives JSON on stdin and can:
- Observe — write an audit log of every prompt the user submitted.
- Modify — redact secrets, append context, rewrite shortcuts.
- Block — refuse a prompt by exiting non-zero, with a message the user (and the model) can see.
The distinguishing property: this hook runs once per user turn, not once per tool call. The right granularity for prompt-shaped concerns (what the user said, what context the model should see) — the wrong granularity for action-shaped concerns (what the model wants to do, what files it’s editing).
When to use it#
Reach for a UserPromptSubmit hook when:
- You want every prompt scrubbed. Strip patterns that look like API keys, OAuth tokens, JWTs, or private SSH keys before they hit the model. The model never sees them, the audit log never sees them.
- You want every prompt to carry extra context. Append the current date, the current branch, the currently-open ticket. The model always has fresh context without you having to remember to type it.
- You want shortcuts. Expand
@todointo “review the TODO list and propose the next action”; expand@prsinto “list my open PRs”. A personal vocabulary that takes one character but lands as the right paragraph. - You want an audit trail. Append every prompt to a log file with a timestamp. Useful for after-the-fact reconstruction of what the user asked across many sessions.
- You want policy enforcement on prompts. Refuse prompts that contain customer PII, refuse prompts that reference a forbidden code path. The block fires before the model sees the input.
Don’t reach for UserPromptSubmit when:
- The concern is about what the model does. That’s PreToolUse / PostToolUse.
- The concern needs to happen every few turns rather than every turn. Just do it inline.
- The processing is slow. A 2-second hook on every prompt makes the session feel like dial-up.
How it works#
Lifecycle#
When the user submits a prompt:
- Claude Code packages the prompt as JSON:
{"event": "UserPromptSubmit", "prompt": "<user text>"}. - It spawns each matching
UserPromptSubmithook (in array order) with that JSON on stdin. - Each hook can:
- Exit 0 and emit nothing → no change; the next hook runs.
- Exit 0 and emit modified JSON on stdout → the prompt is replaced with the modified version for the next hook (and ultimately the model).
- Exit non-zero with a message on stderr → the prompt is blocked; the user sees the stderr message; the model never sees the prompt.
- After all hooks complete, the (possibly modified) prompt is sent to the model as the next user turn.
The chain composes: hook A redacts secrets, hook B appends a date stamp, hook C audits the final form. Each one sees the previous one’s output.
The stdin payload#
{ "event": "UserPromptSubmit", "prompt": "Why is the staging deploy failing? Here's the token: ghp_abc123xyz..."}The hook reads stdin, manipulates the JSON, and either echoes the modified payload to stdout (to mutate) or stays silent (to passively observe).
Mutation pattern#
A hook that wants to redact secrets:
#!/usr/bin/env bashset -euo pipefailpayload="$(cat)"prompt="$(echo "$payload" | jq -r '.prompt')"
# Redact common secret shapesclean=$(echo "$prompt" \ | sed -E 's/ghp_[A-Za-z0-9]{20,}/[REDACTED_GITHUB_TOKEN]/g' \ | sed -E 's/sk-[A-Za-z0-9-]{20,}/[REDACTED_API_KEY]/g' \ | sed -E 's/-----BEGIN [A-Z ]+PRIVATE KEY-----/[REDACTED_PRIVATE_KEY]/g')
echo "$payload" | jq --arg new "$clean" '.prompt = $new'The hook echoes the modified JSON to stdout; Claude Code uses the modified prompt for the rest of the chain.
Block pattern#
A hook that refuses prompts containing customer email addresses:
#!/usr/bin/env bashpayload="$(cat)"prompt="$(echo "$payload" | jq -r '.prompt')"if echo "$prompt" | grep -qE '@(example-customer-domain)\.com'; then echo "Blocked: prompts referencing customer email addresses are not permitted in Claude Code sessions. Use a placeholder." >&2 exit 1fiexit 0The user sees the stderr message and can rephrase; the model never sees the original.
Configuration#
Where they live#
Same as other hooks — ~/.claude/settings.json for user-global, <repo>/.claude/settings.json for project-shared, <repo>/.claude/settings.local.json for individual overrides. The UserPromptSubmit event uses no matchers (there is no “tool name” for a prompt):
{ "hooks": [ { "event": "UserPromptSubmit", "command": "~/.claude/hooks/redact-secrets.sh" }, { "event": "UserPromptSubmit", "command": "~/.claude/hooks/inject-date.sh" }, { "event": "UserPromptSubmit", "command": "~/.claude/hooks/audit-prompt.sh" } ]}The order matters: the redaction hook runs first so the audit hook never sees the unredacted prompt.
Project vs personal hooks#
UserPromptSubmit hooks tend to split by scope cleanly:
- Personal: shortcut expansion (
@todo,@prs), date injection. The team has no reason to know about your private shortcuts. - Project-shared: secret redaction, customer-PII denials. The team should have the same protections.
Personal hooks live in ~/.claude/; project-shared hooks live in <repo>/.claude/ and are committed.
Examples#
Secret-scrubbing#
A defence-in-depth secret redactor. Even if you accidentally paste a token into a prompt, this catches it before the model sees it:
#!/usr/bin/env bashset -euo pipefailpayload="$(cat)"prompt="$(echo "$payload" | jq -r '.prompt')"
clean="$prompt"# GitHub tokensclean=$(echo "$clean" | sed -E 's/(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/[REDACTED_GITHUB]/g')# Generic sk-* style API keysclean=$(echo "$clean" | sed -E 's/\bsk-[A-Za-z0-9_-]{20,}/[REDACTED_API_KEY]/g')# AWS access key IDclean=$(echo "$clean" | sed -E 's/\bAKIA[0-9A-Z]{16}\b/[REDACTED_AWS_KEY]/g')# Slack tokensclean=$(echo "$clean" | sed -E 's/\bxox[abprs]-[A-Za-z0-9-]+/[REDACTED_SLACK]/g')# JWT-looking blobsclean=$(echo "$clean" | sed -E 's/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/[REDACTED_JWT]/g')# PEM blocksclean=$(echo "$clean" | perl -0777 -pe 's/-----BEGIN [A-Z ]+PRIVATE KEY-----.*?-----END [A-Z ]+PRIVATE KEY-----/[REDACTED_PRIVATE_KEY]/gs')
echo "$payload" | jq --arg new "$clean" '.prompt = $new'The patterns aren’t exhaustive — secret regexes never are — but they catch the most common copy-paste mistakes.
Date and context injection#
Append today’s date and the current git branch to every prompt:
#!/usr/bin/env bashset -euo pipefailpayload="$(cat)"prompt="$(echo "$payload" | jq -r '.prompt')"today="$(date -Iseconds)"branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'no-git')"
new="$prompt
[ambient context: today is $today; current branch is $branch]"
echo "$payload" | jq --arg new "$new" '.prompt = $new'The model now always knows the date and the branch. No more “the model thought today was last year”.
Personal shortcut expansion#
Type @prs and have it expand to a much longer prompt:
#!/usr/bin/env bashpayload="$(cat)"prompt="$(echo "$payload" | jq -r '.prompt')"
case "$prompt" in '@prs') new="List my open pull requests with gh pr list --author @me. For each, summarise the last activity and suggest a next action." ;; '@todo') new="Review the TodoWrite list. Propose the next two actions and explain why those are next." ;; '@quick-review') new="Review the diff against main. Output a one-line verdict and at most three bullets — only real issues." ;; *) new="$prompt" ;;esac
echo "$payload" | jq --arg new "$new" '.prompt = $new'A single-character prompt becomes a paragraph. The model gets a precise, repeatable brief without you typing one.
Audit logging#
A passive hook that just writes to a log — exits 0, emits nothing on stdout:
#!/usr/bin/env bashmkdir -p ~/.claude/auditpayload="$(cat)"prompt="$(echo "$payload" | jq -r '.prompt')"{ echo "----" echo "ts: $(date -Iseconds)" echo "cwd: $(pwd)" echo "prompt:" echo "$prompt"} >> ~/.claude/audit/prompts.logexit 0After a week, you can grep the log for “every time I asked about a specific bug” or “every session that started before 10am”. The audit hook should run after the redaction hook so secrets are never written to disk.
Policy enforcement#
A project hook that refuses prompts containing certain file paths the team has decided are off-limits to Claude Code:
#!/usr/bin/env bashpayload="$(cat)"prompt="$(echo "$payload" | jq -r '.prompt')"deny=( 'src/secrets/' 'infrastructure/prod/' '.env.production')for path in "${deny[@]}"; do if echo "$prompt" | grep -qF "$path"; then echo "Blocked: this path is off-limits to Claude Code by team policy. Edit it manually." >&2 exit 1 fidoneexit 0A team member who isn’t aware of the policy gets reminded the first time they try; the model is structurally prevented from being asked to touch the area.
Gotchas#
- The hook sees prompt text only — not your
CLAUDE.md, not your memory. Ambient context the model would normally have is not in the JSON payload. Don’t write hooks that assume they can see project conventions. - Modifications cascade. Hook B sees hook A’s output, not the user’s original. If the redactor mangles a prompt, the audit log records the mangled version. Order your hooks deliberately.
- Blocking is a hard stop. A non-zero exit terminates the chain and the turn. The user has to retype or rephrase. Don’t block on warnings — block on policy violations.
- The stderr message is your UX. When a block fires, the only feedback the user has is your hook’s stderr line. Write it as a clear, actionable sentence — not a stack trace.
- Don’t log unredacted prompts. If you have both a redactor and an auditor, run the redactor first. Otherwise your audit log becomes the very thing you were trying to prevent.
- Hook latency is on the critical path of every conversation turn. A 1-second UserPromptSubmit hook adds 1 second to the felt latency of every message. Test with realistic data and time-bound your hooks.
jqandsedare runtime dependencies. The hooks assume both are available. On a fresh Mac,jqis not installed by default. Document the dep or fall back to portable parsing.- The payload is JSON, not a string. It’s tempting to operate on the raw stdin as if it were the prompt. It is not — it’s the wrapping JSON. Parse properly.
Related features#
- Hooks Overview
- PreToolUse and PostToolUse Hooks
- Building a Security Hook
- CLAUDE.md and Memory Files
- Settings and Configuration
Why UserPromptSubmit beats reminding yourself in CLAUDE.md
A CLAUDE.md instruction like “always include today’s date” is best-effort: the model honours it most of the time, sometimes forgets, sometimes mis-formats. A UserPromptSubmit hook that appends the date deterministically lifts that concern out of the model’s working memory entirely. The same logic applies to redaction, audit, and policy: anything you’d otherwise be reminding yourself or the model to do every turn is a candidate for a hook. The trade-off is the small per-turn latency the hook adds — usually worth it for any policy-shaped concern.