Learn Claude Code: Hooks Deep Dive Command Hooks: Shell Scripts as Lifecycle Handlers

Command Hooks: Shell Scripts as Lifecycle Handlers

Advanced 🕐 13 min Lesson 3 of 12
What you'll learn
  • Write a command hook in both exec form and shell form and explain the behavioral difference between them
  • Parse the JSON input payload and emit a structured JSON response to control hook behavior
  • Use exit codes 0, 2, and non-zero to signal success, blocking errors, and non-blocking errors respectively

Why Shell Scripts Are the Default Choice

Command hooks are the workhorse of the hooks system. When you need to validate a command, log an action, transform input, or send a notification, a shell script is almost always the right starting point. No external service required, no LLM call needed, no MCP server to configure. Just code you control, running locally, with the full power of the shell and any installed tool.

Command hooks receive a JSON payload on stdin, do their work, and communicate back through exit codes and stdout. The protocol is simple and well-defined. A script that works correctly in isolation will work correctly as a hook — you can test it with a sample JSON input file long before connecting it to Claude Code.

Exec Form vs Shell Form

There are two ways to specify a command hook, and the difference matters.

Shell form — used when the args field is absent — passes the command string to the shell for interpretation. The shell tokenizes it, expands variables, resolves pipes and redirections, and runs the result. This lets you write:

{
  "type": "command",
  "command": "cat /tmp/input.json | python3 ./scripts/validate.py | jq ."
}

Shell form is convenient for pipelines and uses everything the shell offers. The default shell is bash on Linux and macOS, PowerShell on Windows. You can override it with the shell field.

Exec form — used when the args field is present — bypasses the shell entirely and executes the binary directly. Arguments are passed as an array, so no shell tokenization occurs and no variable expansion happens unless your program does it itself. This is safer for scripts that receive untrusted input, since there is no risk of a tool input containing shell metacharacters that change the command behavior.

{
  "type": "command",
  "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/validate.sh",
  "args": []
}

Use exec form for hook scripts that live in the project. Use shell form when you need pipes, variable expansion, or multi-command chains.

Reading the JSON Input

Every command hook receives a JSON object on stdin. The object always contains a set of common fields regardless of which event fired the hook.

The common fields include: session_id (the current session identifier), hook_event_name (which event fired), cwd (the current working directory), permission_mode (the active permission mode), transcript_path (path to the full session transcript), and prompt_id (unique identifier for the current prompt). For tool-specific events like PreToolUse and PostToolUse, the payload also includes tool_name and tool_input.

A minimal bash hook that reads the tool name looks like this:

#!/bin/bash
input=$(cat)
tool=$(echo "$input" | jq -r .tool_name)
command=$(echo "$input" | jq -r .tool_input.command // empty)
echo "Hook fired for tool: $tool" >&2

The jq tool is the standard way to parse the input in bash hooks. It handles JSON safely and provides flexible filtering. If jq is not available, you can use Python with python3 -c "import sys,json; data=json.load(sys.stdin)".

Exit Codes: The Control Signal

How a command hook communicates its decision back to Claude Code is entirely through exit codes and stdout. The exit code carries the primary signal.

Exit 0 means success. Claude Code parses stdout for a JSON decision object. If stdout is empty or not valid JSON, Claude Code treats it as a no-op success and proceeds.

Exit 2 means blocking error. Whatever the hook was guarding against is blocked. Claude Code reads stderr and uses it as the reason shown to the user. This is how you prevent a tool call from executing, stop a turn from completing, or reject a user prompt.

Any other exit code means non-blocking error. Something went wrong in the hook, but Claude Code does not surface it as a blocking condition. The tool call proceeds. Use this for hooks that should never interrupt the workflow even when they fail — logging hooks, notification hooks, analytics hooks.

A security hook that blocks rm -rf uses exit 2:

#!/bin/bash
command=$(cat | jq -r .tool_input.command // empty)
if echo "$command" | grep -q "rm -rf"; then
  echo "Destructive rm command is not permitted in this project." >&2
  exit 2
fi
exit 0

Writing JSON Output

When a hook exits 0 and emits valid JSON on stdout, Claude Code processes the JSON for structured control. The output object has two layers: universal fields that apply to any event, and event-specific fields in a hookSpecificOutput block.

Universal fields include continue (set to false to stop processing all subsequent hooks for this event), stopReason (a message shown when the session is stopped), suppressOutput (hide the hook's stdout from the transcript), systemMessage (a warning message added as context), and terminalSequence (an OSC escape sequence for terminal notifications).

Event-specific control lives in hookSpecificOutput, which must include hookEventName matching the current event. For PreToolUse, this is where you put the permission decision. For Stop, this is where you inject context into the next turn. Each event has its own set of recognized fields in this block, covered in detail in their respective lessons.

Async Hooks and Background Execution

By default, hooks block Claude Code until they complete. For hooks that do expensive work — calling an external API, running a build, waiting for a service — this blocking behavior adds latency to every tool call or turn.

Set async: true to run a hook in the background without blocking. Claude Code fires the hook and immediately continues. The hook output is ignored. Use this for logging, analytics, and notification hooks that do not need to influence the current action.

Set asyncRewake: true to run the hook in the background with the ability to resume Claude later. If the background hook exits with code 2, Claude Code wakes up and provides the hook's stderr as additional context. This enables a powerful pattern: start a CI build from a Stop hook, let Claude continue working or go idle, and have the CI hook wake Claude when the build completes or fails. The lesson on advanced patterns covers asyncRewake in depth.

Key takeaways
  • Exec form bypasses the shell — use it when your command has no pipes or redirects and you want deterministic argument handling without shell interpretation
  • Exit code 2 is the blocking signal — it surfaces stderr as the reason shown to Claude and prevents the hooked action from proceeding
  • The JSON input on stdin contains everything you need — session_id, cwd, tool_name, tool_input, permission_mode, and the full transcript path for deeper inspection
  • JSON stdout on exit 0 controls behavior — use universal fields for turn-level control and hookSpecificOutput for event-specific decisions
  • asyncRewake enables background monitoring — a hook running async can exit 2 later to wake Claude with new context, enabling CI polling without blocking the current turn