Tools
Introduction
Overview
A tool is how a model reaches outside its own head. Without tools, the model can only produce text. With a tool, it can run a shell command, query a database, or fetch a web page. The model decides when a tool fits the request; the runtime calls it and sends the result back.
A tool is a Ruby class with a name, a description, and a
LLM::Tool#call
method. The name tells the model what the tool is called. The
description tells the model when to use it. The
LLM::Tool#call
method receives the arguments and returns the result. That is the entire
contract: name, description, parameters, and a method that runs.
How it works
A tool is a subclass of
LLM::Tool
with a name, description, and optional typed parameters. The model sees the name and
description and decides whether to call it. When it does, the
runtime serializes the arguments and passes them to
LLM::Tool#call.
Tools that spawn subprocesses can include
LLM::Tool::Utils
to get shared
wait(command:, timeout:)
and now help. The built-in Shell, Git, Rg, and Mkdir tools
use it to kill a command that exceeds its timeout.
If
LLM::Tool#call
raises, the runtime rescues it and returns a structured
error to the model instead. The conversation stays valid. You can
also handle errors yourself inside
LLM::Tool#call
by rescuing and returning a domain-specific error hash.
class Shell < LLM::Tool
set name: "shell",
description: "execute a shell command",
parameters: [
[:name, String, "the command's name", {required: true}],
[:arguments, Array[String], "command args", {default: []}]
]
def call(name:, arguments: [])
out = `#{name.shellescape} #{arguments.map(&:shellescape).join(" ")}`
{ok: $?.success?, out:}
end
end
llm = LLM.deepseek(key: ENV["KEY"])
agent = LLM::Agent.new(llm, tools: [Shell], stream: $stdout)
agent.talk "What files are in the current working directory?"
Why would I use it?
Tools are how the model interacts with the outside world, and the model is the orchestrator. When you give an agent a handful of tools and ask it to do something open-ended, the model reads each tool’s name and description, chooses which to call, and picks the arguments. The runtime just executes. The model can chain tools into a multi-step workflow, retry after a failure, or fan several calls out in parallel and synthesize the results.
That orchestration is the killer feature. One tool lets the model reach outside itself; several tools let it plan and execute a whole job. A single “research the competitors and compare their pricing” turn can fan out to a web search, a database query, and an API call at once, then return a synthesized answer. See Fan-out with tools below for the pattern.
Notes
Confirmation gates tools behind explicit approval. List tool names
in
LLM::Agent#confirm
to block execution until you override
LLM::Agent#on_tool_confirmation.
Confirmation also accepts a Symbol that resolves to an instance
method, letting the confirmed set change per-instance based on
runtime conditions.
Tool properties can be defined with individual method calls (as shown
in the How it works section) or with
LLM::Tool.set
(see the Set subsection). Both approaches work the same way.
Confirmation
Overview
Tools that perform destructive actions can be gated behind explicit
approval. List their names in
LLM::Agent#confirm
to block execution
until you override
LLM::Agent#on_tool_confirmation.
The default handler cancels
the tool. Override it per-agent to prompt the user, log the decision,
or auto-approve certain tools.
How it works
When you want to override the default approval flow for a gated
tool, override
LLM::Agent#on_tool_confirmation
on the subclass. The method receives the pending function and the
execution strategy. Call
LLM::Function#task
to execute the tool or
LLM::Function#cancel
to block it. The default handler cancels the call.
class AdminAgent < LLM::Agent
set confirm: %w[delete destroy shutdown]
def on_tool_confirmation(fn, strategy)
print "Run #{fn.name} with #{fn.arguments}? [y/N] "
$stdin.gets&.match?(/\Ay\z/i) ? fn.task(strategy).wait : fn.cancel
end
end
Why would I use it?
Confirmation prevents the model from running dangerous tools without user oversight. You decide the approval flow: a terminal prompt, a web socket, a background job queue.
Notes
Confirmation names can be a static array of tool names or a Symbol that resolves to an instance method. The Symbol form lets the confirmed set change per-instance based on runtime conditions.
Errors
Overview
A tool that raises does not crash the conversation. The runtime catches the exception, wraps it into a structured error, and returns it to the model. The model can read the error, decide what went wrong, and try something else. The tool loop stays alive no matter what.
How it works
If
LLM::Tool#call
raises, the runtime returns {error: true, type: "RuntimeError",
message: "boom"} to the model. You can also rescue inside
LLM::Tool#call
and return your own error shape that gives the model more context.
class Shell < LLM::Tool
set name: "shell",
description: "run a shell command",
parameters: [
[:name, String, "the command name", {required: true}],
[:arguments, Array[String], "command args", {default: []}]
]
def call(name:, arguments: [])
out = `#{name} #{arguments.join(" ")}`
{ok: $?.success?, out:}
rescue Errno::ENOENT
{ok: false, error: "command not found: #{name}"}
end
end
Why would I use it?
Custom error handling gives the model domain-specific detail that
helps it recover. Instead of a generic “RuntimeError: boom”, the
model sees {ok: false, error: "command not found: ls"} and knows
to correct the command name and try again.
Notes
The principle is the same either way: return something. A tool call must complete with a tool response. If you do not return a value and you do not raise, the runtime has nothing to send back and the conversation is stuck.
Set
Overview
LLM::Tool.set
is an alternative way to define tool properties using a Hash. It
works the same way as individual method calls and accepts the same
keys: name, description, parameters, required, and
defaults.
How it works
When you want to define tool properties at once, call
LLM::Tool.set
with a Hash. The keys match the individual method names. The
parameters key accepts the same Array of tuples that the
individual parameter method does.
class Shell < LLM::Tool
set name: "shell",
description: "execute a shell command",
parameters: [
[:name, String, "the command's name", {required: true}],
[:arguments, Array[String], "One or more arguments", {default: []}]
]
def call(name:, arguments: [])
out = `#{name.shellescape} #{arguments.map(&:shellescape).join(" ")}`
{ok: $?.success?, out:}
end
end
Why would I use it?
set is useful when you want to keep related properties together.
Instead of spreading name, description, parameters, and
required across multiple lines, you can group them in a single
Hash that reads like a configuration block.
Notes
Unknown keys raise KeyError, so typos are caught at class load
time rather than at runtime.
Fan-out with tools
Overview
The fastest way to see the model’s orchestration in action is to give it several independent tools and let it run them in parallel. This is where tools and concurrency meet: the model decides it needs multiple answers, issues several tool calls, and the runtime executes them concurrently before feeding every result back for synthesis.
How it works
Attach independent tools to an agent and set a concurrency strategy
that matches the workload. For IO-bound tools like HTTP fetches,
:async or :thread give you parallelism without much overhead.
For process isolation or CPU-bound work, reach for :fork or
:ractor. The agent runs the tool loop, so it keeps calling,
collecting, and synthesizing until it has the answer. The model
reads the tool descriptions, decides which are independent, and
issues the calls. The runtime fans them out across the chosen
strategy, waits, and returns the combined results as context so the
model can synthesize a single answer:
require "llm"
llm = LLM.deepseek(key: ENV["KEY"])
tools = [FetchNews, FetchStocks, FetchFeeds]
agent = LLM::Agent.new(llm, tools:, concurrency: :fork)
agent.talk "Run the tools in parallel and summarize the results"
Why would I use it?
Parallel tool execution turns N round trips into one. A research, monitoring, or automation agent that would otherwise call one tool, wait, call the next, now gathers everything at once. The result arrives faster and the model still gets the full picture before it writes. The strategy is a single option, so you tune the trade-off between speed (IO), isolation (fork), and CPU parallelism (ractor) without touching your tool code.
Notes
The six strategies are documented in concurrency. Whichever you choose, the tool loop, confirmation, and error handling behave identically. A single failing tool returns a structured error to the model, which can decide to retry or continue with the results it has.
Built-in tools
Overview
llm.rb ships with twelve ready-made tools that cover filesystem,
search, and shell operations. Load them all with
require "llm/tools". Each tool is documented in the
built-in tools catalog.
How it works
When you want to attach the built-in tools to an agent, require
the catalog and pass the full set of subclasses as the tools:
option.
require "llm"
require "llm/tools"
llm = LLM.deepseek(key: ENV["KEY"])
agent = LLM::Agent.new(llm, tools: LLM::Tool.subclasses)
Why would I use it?
The built-in tools cover the operations a coding or system agent needs most. See the built-in tools catalog for the full reference.
Notes
The tools that spawn subprocesses use the optional test-cmd.rb
gem for process management and interrupt handling.