Hooks: Intercepting Tool Calls

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.hooks in ext.yml and 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.addthe 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 for before_tool_use) → deny execution; reason is fed back to the agent as the denial reason.
  • A callback raising StandardError is 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. — use ext.<extension id>.<event>; anything else makes emit_event raise ArgumentError.
  • persist: false (the default) only reaches frontends currently connected and disappears on refresh — right for progress ticks and heartbeats.
  • persist: true writes 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 the replayed: true marker 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. Returning action: :deny from other events has no effect.
  • Test denial safely. In an isolated test session, request the harmless command echo OPENCLACKY_HOOK_DENY_TEST and 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.