Input & Output

Each hook receives data through environment variables and stdin JSON and reports back with exit codes and stdout or stderr.

Input

Each hook receives data two ways: environment variables and stdin (as JSON). Environment variables are typically easier to work with, with JSON being available when input is more complex.

Environment Variables

The available environment variables are:

VariableDescription
CRUSHAlways 1 when running under Crush.
AGENTAlways crush.
AI_AGENTAlways crush.
CRUSH_EVENTThe hook event name (e.g. PreToolUse).
CRUSH_TOOL_NAMEThe tool being called (e.g. bash).
CRUSH_SESSION_IDCurrent session ID.
CRUSH_CWDWorking directory.
CRUSH_PROJECT_DIRProject root directory.
CLAUDE_PROJECT_DIRSame as CRUSH_PROJECT_DIR, for scripts written for Claude Code.
CRUSH_TOOL_INPUT_COMMANDFor bash calls: the shell command being run.
CRUSH_TOOL_INPUT_FILE_PATHFor file tools: the target file path.

The CRUSH_TOOL_* variables are only set on events that carry a tool call.

The CRUSH, AGENT, and AI_AGENT markers are also set by the bash tool, so a script can detect "am I running under Crush?" the same way in either context.

JSON

Standard input provides the full context as JSON:

{  "event": "PreToolUse", // Hook event name  "hook_event_name": "PreToolUse", // Same thing, under Claude Code's name  "session_id": "313909e", // Current session ID  "cwd": "/home/user/project", // Working directory  "tool_name": "bash", // The tool being called  "tool_input": { "command": "rm -rf /" }, // The tool's input  "tool_use_id": "call_01", // The tool call ID}

Note that tool_input field contains the raw JSON the model sent to the tool. Events without a tool call omit the tool_* fields, and some events add one of their own (tool_response, last_assistant_message, error, decision, source, reason, trigger); see the reference for the full list.

To parse the stdin JSON in your hook script, read from stdin and use a tool like jq:

#!/usr/bin/env bashread -r inputtool_name=$(echo "$input" | jq -r '.tool_name')command=$(echo "$input" | jq -r '.tool_input.command // empty')

You can also use tools like Python:

#!/usr/bin/env python3import json, sys data = json.load(sys.stdin)tool_name = data.get("tool_name", "")command = data.get("tool_input", {}).get("command", "")

Output

Hooks communicate back to Crush via exit code and stdout/stderr. The simplest way to do this is to return an error code and print additional context to stderr. For example:

# Here, error code 2 blocks the tool, using stderr as the reason:if some_bad_condition; then  echo "Blocked: reason here" >&2  exit 2fi
Exit CodeMeaning
0Success. Stdout is parsed as JSON (see fields below).
2Block the tool. Stderr is the deny reason (falling back to the JSON reason, if you printed one).
49Halt the turn. Stderr is the halt reason.
OtherNon-blocking error, logged and ignored. As in Claude Code, a JSON object on stdout still counts and is applied.

The difference between exit 2 and exit 49:

  • Exit 2 blocks the current tool call. The agent sees the error and can try something else.

  • Exit 49 halts the whole turn. The agent doesn't get to respond further; the user takes over. Use this when something is wrong enough that the agent shouldn't keep trying. 49 sits in an empty slice of the exit-code space — between the generic-error range (1-30), the BSD sysexits.h range (64-78), and the killed-by-signal range (128+) — so it can't be hit by accident.

That said, if you need more control, or if you need to rewrite input, you can use JSON on stdout. Exit 0 and print a JSON object to provide context, update the input, or still deny/halt with a reason:

{  "version": 1, // Output envelope version. Optional; defaults to 1.  "decision": "allow", // "allow", "deny", or null. Omit for no opinion.  "halt": false, // If true, halts the turn entirely.  "reason": "LGTM", // Shown when denying or halting.  "context": "Scrubbed secrets", // String or array of strings. Appended to what the model sees.  "updated_input": { "command": "…" }, // Shallow-merged into the tool's input before execution.}

version is an optional integer at the top of the envelope. It defaults to 1 if omitted. Unknown higher versions are still parsed; the field exists so the envelope can evolve without a compatibility shim.

decision: "allow" is affirmative: it pre-approves the tool call and bypasses the permission prompt entirely. Silence (no decision, or decision: null) means "no opinion" — the tool still goes through the normal permission flow. Use "allow" when you want to auto-approve; omit it when you only want to inject context or rewrite input without also vouching for the call.

updated_input is a shallow-merge patch. Keys you include overwrite matching keys in tool_input; keys you don't include are preserved. If the model called bash with {"command": "npm test", "timeout": 60000} and your hook returns {"updated_input": {"command": "bun test"}}, the tool runs with {"command": "bun test", "timeout": 60000} — the timeout isn't dropped. The merge is shallow: nested objects are replaced wholesale, not deep-merged.

halt: true stops the turn entirely. The agent doesn't get to respond further; the user takes over. The exit-code shorthand is exit 49 with stderr as the reason.

context accepts either a string or an array of strings. Use the string form for a single observation; use the array form when a hook produces multiple distinct notes and you'd rather not concatenate them by hand. Empty strings and empty array entries are dropped.

Here's a full shell script that produces this JSON:

#!/usr/bin/env bash# Example: rewrite a bash command using RTK read -r inputoriginal_cmd=$(echo "$input" | jq -r '.tool_input.command')rewritten=$(secret-scrubber rewrite "$original_cmd") cat <<EOF{  "decision": "allow",  "context": "Scrubbed secrets",  "updated_input": {"command": "$rewritten"}}EOF

Read this post in your terminal with glow:glow -p https://crush.charm.land/integrations/hooks/input-output.md

EOF