README.md

  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