Hooks run through Crush's embedded POSIX shell, the same interpreter the bash tool uses.
Execution model
Hooks run through Crush's embedded POSIX shell (mvdan.cc/sh) — the same interpreter the bash tool uses. Inline commands and shebang-less scripts execute in-process; scripts with a #! shebang dispatch to the named interpreter via os/exec. This contract is identical on macOS, Linux, and Windows.
What this means in practice:
Windows without Unix tooling: inline shell (
echo, pipelines,jq,grep), shebang-less.shscripts, inline PowerShell (powershell -Command …), and.exeinvocations all work out of the box with no WSL, Git Bash, Cygwin, or MSYS required.PowerShell scripts (
.ps1) are not auto-dispatched by extension. Invoke them explicitly:powershell -File ./audit.ps1(orpwsh -File ./audit.ps1).Shebang'd scripts require the named interpreter on
PATH. Git for Windows shipsbash.exe, which makes#!/bin/bashand#!/usr/bin/env bashscripts work on Windows the same way they do on Unix. CRLF line endings in the shebang line are tolerated.Permissive shebang fallback: if the absolute path in a shebang doesn't exist (e.g.
#!/bin/bashon Windows), Crush falls back to aPATHlookup of the base name (bash) before giving up. A debug-level log records the fallback. If the interpreter isn't onPATHeither, the hook fails cleanly as a non-blocking warning and the agent proceeds as "no opinion".Environment: every hook sees
CRUSH=1,AGENT=crush, andAI_AGENT=crushon top of theCRUSH_*hook-specific variables. These three markers are guaranteed and match what thebashtool sets, so scripts that detect "am I being run by an AI agent?" behave the same in both contexts.Timeout behavior: when a hook exceeds its timeout, Crush cancels the context and waits a short grace period (~1s) for the interpreter to yield. If the hook still hasn't returned, Crush abandons it, logs a warning, and treats the result as "no opinion" so the agent can proceed. Long-running work should honor context cancellation or run in a subprocess via a shebang.
Configuration
Hooks can be added to your crushrc (or .crushrc) at both the global and project level, with project level hooks taking precedence. Each hook is one hook add line:
# name: friendly name shown in the TUI; matcher: regex tested against the# tool name; timeout: seconds, default 30.hook add PreToolUse \ --name "no-rm-rf" \ --matcher '^bash$' \ --command "./hooks/my-hot-hook.sh" \ --timeout 10hook add TurnEnd \ --name "ding" \ --command 'afplay /System/Library/Sounds/Glass.aiff'The command is resolved relative to your current working directory — not relative to the config file. Relative paths like ./hooks/whatever.sh work fine in project-level .crushrc because the project root is also your working directory. For global config (~/.config/crush/), however, you must use either an absolute path or an inline command:
# Global ~/.config/crush/crushrc with absolute pathhook add PreToolUse \ --command "/home/you/.config/crush/hooks/no-haskell.sh" # Or use an inline command:# hook add PreToolUse \# --command "echo '{\"decision\":\"allow\"}'"Remember, hooks will run in parallel but resolve in config order. Last hook wins when rewriting input, but first deny wins when blocking.
Events
Crush supports 13 hook events. Event names are case-insensitive and accept snake_case (PreToolUse, pre_tool_use, PRETOOLUSE all work). An unknown event name is a config error.
| Event | Fires When | Control | Matcher | Claude Code |
|---|---|---|---|---|
PreToolUse | Before a tool runs | allow/deny, halt, rewrite input, context | tool name | same |
PostToolUse | After a tool succeeds | halt, replace output, context, feedback | tool name | same |
PostToolUseFailure | After a tool fails | halt, context | tool name | same |
PermissionRequest | Permission prompt shown | observe | tool name | same (observe only here) |
PermissionResult | Prompt granted, denied, or cancelled | observe | tool name | none |
SessionStart | Session created, or resumed with --continue | observe | — | same |
SessionEnd | App exits (2s budget) | observe | — | same |
TurnStart | Agent begins processing a prompt | observe | — | UserPromptSubmit |
TurnEnd | Agent finishes a turn | observe | — | Stop |
StopFailure | Turn ends with a provider error | observe | — | same |
Interrupt | User cancels a turn (ctrl-c, escape) | observe | — | none |
PreCompact | Before context compaction (auto or /compact) | block | — | same |
PostCompact | After context compaction | observe | — | same |
Matcher is a regex tested against the tool name (e.g. ^bash$, ^(edit|write)$, ^mcp_). Omit it to match every tool. It is only accepted on the events that carry a tool name; configuring one elsewhere is a config error.
Scope: tool and turn events only fire for the top-level agent. Subagents (the task tool, agentic_fetch, and friends) fire none of them, so a notification hook on TurnEnd speaks once per prompt rather than once per delegated turn. The outer subagent tool call itself is hooked.
Permission events fire only when a prompt is actually shown. Calls that a PreToolUse hook allowed, that are on the allow-list, or that run in YOLO mode never prompt and so fire neither. PermissionResult always follows its PermissionRequest, even when the turn is cancelled while the prompt is open; its decision field says which way it went.
SessionEnd fires once, for the session you were last working in, after the running turn has been cancelled (so you will see Interrupt first). It has a two second budget shared by every SessionEnd hook, so keep it to enqueueing or notifying; do the heavy work elsewhere.
What each control field does
decision: "allow"— pre-approves the tool call, skips the permission prompt."deny"blocks it; the model sees the error and may retry.halt: true— ends the entire turn. ForPreCompact, blocks compaction instead.updated_input— forPreToolUse: shallow-merge patch against tool input.updated_output— forPostToolUse: replaces what the model sees as the tool's output.context— string or array appended to what the model sees. Works on the tool events.reasonwithdecision: "deny"onPostToolUse— the tool already ran, so instead of blocking anything the reason is handed to the model as feedback ("run the tests first"). This is Claude Code'sdecision: "block"and it works the same way here.
Events marked "observe" ignore all control fields. They exist for logging, monitoring, and external integrations (e.g. herdr, dashboards). To auto-approve tools, use PreToolUse with decision: "allow" rather than PermissionRequest.
Blocking compaction (PreCompact) leaves the conversation as it was. When the compaction was automatic, the turn ends there, since the next step would only hit the context limit again; send another message to carry on. A blocked /compact reports the hook's reason.
Control field examples
PreToolUse — rewrite input:
#!/usr/bin/env bash# Replace npm with bun in every bash call.jq -c 'select(.tool_input.command | startswith("npm ")) | {updated_input: {command: (.tool_input.command | sub("^npm "; "bun "))}}'PostToolUse — redact secrets from output:
#!/usr/bin/env bash# Replace anything that looks like an API key in tool output.jq -c '{updated_output: (.tool_response | gsub("(sk-|ghp_|AKIA)[A-Za-z0-9]{20,}"; "[REDACTED]"))}'PostToolUse — halt on unexpected output:
#!/usr/bin/env bash# Stop the turn if bash output contains a credential leak warning.if jq -r '.tool_response' | grep -qi 'credential.*leak'; then echo '{"halt":true,"reason":"Possible credential leak detected in tool output"}'fiPostToolUse — send the model feedback:
#!/usr/bin/env bash# The commit went through, but tell the model what it skipped.if jq -r '.tool_input.command' | grep -q '^git commit' && [ ! -f .tests-passed ]; then echo '{"decision":"deny","reason":"You committed without running the tests. Run them now and amend if needed."}'fiPreCompact — skip compaction during critical work:
#!/usr/bin/env bash# Don't compact if we're in the middle of a migration.if [ -f .migration-in-progress ]; then echo '{"halt":true,"reason":"migration in progress"}'fiPreToolUse — inject context:
#!/usr/bin/env bash# Remind the model about formatting rules when editing Go files.if jq -r '.tool_input.file_path // empty' | grep -q '\.go$'; then echo '{"context":"Remember: run gofumpt -w after editing Go files."}'fiTurnEnd — read the reply aloud:
#!/usr/bin/env bash# Speak the assistant's final message. Background it so the next prompt# is not held up behind the speech.jq -r '.last_assistant_message' | say &JSON configuration
Hooks are keyed by event name. Only command is required:
{ "hooks": { "PreToolUse": [{ "matcher": "^bash$", "command": "./hooks/check.sh" }], "PostToolUse": [{ "matcher": "^bash$", "command": "./hooks/redact.sh" }], "TurnEnd": [{ "command": "./hooks/notify-idle.sh" }], "PreCompact": [{ "command": "./hooks/maybe-skip-compact.sh" }], },}Building Hooks
When a hook fires, Crush:
Filters hooks whose
matcherregex matches the tool name (no matcher = match all).Deduplicates by
command(identical commands run once).Runs all matching hooks in parallel through Crush's embedded POSIX shell (see Execution model).
Waits for all to finish (or time out), then aggregates results in config order: deny wins over allow, allow wins over none;
updated_inputpatches shallow-merge in order.Applies the result before permission checks. If the aggregated decision is
deny, the tool call is blocked and you never see a permission prompt for it. If it'sallow, Crush treats that as affirmative pre-approval and also skips the prompt. Silence (no decision) falls through to the normal permission flow.
Note that you can omit matcher and match in your shell script instead, however you'll incur some additional overhead as Crush will still parse and run each hook.
Multiple Hooks
Hooks run in parallel, but their results compose in config order. Whichever hook finishes first doesn't get to "win" by virtue of timing; composition is deterministic based on the order hooks appear in crushrc.
When multiple hooks match the same tool call:
If any hook denies, the tool call is blocked.
reasonvalues are concatenated in config order (newline-separated).If any hook halts, the turn ends after the tool call is blocked.
If no hook denies or halts but at least one allows, the tool call proceeds and the permission prompt is skipped.
contextvalues are concatenated in config order. Strings and arrays compose uniformly — each string becomes one entry, and array entries are flattened in.updated_inputpatches shallow-merge in config order against the original tool input. Later hooks override earlier ones on colliding keys. If denied or halted,updated_inputpatches are ignored.
Timeouts
If a hook exceeds its timeout, Crush cancels its context and treats the result as a non-blocking error so the tool call proceeds. The default timeout is 30 seconds. Shebang-dispatched subprocesses are killed through exec.CommandContext; in-process hooks get a short grace period to yield and are then abandoned (the agent moves on regardless). Long-running work should honor context cancellation or run out-of-process via a shebang.
Managing hooks
Manage hooks from your crushrc with the hook command.
Usage: hook [command]Available Commands: add Add a hook to an event remove Remove a named hook, or clear an event rm Alias for removehook add
Add a shell command that runs when the given hook event fires.
Usage: hook add <event> --command <command> [flags]Flags: --command string shell command to run (required) --name string name used for later removal --matcher string regex tested against the tool name --timeout int timeout in seconds (default 30)hook add PreToolUse --matcher "^bash$" \ --command "./hooks/no-haskell.sh" --name no-haskellhook remove
Remove hooks from an event. Without --name, remove every hook for the event.
Usage: hook remove <event> [--name <name>] hook rm <event> [--name <name>]Flags: --name string remove hooks with this nameRead this post in your terminal with glow:glow -p https://crush-docs.fly.dev/docs/integrations/hooks/execution.md
