Tool Usage¶
Robots with external capabilities through tools.
Overview¶
This example demonstrates how to give robots access to external systems through tools. Tools are defined as RubyLLM::Tool subclasses or RobotLab::Tool instances and passed to robots via the local_tools: parameter.
[!WARNING] Attaching a tool is not the same as sending it.
Robot#rundefaults totools: :none, so a plainrobot.run("...")sends the LLM zero tools even whenlocal_tools:were supplied at build time. Passtools: :inheriton every call that should be able to use them:For a standalone robot, do not pass
tools: :inheritin the constructor: at build time it resolves against the parent level (:none) and yields an allowlist matching nothing, suppressing the tools even when the run asks for them. Leavetools:unset there.Inside a network, the opposite holds — build-time
tools: :inheritis how a robot opts into the networkconfig:'s allowlist. See Creating Networks.An explicit array (
tools: [Calculator]) is an allowlist, not a local-vs-MCP switch, and its entries must match how each tool was attached — this page attaches classes, so use class names.
RubyLLM::Tool Subclass Pattern¶
The primary way to define tools is by subclassing RubyLLM::Tool:
#!/usr/bin/env ruby
# Tool definitions mirror examples/02_tools.rb
require "bundler/setup"
require "robot_lab"
# Define tools as RubyLLM::Tool subclasses
class Calculator < RubyLLM::Tool
description "Performs basic arithmetic operations"
param :operation,
type: "string",
desc: "The operation to perform (add, subtract, multiply, divide)"
param :a,
type: "number",
desc: "First operand"
param :b,
type: "number",
desc: "Second operand"
def execute(operation:, a:, b:)
case operation
when "add" then a + b
when "subtract" then a - b
when "multiply" then a * b
when "divide" then a.to_f / b
else "Unknown operation: #{operation}"
end
end
end
class FortuneCookie < RubyLLM::Tool
description "Get a fortune cookie message with wisdom and lucky numbers"
param :category,
type: "string",
desc: "The category of fortune (wisdom, love, career, adventure)"
FORTUNES = {
"wisdom" => [
"The obstacle in the path becomes the path.",
"A journey of a thousand miles begins with a single step."
],
"career" => [
"Opportunity dances with those already on the dance floor.",
"Your work is your signature. Sign it with excellence."
]
}.freeze
def execute(category:)
{
category: category,
fortune: FORTUNES.fetch(category, FORTUNES["wisdom"]).sample,
lucky_numbers: Array.new(6) { rand(1..49) }.sort
}
end
end
# Create robot with tools via local_tools
robot = RobotLab.build(
name: "assistant",
system_prompt: "You help with math and dispense fortune cookies.",
local_tools: [Calculator, FortuneCookie],
model: "claude-sonnet-4"
)
# Run the robot -- tools: :inherit is what actually sends Calculator and
# FortuneCookie to the LLM
result = robot.run(
"What is 15 multiplied by 7? Also, give me a career fortune.",
tools: :inherit
)
# Display results
puts "Response: #{result.last_text_content}"
# Confirm which tools were sent for this turn
puts "Tools sent: #{robot.chat.tools.keys.join(', ')}"
[!NOTE]
result.tool_callsis effectively always empty. It reads the final assistant message, and by the time ruby_llm's tool loop has finished that message contains only text. To observe tool activity, use theon_tool_call:callback (below) or the:tool_callhooks.
RobotLab::Tool.create Pattern¶
For simpler tools that do not need their own class, use RobotLab::Tool.create:
require "robot_lab"
# Define an inline tool
get_time = RobotLab::Tool.create(
name: "get_time",
description: "Get the current time"
) { |_args| Time.now.to_s }
# Define a tool with parameters (JSON Schema)
weather_tool = RobotLab::Tool.create(
name: "get_weather",
description: "Get weather for a city",
parameters: {
type: "object",
properties: {
city: { type: "string", description: "City name" }
},
required: ["city"]
}
) { |args| { city: args[:city], temperature: "72F", condition: "sunny" } }
robot = RobotLab.build(
name: "weather_bot",
system_prompt: "You provide weather and time information.",
local_tools: [get_time, weather_tool],
model: "claude-sonnet-4"
)
result = robot.run("What time is it and what's the weather in New York?", tools: :inherit)
puts result.last_text_content
The block receives a single hash of symbol-keyed arguments.
[!NOTE]
RobotLab::Tool.create(parameters:)accepts a JSON-Schema-shaped hash, but onlytypeanddescriptionare read off each property, plus the top-levelrequiredlist. Anything else (enum,default, nesteditems) is ignored — ruby_llm'sparamDSL supports onlytype:,desc:/description:, andrequired:.
Weather API Integration¶
#!/usr/bin/env ruby
require "bundler/setup"
require "robot_lab"
require "http"
require "json"
class GetWeather < RubyLLM::Tool
description "Get current weather for a city"
param :city,
type: "string",
desc: "City name (e.g., 'New York', 'London')"
def execute(city:)
response = HTTP.get(
"https://wttr.in/#{URI.encode_www_form_component(city)}?format=j1"
)
if response.status.success?
data = JSON.parse(response.body)
current = data["current_condition"].first
{
city: city,
temperature_f: current["temp_F"],
temperature_c: current["temp_C"],
condition: current["weatherDesc"].first["value"],
humidity: current["humidity"],
wind_mph: current["windspeedMiles"]
}
else
{ error: "Could not fetch weather for #{city}" }
end
rescue HTTP::Error => e
{ error: "Network error: #{e.message}" }
end
end
class GetForecast < RubyLLM::Tool
description "Get weather forecast for upcoming days"
param :city, type: "string", desc: "City name"
param :days, type: "integer", desc: "Number of days (default 3)"
def execute(city:, days: 3)
response = HTTP.get(
"https://wttr.in/#{URI.encode_www_form_component(city)}?format=j1"
)
if response.status.success?
data = JSON.parse(response.body)
data["weather"].take(days).map do |day|
{
date: day["date"],
high_f: day["maxtempF"],
low_f: day["mintempF"],
condition: day["hourly"].first["weatherDesc"].first["value"]
}
end
else
{ error: "Could not fetch forecast" }
end
rescue HTTP::Error => e
{ error: "Network error: #{e.message}" }
end
end
# Create weather assistant
weather_bot = RobotLab.build(
name: "weather_assistant",
description: "Provides weather information",
system_prompt: <<~PROMPT,
You are a helpful weather assistant. Use your tools to look up weather.
Always provide temperatures in both Fahrenheit and Celsius.
Include relevant advice based on conditions (umbrella, sunscreen, etc).
PROMPT
local_tools: [GetWeather, GetForecast],
model: "claude-sonnet-4"
)
# Interactive session
puts "Weather Assistant (type 'quit' to exit)"
puts "-" * 50
loop do
print "\nYou: "
input = gets&.chomp
break if input.nil? || input.downcase == "quit"
next if input.empty?
result = weather_bot.run(input, tools: :inherit)
puts "\nAssistant: #{result.last_text_content}"
end
puts "\nGoodbye!"
Database Integration¶
require "robot_lab"
# Mock database
ORDERS = {
"ORD001" => { id: "ORD001", status: "shipped", items: ["Widget"], total: 29.99 },
"ORD002" => { id: "ORD002", status: "processing", items: ["Gadget", "Gizmo"], total: 89.99 }
}
class GetOrder < RubyLLM::Tool
description "Look up an order by ID"
param :order_id, type: "string", desc: "The order ID to look up"
def execute(order_id:)
order = ORDERS[order_id.upcase]
order || { error: "Order not found" }
end
end
class ListOrders < RubyLLM::Tool
description "List recent orders"
param :limit, type: "integer", desc: "Maximum number of orders to return"
def execute(limit: 5)
ORDERS.values.take(limit)
end
end
class CancelOrder < RubyLLM::Tool
description "Cancel an order"
param :order_id, type: "string", desc: "The order ID to cancel"
param :reason, type: "string", desc: "Reason for cancellation"
def execute(order_id:, reason: nil)
order = ORDERS[order_id.upcase]
if order.nil?
{ success: false, error: "Order not found" }
elsif order[:status] == "shipped"
{ success: false, error: "Cannot cancel shipped orders" }
else
order[:status] = "cancelled"
order[:cancel_reason] = reason
{ success: true, message: "Order #{order_id} cancelled" }
end
end
end
order_bot = RobotLab.build(
name: "order_assistant",
system_prompt: "You help customers check and manage their orders.",
local_tools: [GetOrder, ListOrders, CancelOrder],
model: "claude-sonnet-4"
)
# Run with a question
result = order_bot.run("What's the status of order ORD001?", tools: :inherit)
puts result.last_text_content
Tool Call Callbacks¶
Use on_tool_call and on_tool_result to monitor tool execution:
robot = RobotLab.build(
name: "monitored_bot",
system_prompt: "You help with calculations.",
local_tools: [Calculator],
model: "claude-sonnet-4",
on_tool_call: ->(tool_call) {
puts "[Tool Call] #{tool_call.name}: #{tool_call.arguments}"
},
# NOTE: one argument -- the result. There is no tool_call parameter here.
on_tool_result: ->(result) {
puts "[Tool Result] #{result}"
}
)
result = robot.run("What is 42 * 17?", tools: :inherit)
[!WARNING]
on_tool_resultreceives exactly one argument, the tool's return value. Writing->(tool_call, result)raisesArgumentError: wrong number of arguments (given 1, expected 2)the first time a tool runs.Both callbacks map onto ruby_llm's legacy hooks and are deprecated as of ruby_llm 1.16 — wiring either one emits
`on_tool_call` is deprecated and will be removed in RubyLLM 2.0. Use `before_tool_call` instead.The additive replacements arebefore_message,after_message,before_tool_call, andafter_tool_result. RobotLab's own Hook system is the supported way to observe tool activity.
Running¶
export ANTHROPIC_API_KEY="your-key"
# Tool definitions and a robot that uses them
ruby examples/02_tools.rb
# Tool loop circuit breaker (max_tool_rounds)
ruby examples/20_circuit_breaker.rb
# Ractor-safe CPU tools (no LLM calls)
ruby examples/29_ractor_tools.rb
Interactive User Input¶
Use the built-in RobotLab::AskUser tool to let robots ask the user questions during execution:
require "robot_lab"
robot = RobotLab.build(
name: "interviewer",
system_prompt: <<~PROMPT,
You are a project setup assistant. Interview the user to understand their
needs, then summarize the project plan. Use the robot_lab--ask_user tool to
gather information one question at a time.
PROMPT
model: "claude-sonnet-4"
)
# Attach an INSTANCE bound to the robot so the tool uses robot.input/robot.output
robot.local_tools << RobotLab::AskUser.new(robot: robot)
result = robot.run("Help me plan a new web application", tools: :inherit)
puts "\nProject Plan:\n#{result.last_text_content}"
[!WARNING] Pass an instance, not the class.
RobotLab::Toolderives its LLM-visible name from the full class name, soRobotLab::AskUseris presented to the model asrobot_lab--ask_user, notask_user— name it that way in your system prompt.An instance created without
robot:hastool.robot == nil, so it ignoresrobot.input/robot.output, falls back to$stdin/$stdout, and labels every prompt[Robot]instead of the robot's name. Always useRobotLab::AskUser.new(robot: robot).
The robot will ask questions interactively:
[interviewer] What programming language would you like to use?
1. Ruby
2. Python
3. TypeScript
> 1
[interviewer] Will you need a database?
> [yes]
[interviewer] What's the main purpose of the application?
> Customer support portal
For testing, inject StringIO objects on the robot before building the tool
instance (the tool reads them through its robot reference at call time):
RobotLab::AskUser can also be driven directly, outside an LLM turn — see
examples/06_prompt_templates.rb,
which calls RobotLab::AskUser.new.call("question" => ..., "default" => ...) to
collect a value before any robot is built.
Key Concepts¶
- RubyLLM::Tool subclass: Define a class with
description,param, andexecutemethod - RobotLab::Tool subclass: Same DSL plus a
robotaccessor for robot-aware tools - RobotLab::Tool.create: Use
RobotLab::Tool.create(name:, description:, &block)for dynamic tools - Built-in tools:
RobotLab::AskUser(LLM-visible namerobot_lab--ask_user) for interactive terminal input - local_tools: Pass tool classes/instances via
local_tools:toRobotLab.buildorRobot.new— this attaches them - tools: :inherit: Required on
runto actually send the attached tools; the default istools: :none - Frontmatter tools: Declare tool class names in template YAML front matter (
tools: [Calculator]); they populatelocal_tools, and still needtools: :inheritat run time. Constructorlocal_tools:overrides the front-matter list - Error Handling: Raised exceptions are caught and returned to the LLM as text (
"Error (tool_name): message"); aRobotLab::ToolErrorwithretryable: trueappends" (retryable)". Setself.raise_on_error = trueon a class to opt out — note this is per-class and is not inherited by subclasses - Tool cap: At most
DEFAULT_MAX_TOOLS(128) tools are sent. Settingmax_toolsto nil, 0, or a negative number falls back to 128 — the cap cannot be disabled - Result Access: Use
result.last_text_content(aliasresult.reply) for the final response.result.tool_callsis effectively always empty — observe tool activity withon_tool_call:or the Hook system instead