before_tool_call hook fires 2–3 times per tool invocation
Summary
A single tool invocation by the LLM triggers the before_tool_call hook 2–3 times instead of once. This is because the hook is called from multiple independent execution paths that all run for the same tool call.
This makes it impossible to build blocking hooks (e.g. approval workflows, rate limiters, audit logging) without client-side deduplication hacks.
Reproduction
Register a before_tool_call hook that logs each invocation:
api.on("before_tool_call", async (event, ctx) => {
console.log(`[hook] ${event.toolName} toolCallId=${event.toolCallId} t=${Date.now()}`);
}, { priority: 1000 });
Ask the agent to make a single tool call (e.g. web_fetch). Observe 2–3 log lines for the same toolCallId.
Root Cause
In dist/extensionAPI.js, the hook is invoked from three separate code paths that all execute for a single tool call:
Path 1 — toToolDefinitions() (line ~64184)
The execute wrapper calls runBeforeToolCallHook() directly:
execute: async (...args) => {
const hookOutcome = await runBeforeToolCallHook({ toolName: name, params, toolCallId });
// ...
const result = await tool.execute(toolCallId, adjustedParams, signal, onUpdate);
Path 2 — wrapToolWithBeforeToolCallHook() (line ~61595)
But tool.execute above is already wrapped by wrapToolWithBeforeToolCallHook (applied at line ~62515), which fires the hook again:
execute: async (toolCallId, params, signal, onUpdate) => {
const outcome = await runBeforeToolCallHook({ toolName, params, toolCallId, ctx });
// ...
return await execute(toolCallId, outcome.params, signal, onUpdate);
So toToolDefinitions fires the hook, then calls tool.execute() which is the wrapped function that fires it a second time.
Path 3 — handleToolExecutionStart stream handler (line ~65763)
Additionally, the stream event handler fires the hook a third time:
if (hookRunner?.hasHooks?.("before_tool_call")) {
const hookEvent = { toolName, params: args };
await hookRunner.runBeforeToolCall(hookEvent, { toolName });
}
Expected Behavior
before_tool_call should fire exactly once per tool invocation.
Impact
- Blocking hooks (approval workflows, security gates) create duplicate requests that users must approve individually
- If one duplicate arrives after the user already approved, the tool call hangs waiting for approval on a request the user doesn't know about
- Audit/logging hooks record phantom entries
- Rate limiters count 2–3x the actual usage
Suggested Fix
Either:
- Remove the hook call from
toToolDefinitions since the wrapped tool.execute() already fires it, or
- Remove the wrapping from
wrapToolWithBeforeToolCallHook and rely solely on toToolDefinitions, or
- Add a per-
toolCallId guard so the hook only fires once regardless of path
Environment
- OpenClaw v2026.2.12 (npm global install)
- macOS, Node.js v22.17.1
before_tool_callhook fires 2–3 times per tool invocationSummary
A single tool invocation by the LLM triggers the
before_tool_callhook 2–3 times instead of once. This is because the hook is called from multiple independent execution paths that all run for the same tool call.This makes it impossible to build blocking hooks (e.g. approval workflows, rate limiters, audit logging) without client-side deduplication hacks.
Reproduction
Register a
before_tool_callhook that logs each invocation:Ask the agent to make a single tool call (e.g.
web_fetch). Observe 2–3 log lines for the sametoolCallId.Root Cause
In
dist/extensionAPI.js, the hook is invoked from three separate code paths that all execute for a single tool call:Path 1 —
toToolDefinitions()(line ~64184)The
executewrapper callsrunBeforeToolCallHook()directly:Path 2 —
wrapToolWithBeforeToolCallHook()(line ~61595)But
tool.executeabove is already wrapped bywrapToolWithBeforeToolCallHook(applied at line ~62515), which fires the hook again:So
toToolDefinitionsfires the hook, then callstool.execute()which is the wrapped function that fires it a second time.Path 3 —
handleToolExecutionStartstream handler (line ~65763)Additionally, the stream event handler fires the hook a third time:
Expected Behavior
before_tool_callshould fire exactly once per tool invocation.Impact
Suggested Fix
Either:
toToolDefinitionssince the wrappedtool.execute()already fires it, orwrapToolWithBeforeToolCallHookand rely solely ontoToolDefinitions, ortoolCallIdguard so the hook only fires once regardless of pathEnvironment