Sequential Hook Executor for Claude Code.
Claude Code hooks let you run commands before or after tool calls — but the execution order between hooks is undefined. hook-chain gives you control: define an ordered sequence of hooks in YAML, and hook-chain runs them as a single pipeline with deterministic ordering, threading accumulated state through the chain with fold/reduce semantics.
┌──deny/ask──▶ (short-circuit, stop chain)
│
Claude Code ──stdin──▶ hook-chain ──▶ hook-1 ──▶ hook-2 ──▶ hook-N ──stdout──▶ Claude Code
│ │
└──── accumulated toolInput state ─────┘
Homebrew:
brew tap Fuabioo/tap
brew install hook-chainGo:
go install github.com/Fuabioo/hook-chain@latestBinary: download from Releases (linux/darwin, amd64/arm64).
1. Create a config file:
mkdir -p ~/.config/hook-chain
cat > ~/.config/hook-chain/config.yaml << 'EOF'
chains:
- event: PreToolUse
tools: [Bash]
hooks:
- name: log-command
command: ~/hooks/log-bash.sh
- name: block-rm-rf
command: ~/hooks/block-rm-rf.sh
EOF2. Point Claude Code at hook-chain:
In your .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": { "tool_name": "Bash" },
"hooks": [
{ "type": "command", "command": "hook-chain" }
]
}
]
}
}3. Verify your setup:
hook-chain validateIf no config file is found, hook-chain silently passes through all tool calls (no chains match, exit 0). Use validate to confirm your config is loaded.
hook-chain reads the hook protocol JSON from stdin, resolves the matching chain from config, and executes hooks sequentially. Each hook receives the full input on stdin and can:
- Pass through — exit 0 with empty or whitespace-only stdout. No effect; next hook runs.
- Modify
toolInput— return JSON withhookSpecificOutput.updatedInput. The updates are shallow-merged into the accumulated state and forwarded to the next hook. - Add context — return
hookSpecificOutput.additionalContext. All context strings are collected and joined with newlines in the final output. - Deny — exit 2, or return
permissionDecision: "deny". Immediately stops the chain and blocks the tool call (exit code 2). - Escalate — return
permissionDecision: "ask". Immediately stops the chain and prompts the user (exit code 0).
When all hooks pass, hook-chain emits the accumulated output (merged updatedInput + combined additionalContext) back to Claude Code. If nothing changed, it exits silently — a clean passthrough.
Note: The hook protocol fields continue, suppressOutput, and systemMessage on individual hook outputs are not forwarded through the chain. hook-chain builds its own final output from hookSpecificOutput fields only.
These are the exit codes of individual hooks within a chain:
| Exit code | Meaning |
|---|---|
| 0 | Success. Parse stdout for hook output (if any). |
| 2 | Deny. Always blocks the tool call, regardless of on_error. |
| Any other | Error. Behavior depends on the hook's on_error policy. |
hook-chain's own exit code to Claude Code: 0 for allow/ask, 2 for deny.
Each hook can set on_error to control what happens on non-zero exits (other than 2), runner-level failures (command not found, timeout), or invalid JSON output:
deny(default) — fail closed. The chain stops and the tool call is blocked.skip— fail open. The broken hook is skipped and the chain continues.
Config file search order:
$HOOK_CHAIN_CONFIG(explicit path — hard error if set but file does not exist)$XDG_CONFIG_HOME/hook-chain/config.yaml~/.config/hook-chain/config.yaml
If none is found, hook-chain runs with an empty config (all tool calls pass through).
chains:
- event: PreToolUse # hook event name (PreToolUse, PostToolUse, etc.)
tools: [Bash, Write, Edit] # tool names to match
hooks:
- name: my-hook # human-readable name (shown in logs and audit)
command: /path/to/hook # executable (supports ~/ expansion)
args: [--flag, value] # additional arguments (optional)
timeout: 10s # per-hook timeout (default: 30s)
env: [KEY=value] # extra environment variables (optional)
on_error: deny # "deny" (default) or "skip"
audit:
disabled: false # set true to disable audit logging (also: HOOK_CHAIN_AUDIT=0)
db_path: /custom/audit.db # override default DB location
retention: 30d # auto-rotation retention (default: 7d)Chain resolution uses first match: the first chain entry where event matches AND the tool name appears in tools is selected. Hook execution order within a chain is preserved exactly as written.
Every chain execution is recorded to a local SQLite database. Audit is enabled by default and runs fail-open — if the database can't be opened, the pipeline runs normally without auditing. Audit can be disabled via HOOK_CHAIN_AUDIT=0 or audit.disabled: true in config.
Old entries are automatically archived to compressed zip files and pruned (including per-hook results) based on the configured retention period (default: 7 days). Rotation runs at most once per hour.
All audit subcommands accept --db <path> to override the database location.
# Recent executions (default: last 10)
hook-chain audit tail
# List with filters (default: 20 entries)
hook-chain audit list --event PreToolUse --outcome deny --limit 50
# Full details of a specific chain execution (including per-hook results)
hook-chain audit show 42
# Aggregate statistics
hook-chain audit stats
# All commands support --json for machine-readable output
hook-chain audit list --json
# Manual pruning (--older-than is required)
hook-chain audit prune --older-than 30d
# View archived entries
hook-chain audit archives
# Print the resolved database path
hook-chain audit db-path| Path | Purpose |
|---|---|
$HOOK_CHAIN_AUDIT_DB |
Explicit DB path override |
$XDG_DATA_HOME/hook-chain/audit.db |
XDG-compliant default |
~/.local/share/hook-chain/audit.db |
Fallback default |
.../hook-chain/archives/ |
Rotated zip archives |
| Variable | Purpose |
|---|---|
HOOK_CHAIN_CONFIG |
Explicit config file path (hard error if file missing) |
HOOK_CHAIN_DEBUG=1 |
Enable debug logging to stderr |
HOOK_CHAIN_AUDIT=0 |
Disable audit logging entirely (also: audit.disabled in config) |
HOOK_CHAIN_AUDIT_DB |
Override audit database path |
hook-chain Run the pipeline (reads hook protocol JSON from stdin)
hook-chain validate Validate config and check that hook commands exist on PATH
hook-chain version Print version and commit info
hook-chain audit All subcommands accept --db <path> to override the database
hook-chain audit list List chain executions (--limit=20, --offset=0, --event, --outcome, --json)
hook-chain audit show Show full details of a chain execution (--json)
hook-chain audit tail Show last N executions (--n=10, --json)
hook-chain audit stats Aggregate statistics (--json)
hook-chain audit prune Delete entries older than a duration (--older-than, required)
hook-chain audit archives List rotated archive files (--json)
hook-chain audit db-path Print the resolved audit database path
main.go Entry point
internal/
├── cli/ Cobra CLI (root pipe handler, validate, version, audit subcommands)
├── hook/ Hook protocol types (Input/Output JSON with round-trip preservation)
├── config/ YAML config loading with ordered chain resolution
├── pipeline/ Core fold/reduce algorithm + shallow JSON merge
├── runner/ Process execution (Runner interface + ProcessRunner)
├── audit/ SQLite audit logging, rotation, archival, and query helpers
└── pathutil/ Tilde expansion utility
- Ordered lists, not maps. Chains and hooks are YAML arrays to preserve execution order deterministically.
- Round-trip JSON preservation. Unknown fields in the hook protocol input survive marshaling/unmarshaling via
json.RawMessage, ensuring forward compatibility as Claude Code evolves. - Shallow merge for
updatedInput. Matches Claude Code's own semantics — top-level keys are replaced, not deep-merged. - Fail closed by default. Config errors, stdin parse failures, and hook errors all result in deny (exit 2) unless explicitly configured otherwise with
on_error: skip. - Audit as a side effect. Recording is fire-and-forget. A broken audit database never blocks the security pipeline.
Requires: Go 1.26+, Docker, just.
just build # Build binary to bin/hook-chain
just install # Install to GOPATH/bin
just test # Run tests in Docker (mandatory — never on host)
just test-verbose # Verbose test output
just test-coverage # Coverage report
just lint # golangci-lint
just vulncheck # govulncheck
just snapshot # GoReleaser snapshot build
just clean # Remove build artifacts