Tool system: Map registration and invocation
Responsibilities
Tools are the only "hands" in the agent main loop: reading files, running commands, writing edits, calling external APIs — all exposed to the model as tools. OpenClaw stores every tool as a Map<string, AgentTool>, keyed by tool name, with values being objects containing a descriptor and an executor. When the model emits a tool_use block in its response, the main loop looks up the tool by name in the Map, calls its executor, and wraps the result as a tool_result block for the next turn.
The tool system itself doesn't execute business logic; it does four things: register (combine plugin, built-in, SDK custom tools into one table), filter (apply allow/deny policy and availability signals to drop invisible tools), wrap (attach before/after hooks to every tool), and dispatch (route the model's tool_use to the right executor). The code that "does work" lives in each tool's own executor; the system only wires it up to the model.
Design motivation
Why a single Map instead of something fancier? Because the tool-call hot path is dead simple: the model gives a name, the system looks up an executor. Map.get(name) is O(1), and deduplication by name is a natural constraint — there can't be two read tools in one session, otherwise the model wouldn't know which to call. refreshToolRegistry in agent-session.ts refreshToolRegistry:L2414-L2511 merges builtin, extension, and custom sources and deduplicates into a new Map that replaces the old one. This "rebuild rather than increment" approach makes policy changes (like a user temporarily disabling a tool) take effect immediately, without worrying about incremental-update misses.
Another motivation is descriptor / executor layering. ToolDescriptor is the public description for the planner: name, input schema, owner, availability expression. ToolExecutorRef is the runtime landing on which executor (core / plugin / channel / mcp). This layering lets the planner evaluate "can this tool be visible right now" without loading the executor, and plugin authors can declare tools without exposing the executor implementation. The planner (planner.ts buildToolPlan:L40-L67) can thus split visible / hidden tools without touching the runtime.
Key files
types.ts ToolDescriptor:L53-L64— public tool descriptor contract, three-part: owner / executor / availability.types.ts ToolPlan:L96-L112— planner output, visible + hidden buckets; hidden carries diagnostics.planner.ts compare/sort:L18-L37— sort + duplicate-name detection; duplicates throwToolPlanContractError.planner.ts buildToolPlan:L40-L67— planner entry, buckets after availability evaluation.agent-session.ts toolRegistry:L395-L397— session-level Map registry field.agent-session.ts refreshToolRegistry:L2414-L2511— core function that rebuilds the Map on every tool-set change.agent-session.ts setActiveToolsByName:L916-L931— pulls the active tool array from the Map and rebuilds the system prompt.agent-session.ts installAgentToolHooks:L497-L551— injection point for thebeforeToolCall/afterToolCallhooks.tool-dispatch.ts resolveSkillDispatchTools:L59-L249— the seam where skill-triggered tool calls are filtered through multi-layer policies.
Data flow
Tool registration merges three sources (agent-session.ts rebuild Map:L2426-L2486):
const registeredTools = this.currentExtensionRunner.getAllRegisteredTools();
const allCustomTools = [
...registeredTools,
...this.customTools.map((definition) => ({
definition,
sourceInfo: createSyntheticSourceInfo(`<sdk:${definition.name}>`, { source: "sdk" }),
})),
].filter((tool) => isAllowedTool(tool.definition.name));
// ...
const toolRegistry = new Map(wrappedBuiltInTools.map((tool) => [tool.name, tool]));
for (const tool of wrappedExtensionTools) {
toolRegistry.set(tool.name, tool);
}
this.toolRegistry = toolRegistry;isAllowedTool is the first gate: if a builtin tool is disabled by disableBuiltInTools, or the tool name isn't in the allowedToolNames allowlist, it never enters the Map. Note that builtin and extension both go through the same wrapRegisteredTools (src/agents/sessions/agent-session.ts:L2469-L2480), which attaches before/after hooks, error capture, and logging in one place, so subsequent executor calls don't repeat this logic.
The planner layer is earlier; it operates on ToolDescriptor and doesn't touch the executor (planner.ts buildToolPlan:L40-L67):
export function buildToolPlan(options: BuildToolPlanOptions): ToolPlan {
const descriptors = options.descriptors.toSorted(compareDescriptors);
assertUniqueNames(descriptors);
const visible: ToolPlanEntry[] = [];
const hidden: HiddenToolPlanEntry[] = [];
for (const descriptor of descriptors) {
const diagnostics = [
...evaluateToolAvailability({ descriptor, context: options.availability }),
];
if (diagnostics.length > 0) {
hidden.push({ descriptor, diagnostics });
continue;
}
if (!descriptor.executor) {
throw new ToolPlanContractError({
code: "missing-executor",
toolName: descriptor.name,
message: `Visible tool descriptor has no executor ref: ${descriptor.name}`,
});
}
visible.push({ descriptor, executor: descriptor.executor });
}evaluateToolAvailability evaluates each descriptor's availability expression: missing auth, missing config, unset env, plugin disabled, context mismatch — any signal hit moves the tool into the hidden bucket with diagnostics. If a descriptor in the visible bucket has no executor, the planner throws missing-executor — visible means callable, that's the contract.
When the model emits a tool_use block, the main loop goes through the hook registered in agent-session.ts installAgentToolHooks:L497-L551:
this.agent.beforeToolCall = async ({ toolCall, args }) => {
const runner = this.currentExtensionRunner;
return await this.runWithSessionWriteLock(async () => {
if (!runner.hasHandlers("tool_call")) {
return undefined;
}
try {
return await runner.emitToolCall({
type: "tool_call",
toolName: toolCall.name,
toolCallId: toolCall.id,
input: args as Record<string, unknown>,
});
} catch (err) {
if (err instanceof Error) {
throw err;
}
throw new Error(`Extension failed, blocking execution: ${String(err)}`, { cause: err });
}
});
};beforeToolCall gives plugins an interception opportunity: return undefined to allow, return an override value to replace inputs, or throw to block. afterToolCall is symmetric, but fires after the executor runs and can rewrite the tool_result content or flip isError to true. Note the hook runs inside runWithSessionWriteLock, ensuring session state isn't concurrently modified during before/after.
Boundaries and failures
- duplicate names throw hard:
assertUniqueNamesintercepts duplicate tool names at the planner stage and throwsduplicate-tool-name— much cheaper than letting the model later be confused about which to call. Notesrc/agents/sessions/agent-session.ts:L2434-L2450uses "later writes overwrite earlier"; extension tools overwrite same-named builtins intentionally, but the planner layer is "see duplicate, throw". The two layers differ because the Map layer has already merged; the planner layer is a contract check. - visible must have an executor: if a descriptor declares availability passing but no
executorfield, the planner throwsmissing-executor. This prevents "declared as callable but not implemented" dangling tools from reaching the model. - availability signals: auth / config / env / plugin-enabled / context — five signals can be composed into
allOf/anyOfboolean expressions (src/tools/types.ts:L33-L50). Any signal failure sends the tool to the hidden bucket; diagnostics give reason + message for debugging why a tool suddenly disappeared. - hook throws block execution: a
beforeToolCallthrow is caught byrunWithSessionWriteLockand bubbles up; the main loop marks this tool_use as failed. AnafterToolCallthrow only affects result rewriting — the executor has already finished. - cost of rebuilding the Map:
refreshToolRegistryalways news up a Map, looking wasteful, but tool counts are typically in the tens — rebuilding is far simpler than maintaining incremental state. Session-level state consistency beats micro-optimization. - active tools vs registry separation:
toolRegistryis "which tools exist";activeToolNamesis "which tools this conversation uses".setActiveToolsByName(src/agents/sessions/agent-session.ts:L916-L931) only pulls names that exist in the Map intoagent.state.tools; missing names are silently skipped — this lets callers degrade gracefully when tools aren't registered rather than hard-failing.
Summary
The tool system is the agent's only "hands": Map registration + planner bucketing + before/after hook wrapping + executor invocation. The descriptor / executor layering lets the planner evaluate availability functionally without touching the runtime; Map rebuild makes tool-set changes take effect immediately. The model emits tool_use, the system looks up the Map, runs the hook, calls the executor, and backfills tool_result — this path is the hot path every turn in Agent main loop. Difference from Skills: skills are Markdown instructions the model decides whether to read; tools are functions the system must register, and the model can only call names. Plugins can register tools (see Plugins); tools exposed by an MCP server are also bridged as entries in the same Map (see MCP).
Official references: Tools docs · README.