Input & Output
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. |
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, 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
"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
}Note that tool_input field contains the raw JSON the model sent to the tool.
To parse the stdin JSON in your hook script, read from stdin and use a tool like jq:
#!/usr/bin/env bash
read -r input
tool_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 python3
import 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 2
fi| Exit Code | Meaning |
|---|---|
| 0 | Success. Stdout is parsed as JSON (see fields below). |
| 2 | Block the tool. Stderr is used as the deny reason (no JSON). |
| 49 | Halt the turn. Stderr is used as the halt reason (no JSON). |
| Other | Non-blocking error. Logged and ignored — the tool call proceeds. |
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 input
original_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