Originally published at https://aicoding-guide.com.
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 thattranscript_pathmay 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.
Glossary
stdin (standard input): the channel that feeds data into a process from outside. Reading it withcattreats 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
Top comments (0)