Hooks: Intercepting Tool Calls
OpenClacky has a built-in 7-event hook system (before/after a tool call, on error, task start/complete, per iteration, session rollback). Hooks let you attach your own Ruby logic at those moments — e.g. audit a command before any terminal tool runs and block it when dangerous, or send a notification when a task completes.
Hooks are now one kind of extension-container contribution — declare
contributes.hooksinext.ymland ship the callback script inside~/.clacky/ext/local/<id>/. To understand the overall model first, read the Extension System Overview.
How it works
An extension container declares the hooks it contributes in ext.yml:
# ~/.clacky/ext/local/audit-guard/ext.yml
id: audit-guard
name: Command Audit
version: "0.1.0"
contributes:
hooks:
- event: before_tool_use # event name (see table below)
file: hooks/audit.rb # callback script (relative to container root)
At process start, OpenClacky resolves every container and requires each hook file once. The file registers callbacks via Clacky::ExtensionHookRegistry.add — the event name comes from ext.yml, so you don't repeat it in the callback. Each agent copies these callbacks onto its own HookManager at init, so every agent gets an isolated hook chain.
The runtime hook loader logs and skips invalid event names and file loading errors. clacky ext verify only checks manifest structure/file existence; it does not execute callbacks or validate event names.
After changing a hook, follow the restart guidance in Extension System Overview. Browser refresh does not reload already-required Ruby callbacks.
What a Hook Callback Looks Like
The callback block receives the event's arguments and returns a Hash expressing an action. Intercepting a tool call, for example:
# ~/.clacky/ext/local/audit-guard/hooks/audit.rb
Clacky::ExtensionHookRegistry.add do |call|
# call is the tool call about to run: { name:, arguments: }
cmd = call.dig(:arguments, "command").to_s
if call[:name] == "terminal" && cmd.include?("OPENCLACKY_HOOK_DENY_TEST")
next { action: :deny, reason: "blocked harmless test marker" }
end
Clacky::Logger.debug("[audit] tool", tool: call[:name])
{ action: :allow }
end
This is a deny-flow demonstration, not a shell security filter. Its marker is harmless even if the hook fails to run; substring matching is not a general defense against dangerous commands.
- Return
{ action: :allow }(or no Hash) → allow, the agent continues. - Return
{ action: :deny, reason: "..." }(only meaningful forbefore_tool_use) → deny execution;reasonis fed back to the agent as the denial reason. - A callback raising
StandardErroris logged and skipped; later hooks still run. If none denies or handles the call, the default is allow. Hooks are not a fail-closed security boundary.
Reaching the agent: pushing custom events to the frontend
Every event passes the triggering agent instance as its last argument, after the event's own parameters. Callback blocks are procs, so declaring fewer parameters silently drops the extras — only spell it out when you need it.
# before_tool_use → |call, agent|
# after_tool_use → |call, result, agent|
# on_iteration → |n, agent|
With the agent in hand you can call agent.emit_event to push structured events to the web frontend (extension panels and custom UIs both receive them):
# ~/.clacky/ext/local/design-progress/hooks/progress.rb
Clacky::ExtensionHookRegistry.add do |call, _result, agent|
next unless call[:name] == "write"
path = call.dig(:arguments, "path").to_s
next unless path.end_with?(".html")
agent&.emit_event("ext.design.file_ready", path: path, persist: true)
end
- Event names must start with
ext.— useext.<extension id>.<event>; anything else makesemit_eventraiseArgumentError. persist: false(the default) only reaches frontends currently connected and disappears on refresh — right for progress ticks and heartbeats.persist: truewrites into session history and survives compression archives. Emit a terminal event with the final result if progress must be recoverable. Replay is paginated/on demand, not the entire session at mount time. See Web UI Extensions for thereplayed: truemarker and idempotent, side-effect-free restoration.- Shell hooks (the
command:form) never see the agent — it stays out of their JSON payload. Only Ruby callbacks can use it.
The 7 Hookable Events
| Event | When it fires | Can deny |
|---|---|---|
before_tool_use |
Before a tool runs | ✅ (action: :deny) |
after_tool_use |
After a tool runs | ❌ |
on_tool_error |
When a tool raises | ❌ |
on_start |
A task starts | ❌ |
on_complete |
A task completes | ❌ |
on_iteration |
Each ReAct iteration | ❌ |
session_rollback |
Session rollback | ❌ |
A single container can contribute multiple hooks (add more entries to contributes.hooks, pointing at different files or the same event).
CLI Workflow
1. Scaffold
clacky ext new audit-guard --full
The --full reference container includes a hooks/audit.rb sample (plus the other seven contributions). You can also run clacky ext new audit-guard, trim ext.yml down to just contributes.hooks, and write hooks/audit.rb yourself.
Keep only the contributions required for this test in the new container's manifest; do not leave the full scaffold's sample patches, tools, or adapters enabled unintentionally.
2. Write the callback
Follow the sample above. Note that before_tool_use's argument is the tool-call Hash (call[:name] / call[:arguments]), not a raw shell string.
3. Verify registration
clacky ext verify
Sample output:
[OK] audit-guard/before_tool_use/audit (hook, local)
Errors cause a non-zero exit. This [OK] line confirms structural resolution only. An unknown event is rejected later by the runtime hook loader, not reported as hook.event.unknown by verify. Check runtime logs and test the callback behavior separately.
Debugging tips
- Not sure what the argument looks like? Log it at the top of the callback:
Clacky::Logger.debug("hook payload", call: call), run the agent once, and you'll see the real shape. - Don't do heavy work in the callback. It runs on the agent's main path; blocking slows every tool call. For heavy-network auditing, use a logging-only event (
after_tool_use) or handle it asynchronously. - Deny only works for
before_tool_use. Returningaction: :denyfrom other events has no effect. - Test denial safely. In an isolated test session, request the harmless command
echo OPENCLACKY_HOOK_DENY_TESTand check the denial reason. If interception fails, it only prints a marker. Never execute a destructive command to test a guard.
⚠️ Hook files are arbitrary Ruby and, like patches, carry supply-chain risk. Before installing someone's extension, check which hooks it contributes and what they do.