Hook Examples
A cookbook of hooks, from one-liners to full scripts. Remember: hooks are just executables, so any language works.
Block destructive commands
Prevent the agent from running rm -rf in bash:
{
"hooks": {
"PreToolUse": [
{
"matcher": "^bash$",
"command": "./hooks/no-rm-rf.sh"
}
]
}
}hooks/no-rm-rf.sh:
#!/usr/bin/env bash
# Block rm -rf commands in the bash tool. Otherwise stay silent so the
# normal permission flow runs.
if echo "$CRUSH_TOOL_INPUT_COMMAND" | grep -qE 'rm\s+-(rf|fr)\s+/'; then
echo "Refusing to run rm -rf against root" >&2
exit 2
fi
exit 0Auto-approve read-only tools
Skip the permission prompt for tools that can't change anything. The hook returns decision: "allow", which tells Crush to pre-approve the call:
{
"hooks": {
"PreToolUse": [
{
"matcher": "^(view|ls|grep|glob)$",
"command": "echo '{\"decision\":\"allow\"}'",
},
],
},
}No script file needed — the command is inline. Every view/ls/grep/glob call now runs without prompting. Add the bash tool to this list at your own risk; consider a more targeted allowlist instead:
#!/usr/bin/env bash
# hooks/safe-bash.sh — auto-approve read-only bash commands.
case "$CRUSH_TOOL_INPUT_COMMAND" in
ls*|cat*|grep*|rg*|echo*|pwd*)
echo '{"decision":"allow"}'
;;
*)
# Silent — fall through to the normal permission prompt.
exit 0
;;
esacInject context into file writes
Add a reminder to the model whenever it writes a Go file:
{
"hooks": {
"PreToolUse": [
{
"matcher": "^(edit|write|multiedit)$",
"command": "./hooks/go-context.sh"
}
]
}
}hooks/go-context.sh:
#!/usr/bin/env bash
# Remind the model about Go formatting when editing .go files.
# Emit context only; stay silent on `decision` so the normal permission
# prompt still runs for edits/writes.
if [[ "$CRUSH_TOOL_INPUT_FILE_PATH" == *.go ]]; then
echo '{"context": "Remember: run gofumpt after editing Go files."}'
else
echo '{}'
fiBlock all MCP tools
The command can be inline. This one-liner matches all MCP tools and blocks them:
{ "matcher": "^mcp_", "command": "echo 'MCP tools are disabled' >&2; exit 2" }Log every tool call
With no matcher this fires for every tool. It exits 0 with no stdout so the tool call always proceeds.
{ "command": "echo \"$(date -Iseconds) $CRUSH_TOOL_NAME\" >> ./tools.log" }A real-world example
For a more practical example, see rtk-rewrite.sh, which demonstrates how to rewrite tool input using RTK to save tokens.
Using other languages
Hooks aren't limited to shell scripts: any executable works. Here's the same "block rm -rf" example in some other languages.
Lua
{"matcher": "^bash$", "command": "lua ./hooks/no-rm-rf.lua"}
local input = io.read("*a")
local tool_input = input:match('"command":"(.-)"') or ""
if tool_input:match("rm%s+%-[rf][rf]%s+/") then
io.stderr:write("Refusing to run rm -rf against root\n")
os.exit(2)
endJavaScript
{"matcher": "^bash$", "command": "node ./hooks/no-rm-rf.js"}
let input = "";
process.stdin.on("data", (chunk) => (input += chunk));
process.stdin.on("end", () => {
const { tool_input: toolInput } = JSON.parse(input);
if (/rm\s+-[rf]{2}\s+\//.test(toolInput.command)) {
process.stderr.write("Refusing to run rm -rf against root\n");
process.exit(2);
}
});Claude Code compatibility
Crush hooks are broadly compatible with Claude Code hooks: the config shape, stdin payload, output envelope, and exit codes line up so most Claude Code hooks run under Crush unchanged. This document covers the Crush-specific API only — anything not documented here isn't guaranteed to work.
One intentional divergence: Crush treats updated_input as a shallow-merge patch against the original tool_input rather than a full replacement. Keys you omit are preserved. See Input & Output for details.
Reference
This is the official reference of the narrative above. If prose and this section disagree, the prose should be presumed canonical for intent, while this section is canonical for shape.
Both the stdin payload and the output envelope have common fields that apply to every event and per-event fields that only some events recognize. When an event doesn't understand a field, it's ignored.
Hook config
Each entry under a hooks.<EventName> array:
{
// string. Optional. Friendly display name shown in the TUI. Falls back to
// command when omitted.
"name": "no-rm-rf",
// string. Optional. Regex tested against the tool name. Omit to match all.
"matcher": "^bash$",
// string. Required. Shell command to run.
"command": "./hooks/my-hook.sh",
// number. Optional. Seconds before the hook is killed. Defaults to 30.
"timeout": 10,
}Stdin payload (common)
Present in every hook event:
{
// string. Hook event name.
"event": "PreToolUse",
// string. Current session ID.
"session_id": "313909e",
// string. Working directory when invoked.
"cwd": "/home/user/project",
}Stdin payload — PreToolUse
Extends the common payload:
{
// ...common fields...
// string. The tool being called.
"tool_name": "bash",
// object. Raw JSON input the model sent to the tool. Shape is per-tool.
"tool_input": {
"command": "npm test",
},
}Output envelope (common)
Fields a hook may print to stdout on exit 0. All are optional and apply to every event:
{
// number. Defaults to 1. Unknown higher values still parse; exists for
// forward-compat.
"version": 1,
// boolean. If true, ends the turn entirely. User takes over.
"halt": false,
// string. Shown when denying (to the model) or halting (to the model and
// user).
"reason": "not allowed",
// string | string[]. Appended to what the model sees. Empty entries are
// dropped.
"context": "Rewrote with RTK",
}Output envelope — PreToolUse
Extends the common envelope:
{
// ...common fields...
// "allow" | "deny" | null. null/omitted = no opinion, the tool still goes
// through the normal permission prompt. "allow" is affirmative: pre-approves
// the tool call and bypasses the prompt. "deny" blocks the call; the model
// sees the error and may try something else.
"decision": "allow",
// object. Shallow-merge patch against tool_input. Nested objects are
// replaced wholesale, not deep-merged.
"updated_input": {
"command": "bun test",
},
}Exit codes
| Code | Meaning |
|---|---|
0 | Success. Stdout is parsed as the output envelope. |
2 | Block this tool call. Stderr becomes the deny reason. Stdout is ignored. |
49 | Halt the whole turn. Stderr becomes the halt reason. Stdout is ignored. |
| other | Non-blocking error. Logged and ignored; the tool call proceeds. |
Exit 2 only applies to events that can block something. On events where there's nothing to block, it's treated as a non-blocking error.
Aggregation
When multiple hooks match the same event, results compose in config order.
Universal rules:
haltis sticky: if any hook halts, the turn ends.reasonvalues concatenate with\nin config order. Halt-only hooks without a deny still contribute their reason.contextvalues concatenate with\nin config order. String entries and array entries flatten uniformly.
PreToolUse-specific rules:
decisionprecedence:deny>allow>null. First deny determines the outcome; subsequent allows don't override. If the final aggregated decision isallow, Crush pre-approves the tool call and skips the permission prompt. If it'snull(no hook allowed), the tool goes through the normal permission flow.updated_inputpatches shallow-merge sequentially against the originaltool_input. Later patches override earlier ones on colliding keys. Patches are ignored if the final decision is deny or halt.
Environment variables
See Environment Variables for the full list.
