Declarative Shell Hooks
OpenClacky has a 7-event hook system (before/after tool use, errors, session start/end, etc.). Shell Hooks let you plug into it without writing a single line of Ruby — just YAML declarations and shell scripts. Audit any terminal invocation before it runs and block dangerous ones; fire a desktop notification when a session ends; whatever you can express in shell.
User config lives in ~/.clacky/hooks.yml and survives gem upgrades. Crashing or hanging scripts are logged but can never wedge the agent.
How it works
After registering built-in tools at startup, the Agent calls Clacky::ShellHookLoader.load_into(@hooks). The loader reads hooks.yml and translates each declaration into a wrapper block registered on the HookManager:
- When the event fires, the agent serializes the event payload as JSON and pipes it to your command on STDIN.
- The command runs with a timeout (default 10 seconds).
- The exit code decides what happens:
| Exit code | Behavior |
|---|---|
0 |
Allow (default). Agent continues. |
2 |
Deny (only meaningful for before_tool_use). STDOUT becomes the denial reason shown to the agent. |
| anything else / timeout / crash | Logged, treated as allow. A broken hook must never wedge the agent. |
Exit code
2is the deny convention —exit 1is treated as "the script itself is broken" and falls into the allow path.
The 7 hookable events
| Event | When it fires | Can deny |
|---|---|---|
before_tool_use |
Before a tool runs | ✅ (exit 2) |
after_tool_use |
After a tool finishes | ❌ |
on_tool_error |
Tool raised | ❌ |
on_start |
Task starts | ❌ |
on_complete |
Task finishes | ❌ |
on_iteration |
Each ReAct iteration | ❌ |
session_rollback |
Session rolled back | ❌ |
Config format
hooks:
before_tool_use:
- name: guard # optional; shows up in logs
command: "~/.clacky/hook-scripts/deny-example.sh"
timeout: 10 # optional, seconds, default 10
on_complete:
- command: "notify-send 'task done'"
Each event is a list — you can register multiple hooks per event; they run in declaration order.
CLI workflow
1. Scaffold
clacky hook_new
Creates:
~/.clacky/hooks.yml— a starter config with onebefore_tool_usehook.~/.clacky/hook-scripts/deny-example.sh— an executable example demonstrating how to read STDIN and when to returnexit 2.
If hooks.yml already exists, the command errors out — your existing config is never clobbered.
2. Edit the script
The example script already shows the deny pattern:
#!/usr/bin/env bash
payload="$(cat)"
# Block any terminal command containing "rm -rf /"
if echo "$payload" | grep -q 'rm -rf /'; then
echo "blocked dangerous command" # STDOUT becomes the deny reason
exit 2
fi
exit 0
3. Verify
clacky hook_verify
Sample output:
[OK] before_tool_use → deny-example
[SKIP] broken-one — command not found
[SKIP] does not block the other hooks from registering, but the command exits non-zero — useful for CI.
Event payload shape
Every event's STDIN JSON has different fields, but they all include event (the event name) plus context specific to that event (e.g. before_tool_use includes tool_name and arguments). Your script should read the whole thing first, then jq what you need:
payload="$(cat)"
tool="$(echo "$payload" | jq -r .tool_name)"
case "$tool" in
terminal) ... ;;
*) exit 0 ;;
esac
Debugging tips
- Don't know what the payload looks like? Log it once:
echo "$payload" >> ~/.clacky/hook-debug.log, exit 0, run an agent task, then read the file. - Don't block too long. Default timeout is 10 seconds, and timeouts fall back to allow — meaning the dangerous operation you tried to block will run anyway. Bump
timeout, or move slow audits to async log-only hooks likeafter_tool_use. - Use absolute paths or
~. Thecommandfield goes through a shell, but the working directory is not necessarily the project root. - Try a deny in practice: run
clacky -m "please delete everything in /tmp"inconfirm_safesmode and watch your hook intercept it.