Hook Examples

A cookbook of hooks, from one-liners to full scripts, in any language.

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:

hook add 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 2fi 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:

hook add 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    ;;esac

Inject context into file writes

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

hook add 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 '{}'fi

Block all MCP tools

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

hook add PreToolUse \  --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.

hook add PreToolUse \  --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:

#!/usr/bin/env bash # RTK hook for Crush# This hook rewrites bash commands to use rtk for token savings.## https://github.com/rtk-ai/rtk## Requires: rtk >= 0.23.0, jq## All rewrite logic lives in `rtk rewrite`, which is the single source of# truth. To add or change rewrite rules, edit the Rust registry, not this# file.## Exit code protocol for `rtk rewrite`:#   0 + stdout  Rewrite found → allow with updated input#   1           No RTK equivalent → pass through unchanged#   2           Deny rule matched → pass through set -euo pipefail if ! command -v jq &>/dev/null; then    echo "[rtk] WARNING: jq is not installed. Install: https://jqlang.github.io/jq/download/" >&2    exit 0fi if ! command -v rtk &>/dev/null; then    echo "[rtk] WARNING: rtk is not installed. Install: https://github.com/rtk-ai/rtk#installation" >&2    exit 0fi # Version guard: rtk rewrite requires >= 0.23.0.RTK_VERSION=$(rtk --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)if [ -n "$RTK_VERSION" ]; then    MAJOR=$(echo "$RTK_VERSION" | cut -d. -f1)    MINOR=$(echo "$RTK_VERSION" | cut -d. -f2)    if [ "$MAJOR" -eq 0 ] && [ "$MINOR" -lt 23 ]; then        echo "[rtk] WARNING: rtk $RTK_VERSION is too old (need >= 0.23.0). Upgrade: cargo install rtk" >&2        exit 0    fifi CMD="${CRUSH_TOOL_INPUT_COMMAND:-}"if [ -z "$CMD" ]; then    exit 0fi REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) && EXIT_CODE=0 || EXIT_CODE=$? case $EXIT_CODE in0 | 3)    # Rewrite found. If identical, the command already uses rtk.    [ "$CMD" = "$REWRITTEN" ] && exit 0    jq -n --arg cmd "$REWRITTEN" \        '{decision: "allow", updated_input: ({command: $cmd} | tostring)}'    ;;*)    # No rewrite (1), deny (2), or unexpected — pass through.    exit 0    ;;esac

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

hook add PreToolUse \  --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)end

JavaScript

hook add PreToolUse \  --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    process.stderr.write("Refusing to run rm -rf against root\n");    process.exit(2);  }});

Claude Code compatibility

Crush hooks are largely compatible with Claude Code hooks. Most hook scripts written for Claude Code run under Crush unchanged:

  • Payload fields use Claude Code's names where one exists: hook_event_name, session_id, cwd, tool_name, tool_input, tool_use_id, tool_response, error, last_assistant_message, source, reason, trigger. CLAUDE_PROJECT_DIR is exported alongside CRUSH_PROJECT_DIR.

  • Output accepts Claude Code's forms as well as Crush's: hookSpecificOutput (permissionDecision, permissionDecisionReason, updatedInput, updatedToolOutput, additionalContext), the top-level decision: "block" with reason, and continue: false with stopReason.

  • Exit codes behave the same: 0 applies the JSON, 2 blocks with stderr as the reason, and any other code is a non-blocking error unless stdout held a JSON object.

Where Crush differs:

  • Config shape is flat: a list of {matcher, command} per event, not Claude Code's nested matcher groups with hooks arrays. Only command hooks exist; there are no http, prompt, or agent hooks.

  • Event names: TurnStart, TurnEnd, Interrupt, and PermissionResult are Crush's names for the per-turn events; the table in Events maps each one to its Claude Code counterpart. PermissionRequest is observe-only here; use PreToolUse to allow or deny.

  • updated_input on PreToolUse is a shallow-merge patch against the original tool_input rather than a full replacement, so keys you omit are preserved. Claude Code's updatedInput is treated the same way.

  • tool_response is the tool's text output as one string, not a per-tool object. Crush's own field for replacing it is updated_output; Claude Code's updatedToolOutput maps onto it.

  • halt and exit 49 have no Claude Code equivalent; they end the turn.

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. The same name again, under the field Claude Code uses.  "hook_event_name": "PreToolUse",   // string. Current session ID.  "session_id": "313909e",   // string. Working directory when invoked.  "cwd": "/home/user/project",}

Stdin payload — tool events

PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, and PermissionResult extend the common payload with the tool call:

{  // ...common fields...   // string. The tool being called.  "tool_name": "bash",   // object. Raw JSON input the model sent to the tool. Shape is per-tool.  // For the permission events this is the request the prompt showed.  "tool_input": {    "command": "npm test",  },   // string. The tool call ID.  "tool_use_id": "call_01",}

PostToolUse adds the tool's output, which is what updated_output replaces:

{  // string. Always present, even when the tool printed nothing.  "tool_response": "PASS\nok  \texample.com/testproject\t0.004s",}

PostToolUseFailure adds the failure instead:

{  // string. The tool's error text.  "error": "exit status 1\nFAIL\t...",}

PermissionResult adds how the prompt was resolved:

{  // "granted" | "denied" | "cancelled". Cancelled means the turn was  // cancelled while the prompt was open.  "decision": "granted",}

Stdin payload — turn events

TurnStart and Interrupt use only the common fields. TurnEnd adds the assistant's reply and StopFailure adds the provider error:

{  // string (TurnEnd). The assistant's text for the turn.  "last_assistant_message": "Here's what I found...",   // string (StopFailure). The provider error.  "error": "Rate limit reached",}

Stdin payload — session and compaction events

{  // "startup" | "resume" (SessionStart). Resume means --continue or --last.  "source": "startup",   // string (SessionEnd). Currently always "exit".  "reason": "exit",   // "auto" | "manual" (PreCompact, PostCompact). Manual is /compact.  "trigger": "auto",}

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",  },}

Output envelope — PostToolUse

Same as the common envelope plus updated_output, a string that replaces what the model sees as the tool's response. The tool has already run, so decision: "deny" cannot block it; its reason is appended to the output as feedback for the model instead.

PostToolUseFailure accepts halt and context only.

Output envelope — PreCompact

Same as the common envelope. halt: true, decision: "deny", or exit 2 blocks compaction and leaves the conversation as it was. Claude Code's decision: "block" works too.

Exit codes

CodeMeaning
0Success. Stdout is parsed as the output envelope.
2Block. Stderr becomes the reason, falling back to the envelope's reason.
49Halt the whole turn. Stderr becomes the halt reason.
otherNon-blocking error, logged and ignored. If stdout holds a JSON object it is applied all the same.

Exit 2 only blocks on events that can block something (PreToolUse, PreCompact). On PostToolUse it becomes feedback for the model; on observe-only events it is logged.

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.

PostToolUse-specific rules:

  1. updated_output replaces the tool output. When several hooks set it, the last one in config order wins.

PreCompact-specific rules:

  1. halt or deny blocks compaction and leaves the conversation as it was.

Environment variables

See Environment Variables for the full list.

Read this post in your terminal with glow:glow -p https://crush-docs.fly.dev/docs/integrations/hooks/examples.md

EOF