DEV Community

aicoding-guide
aicoding-guide

Posted on Originally published at aicoding-guide.com

The JSON your Claude Code hooks receive on stdin: common and per-event fields

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"
  }
}
Enter fullscreen mode Exit fullscreen mode
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..."
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Two details matter here:

  1. // empty prevents jq from returning the literal string null when the hook fires for a tool that has no file_path
  2. 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 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"
}
Enter fullscreen mode Exit fullscreen mode

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_name and effort
  • Tool events add tool_name, tool_input and tool_use_id, and PostToolUse adds tool_output
  • Field names differ per event: how for SessionStart, why for SessionEnd, what for compaction
  • In scripts, capture with INPUT=$(cat) and read fields with jq -r '... // empty'
  • The project root is not in the JSON; read $CLAUDE_PROJECT_DIR instead

Top comments (0)