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 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:
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 ;;esacInject 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 '{}'fiBlock 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 ;;esacUsing 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)endJavaScript
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_DIRis exported alongsideCRUSH_PROJECT_DIR.Output accepts Claude Code's forms as well as Crush's:
hookSpecificOutput(permissionDecision,permissionDecisionReason,updatedInput,updatedToolOutput,additionalContext), the top-leveldecision: "block"withreason, andcontinue: falsewithstopReason.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 nestedmatchergroups withhooksarrays. Onlycommandhooks exist; there are nohttp,prompt, oragenthooks.Event names:
TurnStart,TurnEnd,Interrupt, andPermissionResultare Crush's names for the per-turn events; the table in Events maps each one to its Claude Code counterpart.PermissionRequestis observe-only here; usePreToolUseto allow or deny.updated_inputonPreToolUseis a shallow-merge patch against the originaltool_inputrather than a full replacement, so keys you omit are preserved. Claude Code'supdatedInputis treated the same way.tool_responseis the tool's text output as one string, not a per-tool object. Crush's own field for replacing it isupdated_output; Claude Code'supdatedToolOutputmaps onto it.haltand 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
| Code | Meaning |
|---|---|
0 | Success. Stdout is parsed as the output envelope. |
2 | Block. Stderr becomes the reason, falling back to the envelope's reason. |
49 | Halt the whole turn. Stderr becomes the halt reason. |
| other | Non-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:
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.
PostToolUse-specific rules:
updated_outputreplaces the tool output. When several hooks set it, the last one in config order wins.
PreCompact-specific rules:
haltordenyblocks 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
