Using Tools¶
Tools give robots the ability to interact with external systems. RobotLab supports three approaches: RubyLLM::Tool subclasses, RobotLab::Tool subclasses (with robot access), and RobotLab::Tool.create for dynamic tools.
Defining Tools¶
RubyLLM::Tool Subclass¶
For reusable tools that don't need robot access:
class GetWeather < RubyLLM::Tool
description "Get current weather for a location"
param :location, type: :string, desc: "City name or zip code"
param :unit, type: :string, desc: "Temperature unit", required: false
def execute(location:, unit: "celsius")
WeatherService.current(location, unit: unit)
end
end
RobotLab::Tool Subclass¶
For tools that need access to their owning robot (self-modification, spawning, etc.):
class AdjustEnergy < RobotLab::Tool
description "Adjust the robot's creativity level"
param :level, type: "number", desc: "Temperature from 0.0 to 1.0"
def execute(level:)
robot.with_temperature(level)
"Temperature adjusted to #{level}"
end
end
Pass robot: self when constructing:
class MyRobot < RobotLab::Robot
def initialize
super(
name: "creative_bot",
system_prompt: "You are creative.",
local_tools: [AdjustEnergy.new(robot: self)]
)
end
end
RobotLab::Tool.create (Dynamic Tools)¶
For quick, inline tools use the Tool.create factory:
get_time = RobotLab::Tool.create(
name: "get_time",
description: "Get the current time"
) { |_args| Time.now.to_s }
With parameters:
weather_tool = RobotLab::Tool.create(
name: "get_weather",
description: "Get current weather for a location",
parameters: {
type: "object",
properties: {
location: { type: "string", description: "City name" }
},
required: ["location"]
}
) { |args| WeatherService.current(args[:location]) }
Built-in Tools¶
AskUser¶
RobotLab::AskUser lets a robot ask the user a question via the terminal. The LLM decides when it needs human input and calls the tool with a question, optional choices, and an optional default.
AskUser reads its IO from robot.input / robot.output, and it finds the
robot through its own robot accessor — which is only populated when you attach
an instance constructed with robot:. Build the robot first, then attach:
robot = RobotLab.build(
name: "onboarding",
system_prompt: "Walk the user through project setup. Ask questions to understand their needs."
)
robot.local_tools << RobotLab::AskUser.new(robot: robot)
robot.run("Help the user set up a new project", tools: :inherit)
[!WARNING] Passing the bare class —
local_tools: [RobotLab::AskUser]— leavestool.robotasnil. The tool then ignoresrobot.input/robot.output, reads from the real$stdin(blocking your process, and hanging any test suite), and labels its prompt[Robot]instead of the robot's name. Note also thetools: :inheritabove: a plainrun()sends zero tools, so the LLM would never seeAskUserat all.
The tool displays the robot's name and question, then waits for input:
Features:
- Open-ended: just a question, free-text response
- Multiple choice: numbered options, user types the number or text
- Default value: shown in the prompt, used when user presses Enter
Because the IO is sourced from the robot, a StringIO pair makes the tool
testable without a terminal:
robot.input = StringIO.new("2\n")
robot.output = StringIO.new
# ... the LLM calls ask_user(question: "Which language?", choices: %w[Ruby Python Go])
robot.output.string
# => "\n[onboarding] Which language?\n 1. Ruby\n 2. Python\n 3. Go\n> "
[!NOTE] The name the LLM sees is
robot_lab--ask_user, notask_user. RubyLLM derives tool names from the full class name including its namespace, soRobotLab::AskUserbecomesrobot_lab--ask_user. (A trailingToolis stripped: a top-levelWeatherToolis exposed asweather.) These derived names are what an allowlist must match for tools attached as instances — a tool attached as a class matches its class name instead. See Runtime Tool Filtering.
See the AskUser API reference for full details.
Attaching Tools to Robots¶
Via Constructor¶
Pass tools via the local_tools: parameter when building a robot:
robot = RobotLab.build(
name: "assistant",
system_prompt: "You are a helpful assistant with tool access.",
local_tools: [GetWeather, CalculatorTool]
)
local_tools: and tools: are different mechanisms — don't confuse them. local_tools: attaches tool instances or classes; tools: (see Runtime Tool Filtering below) is a name allowlist that filters which of the already-attached tools are sent for a given turn. Passing an instance or class to tools: raises ArgumentError immediately, naming the offending class and pointing you at local_tools: instead:
RobotLab.build(name: "bot", tools: [GetWeather.new])
# => ArgumentError: `tools:` expects tool names (String/Symbol) to allow, but
# received GetWeather. To attach tool instances or classes, pass them as
# `local_tools:` (e.g. RobotLab.build(local_tools: [MyTool.new])).
Via Template Front Matter¶
Declare tool class names in the template's YAML front matter. RobotLab resolves each string to a Ruby constant via Object.const_get and instantiates it:
---
description: Weather assistant with forecast tools
tools:
- GetWeather
- GetForecast
---
You are a weather assistant. Use your tools to look up weather information.
# Tools are resolved from frontmatter — no local_tools: needed
robot = RobotLab.build(template: :weather_bot)
Tool classes must be defined and loaded before building the robot. Unresolvable names are skipped with a warning. Constructor local_tools: overrides frontmatter tools: when provided.
Via Chaining¶
with_tools is delegated straight to the underlying RubyLLM::Chat, so it
writes tools onto the chat rather than onto the robot's local_tools:
robot = RobotLab.build(name: "assistant", system_prompt: "...")
robot.with_tools(GetWeather, CalculatorTool)
[!WARNING] Chained tools do not survive a
run. Becauserundefaults totools: :none, it replaces the chat's tool list with an empty one before asking the LLM — wiping whateverwith_toolsput there:robot.with_tools(GetWeather, CalculatorTool) robot.chat.tools.keys # => [:get_weather, :calculator] robot.run("Hello") robot.chat.tools.keys # => [] <- cleared by the runFor tools that should persist across runs, attach them with
local_tools:and passtools: :inheriton eachrun.
Runtime Tool Filtering¶
tools: (and mcp:) also work as a per-run override, passed to run() itself, on top of the build-time/network/global hierarchy described in Hierarchical MCP and Tools:
robot.run("What's the weather?", tools: :inherit) # every attached tool
robot.run("Just chat, no tools needed.", tools: :none) # zero tools this turn
# Allowlist. This robot attached its tools as CLASSES, so match the class names:
robot.run("Only use the calculator.", tools: %w[CalculatorTool])
| Value | Meaning |
|---|---|
:inherit |
Propagate the parent level's allowlist (build-time / network / global) |
:none, [] |
Send zero tools this turn |
nil |
No filter at all — every attached tool, discarding any parent allowlist |
["name", ...] |
Only these tools, matched against how each was attached |
[!CAUTION]
tools: nildoes not send zero tools, and it is not the same as:inherit.:inheritcarries the parent's allowlist down;nilthrows the filter away entirely. With a robot builttools: %w[GetWeather]holdingGetWeatherandCalculatorTool:run(tools: :inherit) -> [:get_weather] # parent allowlist honored run(tools: nil) -> [:get_weather, :calculator] # allowlist discardedIf you mean zero tools, write
:none.[!IMPORTANT] An allowlist entry must match how the tool was attached, because the comparison uses
tool.nameandClass#namediffers fromRubyLLM::Tool#name:
Attached as Allowlist that matches local_tools: [CalculatorTool](class)[CalculatorTool]or%w[CalculatorTool]local_tools: [CalculatorTool.new](instance)%w[calculator]Note the constructor's
tools:is validated and rejects classes and instances outright (ArgumentError); only task-level andrun()values accept them.
An explicit :none/[] is useful for a relevance filter that decided no tool is useful for the current message — it now genuinely sends zero tools for that turn (previously an empty allowlist was silently treated as "all tools," which could overflow small-context local models with the full tool set). Each turn's resolved tool set fully replaces the chat's tools rather than accumulating, so a later :none turn correctly clears whatever a prior turn attached.
[!CAUTION] Watch the default. Both
Robot.new's andrun()'stools:/mcp:parameters default to:none, not:inherit. If you build a robot withlocal_tools:and then callrobot.run(message)with notools:override at all, the runtime default takes the explicit-:nonepath above — sending no tools for that turn. Passtools: :inheriton therun()call (or on the networktask, which is forwarded torun()) anywhere you need the robot's attached tools available.[!WARNING] For a standalone robot, do not pass
tools: :inheritat build time as a way to turn tools on. Build-time:inheritresolves against the level above it, which for a standalone robot is the global:none— producing an allowlist of["none"]that matches nothing, and the empty result then carries into the runtime pass. Leave build-timetools:unset:# build tools: unset + run(tools: :inherit) -> [:t1] correct # build tools: :inherit + run(tools: :inherit) -> [] the standalone trap # build tools: :none + run(tools: :inherit) -> [:t1] also fineThis applies to a robot run on its own. Inside a network whose
config:suppliestools:/mcp:, the parent level is that network list rather than:none, and build-time:inheritis required — it is the robot's opt-in to the network value. See Network-Wide Tool and MCP Defaults.
Tool Capping and Per-Turn Filtering¶
Most LLM providers reject a tool array longer than 128 entries and fail the whole turn. RobotLab clamps the fully-resolved tool list to a ceiling right before handing it to the chat provider — the definitive choke point regardless of how the tools were configured, filtered, or MCP-connected:
robot = RobotLab.build(
name: "power_user",
system_prompt: "...",
local_tools: many_tools, # say, 150 tools
config: RobotLab::RunConfig.new(max_tools: 50) # override the default ceiling
)
- Default ceiling: 128 tools per turn (
RobotLab::Robot::DEFAULT_MAX_TOOLS) - Override with a positive
max_tools:onRunConfig(or themax_tools:cascade field — see Available Fields) - When a turn's resolved tools exceed the cap, RobotLab logs a warning naming how many were dropped and sends the first
max_toolsentries
[!WARNING] The cap cannot be disabled.
max_tools: nil,0, or a negative number all fall back to the 128 default — only a positive integer changes the ceiling. If you attach more than 128 tools and setmax_tools: 0expecting "unlimited", you get the first 128.The truncation is not silent, though —
cap_toolslogs it atWARNthroughRobotLab.config.loggerevery time it fires:
Skill Scripts and Sandboxing¶
A skill bundle (a directory with a SKILL.md plus scripts/, discovered via AgentSkill) can expose its scripts as tools (ScriptTool). Because those scripts run as real OS processes, each SKILL.md can declare the capabilities its scripts need directly in front matter, alongside name/description:
---
name: deploy-checker
description: Verifies a deployment's health before promoting it.
fs_read: ["./data", "/etc/hosts"]
fs_write: ["./out"]
network: true
timeout: 30
trust: external # or "core" for trusted, always-unconfined skills
---
Sandboxing itself is opt-in and off by default — see the sandbox: config section. When disabled, scripts run exactly as they always have, unconfined. When enabled:
- The global
sandbox:config is a ceiling (fs_read,fs_write,network,timeout); each skill's front matter is its declared request. The script actually runs under the intersection of the two — a path outside the ceiling's roots is dropped even if the skill declares it,networkrequires both sides to allow it, andtimeoutis the smaller of the two. - On macOS, confinement is enforced with a generated
sandbox-exec(Seatbelt) profile: deny-by-default, with narrow allowances for the interpreter to boot, the granted read/write paths, and (optionally) the network. Notably,$HOMEis never implicitly readable — SSH keys and cloud credentials stay out of reach unless a path under$HOMEis explicitly granted. - Off macOS, or for any skill declaring
trust: core, sandboxing is a passthrough — confinement is currently macOS-only and is always skipped for trusted "core" skills regardless of platform. - A script that runs past its
timeoutis killed (its whole process group) and reported back to the LLM as a timed-out error rather than hanging the turn.
Parameter Types¶
Define parameters on RubyLLM::Tool subclasses using param:
String¶
Integer¶
Number (Float)¶
Boolean¶
Array¶
Enumerated Values¶
param has no enum: option. Its full signature is
param(name, type: "string", desc: nil, description: nil, required: true) —
anything else raises ArgumentError: unknown keyword: :enum. Express the
allowed values in the description instead, and validate in execute:
STATUSES = %w[pending active completed].freeze
param :status, type: :string, desc: "Order status — one of: pending, active, completed"
def execute(status:)
return { error: "status must be one of #{STATUSES.join(', ')}" } unless STATUSES.include?(status)
# ...
end
Required vs Optional¶
Parameters are required by default. Mark optional with required: false:
param :query, type: :string, desc: "Search query" # required
param :limit, type: :integer, desc: "Max results", required: false # optional
Tool Patterns¶
Database Lookup¶
class FindUser < RubyLLM::Tool
description "Find user by email or ID"
param :identifier, type: :string, desc: "Email address or user ID"
def execute(identifier:)
user = User.find_by(id: identifier) || User.find_by(email: identifier)
user ? user.to_h : { error: "User not found" }
end
end
API Integration¶
class GetStockPrice < RubyLLM::Tool
description "Get current stock price for a ticker symbol"
param :symbol, type: :string, desc: "Stock ticker symbol (e.g. AAPL)"
def execute(symbol:)
response = HTTP.get("https://api.stocks.example/quote/#{symbol}")
JSON.parse(response.body)
rescue HTTP::Error => e
{ error: "Failed to fetch stock price: #{e.message}" }
end
end
File Operations¶
class ReadFile < RubyLLM::Tool
description "Read contents of a file"
param :path, type: :string, desc: "Absolute path to the file"
def execute(path:)
if File.exist?(path) && File.readable?(path)
{ content: File.read(path), size: File.size(path) }
else
{ error: "File not found or not readable" }
end
end
end
Multi-Step Operations¶
class ProcessOrder < RubyLLM::Tool
description "Validate and process a customer order"
param :order_id, type: :string, desc: "The order ID to process"
def execute(order_id:)
order = Order.find(order_id)
# Validate
return { error: "Invalid order" } unless order.valid?
# Process payment
result = PaymentProcessor.charge(order)
return { error: result[:error] } unless result[:success]
# Update status
order.update!(status: "paid")
{ success: true, order_id: order.id, amount: order.total }
end
end
Tool Return Values¶
Structured Data¶
Return hashes with consistent structure:
def execute(user_id:)
user = User.find(user_id)
{
id: user.id,
name: user.name,
email: user.email,
created_at: user.created_at.iso8601
}
end
Simple Values¶
Lists¶
def execute(query:)
results = Search.query(query)
results.map { |r| { id: r.id, title: r.title, score: r.score } }
end
Error Handling¶
Automatic Error Handling¶
RobotLab::Tool automatically catches StandardError exceptions from execute and returns a plain-text error string to the LLM. The LLM can then reason about the failure and try an alternative approach — without crashing the run.
class FetchResource < RobotLab::Tool
description "Fetch a resource from an external API"
param :id, type: :string, desc: "Resource ID"
def execute(id:)
ExternalAPI.fetch(id)
end
end
tool = FetchResource.new
result = tool.call({ "id" => "missing" })
# If ExternalAPI.fetch raises, result is:
# => "Error (fetch_resource): connection refused"
This applies to all RobotLab::Tool variants — subclasses, Tool.create factory tools, and MCP tools.
RobotLab::ToolError and Retryability¶
RobotLab::ToolError is handled on a separate path from ordinary
StandardErrors. Raising one with retryable: true appends " (retryable)"
to the text the LLM sees, hinting that another attempt may succeed:
class FetchResource < RobotLab::Tool
description "Fetch a resource from an external API"
param :id, type: :string, desc: "Resource ID"
def execute(id:)
raise RobotLab::ToolError.new("upstream 503", retryable: true)
end
end
FetchResource.new.call({ "id" => "1" })
# => "Error (fetch_resource): upstream 503 (retryable)"
# Without retryable:, no suffix is appended:
# raise RobotLab::ToolError, "bad input"
# => "Error (fetch_resource): bad input"
[!WARNING] Only the ordinary
StandardErrorpath writes to the log (RobotLab.config.logger.warn("Tool 'name' error: RuntimeError: …")). ARobotLab::ToolErroris turned into text for the LLM and logged nowhere — if you rely onToolErrorfor expected failures, add your own logging insideexecute, or those failures leave no trace outside the transcript.
Critical Tools (Opt-Out)¶
For tools where you want exceptions to propagate (e.g., a tool whose failure should abort the run), set raise_on_error on the class:
class CriticalPayment < RobotLab::Tool
self.raise_on_error = true
description "Process a payment"
param :amount, type: :number, desc: "Payment amount"
def execute(amount:)
PaymentGateway.charge(amount)
end
end
raise_on_error is per-class and defaults to false. Setting it on one class does not affect others.
[!WARNING]
raise_on_errordoes not walk the inheritance chain. A subclass of a class that set it silently reverts tofalse:class CriticalBase < RobotLab::Tool self.raise_on_error = true end class ChargeCard < CriticalBase; end CriticalBase.raise_on_error? # => true ChargeCard.raise_on_error? # => false <- NOT inheritedSet
self.raise_on_error = trueon every class that needs it. (This differs fromractor_safe, below, which does consult the superclass.)
Manual Error Handling¶
You can still handle specific errors inside execute for domain-specific responses:
class FetchResource < RobotLab::Tool
description "Fetch a resource from an external API"
param :id, type: :string, desc: "Resource ID"
def execute(id:)
result = ExternalAPI.fetch(id)
{ success: true, data: result }
rescue ExternalAPI::NotFound
{ success: false, error: "Resource not found", id: id }
rescue ExternalAPI::RateLimited => e
{ success: false, error: "Rate limited", retry_after: e.retry_after }
end
# Any other StandardError is still caught by the automatic handler
end
Ractor-Safe Tools¶
A tool class can declare itself safe to run inside a Ractor. When it is, and the
robot_lab-ractor extension
gem is loaded, call dispatches the work to RobotLab.ractor_pool instead of
running it inline on the calling thread — giving real CPU parallelism for
compute-bound tools.
class Fibonacci < RobotLab::Tool
ractor_safe true
description "Compute the nth Fibonacci number"
param :n, type: :integer, desc: "Which Fibonacci number to compute"
def execute(n:)
a, b = 0, 1
n.times { a, b = b, a + b }
a
end
end
Fibonacci.ractor_safe? # => true
Dispatch to the pool happens only when all three hold:
ractor_safe?is true- the class has a resolvable name — anonymous classes (including every tool
built by
Tool.create) fall back to the inline path - the
robot_lab-ractorextension is loaded
Otherwise execute runs inline, exactly as an ordinary tool would. Pool size
comes from the ractor_pool_size RunConfig field (:auto when unset).
[!WARNING] A Ractor-safe tool must be genuinely stateless: it is instantiated fresh inside the Ractor worker for every call, so
robot, captured closures, and mutable class-level state are not available to it, and the result comes back frozen. Do not setractor_safe trueon a tool that touches its robot.
Unlike raise_on_error, ractor_safe does walk the inheritance chain — a
subclass of a Ractor-safe tool is Ractor-safe unless it says otherwise.
Tool Callbacks¶
Robots support on_tool_call and on_tool_result callbacks for monitoring tool usage. Each receives exactly one argument — on_tool_result gets the result only, not the originating call:
robot = RobotLab.build(
name: "assistant",
system_prompt: "...",
local_tools: [GetWeather],
on_tool_call: ->(tool_call) { puts "Calling: #{tool_call.name}" },
on_tool_result: ->(result) { puts "Result: #{result}" }
)
robot.run("What's the weather in Tokyo?", tools: :inherit)
See Streaming for details, including the RubyLLM deprecation warning these emit.
RobotLab::Tool.create with Schema¶
For dynamic tools via Tool.create, pass parameters as a JSON Schema hash:
tool = RobotLab::Tool.create(
name: "search",
description: "Search for items",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "Search query" },
limit: { type: "integer", description: "Max results" }
},
required: ["query"]
}
) { |args| Search.query(args[:query], limit: args[:limit] || 10) }
Best Practices¶
1. Clear Descriptions¶
Write descriptions that help the LLM understand when and how to use the tool:
# Good: Specific and actionable
class SearchOrders < RubyLLM::Tool
description "Search customer orders by date range, status, or customer email. Returns up to 50 matching orders sorted by date."
# ...
end
# Bad: Vague
class Search < RubyLLM::Tool
description "Searches stuff"
# ...
end
2. Validate Inputs¶
def execute(email:)
unless email.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
return { error: "Invalid email format" }
end
# ... rest of logic
end
3. Return Structured Data¶
# Good: Structured and consistent
def execute(**_)
{
success: true,
data: { id: 1, name: "Item" },
metadata: { fetched_at: Time.now.iso8601 }
}
end
# Bad: Unstructured
def execute(**_)
"Found item with id 1 named Item"
end
4. Keep Tools Focused¶
Each tool should do one thing well. Prefer multiple focused tools over one tool that does everything.
Next Steps¶
- MCP Integration - External tool servers
- Building Robots - Robot creation patterns
- API Reference: Tool - Complete API