class RubyLLM::Tool
A Tool is an action an AI model can call during a chat. Subclasses describe themselves with ::description, declare their arguments, and implement execute:
class Weather < RubyLLM::Tool description "Gets current weather for a location" def execute(latitude:, longitude:) response = Faraday.get "https://api.open-meteo.com/v1/forecast", latitude: latitude, longitude: longitude, current: "temperature_2m,wind_speed_10m" JSON.parse(response.body) end end chat.with_tools(Weather).ask "What's the weather in Berlin?"
When no parameters are declared, the argument schema is inferred from execute’s keyword arguments: required keywords become required string parameters and optional keywords become optional ones. Use ::parameter or ::parameters when arguments need explicit types, descriptions, or structure.
Public Class Methods
Source
# File lib/ruby_llm/tool.rb, line 236 def defer @deferred = true end
Keeps this tool’s definition out of the model’s context until the provider’s tool search loads it. Pass +defer: false+ to Chat#with_tools to offer it up front in one chat. See Chat#deferred_tools.
class DeepResearch < RubyLLM::Tool description "Runs a multi-step web investigation" defer end
# File lib/ruby_llm/tool.rb, line 103 def description(text = nil) return @description unless text @description = text end
Sets the description the model sees for this tool, or returns the current description when called without an argument.
class Weather < RubyLLM::Tool description "Gets current weather for a location" end
# File lib/ruby_llm/tool.rb, line 202 def max_run_time(seconds = nil) @max_run_time = seconds if seconds @max_run_time end
Sets how long a run of this tool may take before a persisted chat treats it as crashed, overriding Configuration#max_run_time. Another process that resumes the chat after that time runs the call again. Without an argument, returns the declared value, or nil.
class GenerateReport < RubyLLM::Tool max_run_time 2.hours end
Source
# File lib/ruby_llm/tool.rb, line 120 def parameter(name, **) declared_parameters[name] = Parameter.new(name, **) end
Declares a parameter for the tool. options accepts type: (defaults to 'string'), description:, and required: (defaults to true).
class Distance < RubyLLM::Tool description "Calculates distance between two cities" parameter :origin, description: "Origin city name" parameter :destination, description: "Destination city name" parameter :units, type: :string, description: "metric or imperial", required: false end
# File lib/ruby_llm/tool.rb, line 145 def parameters(schema = nil, &block) if schema.nil? && block.nil? raise ArgumentError, 'parameters requires a schema or a block; declare single arguments with parameter' end @parameters_schema_definition = SchemaDefinition.new(schema:, block:) self end
Sets the JSON Schema for the tool’s arguments. Accepts a schema hash, a Schematist::Schema class or instance, or a block written in the schematist DSL. Returns self.
class Scheduler < RubyLLM::Tool description "Books a meeting" parameters do object :window, description: "Time window to reserve" do string :start, description: "ISO8601 start time" string :finish, description: "ISO8601 end time" end array :participants, of: :string end end
Raises ArgumentError when called without a schema or a block.
# File lib/ruby_llm/tool.rb, line 218 def provider_options(options = (get = true)) return @provider_options ||= {} if get raise ArgumentError, 'provider_options does not accept nil' if options.nil? @provider_options = options.to_h self end
Sets provider-specific metadata, such as Anthropic’s cache_control hints, merged verbatim into the tool payload sent to the provider. Without an argument, returns the current options.
provider_options cache_control: { type: "ephemeral" }
Raises ArgumentError if options is nil.
# File lib/ruby_llm/tool.rb, line 180 def requires_approval(&resolver) @requires_approval = true @approval_resolver = resolver end
Declares that this tool must be approved before it executes. The conversation loop pauses the tool call until a decision is recorded with Chat#approve or Chat#deny, so Chat#complete returns cleanly and can be called again once the decision exists. In Rails the decision persists on the tool call record and survives process restarts.
class IssueRefund < RubyLLM::Tool requires_approval def execute(order_id:) Refunds.issue!(order_id) end end
Pass a block to resolve the decision yourself instead of using the recorded one. The block receives the ToolCall and returns true to execute, false to deny, or nil while the decision is pending.
The block never runs at class definition. The loop consults it whenever it needs the decision, which can be several times while the call is pending, including after a crashed job resumes, so write it as an idempotent read. If it also creates the approval request, make that a find-or-create.
requires_approval { |tool_call| Approvals.status(tool_call.id) }
Source
# File lib/ruby_llm/tool.rb, line 260 def spill_to_file(characters = (get = true)) return @spill_to_file if get unless characters == false || (characters.is_a?(Integer) && characters.positive?) raise ArgumentError, 'spill_to_file takes a positive number of characters or false' end @spill_to_file = characters end
Sets how many characters this tool’s results may have before a chat with files saves them as a file and sends the model their start and the file’s path, overriding the chat’s own limit (see Chat#with_file_store). false always sends them whole, for tools that already return what they find in pages. Without an argument, returns the declared value, or nil.
class ExportOrders < RubyLLM::Tool spill_to_file 50_000 end
Source
# File lib/ruby_llm/tool.rb, line 82 def tool_name return @tool_name if @tool_name normalized = name.to_s.dup.force_encoding('UTF-8').unicode_normalize(:nfkd) ascii_name = normalized.encode('ASCII', replace: '').gsub(/[^a-zA-Z0-9_-]/, '-') derived = Support::Utils.underscore(ascii_name).delete_suffix('_tool') @tool_name = derived if name derived end
Returns the name the model calls this tool by, derived from the class name: underscored, reduced to ASCII, with a trailing “_tool” removed. Override this method to choose a different name.
WeatherLookup.tool_name # => "weather_lookup"
Public Instance Methods
# File lib/ruby_llm/tool.rb, line 420 def ask(message, url: nil, **fields) raise ArgumentError, 'url: takes the page to visit; give a form field another name' if url.is_a?(Hash) state = (Fiber[ASKING] ||= { 'input' => nil, 'asked' => 0 }) index = state['asked'] state['asked'] += 1 requests = Array(state['input']&.[]('requests')) if (data = requests[index]) && data['response'] InputRequest::Answer.new(data['response']) else asked = requests.first(index).map { |data| InputRequest.from_h(data) } asked << InputRequest.ask(index, message, url:, fields:) raise InputRequiredError.new(name, { 'requests' => asked }) end end
Asks the user something while execute runs, and returns their InputRequest::Answer. In a chat, the call pauses until Chat#answer or Chat#decline settles the question (see Chat#pending_inputs), then runs again from the start, and this ask returns the answer. Code before it runs again too, so do work after asking. Pass the fields to ask for as JSON Schema properties, with required: true on the ones the user must fill, or url: to ask them to visit a page.
def execute(question:) answer = ask(question, answer: { type: "string", title: "Answer" }) answer.accepted? ? { answer: answer[:answer] } : { error: "Nobody answered." } end
Each call may ask several questions, one after another. Outside a chat, an unanswered question raises InputRequiredError. url: is reserved for the page, so name a form field for a URL something else.
# File lib/ruby_llm/tool.rb, line 392 def call(tool_call: nil, **arguments) normalized_args = arguments.transform_keys(&:to_sym) validation_error = validate_keyword_arguments(normalized_args) return { error: "Invalid tool arguments: #{validation_error}" } if validation_error RubyLLM.logger.debug { "Tool #{name} called with: #{normalized_args.inspect}" } normalized_args[TOOL_CALL_KEYWORD] = tool_call if execute_accepts_tool_call? result = asking { execute(**normalized_args) } RubyLLM.logger.debug { "Tool #{name} returned: #{result.inspect}" } result end
Source
# File lib/ruby_llm/tool.rb, line 354 def defer @deferred = true self end
Keeps this tool, and no other of its class, out of the model’s context until tool search loads it, as ::defer does for the class. Returns self. Chat#with_tools and Chat#with_mcp take defer: to decide for every tool they register instead.
chat.with_tools(CurrentTime.new, *connector_tools.map(&:defer))
Source
# File lib/ruby_llm/tool.rb, line 361 def deferred? @deferred || self.class.deferred? end
Source
# File lib/ruby_llm/tool.rb, line 311 def description self.class.description end
Returns the tool description declared on the class with ::description.
Source
# File lib/ruby_llm/tool.rb, line 452 def execute(...) raise NotImplementedError, 'Subclasses must implement #execute' end
Runs the tool with the arguments chosen by the model. Subclasses must implement this method; the base implementation raises NotImplementedError. The return value is sent back to the model. Return a Hash like { error: "..." } to report a recoverable failure.
Declare an optional tool_call: keyword to receive the ToolCall being executed. The keyword is reserved: it never appears in the tool’s argument schema and is filled in by RubyLLM, not by the model.
def execute(query:, tool_call: nil) AuditLog.create!(tool_call_id: tool_call&.id) Search.run(query) end
Source
# File lib/ruby_llm/tool.rb, line 334 def max_run_time self.class.max_run_time end
Returns the run time declared on the class with ::max_run_time, or nil to use Configuration#max_run_time.
Source
# File lib/ruby_llm/tool.rb, line 306 def name self.class.tool_name end
Returns the name the model calls this tool by, delegating to ::tool_name. Override either one to choose a different name.
WeatherLookup.new.name # => "weather_lookup"
Source
# File lib/ruby_llm/tool.rb, line 370 def parameters_schema return @parameters_schema if defined?(@parameters_schema) @parameters_schema = begin definition = self.class.parameters_schema_definition if definition&.present? definition.json_schema elsif declared_parameters.any? SchemaDefinition.from_parameters(declared_parameters)&.json_schema else SchemaDefinition.from_parameters(inferred_parameters, allow_empty: true)&.json_schema end end end
Returns the JSON Schema for the tool’s arguments, whether declared explicitly or inferred from the execute signature.
# File lib/ruby_llm/tool.rb, line 503 def progress(message = nil, value: nil, total: nil) Support::ProgressReporter.report(Progress.new(value:, total:, message:)) nil end
Reports what the tool is doing while execute runs, so an application can show it before the result arrives. Chat#after_tool_progress callbacks receive it as a Progress. Pass value: and total: when you can count the work. Outside a chat, the report goes nowhere.
def execute(url:) progress "Downloading #{File.basename(url)}" pages = Scanner.pages(url) pages.each_with_index.map do |page, index| progress "Reading page #{index + 1} of #{pages.size}", value: index + 1, total: pages.size page.text end.join("\n") end
Source
# File lib/ruby_llm/tool.rb, line 343 def provider_options self.class.provider_options end
Returns the provider-specific tool metadata declared on the class.
# File lib/ruby_llm/tool.rb, line 318 def requires_approval?(tool_call = nil) # rubocop:disable Lint/UnusedMethodArgument self.class.requires_approval? end
Returns whether tool_call must be approved before it runs: whether this tool was declared with ::requires_approval. A subclass may override it to decide from the call’s arguments.
Source
# File lib/ruby_llm/tool.rb, line 468 def resume(task, **arguments) raise NotImplementedError, "#{self.class} returned a Task, so it must implement #resume" end
Carries on a call that paused on task, the Task execute returned, with the call’s arguments. Each Chat#complete calls it once for every paused call of the round. Return the call’s result to finish it, or a Task, task itself or a new one, to keep it paused. Tools that return a Task must implement this method; the base implementation raises NotImplementedError.
def resume(task, **) run = SubAgentRun.find(task.data["run_id"]).continue run.finished? ? run.answer : task end
Source
# File lib/ruby_llm/tool.rb, line 328 def spill_to_file self.class.spill_to_file end
Returns the limit declared on the class with ::spill_to_file: a number of characters, false, or nil to use the chat’s.
Source
# File lib/ruby_llm/tool.rb, line 479 def task(state, tool_call: nil) Task.load(state, tool_call:) end