The JSON your Claude Code hooks receive on stdin: common and per-event fields
Command hooks read JSON from standard input. The fields every event includes, the extra fields for PreToolUse, Stop and SessionStart, and jq examples for reading them.
Contents
You have the hook configured and your script is running, but nothing tells you which file was just edited or which command is about to execute.
The answer is the JSON that arrives on standard input. Command hooks read JSON from stdin; HTTP hooks receive the same JSON as a POST body with Content-Type: application/json. This article lays out that JSON, split into the fields every event carries and the fields each event adds.
KEY POINT
What you will learn
- The fields present on every hook event and what each one means
- The extra fields for PreToolUse, Stop, SessionStart and the other events
- How to read values with
jq, and where to get what the JSON doesn't include
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.
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.
{
"tool_output": {
"text": "Test results...",
"error": null
}
}
PostToolUseFailure uses the same structure, with the error type in error (for example TimeoutError).
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 |
Note that 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. For which events match on what, 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 here:
// emptypreventsjqfrom returning the literal stringnullwhen the hook fires for a tool that has nofile_path- Standard input can only be read once. Capture it with
INPUT=$(cat)and pull fields from the variable as many times as you need
Exit code 2 blocks the action. For what each exit code means, see the hooks reference.
用語解説
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"
}
For a concrete lint-and-format setup built on this, see Run lint and format automatically after every edit.
Summary
- Command hooks read JSON from stdin; HTTP hooks get the same JSON as a POST body
- Every event includes
session_id,transcript_path,cwd,permission_mode,hook_event_nameandeffort - Tool events add
tool_name,tool_inputandtool_use_id, andPostToolUseaddstool_output - 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 not in the JSON; read
$CLAUDE_PROJECT_DIRinstead
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.
- Are there fields available on every event?
- Yes. session_id, transcript_path, cwd, permission_mode and hook_event_name are included on every hook event.
- 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.
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.