Claude Code hook input and output, the JSON on stdin and what exit codes 0, 2 and everything else do
Command hooks read JSON from stdin and answer with an exit code. The fields every event includes, the extras per event, jq examples, what exit 2 does per event, and JSON output.
Contents
You have the hook configured and your script is running, but two things are unclear: how the script learns which file was just edited, and how it tells Claude that lint failed. Those are the input and output sides of the hook contract. Get either wrong and a failing lint goes unnoticed, or a note you meant as a warning halts the turn.
The short version: input is the JSON that arrives on standard input, output is the exit code. Command hooks read JSON from stdin; HTTP hooks receive the same JSON as a POST body with Content-Type: application/json. Exit 0 succeeds, exit 2 blocks and hands your stderr to Claude, and anything else is a non-blocking error shown to you.
KEY POINT
What you will learn
- The fields present on every hook event, and the extra fields for PreToolUse, Stop, SessionStart and the others
- How to read values with
jq, and where to get what the JSON doesn't include - How exit 0, 2 and everything else differ, and what "blocking" means for each event
- When to print JSON instead of relying on an exit code
Input, fields present on every event
Every hook event receives these, in addition to its event-specific fields.
{
"session_id": "abc123",
"prompt_id": "550e8400-e29b-41d4-a716-446655440000",
"transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
"cwd": "/home/user/my-project",
"scratchpad_dir": "/tmp/claude-1000/-home-user-my-project/abc123/scratchpad",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"effort": {
"level": "medium"
}
}
| Field | Description |
|---|---|
session_id | Current session identifier |
prompt_id | UUID identifying the user prompt (absent until first user input; requires v2.1.196+) |
transcript_path | Path to the conversation JSON (may lag the current turn) |
cwd | Working directory when the hook is invoked |
scratchpad_dir | The session's scratchpad directory (absent if unavailable; requires v2.1.257+) |
permission_mode | default, plan, acceptEdits, auto, dontAsk or bypassPermissions |
effort | Object with a level field: low, medium, high, xhigh or max |
hook_event_name | Name of the event that fired |
agent_id | Subagent identifier (only in subagent context) |
agent_type | Agent name such as Explore (in a subagent, or with --agent) |
Because hook_event_name is always present, you can point one script at several events and branch on it inside.
transcript_path is not always current
The documentation notes that transcript_path may lag the current turn. Don't build logic on it that assumes the in-flight exchange has already been written.
Input, what each event adds
PreToolUse and PostToolUse
These are the events you will use most. PreToolUse adds three fields.
{
"tool_name": "Bash",
"tool_input": {
"command": "npm test",
"description": "Run test suite",
"timeout": 120000,
"run_in_background": false
},
"tool_use_id": "toolu_01ABC123..."
}
The contents of tool_input depend on the tool: Bash carries command, while Edit and Write carry file_path. PostToolUse has the same shape plus tool_output, and PostToolUseFailure uses the same structure with the error type in error (for example TimeoutError).
{
"tool_output": {
"text": "Test results...",
"error": null
}
}
The other events
| Event | Fields added | Example values |
|---|---|---|
UserPromptSubmit | user_input | The prompt text |
Stop | last_assistant_message, stop_reason | end_turn |
SubagentStop | agent_type, agent_id, last_assistant_message, stop_reason | code-reviewer |
SessionStart | how, model | startup, resume, clear, compact, fork |
SessionEnd | why, model | clear, resume, logout, prompt_input_exit, other |
PreCompact / PostCompact | what | manual, auto |
Notification | type, metadata | permission_prompt, idle_prompt, auth_success |
The field name changes per event: SessionStart uses how, SessionEnd uses why, and compaction uses what. Check the table rather than guessing. The docs also note that model is not always present. Most of these values can be filtered at the config level too; see Every value you can put in a Claude Code hook matcher.
Reading values with jq
The shortest form pipes the command straight into jq. The documentation uses exactly this shape for running Prettier after an edit.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
In a script file, read the whole payload first, then pull fields out of it. This is the documented file-protection script:
#!/usr/bin/env bash
# protect-files.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Normalize Windows backslash separators so the patterns below match
FILE_PATH="${FILE_PATH//\\//}"
PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
exit 2
fi
done
Two details matter. // empty prevents jq from returning the literal string null when the hook fires for a tool that has no file_path. Standard input can only be read once, so capture it with INPUT=$(cat) and pull fields from the variable as many times as you need. The final exit 2 is the block described in the next section.
用語解説
stdin (standard input): the channel that feeds data into a process from outside. Reading it with cat treats it like a file. Once consumed it cannot be read again, which is why you store it in a variable.
What the JSON doesn't include
Path roots and similar context come from environment variables instead. Hook processes inherit the parent environment plus these:
| Variable | Contents |
|---|---|
$CLAUDE_PROJECT_DIR | Project root where the session started |
$CLAUDE_PLUGIN_ROOT | Plugin installation directory (for plugin hooks) |
$CLAUDE_PLUGIN_DATA | The plugin's persistent data directory |
$CLAUDE_EFFORT | Current effort level (low, medium, high, xhigh, max) |
$CLAUDE_CODE_REMOTE | Set to true in remote web environments |
$CLAUDE_CODE_BRIDGE_SESSION_ID | Remote Control session ID (v2.1.199+, while connected) |
The JSON's cwd is the working directory at invocation time, so it shifts when you work in a subdirectory. To reference a script at a fixed location, use $CLAUDE_PROJECT_DIR.
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm-rf.sh"
}
Output, what the exit code means
A hook's exit code is the signal that tells Claude Code whether to continue, stop, or merely warn.
| Exit code | Treated as | stdout | stderr |
|---|---|---|---|
0 | Success | Shown to you; for a few events it is added to Claude's context as plain text | Not shown |
2 | Blocking error | Ignored | Fed to Claude as feedback on events that support it |
| Anything else | Non-blocking error | Parsed as JSON when it looks like JSON; otherwise ignored | First line shown with Failed with non-blocking status code: |
Use 2 when you want to tell Claude something. Use 1 when you want to tell yourself something. On exit 0, the events that add stdout to Claude's context are UserPromptSubmit, UserPromptExpansion, SessionStart and PostModelSwitch. For PreToolUse, exit 0 does not approve the tool call; the normal permission flow still applies.
What exit 2 does per event
| Event | Can block? | What exit 2 does |
|---|---|---|
PreToolUse | Yes | Blocks the tool call; stderr goes to Claude |
PostToolUse | Cannot undo the call | Feeds stderr to Claude so it fixes the result |
UserPromptSubmit | Yes | Blocks prompt processing and erases the prompt; stderr shown to you |
UserPromptExpansion | Yes | Blocks the expansion |
Stop / SubagentStop | Yes | Prevents Claude from stopping and continues with stderr as the instruction |
PermissionRequest | No | Not honored; the permission flow proceeds. Deny through the decision object instead |
SessionStart and others | No | Shows stderr to the user and continues |
A few events, such as ConfigChange and Elicitation, surface no message at all. Check the reference's per-event table before relying on a block.
用語解説
Blocking on PostToolUse doesn't undo anything: PostToolUse runs after the tool has executed, so the file is already written. The point of exiting 2 there is not to revert, but to tell Claude "lint failed, fix it".
Three worked examples
Exit 2 on PreToolUse to stop a dangerous command. For the full pattern and its limits, see Block rm -rf with a PreToolUse hook.
#!/usr/bin/env bash
cmd=$(jq -r '.tool_input.command // empty')
if echo "$cmd" | grep -Eq 'rm -rf /|git push --force'; then
echo "This command is not allowed here: $cmd" >&2
exit 2
fi
exit 0
Exit 2 on PostToolUse to make Claude fix a lint failure.
#!/usr/bin/env bash
file=$(jq -r '.tool_input.file_path // empty')
[[ "$file" == *.ts ]] || exit 0
if ! npx eslint "$file"; then
echo "ESLint reported errors. Please fix: $file" >&2
exit 2
fi
exit 0
Exit 1 to tell only the human. Claude keeps working; only you see the warning.
#!/usr/bin/env bash
file=$(jq -r '.tool_input.file_path // empty')
if [[ "$file" == */migrations/* ]]; then
echo "Note: a migration file was changed: $file" >&2
exit 1
fi
exit 0
Output, JSON for finer control
Exit 0 and print a JSON object to stdout when an exit code isn't expressive enough. The field differs by event, which is the part people get wrong.
PreToolUse uses hookSpecificOutput.permissionDecision:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive command blocked by hook"
}
}
PostToolUse and Stop use a top-level decision: "block" field with a reason, and PermissionRequest uses hookSpecificOutput.decision.behavior. Check the reference's decision-control table for the event you are writing.
Nothing else may reach stdout
Claude Code parses stdout as JSON only when it starts with { and ends with }. A stray echo, including an unconditional one in your shell profile that a non-interactive sh -c can still source, breaks the parse silently. Send debug output to stderr.
Related
For the hook configuration itself and a complete lint-and-format setup, see Run lint and format automatically after every edit. A Stop hook that sends a desktop notification is in Get a desktop notification when Claude Code finishes.
Summary
- Command hooks read JSON from stdin. Every event includes
session_id,cwd,permission_mode,hook_event_nameandeffort; tool events addtool_name,tool_inputandtool_use_id - Field names differ per event:
howforSessionStart,whyforSessionEnd,whatfor compaction - In scripts, capture with
INPUT=$(cat)and read fields withjq -r '... // empty'; the project root is$CLAUDE_PROJECT_DIR - Exit 0 succeeds, 2 blocks and feeds stderr to Claude, anything else is a non-blocking error shown to you; exit 2 on
PostToolUseasks for a fix rather than undoing PermissionRequestignores exit 2; JSON control fields differ per event, and nothing but JSON may reach stdout
FAQ
- How does a hook script receive its input?
- Command hooks receive JSON on standard input. HTTP hooks receive the same JSON as the body of a POST request with Content-Type application/json.
- Where is the path of the file that was edited?
- Inside tool_input on PreToolUse and PostToolUse. For Edit or Write, read it with .tool_input.file_path.
- What happens if my hook exits with code 1?
- Any code other than 0 and 2 is a non-blocking error. The action proceeds and the first line of stderr appears prefixed with "Failed with non-blocking status code:".
- Can exit 2 on PostToolUse undo the tool call?
- No. PostToolUse runs after the tool has executed, so the change is already on disk. Exit 2 there feeds your stderr to Claude so it can fix the result.
- How do I get the project root?
- The JSON's cwd is the working directory at the moment the hook runs. For the project root where the session started, use the $CLAUDE_PROJECT_DIR environment variable.
Questions & answers
Q. Several of my hooks return additionalContext, and the order Claude receives them in changes every run. Shouldn't it follow registration order?
The order is not guaranteed. What the documentation guarantees is only that every value arrives: "When several hooks return additionalContext for the same event, Claude receives all of the values." It says nothing about order.
The reason it varies is parallel execution. The docs are explicit: "When an event fires, its matching hooks run in parallel."
| Aspect | What the documentation says |
|---|---|
| Execution | Hooks matching the same event run in parallel |
| Concurrency | The sources differ; see below |
additionalContext | Claude receives all the values several hooks return |
| Order | Not specified |
The concurrency limit is stated two ways
As of 2026-11-01 the hooks documentation says "If more than 12 of them are prompt or agent hooks, 12 of those run at once and the rest start as running ones finish." The v2.1.312 CHANGELOG instead says hooks "run at most 24 at a time per session, sub-agents included, plus at most 12 that have waited longer than their event's timeout; a timeout under 1 second now counts as 1 second." Which figure is current cannot be settled from the documents. Either way they run in parallel, so write hooks that do not depend on order.
If the order carries meaning, build the string in a single hook and return that. Putting the ordering decision in one place is more reliable than concatenating several hooks' output and hoping.
For fixed instructions that should apply to the whole session, a launch flag fits better than a hook:
# Append to the default system prompt
claude --append-system-prompt-file ./extra-rules.txt
A value over 10,000 characters is moved to a file
additionalContext, systemMessage, initialUserMessage and plain stdout are each capped at 10,000 characters. Past that, Claude Code saves the output to a file in the session directory and passes the path plus a preview of up to the first 2,000 characters. The documentation notes that "Claude Code doesn't ask Claude to read the file", so keep anything Claude must see within the cap. No setting or environment variable raises it.
Where the reminder lands depends on the event: SessionStart and SubagentStart at the start of the conversation, UserPromptSubmit alongside the submitted prompt, PreToolUse and PostToolUse next to the tool result, and Stop and SubagentStop at the end of the turn.
One more detail worth knowing: defining the same handler in more than one settings file still runs it once, though a plugin's or skill's copy stays separate. For the full input and output contract, see Claude Code hook input and output.
Primary sources
This article was drafted by AI from official documentation and reviewed by the site operator before publishing. Found a mistake? Let us know via the contact page.