docs(hooks): document the lifecycle events and Claude Code compatibility

Cover every event with its payload fields and control surface, map each
one to its Claude Code counterpart and list where Crush differs, fix the
PostToolUse examples to read tool_response and build JSON with jq, add
the crushrc form of hook config, and bring the builtin hook skills up to
date.
This commit is contained in:
Kieran Klukas committed 2026-10-26 15:59:04 -04:00
1 parent 48c0224d2c
commit 6c3ce0c849
4 files changed
+389 -74

No files matched your search

+9 -7
View File
@@ -152,16 +152,18 @@ unaffected.
## `UserPromptSubmit` event
**Status:** not implemented.
**Status:** partially implemented as `TurnStart`. The current `TurnStart` event
fires when the agent begins processing a prompt but does not yet support
`decision` (blocking prompts) or `updated_prompt` (rewriting prompts). Those
control features are still planned.
### Motivation
Today Crush supports exactly one hook event, `PreToolUse`. That's enough to gate
and rewrite tool calls but nothing else. The next-most-useful event is
`UserPromptSubmit`: fires after the user hits Enter but before the turn hits the
LLM. Lets hooks inject context, rewrite prompts, or gate on content without the
mutation complexity of `PostToolUse` (output scrubbing, error coercion, size
limits — all rabbit holes).
Crush now supports 13 hook events including `TurnStart`, which covers the basic
observability use case. The remaining gap is **control**: letting hooks block
prompts matching a policy or rewrite them before they reach the LLM. This would
enable secret redaction from prompts, policy enforcement ("don't mention
production credentials"), and shorthand expansion.
### Use cases
+303 -46
View File
@@ -16,11 +16,12 @@ forward.
- Hooks are just shell commands
- Hooks can be written in any language because they're just executables: Bash, Python, Node, Rust, Haskell, whatever
- Hooks are Claude Code-compatible
- Hooks are largely Claude Code-compatible: same payload field names, same
output forms, same exit codes
- Crush ships with a builtin `crush-hook` skill to write, edit, and configure
hooks; just tell Crush how to configure Crush
- Crush currently supports just one hook, `PreToolUse`, with plans to support
the full gamut; please let us know which hooks you'd like to see next
- Crush supports 13 hook events covering the full agent lifecycle: tool use,
session management, turn control, permissions, compaction, and error handling
- Hooks run in parallel for speed, but their results compose in config order
for determinism
@@ -127,15 +128,21 @@ What this means in practice:
## Configuration
Hooks can be added to your `crushrc` (or `.crushrc`) at both the global
and project-level, with project level hooks taking precedence.
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:
```bash
# 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 10
hook add TurnEnd \
--name "ding" \
--command 'afplay /System/Library/Sounds/Glass.aiff'
```
> [!IMPORTANT]
@@ -160,29 +167,155 @@ wins when rewriting input, but first deny wins when blocking.
## Events
Here are the events you can hook into (spoiler: there's currently just one):
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.
### PreToolUse
| 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 |
This hook fires before every tool call. Use it to block dangerous commands,
enforce policies, rewrite tool input, inject context the model should see, log
stuff, and so on.
**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.
**Matched against**: the tool name (e.g. `bash`, `edit`, `write`,
`mcp_github_create_pull_request`).
**Scope**: tool and turn events only fire for the **top-level agent**.
Sub-agents (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 sub-agent tool call itself _is_ hooked.
> [!NOTE]
> Event names are case insensitive and snake-caseable, so `PreToolUse`,
> `pretooluse`, `PRETOOLUSE`, `pre_tool_use`, and `PRE_TOOL_USE` all work.
**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.
**Scope**: `PreToolUse` only fires on the **top-level agent's** tool calls.
Sub-agents (the `agent` task tool, `agentic_fetch`, etc.) run without hook
interception so a single delegated turn doesn't trigger your hook N times. The
outer sub-agent tool call itself _is_ hooked, so policy like "never let the
agent spawn sub-agents" still works.
**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.
Hooks are keyed by event name. Only `command` is required, and you can omit
`matcher` to match all tools.
### 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. For `PreCompact`, blocks compaction
instead.
- **`updated_input`** — for `PreToolUse`: shallow-merge patch against tool
input.
- **`updated_output`** — for `PostToolUse`: 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.
- **`reason`** with `decision: "deny"` on `PostToolUse` — 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's `decision: "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:**
```bash
#!/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:**
```bash
#!/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:**
```bash
#!/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"}'
fi
```
**PostToolUse — send the model feedback:**
```bash
#!/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."}'
fi
```
**PreCompact — skip compaction during critical work:**
```bash
#!/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"}'
fi
```
**PreToolUse — inject context:**
```bash
#!/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."}'
fi
```
**TurnEnd — read the reply aloud:**
```bash
#!/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 &
```
### Configuration
Hooks are keyed by event name. Only `command` is required:
```jsonc
{
"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
@@ -226,9 +359,12 @@ The available environment variables are:
| `CRUSH_SESSION_ID` | Current session ID. |
| `CRUSH_CWD` | Working directory. |
| `CRUSH_PROJECT_DIR` | Project root directory. |
| `CLAUDE_PROJECT_DIR` | Same as `CRUSH_PROJECT_DIR`, for scripts written for Claude Code. |
| `CRUSH_TOOL_INPUT_COMMAND` | For `bash` calls: the shell command being run. |
| `CRUSH_TOOL_INPUT_FILE_PATH` | For file tools: the target file path. |
The `CRUSH_TOOL_*` variables are only set on events that carry a tool call.
The `CRUSH`, `AGENT`, and `AI_AGENT` markers are also set by the `bash`
tool, so a script can detect "am I running under Crush?" the same way in
either context.
@@ -240,14 +376,20 @@ Standard input provides the full context as JSON:
```jsonc
{
"event": "PreToolUse", // Hook event name
"hook_event_name": "PreToolUse", // Same thing, under Claude Code's name
"session_id": "313909e", // Current session ID
"cwd": "/home/user/project", // Working directory
"tool_name": "bash", // The tool being called
"tool_input": { "command": "rm -rf /" }, // The tool's input
"tool_use_id": "call_01", // The tool call ID
}
```
Note that `tool_input` field contains the raw JSON the model sent to the tool.
Events without a tool call omit the `tool_*` fields, and some events add one
of their own (`tool_response`, `last_assistant_message`, `error`, `decision`,
`source`, `reason`, `trigger`); see the [reference](#reference) for the full
list.
To parse the stdin JSON in your hook script, read from stdin and use a tool like
`jq`:
@@ -284,12 +426,12 @@ if some_bad_condition; then
fi
```
| Exit Code | Meaning |
| --------- | ---------------------------------------------------------------- |
| 0 | Success. Stdout is parsed as JSON (see fields below). |
| 2 | **Block the tool.** Stderr is used as the deny reason (no JSON). |
| 49 | **Halt the turn.** Stderr is used as the halt reason (no JSON). |
| Other | Non-blocking error. Logged and ignored — the tool call proceeds. |
| Exit Code | Meaning |
| --------- | --------------------------------------------------------------------------------------------------------------- |
| 0 | Success. Stdout is parsed as JSON (see fields below). |
| 2 | **Block the tool.** Stderr is the deny reason (falling back to the JSON `reason`, if you printed one). |
| 49 | **Halt the turn.** Stderr is the halt reason. |
| Other | Non-blocking error, logged and ignored. As in Claude Code, a JSON object on stdout still counts and is applied. |
The difference between exit 2 and exit 49:
@@ -547,15 +689,39 @@ process.stdin.on("end", () => {
## Claude Code compatibility
Crush hooks are broadly compatible with [Claude Code
hooks](https://docs.claude.com/en/docs/claude-code/hooks): the config shape,
stdin payload, output envelope, and exit codes line up so most Claude Code
hooks run under Crush unchanged. This document covers the Crush-specific API
only — anything not documented here isn't guaranteed to work.
Crush hooks are largely compatible with [Claude Code
hooks](https://code.claude.com/docs/en/hooks). Most hook scripts written for
Claude Code run under Crush unchanged:
One intentional divergence: Crush treats `updated_input` as a shallow-merge
patch against the original `tool_input` rather than a full replacement. Keys
you omit are preserved. See [Output](#output) for details.
- **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_DIR` is exported alongside
`CRUSH_PROJECT_DIR`.
- **Output** accepts Claude Code's forms as well as Crush's:
`hookSpecificOutput` (`permissionDecision`, `permissionDecisionReason`,
`updatedInput`, `updatedToolOutput`, `additionalContext`), the top-level
`decision: "block"` with `reason`, and `continue: false` with `stopReason`.
- **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 nested `matcher` groups with `hooks` arrays. Only `command`
hooks exist; there are no `http`, `prompt`, or `agent` hooks.
- **Event names**: `TurnStart`, `TurnEnd`, `Interrupt`, and `PermissionResult`
are Crush's names for the per-turn events; the table in [Events](#events)
maps each one to its Claude Code counterpart. `PermissionRequest` is
observe-only here; use `PreToolUse` to allow or deny.
- **`updated_input` on `PreToolUse`** is a shallow-merge patch against the
original `tool_input` rather than a full replacement, so keys you omit are
preserved. Claude Code's `updatedInput` is treated the same way.
- **`tool_response`** is the tool's text output as one string, not a
per-tool object. Crush's own field for replacing it is `updated_output`;
Claude Code's `updatedToolOutput` maps onto it.
- **`halt` and exit 49** have no Claude Code equivalent; they end the turn.
---
@@ -599,6 +765,9 @@ 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",
@@ -607,9 +776,10 @@ Present in every hook event:
}
```
### Stdin payload — PreToolUse
### Stdin payload — tool events
Extends the common payload:
`PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, and
`PermissionResult` extend the common payload with the tool call:
```jsonc
{
@@ -619,9 +789,71 @@ Extends the common payload:
"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:
```jsonc
{
// string. Always present, even when the tool printed nothing.
"tool_response": "PASS\nok \texample.com/testproject\t0.004s",
}
```
`PostToolUseFailure` adds the failure instead:
```jsonc
{
// string. The tool's error text.
"error": "exit status 1\nFAIL\t...",
}
```
`PermissionResult` adds how the prompt was resolved:
```jsonc
{
// "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:
```jsonc
{
// 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
```jsonc
{
// "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",
}
```
@@ -671,17 +903,33 @@ Extends the common envelope:
}
```
### 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 this tool call. Stderr becomes the deny reason. Stdout is ignored. |
| `49` | Halt the whole turn. Stderr becomes the halt reason. Stdout is ignored. |
| other | Non-blocking error. Logged and ignored; the tool call proceeds. |
| 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 applies to events that can block something. On events where
there's nothing to block, it's treated as a non-blocking error.
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
@@ -706,6 +954,15 @@ PreToolUse-specific rules:
`tool_input`. Later patches override earlier ones on colliding keys. Patches
are **ignored** if the final decision is deny or halt.
PostToolUse-specific rules:
6. `updated_output` **replaces** the tool output. When several hooks set it,
the last one in config order wins.
PreCompact-specific rules:
7. `halt` or `deny` blocks compaction and leaves the conversation as it was.
### Environment variables
See [Environment Variables](#environment-variables) above for the full list.
+29 -9
View File
@@ -212,9 +212,12 @@ option ui exit-banner compact
## Hooks runtime
Hooks are user-defined shell commands that fire on agent events. Currently only
`PreToolUse` is supported, which runs before a tool executes. This behavior is
the same however the hook is defined (`hook add` or JSON).
Hooks are user-defined shell commands that fire on agent events. Crush supports
13 events: `PreToolUse`, `PostToolUse`, `PostToolUseFailure`,
`PermissionRequest`, `PermissionResult`, `SessionStart`, `SessionEnd`,
`TurnStart`, `TurnEnd`, `StopFailure`, `Interrupt`, `PreCompact`, and
`PostCompact`. `--matcher` is only accepted on the first five. This behavior
is the same however the hook is defined (`hook add` or JSON).
### How hooks work
@@ -232,13 +235,21 @@ Event names are case-insensitive and accept snake_case: `PreToolUse`,
```json
{
"event": "PreToolUse",
"hook_event_name": "PreToolUse",
"session_id": "abc-123",
"cwd": "/path/to/project",
"tool_name": "bash",
"tool_input": { "command": "ls -la" }
"tool_input": { "command": "ls -la" },
"tool_use_id": "call_01"
}
```
Events without a tool call omit the `tool_*` fields. Some add one of their
own: `tool_response` (PostToolUse), `error` (PostToolUseFailure,
StopFailure), `last_assistant_message` (TurnEnd), `decision`
(PermissionResult), `source` (SessionStart), `reason` (SessionEnd), and
`trigger` (PreCompact, PostCompact).
### Hook environment variables
| Variable | Description |
@@ -248,6 +259,7 @@ Event names are case-insensitive and accept snake_case: `PreToolUse`,
| `CRUSH_SESSION_ID` | Current session ID |
| `CRUSH_CWD` | Current working directory |
| `CRUSH_PROJECT_DIR` | Project root directory |
| `CLAUDE_PROJECT_DIR` | Same value, for scripts written for Claude Code |
| `CRUSH_TOOL_INPUT_COMMAND` | Value of `command` from tool input (if present) |
| `CRUSH_TOOL_INPUT_FILE_PATH` | Value of `file_path` from tool input (if present) |
@@ -262,23 +274,31 @@ Event names are case-insensitive and accept snake_case: `PreToolUse`,
- `decision`: `allow` to explicitly allow, `deny` to block, `none` (or omit).
- `reason`: explanation (used when denying).
- `context`: extra context appended to the tool result.
- `updated_input`: replacement JSON for the tool input; last non-empty wins.
- `updated_input`: on PreToolUse, a shallow-merge patch against the tool
input.
- `updated_output`: on PostToolUse, a string that replaces the tool output.
- `halt`: `true` ends the turn (blocks compaction on PreCompact).
**Exit code 2** — the tool call is blocked; stderr is the deny reason.
**Any other exit code** — non-blocking error; the tool call proceeds.
**Exit code 49** — the whole turn is halted; stderr is the reason.
**Any other exit code** — non-blocking error; the tool call proceeds, unless
stdout holds a JSON object, which is applied anyway.
### Decision aggregation
- **Deny wins over allow** — any deny blocks the call.
- **Allow wins over none** — a lone allow lets it proceed.
- Deny reasons and context strings are concatenated (newline-separated).
- For `updated_input`, the last non-empty value wins.
- `updated_input` patches shallow-merge in config order on PreToolUse;
`updated_output` on PostToolUse is last-one-wins.
### Claude Code compatibility
Crush also accepts the Claude Code hook output format, so existing hooks work
unchanged:
Crush also accepts the Claude Code hook output formats (`hookSpecificOutput`,
top-level `decision: "block"` with `reason`, `continue: false`), so existing
hooks work unchanged:
```json
{
+48 -12
View File
@@ -16,8 +16,31 @@ need to author correct hooks.
## Supported Events
Only `PreToolUse` is currently supported. Event names are case-insensitive and
accept snake_case (`PreToolUse`, `pretooluse`, `pre_tool_use` all work).
Crush supports 13 hook events: `PreToolUse`, `PostToolUse`,
`PostToolUseFailure`, `PermissionRequest`, `PermissionResult`,
`SessionStart`, `SessionEnd`, `TurnStart`, `TurnEnd`, `StopFailure`,
`Interrupt`, `PreCompact`, `PostCompact`. Event names are case-insensitive
and accept snake_case (`PreToolUse`, `pretooluse`, `pre_tool_use` all work);
an unknown name is a config error. The five events that carry a tool call
(`PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`,
`PermissionResult`) accept `matcher`; the rest run every configured hook and
reject `matcher`.
Only `PreToolUse` (allow/deny/halt/rewrite/context), `PostToolUse`
(halt/replace output/context/feedback), `PostToolUseFailure`
(halt/context), and `PreCompact` (block) honor output. The rest are
observe-only. Turn and tool events fire only for the top-level agent, never
for sub-agents (`agentic_fetch`, the task tool), so a `TurnEnd` hook runs
once per user prompt.
Per-event payload extras: `PostToolUse` has `tool_response` (the output
string), `PostToolUseFailure` and `StopFailure` have `error`, `TurnEnd` has
`last_assistant_message`, `PermissionResult` has `decision`
(`granted`/`denied`/`cancelled`), `SessionStart` has `source`
(`startup`/`resume`), `SessionEnd` has `reason`, and the compaction events
have `trigger` (`auto`/`manual`). Payloads also carry `hook_event_name` and
`tool_use_id`, and hooks see `CLAUDE_PROJECT_DIR`, so scripts written for
Claude Code usually work unchanged.
## Configuration
@@ -56,18 +79,23 @@ the input/output contract is identical regardless of language.
| `CRUSH_SESSION_ID` | Current session ID |
| `CRUSH_CWD` | Working directory |
| `CRUSH_PROJECT_DIR` | Project root directory |
| `CLAUDE_PROJECT_DIR` | Same value, for Claude Code scripts |
| `CRUSH_TOOL_INPUT_COMMAND` | For `bash` calls: the shell command |
| `CRUSH_TOOL_INPUT_FILE_PATH` | For file tools: the target file path |
The `CRUSH_TOOL_*` variables are only set on events that carry a tool call.
**JSON on stdin:**
```json
{
"event": "PreToolUse",
"hook_event_name": "PreToolUse",
"session_id": "313909e",
"cwd": "/home/user/project",
"tool_name": "bash",
"tool_input": {"command": "rm -rf /"}
"tool_input": {"command": "rm -rf /"},
"tool_use_id": "call_01"
}
```
@@ -80,7 +108,7 @@ Communicate back via exit code (+ stderr) or JSON on stdout.
| 0 | Success. Stdout is parsed as the JSON envelope below. |
| 2 | Block this tool call. Stderr becomes the deny reason. |
| 49 | Halt the whole turn. Stderr becomes the halt reason. |
| Other | Non-blocking error. Logged and ignored; tool call proceeds. |
| Other | Non-blocking error unless stdout holds a JSON object. |
Exit 2 blocks one tool call (agent sees the reason and can try again); exit 49
ends the whole turn (user takes over). Default to deny — reach for halt only
@@ -111,6 +139,12 @@ policy violation).
- **`updated_input`**: **shallow-merge patch** against `tool_input`, not a
replacement. Keys you include overwrite; keys you don't are preserved.
Nested objects are replaced wholesale, not deep-merged. Ignored on deny/halt.
- **`updated_output`** (`PostToolUse` only): a string that replaces the tool
output. The last hook to set it wins.
- On `PostToolUse`, `decision: "deny"` cannot block (the tool already ran);
its `reason` is appended to the output as feedback for the model.
- Claude Code forms are accepted too: `hookSpecificOutput`, top-level
`decision: "block"` with `reason`, and `continue: false` with `stopReason`.
## Aggregation (Multiple Hooks)
@@ -168,15 +202,17 @@ Config: `{"matcher": "^(edit|write|multiedit)$", "command": "./hooks/go-context.
#!/usr/bin/env bash
set -euo pipefail
read -r input
rewritten=$(echo "$input" | jq -r '.tool_input.command' | some-rewriter)
rewritten=$(jq -r '.tool_input.command' | some-rewriter)
jq -n --arg cmd "$rewritten" '{context: "Rewrote command", updated_input: {command: $cmd}}'
```
cat <<EOF
{
"context": "Rewrote command",
"updated_input": {"command": "$rewritten"}
}
EOF
Build JSON with `jq -n --arg` rather than string interpolation so quotes and
newlines in the value cannot break the envelope.
### Redact tool output (PostToolUse)
```jsonc
{"matcher": "^bash$", "command": "jq -c '{updated_output: (.tool_response | gsub(\"(sk-|ghp_)[A-Za-z0-9]{20,}\"; \"[REDACTED]\"))}'"}
```
If the original call was `{"command": "npm test", "timeout": 60000}`, the