4> This document was designed for both humans and agents.
6Hooks are user-defined shell scripts that run when various events happen during
7the agent lifecycle, allowing you to both build on top of Crush, customize
8its behavior, and exert deterministic control over an agent's wily behavior.
10Hooks are just shell commands, and were designed to be both simple and future
15- Hooks just shell commands
16- Hooks can be written in any language because they’re just executables: Bash, Python, Node, Rust, Haskell, whatever
17- Hooks are Claude Code-compatible
18- Crush ships with a builtin `crush-hook` skill write, edit, and configure
19 hooks; just tell Crush how to configure Crush
20- Crush currently supports just one hook, `PreToolUse`, with plans to support
21 the full gamut; please let us know which hooks you'd like to see next
22- Hooks run in parallel for speed, but their results compose in config order
25### Some things you can do with hooks:
27- Block "dangerous" commands: no more `git push -f` or `cabal init`
28- Rewrite tool input: turn `node` calls info `deno`, scrub secrets from
29 commands, rewrite all mentions of "Haskell" into "Haskell, The Best
31- Inject context: add notes to the model's context whenever certain tools are
32 called. For example: "remember to run gofumpt after editing Go files"
33- Auto-approve tools: skip the permission prompt for bash commands that
35- Log certain tool calls
37…And lots more. Show us what you're building!
41Let's just dive into it and make a simple hook. This particular hook will
42disallow the use of Haskell (but we love you, Simon Peyton Jones).
46The first thing we need to do is hook up our hook. Let's add the following to
47our local `crush.json`. You can, of course, do this globally, too.
51 // As expected, hooks go in a "hooks" object.
53 // PreToolUse is an event that fires before a tool is used.
56 // What tool do we want to hook into? In this case, Bash, because it
57 // runs the stuff we wanna block.
60 // The path to our actual hook script.
61 "command": "./no-haskell.sh",
68Now, let's make our `no-haskell.sh` hook script.
73# Disallow ghc, cabal, and stack. Pipe the bash command output
74# ($CRUSH_TOOL_INPUT_COMMAND) to grep and match on a regexp.
75if echo "$CRUSH_TOOL_INPUT_COMMAND" | grep -qE '(^| )((ghc|cabal|stack)(\.exe)?)( |$)'; then
77 # Someone is trying to use Haskell. Let's send a message back to the model
78 # and user explaining why we're blocking this. Note that we send all feedback
79 # like this to stderr.
80 echo "No Haskell allowed, kiddo." >&2
82 # Now, block the tool call by exiting with code 2.
87That's basically it. For the full guide on how hooks work, however, read on.
93Hooks can be added to your `crush.json` (or `.crush.json`) at both the global
94and project-level, with project level hooks taking precedence.
101 "matcher": "bash", // regex tested against the tool name
102 "command": "./hooks/my-hot-hook.sh", // the path to the hook
103 "timeout": 10, // in seconds; default 30
110Remember, hooks will run in parallel but resolve in config order. Last hook
111wins when rewriting input, but first deny wins when blocking.
115Here are the events you can hook into (spoiler: there's currently just one):
119This hook fires before every tool call. Use it to block dangerous commands,
120enforce policies, rewrite tool input, inject context the model should see, log
123**Matched against**: the tool name (e.g. `bash`, `edit`, `write`,
124`mcp_github_create_pull_request`).
127> Event names are case insensitive and snake-caseable, so `PreToolUse`,
128> `pretooluse`, `PRETOOLUSE`, `pre_tool_use`, and `PRE_TOOL_USE` all work.
130**Scope**: `PreToolUse` only fires on the **top-level agent's** tool calls.
131Sub-agents (the `agent` task tool, `agentic_fetch`, etc.) run without hook
132interception so a single delegated turn doesn't trigger your hook N times. The
133outer sub-agent tool call itself _is_ hooked, so policy like "never let the
134agent spawn sub-agents" still works.
136Hooks are keyed by event name. Only `command` is required, and you can omit
137`matcher` to match all tools.
141When a hook fires, Crush:
1431. Filters hooks whose `matcher` regex matches the tool name (no matcher = match
1452. Deduplicates by `command` (identical commands run once).
1463. Runs all matching hooks **in parallel** as subprocesses.
1474. Waits for all to finish (or time out), then aggregates results **in config
148 order**: deny wins over allow, allow wins over none; `updated_input` patches
149 shallow-merge in order.
1505. Applies the result **before** permission checks. If the aggregated decision
151 is `deny`, the tool call is blocked and you never see a permission prompt
152 for it. If it's `allow`, Crush treats that as affirmative pre-approval and
153 also skips the prompt. Silence (no decision) falls through to the normal
156Note that you can omit `matcher` and match in your shell script instead, however
157you'll incur some additional overhead as Crush will `exec` each script.
161Each hook receives data two ways: environment variables and stdin (as JSON).
162Environment variables are typically easier to work with, with JSON being
163available when input is more complex.
165#### Environment Variables
167The available environment variables are:
169| Variable | Description |
170| ---------------------------- | ---------------------------------------------- |
171| `CRUSH_EVENT` | The hook event name (e.g. `PreToolUse`). |
172| `CRUSH_TOOL_NAME` | The tool being called (e.g. `bash`). |
173| `CRUSH_SESSION_ID` | Current session ID. |
174| `CRUSH_CWD` | Working directory. |
175| `CRUSH_PROJECT_DIR` | Project root directory. |
176| `CRUSH_TOOL_INPUT_COMMAND` | For `bash` calls: the shell command being run. |
177| `CRUSH_TOOL_INPUT_FILE_PATH` | For file tools: the target file path. |
181Standard input provides the full context as JSON:
185 "event": "PreToolUse", // Hook event name
186 "session_id": "313909e", // Current session ID
187 "cwd": "/home/user/project", // Working directory
188 "tool_name": "bash", // The tool being called
189 "tool_input": { "command": "rm -rf /" }, // The tool's input
193Note that `tool_input` field contains the raw JSON the model sent to the tool.
195To parse the stdin JSON in your hook script, read from stdin and use a tool like
201tool_name=$(echo "$input" | jq -r '.tool_name')
202command=$(echo "$input" | jq -r '.tool_input.command // empty')
205You can also use tools like Python:
208#!/usr/bin/env python3
211data = json.load(sys.stdin)
212tool_name = data.get("tool_name", "")
213command = data.get("tool_input", {}).get("command", "")
218Hooks communicate back to Crush via **exit code** and `stdout`/`stderr`. The
219simplest way to do this is to return an error code and print additional context
220to stderr. For example:
223# Here, error code 2 blocks the tool, using stderr as the reason:
224if some_bad_condition; then
225 echo "Blocked: reason here" >&2
230| Exit Code | Meaning |
231| --------- | ---------------------------------------------------------------- |
232| 0 | Success. Stdout is parsed as JSON (see fields below). |
233| 2 | **Block the tool.** Stderr is used as the deny reason (no JSON). |
234| 49 | **Halt the turn.** Stderr is used as the halt reason (no JSON). |
235| Other | Non-blocking error. Logged and ignored — the tool call proceeds. |
237The difference between exit 2 and exit 49:
239- **Exit 2** blocks the current tool call. The agent sees the error and can try
241- **Exit 49** halts the whole turn. The agent doesn't get to respond further;
242 the user takes over. Use this when something is wrong enough that the agent
243 shouldn't keep trying. 49 sits in an empty slice of the exit-code space —
244 between the generic-error range (1-30), the BSD `sysexits.h` range (64-78),
245 and the killed-by-signal range (128+) — so it can't be hit by accident.
247That said, if you need more control, or if you need to rewrite input, you can
248use JSON on stdout. Exit 0 and print a JSON object to provide context, update
249the input, or still deny/halt with a reason:
253 "version": 1, // Output envelope version. Optional; defaults to 1.
254 "decision": "allow", // "allow", "deny", or null. Omit for no opinion.
255 "halt": false, // If true, halts the turn entirely.
256 "reason": "LGTM", // Shown when denying or halting.
257 "context": "Scrubbed secrets", // String or array of strings. Appended to what the model sees.
258 "updated_input": { "command": "…" }, // Shallow-merged into the tool's input before execution.
262`version` is an optional integer at the top of the envelope. It defaults to `1`
263if omitted. Unknown higher versions are still parsed; the field exists so the
264envelope can evolve without a compatibility shim.
266`decision: "allow"` is **affirmative**: it pre-approves the tool call and
267bypasses the permission prompt entirely. Silence (no `decision`, or
268`decision: null`) means "no opinion" — the tool still goes through the
269normal permission flow. Use `"allow"` when you want to auto-approve; omit it
270when you only want to inject context or rewrite input without also vouching
273`updated_input` is a shallow-merge patch. Keys you include overwrite matching
274keys in `tool_input`; keys you don't include are preserved. If the model called
275`bash` with `{"command": "npm test", "timeout": 60000}` and your hook returns
276`{"updated_input": {"command": "bun test"}}`, the tool runs with
277`{"command": "bun test", "timeout": 60000}` — the timeout isn't dropped. The
278merge is shallow: nested objects are replaced wholesale, not deep-merged.
280`halt: true` stops the turn entirely. The agent doesn't get to respond further;
281the user takes over. The exit-code shorthand is `exit 49` with stderr as the
284`context` accepts either a string or an array of strings. Use the string form
285for a single observation; use the array form when a hook produces multiple
286distinct notes and you'd rather not concatenate them by hand. Empty strings and
287empty array entries are dropped.
289Here's a full shell script that produces this JSON:
293# Example: rewrite a bash command using RTK
296original_cmd=$(echo "$input" | jq -r '.tool_input.command')
297rewritten=$(secret-scrubber rewrite "$original_cmd")
302 "context": "Scrubbed secrets",
303 "updated_input": {"command": "$rewritten"}
310Hooks run in parallel, but their results compose in config order. Whichever hook
311finishes first doesn't get to "win" by virtue of timing; composition is
312deterministic based on the order hooks appear in `crush.json`.
314When multiple hooks match the same tool call:
316- If **any** hook denies, the tool call is blocked. `reason` values are
317 concatenated in config order (newline-separated).
318- If **any** hook halts, the turn ends after the tool call is blocked.
319- If no hook denies or halts but at least one allows, the tool call proceeds
320 **and the permission prompt is skipped**.
321- `context` values are concatenated in config order. Strings and arrays compose
322 uniformly — each string becomes one entry, and array entries are flattened in.
323- `updated_input` patches shallow-merge in config order against the original
324 tool input. Later hooks override earlier ones on colliding keys. If denied or
325 halted, `updated_input` patches are ignored.
329If a hook exceeds its timeout, the process is killed and treated as a
330non-blocking error and the tool call proceeds. The default timeout is 30
335### Block destructive commands
337Prevent the agent from running `rm -rf` in bash:
345 "command": "./hooks/no-rm-rf.sh"
356# Block rm -rf commands in the bash tool. Otherwise stay silent so the
357# normal permission flow runs.
359if echo "$CRUSH_TOOL_INPUT_COMMAND" | grep -qE 'rm\s+-(rf|fr)\s+/'; then
360 echo "Refusing to run rm -rf against root" >&2
367### Auto-approve read-only tools
369Skip the permission prompt for tools that can't change anything. The hook
370returns `decision: "allow"`, which tells Crush to pre-approve the call:
377 "matcher": "^(view|ls|grep|glob)$",
378 "command": "echo '{\"decision\":\"allow\"}'",
385No script file needed — the command is inline. Every `view`/`ls`/`grep`/`glob`
386call now runs without prompting. Add the `bash` tool to this list at your own
387risk; consider a more targeted allowlist instead:
391# hooks/safe-bash.sh — auto-approve read-only bash commands.
393case "$CRUSH_TOOL_INPUT_COMMAND" in
394 ls*|cat*|grep*|rg*|echo*|pwd*)
395 echo '{"decision":"allow"}'
398 # Silent — fall through to the normal permission prompt.
404### Inject context into file writes
406Add a reminder to the model whenever it writes a Go file:
413 "matcher": "^(edit|write|multiedit)$",
414 "command": "./hooks/go-context.sh"
421`hooks/go-context.sh`:
425# Remind the model about Go formatting when editing .go files.
426# Emit context only; stay silent on `decision` so the normal permission
427# prompt still runs for edits/writes.
429if [[ "$CRUSH_TOOL_INPUT_FILE_PATH" == *.go ]]; then
430 echo '{"context": "Remember: run gofumpt after editing Go files."}'
436### Block all MCP tools
438The `command` can be inline. This one-liner matches all MCP tools and blocks
442{ "matcher": "^mcp_", "command": "echo 'MCP tools are disabled' >&2; exit 2" }
445### Log every tool call
447With no `matcher` this fires for every tool. It exits 0 with no stdout so the
448tool call always proceeds.
451{ "command": "echo \"$(date -Iseconds) $CRUSH_TOOL_NAME\" >> ./tools.log" }
454### A real-world Example:
456For a more practical example, see [`rtk-rewrite.sh`](./examples/rtk-rewrite.sh),
457which demonstrates how to rewrite tool input using
458[RTK](https://github.com/rtk-ai/rtk) to save tokens.
460### Using other languages
462Hooks aren't limited to shell scripts: any executable works. Here's the same
463"block rm -rf" example in some other languages.
467`{"matcher": "^bash$", "command": "lua ./hooks/no-rm-rf.lua"}`
470local input = io.read("*a")
471local tool_input = input:match('"command":"(.-)"') or ""
473if tool_input:match("rm%s+%-[rf][rf]%s+/") then
474 io.stderr:write("Refusing to run rm -rf against root\n")
481`{"matcher": "^bash$", "command": "node ./hooks/no-rm-rf.js"}`
485process.stdin.on("data", (chunk) => (input += chunk));
486process.stdin.on("end", () => {
487 const { tool_input: toolInput } = JSON.parse(input);
489 if (/rm\s+-[rf]{2}\s+\//.test(toolInput.command)) {
490 process.stderr.write("Refusing to run rm -rf against root\n");
498## Claude Code compatibility
500Crush hooks are broadly compatible with [Claude Code
501hooks](https://docs.claude.com/en/docs/claude-code/hooks): the config shape,
502stdin payload, output envelope, and exit codes line up so most Claude Code
503hooks run under Crush unchanged. This document covers the Crush-specific API
504only — anything not documented here isn't guaranteed to work.
506One intentional divergence: Crush treats `updated_input` as a shallow-merge
507patch against the original `tool_input` rather than a full replacement. Keys
508you omit are preserved. See [Output](#output) for details.
514This is the official reference of the narrative above. If prose and this section
515disagree, the prose should be presumed canonical for intent, while this section
516is canonical for shape.
518Both the stdin payload and the output envelope have **common fields** that apply
519to every event and **per-event fields** that only some events recognize. When an
520event doesn't understand a field, it's ignored.
524Each entry under a `hooks.<EventName>` array:
528 // string. Optional. Regex tested against the tool name. Omit to match all.
531 // string. Required. Shell command to run.
532 "command": "./hooks/my-hook.sh",
534 // number. Optional. Seconds before the hook is killed. Defaults to 30.
539### Stdin payload (common)
541Present in every hook event:
545 // string. Hook event name.
546 "event": "PreToolUse",
548 // string. Current session ID.
549 "session_id": "313909e",
551 // string. Working directory when invoked.
552 "cwd": "/home/user/project",
556### Stdin payload — PreToolUse
558Extends the common payload:
562 // ...common fields...
564 // string. The tool being called.
567 // object. Raw JSON input the model sent to the tool. Shape is per-tool.
569 "command": "npm test",
574### Output envelope (common)
576Fields a hook may print to stdout on exit 0. All are optional and apply to every
581 // number. Defaults to 1. Unknown higher values still parse; exists for
585 // boolean. If true, ends the turn entirely. User takes over.
588 // string. Shown when denying (to the model) or halting (to the model and
590 "reason": "not allowed",
592 // string | string[]. Appended to what the model sees. Empty entries are
594 "context": "Rewrote with RTK",
598### Output envelope — PreToolUse
600Extends the common envelope:
604 // ...common fields...
606 // "allow" | "deny" | null. null/omitted = no opinion, the tool still goes
607 // through the normal permission prompt. "allow" is affirmative: pre-approves
608 // the tool call and bypasses the prompt. "deny" blocks the call; the model
609 // sees the error and may try something else.
612 // object. Shallow-merge patch against tool_input. Nested objects are
613 // replaced wholesale, not deep-merged.
615 "command": "bun test",
623| ----- | ------------------------------------------------------------------------ |
624| `0` | Success. Stdout is parsed as the output envelope. |
625| `2` | Block this tool call. Stderr becomes the deny reason. Stdout is ignored. |
626| `49` | Halt the whole turn. Stderr becomes the halt reason. Stdout is ignored. |
627| other | Non-blocking error. Logged and ignored; the tool call proceeds. |
629Exit `2` only applies to events that can block something. On events where
630there's nothing to block, it's treated as a non-blocking error.
634When multiple hooks match the same event, results compose in **config order**.
6381. `halt` is sticky: if any hook halts, the turn ends.
6392. `reason` values concatenate with `\n` in config order. Halt-only hooks
640 without a deny still contribute their reason.
6413. `context` values concatenate with `\n` in config order. String entries and
642 array entries flatten uniformly.
644PreToolUse-specific rules:
6464. `decision` precedence: `deny` > `allow` > `null`. First deny determines the
647 outcome; subsequent allows don't override. If the final aggregated decision
648 is `allow`, Crush pre-approves the tool call and skips the permission
649 prompt. If it's `null` (no hook allowed), the tool goes through the normal
6515. `updated_input` patches shallow-merge sequentially against the original
652 `tool_input`. Later patches override earlier ones on colliding keys. Patches
653 are **ignored** if the final decision is deny or halt.
655### Environment variables
657See [Environment Variables](#environment-variables) above for the full list.
663We'd love to hear your thoughts on this project. Need help? We gotchu. You can
666- [Twitter](https://twitter.com/charmcli)
667- [Slack](https://charm.land/slack)
668- [Discord](https://charm.land/discord)
669- [The Fediverse](https://mastodon.social/@charmcli)
670- [Bluesky](https://bsky.app/profile/charm.land)
674Part of [Charm](https://charm.land).
676<a href="https://charm.land/"><img alt="The Charm logo" width="400" src="https://stuff.charm.sh/charm-banner-softy.jpg" /></a>
678<!--prettier-ignore-->
679Charm热爱开源 • Charm loves open source
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list