The Problem
LLMs can request tool calls by name — but the agent needs to route that name to the right Go function, validate arguments, and return a structured result. Hardcoding a switch on tool names couples the agent to every tool and makes adding new tools a surgery on the core loop.
The Solution
Define a Tool interface with Name(), Description(), and Execute(). A ToolRegistry holds a map[string]Tool and exposes a single Dispatch(call ToolCall) method. The agent remains unaware of concrete tool types — it just passes ToolCall structs from the LLM to the registry. New tools are registered at startup with no changes to the agent.
Structure
LLM Selects a Tool
The LLM response contains a structured ToolCall with the tool name and JSON arguments. The agent deserializes this into a ToolCall struct and passes it to the registry — no branching required.
flowchart LR
LLM["LLM Response
(ToolCall JSON)"]
Agent["Agent"]
Registry["ToolRegistry
Dispatch()"]
Web["WebSearchTool"]
Calc["CalculatorTool"]
Result["ToolResult"]
LLM -->|"ToolCall{name, args}"| Agent
Agent --> Registry
Registry -->|"name=web_search"| Web
Registry -->|"name=calculator"| Calc
Web --> Result
Calc --> Result
Result --> Agent Implementation
package main
import "context"
// Tool is a named, self-describing capability the agent can invoke.
type Tool interface {
Name() string
Description() string
Execute(ctx context.Context, args map[string]any) (string, error)
}
// ToolCall is the LLM-selected invocation received from the model.
type ToolCall struct {
Name string
Args map[string]any
}
// ToolResult pairs a call with its output.
type ToolResult struct {
Call ToolCall
Output string
Err error
} Real-World Analogy
A company switchboard: callers ask for a department by name, and the operator routes them. The caller doesn’t know the internal extension; the switchboard does. Adding a new department means registering a new extension — the operator’s routing logic stays unchanged.
Pros and Cons
| Pros | Cons |
|---|---|
| Open/closed — add tools without touching the agent | map[string]any args lose type safety at the boundary |
| Registry serves as a schema catalog for the LLM prompt | Requires careful description writing so the LLM selects the right tool |
| Tools are independently testable units | Dispatch errors are silent if not surfaced back to the LLM |
| Schemas can be serialized to JSON for the LLM’s function-calling API | Large tool registries can overwhelm the model’s context window |
Best Practices
- Keep tool
Description()precise and action-oriented — the LLM uses it verbatim to decide when to call the tool. - Validate required args inside
Execute()and return descriptive errors so the agent can reason about what went wrong. - Expose
Schemas()from the registry to auto-generate the function-calling spec for your LLM provider. - Use strongly-typed wrapper structs internally and unmarshal from
map[string]anyat the tool boundary only. - Group related tools under a common prefix (e.g.,
fs_read,fs_write) to help the LLM reason about tool families.
When to Use
- Any agent that calls external APIs, executes code, reads files, or queries databases.
- When you want to add or remove capabilities at runtime without redeploying the agent logic.
- Multi-tenant systems where different tenants have different tool sets.
When NOT to Use
- Agents with a single, fixed action — the registry adds unnecessary indirection.
- Deterministic pipelines where tools are always called in a fixed order — use a plain function call.