Hooks: Tool Interception and Session Observations
OpenClacky provides hooks for tool calls, task execution, and session observations. 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: "..." }forbefore_tool_use→ deny tool execution;reasonis fed back to the agent. Foron_start, deny stops the task before the loop;handledreturns the hook-supplied result. - 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.
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 |
After task/history initialization, before the loop; not steering input | ✅ (deny or handled) |
on_complete |
Task completion, waiting, exceptions, interruptions, denial or handled starts | ❌ |
on_iteration |
Each ReAct iteration | ❌ |
session_rollback |
Session rollback | ❌ |
The runtime requirement for expanded completion paths is described below.
A single container can contribute multiple hooks (add more entries to contributes.hooks, pointing at different files or the same event).
Task lifecycle: onstart / oncomplete
Availability: the additional context and abnormal completion paths require
the runtime change on branchsession-lifecycle-hooks. They are not
available in 1.5.16. Upgrade the runtime before relying on these fields.
Use the existing events without extra configuration. on_start runs after task
and history initialization, before execution, and retains deny / handled.
on_complete observes the outcome of each Agent turn, including normal completion,
waiting, handled starts, denial, exceptions and interruption. Its return value is
ignored, including deny and handled; all completion callbacks can run.
Ruby callbacks receive (input, agent, context) or (result, agent, context).
The first two positions are unchanged; ordinary two-argument Ruby blocks ignore
the extra argument. Existing completion callbacks must check result[:status] == before doing success-only work. A handled result has
:successcontext[:handled]
set to true; skip follow-up work already performed by the handler.
These are Agent-turn events for server, CLI and channel execution, not server
persistence notifications. Queued input emits start only when consumed; steering
inside an active turn does not create an extra start or completion. Automatic goal
continuations have their own turn events. Normal completion fires before goal
continuation, and exceptions still propagate after notification. A superseded
turn's exception cleanup cannot settle its successor.
Context contains session_id, run_id (process-local task ID, not durable),
source (execution source, not authenticated identity), input_created_at (the
optional timestamp passed to run), observed_at (UTC ISO8601), and status.
Start status is running. No input text is added to context; input and result
arguments can contain sensitive data.
| Completion context status | Meaning |
|---|---|
completed |
Result explicitly has status: :success, without a feedback request. This does not certify App generation or validation. |
awaiting_user |
Successful result requests awaiting_user_feedback. |
failed |
Exception or non-success result. Structured exceptions can supply error_code. |
cancelled |
Interruption or denial. reason may be user, replacement, hook_denied or tool_denied. |
Cancelled completion notifications carry result[:status] == :cancelled, even
when the original denial return value remains unchanged for queue compatibility.
An execution start alone does not establish recovery from a previous failure.
id: session-observer
name: Session Observer
version: "0.1.0"
contributes:
hooks:
- event: on_start
file: hooks/start.rb
- event: on_complete
file: hooks/complete.rb
# hooks/start.rb
Clacky::ExtensionHookRegistry.add do |_input, _agent, context|
next unless context
Clacky::Logger.debug("[session-observer] started", session_id: context[:session_id],
run_id: context[:run_id])
nil
end
# hooks/complete.rb
Clacky::ExtensionHookRegistry.add do |_result, _agent, context|
next unless context
Clacky::Logger.debug("[session-observer] settled", session_id: context[:session_id],
status: context[:status], run_id: context[:run_id])
nil
end
Use separate files for separate registrations: each Ruby file is required only
once. Programmatic registration uses agent.add_hook(:on_complete) { |result,. Shell hooks keep their JSON payload shape; neither the
agent, context| ... }
Agent nor context is serialized, while abnormal completion results are now reported.
Callbacks execute synchronously; keep them short and dispatch network work
asynchronously. StandardError is logged without stopping later callbacks.
The host does not persist, retry, replay or acknowledge hook delivery. Consumers
must tolerate missing events and cannot infer inactivity solely from missing data.
agent.emit_event(persist: true) is a separate frontend-history feature.
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. - Use control hooks to intercept work.
before_tool_usecan deny a tool;on_startcan deny or handle a task.on_completeignores all return values. - 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.