1# Hooks
2
3> [!NOTE]
4> This document was designed for both humans and agents.
5
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.
9
10Hooks are just shell commands, and were designed to be both simple and future
11forward.
12
13### Hot Hook Facts
14
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
23 for determinism
24
25### Some things you can do with hooks:
26
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
30 Language", and so on
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
34 you know are safe
35- Log certain tool calls
36
37β¦And lots more. Show us what you're building!
38
39## Baby's First Hook
40
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).
43
44### Config
45
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.
48
49```jsonc
50{
51 // As expected, hooks go in a "hooks" object.
52 "hooks": {
53 // PreToolUse is an event that fires before a tool is used.
54 "PreToolUse": [
55 {
56 // What tool do we want to hook into? In this case, Bash, because it
57 // runs the stuff we wanna block.
58 "matcher": "^bash$",
59
60 // The path to our actual hook script.
61 "command": "./no-haskell.sh",
62 },
63 ],
64 },
65}
66```
67
68Now, let's make our `no-haskell.sh` hook script.
69
70```bash
71#!/usr/bin/env bash
72
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
76
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
81
82 # Now, block the tool call by exiting with code 2.
83 exit 2
84fi
85```
86
87That's basically it. For the full guide on how hooks work, however, read on.
88
89---
90
91## Configuration
92
93Hooks can be added to your `crush.json` (or `.crush.json`) at both the global
94and project-level, with project level hooks taking precedence.
95
96```jsonc
97{
98 "hooks": {
99 "PreToolUse": [
100 {
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
104 },
105 ],
106 },
107}
108```
109
110Remember, hooks will run in parallel but resolve in config order. Last hook
111wins when rewriting input, but first deny wins when blocking.
112
113## Events
114
115Here are the events you can hook into (spoiler: there's currently just one):
116
117### PreToolUse
118
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
121stuff, and so on.
122
123**Matched against**: the tool name (e.g. `bash`, `edit`, `write`,
124`mcp_github_create_pull_request`).
125
126> [!NOTE]
127> Event names are case insensitive and snake-caseable, so `PreToolUse`,
128> `pretooluse`, `PRETOOLUSE`, `pre_tool_use`, and `PRE_TOOL_USE` all work.
129
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.
135
136Hooks are keyed by event name. Only `command` is required, and you can omit
137`matcher` to match all tools.
138
139## Building Hooks
140
141When a hook fires, Crush:
142
1431. Filters hooks whose `matcher` regex matches the tool name (no matcher = match
144 all).
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
154 permission flow.
155
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.
158
159### Input
160
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.
164
165#### Environment Variables
166
167The available environment variables are:
168
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. |
178
179#### JSON
180
181Standard input provides the full context as JSON:
182
183```jsonc
184{
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
190}
191```
192
193Note that `tool_input` field contains the raw JSON the model sent to the tool.
194
195To parse the stdin JSON in your hook script, read from stdin and use a tool like
196`jq`:
197
198```bash
199#!/usr/bin/env bash
200read -r input
201tool_name=$(echo "$input" | jq -r '.tool_name')
202command=$(echo "$input" | jq -r '.tool_input.command // empty')
203```
204
205You can also use tools like Python:
206
207```python
208#!/usr/bin/env python3
209import json, sys
210
211data = json.load(sys.stdin)
212tool_name = data.get("tool_name", "")
213command = data.get("tool_input", {}).get("command", "")
214```
215
216### Output
217
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:
221
222```bash
223# Here, error code 2 blocks the tool, using stderr as the reason:
224if some_bad_condition; then
225 echo "Blocked: reason here" >&2
226 exit 2
227fi
228```
229
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. |
236
237The difference between exit 2 and exit 49:
238
239- **Exit 2** blocks the current tool call. The agent sees the error and can try
240 something else.
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.
246
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:
250
251```jsonc
252{
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.
259}
260```
261
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.
265
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
271for the call.
272
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.
279
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
282reason.
283
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.
288
289Here's a full shell script that produces this JSON:
290
291```bash
292#!/usr/bin/env bash
293# Example: rewrite a bash command using RTK
294
295read -r input
296original_cmd=$(echo "$input" | jq -r '.tool_input.command')
297rewritten=$(secret-scrubber rewrite "$original_cmd")
298
299cat <<EOF
300{
301 "decision": "allow",
302 "context": "Scrubbed secrets",
303 "updated_input": {"command": "$rewritten"}
304}
305EOF
306```
307
308### Multiple Hooks
309
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`.
313
314When multiple hooks match the same tool call:
315
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.
326
327### Timeouts
328
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
331seconds.
332
333## Examples
334
335### Block destructive commands
336
337Prevent the agent from running `rm -rf` in bash:
338
339```json
340{
341 "hooks": {
342 "PreToolUse": [
343 {
344 "matcher": "^bash$",
345 "command": "./hooks/no-rm-rf.sh"
346 }
347 ]
348 }
349}
350```
351
352`hooks/no-rm-rf.sh`:
353
354```bash
355#!/usr/bin/env bash
356# Block rm -rf commands in the bash tool. Otherwise stay silent so the
357# normal permission flow runs.
358
359if echo "$CRUSH_TOOL_INPUT_COMMAND" | grep -qE 'rm\s+-(rf|fr)\s+/'; then
360 echo "Refusing to run rm -rf against root" >&2
361 exit 2
362fi
363
364exit 0
365```
366
367### Auto-approve read-only tools
368
369Skip the permission prompt for tools that can't change anything. The hook
370returns `decision: "allow"`, which tells Crush to pre-approve the call:
371
372```jsonc
373{
374 "hooks": {
375 "PreToolUse": [
376 {
377 "matcher": "^(view|ls|grep|glob)$",
378 "command": "echo '{\"decision\":\"allow\"}'",
379 },
380 ],
381 },
382}
383```
384
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:
388
389```bash
390#!/usr/bin/env bash
391# hooks/safe-bash.sh β auto-approve read-only bash commands.
392
393case "$CRUSH_TOOL_INPUT_COMMAND" in
394 ls*|cat*|grep*|rg*|echo*|pwd*)
395 echo '{"decision":"allow"}'
396 ;;
397 *)
398 # Silent β fall through to the normal permission prompt.
399 exit 0
400 ;;
401esac
402```
403
404### Inject context into file writes
405
406Add a reminder to the model whenever it writes a Go file:
407
408```json
409{
410 "hooks": {
411 "PreToolUse": [
412 {
413 "matcher": "^(edit|write|multiedit)$",
414 "command": "./hooks/go-context.sh"
415 }
416 ]
417 }
418}
419```
420
421`hooks/go-context.sh`:
422
423```bash
424#!/usr/bin/env bash
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.
428
429if [[ "$CRUSH_TOOL_INPUT_FILE_PATH" == *.go ]]; then
430 echo '{"context": "Remember: run gofumpt after editing Go files."}'
431else
432 echo '{}'
433fi
434```
435
436### Block all MCP tools
437
438The `command` can be inline. This one-liner matches all MCP tools and blocks
439them:
440
441```jsonc
442{ "matcher": "^mcp_", "command": "echo 'MCP tools are disabled' >&2; exit 2" }
443```
444
445### Log every tool call
446
447With no `matcher` this fires for every tool. It exits 0 with no stdout so the
448tool call always proceeds.
449
450```jsonc
451{ "command": "echo \"$(date -Iseconds) $CRUSH_TOOL_NAME\" >> ./tools.log" }
452```
453
454### A real-world Example:
455
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.
459
460### Using other languages
461
462Hooks aren't limited to shell scripts: any executable works. Here's the same
463"block rm -rf" example in some other languages.
464
465#### Lua
466
467`{"matcher": "^bash$", "command": "lua ./hooks/no-rm-rf.lua"}`
468
469```lua
470local input = io.read("*a")
471local tool_input = input:match('"command":"(.-)"') or ""
472
473if tool_input:match("rm%s+%-[rf][rf]%s+/") then
474 io.stderr:write("Refusing to run rm -rf against root\n")
475 os.exit(2)
476end
477```
478
479#### JavaScript
480
481`{"matcher": "^bash$", "command": "node ./hooks/no-rm-rf.js"}`
482
483```js
484let input = "";
485process.stdin.on("data", (chunk) => (input += chunk));
486process.stdin.on("end", () => {
487 const { tool_input: toolInput } = JSON.parse(input);
488
489 if (/rm\s+-[rf]{2}\s+\//.test(toolInput.command)) {
490 process.stderr.write("Refusing to run rm -rf against root\n");
491 process.exit(2);
492 }
493});
494```
495
496---
497
498## Claude Code compatibility
499
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.
505
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.
509
510---
511
512## Reference
513
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.
517
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.
521
522### Hook config
523
524Each entry under a `hooks.<EventName>` array:
525
526```jsonc
527{
528 // string. Optional. Regex tested against the tool name. Omit to match all.
529 "matcher": "^bash$",
530
531 // string. Required. Shell command to run.
532 "command": "./hooks/my-hook.sh",
533
534 // number. Optional. Seconds before the hook is killed. Defaults to 30.
535 "timeout": 10,
536}
537```
538
539### Stdin payload (common)
540
541Present in every hook event:
542
543```jsonc
544{
545 // string. Hook event name.
546 "event": "PreToolUse",
547
548 // string. Current session ID.
549 "session_id": "313909e",
550
551 // string. Working directory when invoked.
552 "cwd": "/home/user/project",
553}
554```
555
556### Stdin payload β PreToolUse
557
558Extends the common payload:
559
560```jsonc
561{
562 // ...common fields...
563
564 // string. The tool being called.
565 "tool_name": "bash",
566
567 // object. Raw JSON input the model sent to the tool. Shape is per-tool.
568 "tool_input": {
569 "command": "npm test",
570 },
571}
572```
573
574### Output envelope (common)
575
576Fields a hook may print to stdout on exit 0. All are optional and apply to every
577event:
578
579```jsonc
580{
581 // number. Defaults to 1. Unknown higher values still parse; exists for
582 // forward-compat.
583 "version": 1,
584
585 // boolean. If true, ends the turn entirely. User takes over.
586 "halt": false,
587
588 // string. Shown when denying (to the model) or halting (to the model and
589 // user).
590 "reason": "not allowed",
591
592 // string | string[]. Appended to what the model sees. Empty entries are
593 // dropped.
594 "context": "Rewrote with RTK",
595}
596```
597
598### Output envelope β PreToolUse
599
600Extends the common envelope:
601
602```jsonc
603{
604 // ...common fields...
605
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.
610 "decision": "allow",
611
612 // object. Shallow-merge patch against tool_input. Nested objects are
613 // replaced wholesale, not deep-merged.
614 "updated_input": {
615 "command": "bun test",
616 },
617}
618```
619
620### Exit codes
621
622| Code | Meaning |
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. |
628
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.
631
632### Aggregation
633
634When multiple hooks match the same event, results compose in **config order**.
635
636Universal rules:
637
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.
643
644PreToolUse-specific rules:
645
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
650 permission flow.
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.
654
655### Environment variables
656
657See [Environment Variables](#environment-variables) above for the full list.
658
659---
660
661## Whatcha think?
662
663We'd love to hear your thoughts on this project. Need help? We gotchu. You can
664find us on:
665
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)
671
672---
673
674Part of [Charm](https://charm.land).
675
676<a href="https://charm.land/"><img alt="The Charm logo" width="400" src="https://stuff.charm.sh/charm-banner-softy.jpg" /></a>
677
678<!--prettier-ignore-->
679Charmηη±εΌζΊ β’ Charm loves open source