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:

  1. When the event fires, the agent serializes the event payload as JSON and pipes it to your command on STDIN.
  2. The command runs with a timeout (default 10 seconds).
  3. 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 2 is the deny convention — exit 1 is 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 one before_tool_use hook.
  • ~/.clacky/hook-scripts/deny-example.sh — an executable example demonstrating how to read STDIN and when to return exit 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 like after_tool_use.
  • Use absolute paths or ~. The command field 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" in confirm_safes mode and watch your hook intercept it.