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:
| Variable | Description |
|---|---|
CRUSH | Always 1 when running under Crush. |
AGENT | Always crush. |
AI_AGENT | Always crush. |
CRUSH_EVENT | The hook event name (e.g. PreToolUse). |
CRUSH_TOOL_NAME | The tool being called (e.g. bash). |
CRUSH_SESSION_ID | Current session ID. |
CRUSH_CWD | Working directory. |
CRUSH_PROJECT_DIR | Project root directory. |
CLAUDE_PROJECT_DIR | Same as CRUSH_PROJECT_DIR, for scripts written for Claude Code. |
CRUSH_TOOL_INPUT_COMMAND | For bash calls: the shell command being run. |
CRUSH_TOOL_INPUT_FILE_PATH | For 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 Code | Meaning |
|---|---|
| 0 | Success. Stdout is parsed as JSON (see fields below). |
| 2 | Block the tool. Stderr is the deny reason (falling back to the JSON reason, if you printed one). |
| 49 | Halt the turn. Stderr is the halt reason. |
| Other | Non-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.hrange (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"}}EOFRead this post in your terminal with glow:glow -p https://crush-docs.fly.dev/docs/integrations/hooks/input-output.md
