Claude Code hook input and output, the JSON on stdin and what exit codes 0, 2 and everything else do

Claude Code Published: Updated:

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.

Verified on Oct 10, 2026 These tools change quickly. Please also check the latest official documentation. How articles are researched and verified
Contents
  1. Input, fields present on every event
  2. Input, what each event adds
    1. PreToolUse and PostToolUse
    2. The other events
  3. Reading values with jq
  4. What the JSON doesn't include
  5. Output, what the exit code means
    1. What exit 2 does per event
    2. Three worked examples
  6. Output, JSON for finer control
  7. Related
  8. Summary

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"
  }
}
FieldDescription
session_idCurrent session identifier
prompt_idUUID identifying the user prompt (absent until first user input; requires v2.1.196+)
transcript_pathPath to the conversation JSON (may lag the current turn)
cwdWorking directory when the hook is invoked
scratchpad_dirThe session's scratchpad directory (absent if unavailable; requires v2.1.257+)
permission_modedefault, plan, acceptEdits, auto, dontAsk or bypassPermissions
effortObject with a level field: low, medium, high, xhigh or max
hook_event_nameName of the event that fired
agent_idSubagent identifier (only in subagent context)
agent_typeAgent 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

EventFields addedExample values
UserPromptSubmituser_inputThe prompt text
Stoplast_assistant_message, stop_reasonend_turn
SubagentStopagent_type, agent_id, last_assistant_message, stop_reasoncode-reviewer
SessionStarthow, modelstartup, resume, clear, compact, fork
SessionEndwhy, modelclear, resume, logout, prompt_input_exit, other
PreCompact / PostCompactwhatmanual, auto
Notificationtype, metadatapermission_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:

VariableContents
$CLAUDE_PROJECT_DIRProject root where the session started
$CLAUDE_PLUGIN_ROOTPlugin installation directory (for plugin hooks)
$CLAUDE_PLUGIN_DATAThe plugin's persistent data directory
$CLAUDE_EFFORTCurrent effort level (low, medium, high, xhigh, max)
$CLAUDE_CODE_REMOTESet to true in remote web environments
$CLAUDE_CODE_BRIDGE_SESSION_IDRemote 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 codeTreated asstdoutstderr
0SuccessShown to you; for a few events it is added to Claude's context as plain textNot shown
2Blocking errorIgnoredFed to Claude as feedback on events that support it
Anything elseNon-blocking errorParsed as JSON when it looks like JSON; otherwise ignoredFirst 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

EventCan block?What exit 2 does
PreToolUseYesBlocks the tool call; stderr goes to Claude
PostToolUseCannot undo the callFeeds stderr to Claude so it fixes the result
UserPromptSubmitYesBlocks prompt processing and erases the prompt; stderr shown to you
UserPromptExpansionYesBlocks the expansion
Stop / SubagentStopYesPrevents Claude from stopping and continues with stderr as the instruction
PermissionRequestNoNot honored; the permission flow proceeds. Deny through the decision object instead
SessionStart and othersNoShows 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.

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_name and effort; tool events add tool_name, tool_input and tool_use_id
  • 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 $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 PostToolUse asks for a fix rather than undoing
  • PermissionRequest ignores 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?

Answer by the editorsSource: GitHub anthropics/claude-code Issue #105799

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."

AspectWhat the documentation says
ExecutionHooks matching the same event run in parallel
ConcurrencyThe sources differ; see below
additionalContextClaude receives all the values several hooks return
OrderNot 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.