Skip to content

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:

json
{
    "hooks": {
        "PreToolUse": [
            {
                "matcher": "^bash$",
                "command": "./hooks/no-rm-rf.sh"
            }
        ]
    }
}

hooks/no-rm-rf.sh:

bash
#!/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 0

Auto-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:

jsonc
{
    "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:

bash
#!/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
    ;;
esac

Inject context into file writes ​

Add a reminder to the model whenever it writes a Go file:

json
{
    "hooks": {
        "PreToolUse": [
            {
                "matcher": "^(edit|write|multiedit)$",
                "command": "./hooks/go-context.sh"
            }
        ]
    }
}

hooks/go-context.sh:

bash
#!/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 '{}'
fi

Block all MCP tools ​

The command can be inline. This one-liner matches all MCP tools and blocks them:

jsonc
{ "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.

jsonc
{ "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"}

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)
end

JavaScript ​

{"matcher": "^bash$", "command": "node ./hooks/no-rm-rf.js"}

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:

jsonc
{
    // 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:

jsonc
{
    // 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:

jsonc
{
    // ...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:

jsonc
{
    // 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:

jsonc
{
    // ...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 ​

CodeMeaning
0Success. Stdout is parsed as the output envelope.
2Block this tool call. Stderr becomes the deny reason. Stdout is ignored.
49Halt the whole turn. Stderr becomes the halt reason. Stdout is ignored.
otherNon-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:

  1. halt is sticky: if any hook halts, the turn ends.
  2. reason values concatenate with \n in config order. Halt-only hooks without a deny still contribute their reason.
  3. context values concatenate with \n in config order. String entries and array entries flatten uniformly.

PreToolUse-specific rules:

  1. decision precedence: deny > allow > null. First deny determines the outcome; subsequent allows don't override. If the final aggregated decision is allow, Crush pre-approves the tool call and skips the permission prompt. If it's null (no hook allowed), the tool goes through the normal permission flow.
  2. updated_input patches shallow-merge sequentially against the original tool_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.