Controlling Tool Execution
Choose tools, require approval, run calls concurrently, and observe their results
After reading this guide, you will know:
- How to control whether the model can call tools and which one.
- How to limit how many tool calls appear in one assistant response.
- How to require a human decision before a tool executes.
- How to run multiple tool calls concurrently for I/O-bound work.
- How to access the executing tool call from inside a tool.
- What happens when a model does not support function calling.
- How to observe tool calls and results with callbacks.
- How to report what a slow tool is doing while it runs.
- How to cap tool usage to prevent runaway loops.
By default RubyLLM lets the model decide when to call tools and runs them sequentially. When you need tighter control, these options let you steer tool choice, parallelism, and observability per chat.
Tool Call Controls
Use choice: to require or select a tool and calls: to limit how many calls the model returns in one response:
chat.with_tools(Weather, Calculator)
chat.with_tool_options(choice: :required, calls: :one)
Tool Choice
Use choice to control whether the model can call tools and which one it can call.
# Model decides if a tool is needed
chat.with_tools(Weather, Calculator).with_tool_options(choice: :auto)
# Model must call a tool
chat.with_tools(Weather, Calculator).with_tool_options(choice: :required)
# Disable tool calls
chat.with_tools(Weather, Calculator).with_tool_options(choice: :none)
# Force one specific tool (symbol or class)
chat.with_tools(Weather, Calculator).with_tool_options(choice: :weather)
chat.with_tools(Weather, Calculator).with_tool_options(choice: Weather)
After a required or specific tool executes, RubyLLM clears that choice so the model can answer without calling it again.
Disabling tool calls keeps the tools registered on the chat. Set choice: :auto to make them available again:
chat.with_tool_options(choice: :none)
chat.ask "Explain what you can answer without calling a tool."
chat.with_tool_options(choice: :auto)
Calls Per Response
Use calls to control how many tool calls the model may return in a single assistant response.
chat.with_tools(Weather, Calculator).with_tool_options(calls: :many)
chat.with_tools(Weather, Calculator).with_tool_options(calls: :one)
# equivalent:
chat.with_tools(Weather, Calculator).with_tool_options(calls: 1)
Without calls:, the provider’s default applies. OpenAI and Anthropic honor calls: :one; Gemini can still return several calls. Multiple calls run sequentially unless you enable concurrent execution.
Clearing Tools and Options
Call with_tools(nil) to clear the attached tool list. It leaves the options set with with_tool_options in place; pass nil options to reset choice, calls, and concurrency:
chat.with_tools(nil) # forget the tools, keep the options
chat.with_tool_options(choice: nil, calls: nil, concurrency: nil) # reset all three
Requiring Approval
Some tools should not run without a human decision: issuing refunds, deleting records, sending email. Declare them with requires_approval to pause execution until a decision is recorded:
class IssueRefund < RubyLLM::Tool
description "Issues a refund for an order"
requires_approval
def execute(order_id:)
Refunds.issue!(order_id)
end
end
chat = RubyLLM.chat.with_tools(IssueRefund)
response = chat.ask "Refund order 42"
chat.awaiting_approval? # => true, and nothing has executed
complete returns cleanly instead of running the tool. Record a decision, then continue the loop:
tool_call = response.tool_calls.values.first
chat.approve(tool_call)
chat.complete # executes the tool, then lets the model continue
approve and deny accept a ToolCall or its id, and chat.pending_approvals lists the calls still waiting for a decision. A denied call never executes: the model receives a structured result saying the user denied the call, and the conversation continues from there.
chat.deny(tool_call)
chat.complete # appends the denial result and asks the model to respond
Calls that need no approval still run while protected calls wait. ask and complete return the assistant message that holds the waiting calls, so response.tool_calls is always there to inspect. Use chat.pending_approvals to pick out the calls that still need a decision.
When the user approves a call and says “don’t ask again”, pass remember: true:
chat.approve(tool_call, remember: true)
chat.complete
Later calls to the same tool run without asking for the rest of the chat. A code execution call that asks for a wider sandbox is the exception: each one waits for its own approval, and remember: true raises ArgumentError for it. See Sandboxes.
If you drive the loop yourself, stop when the chat is waiting: chat.step until chat.complete? || chat.waiting?. waiting? is true while every pending call waits on something outside the chat, such as an approval, and awaiting_approval? tells you an approval is what it waits on.
Finish the pending calls before asking another question. Otherwise ask raises RubyLLM::PendingToolCallsError.
To read decisions from your application, pass a resolver block. It receives the ToolCall and returns true to execute, false to deny, or nil while the decision is pending:
class DeleteRecord < RubyLLM::Tool
requires_approval { |tool_call| Approvals.status(tool_call.id) }
end
The resolver can run repeatedly while a call waits, including after a job resumes. Make it an idempotent read. If it creates an approval request, use a find-or-create operation.
Remote Tool Approval
Providers can also request approval for a tool running on an MCP server. These requests appear in chat.pending_approvals with a server label. Use approve, deny, and complete as above; RubyLLM sends the decision to the provider instead of executing a Ruby tool. See MCP servers for a working example.
Approval in Rails
With acts_as_chat, decisions persist on tool-call records. One process can request approval and another can resume execution. Use chat.pending_approvals to render each call’s name and arguments in your approval UI.
The chat UI generator builds this interface for you: an approval card for each pending call, with Approve and Deny buttons that record the decision and continue the conversation in a job. To write your own, follow the same pattern.
After recording a decision, enqueue the job again. Load the chat through its agent class to restore the tools and approval rules:
class MessagesController < ApplicationController
def create
chat = Chat.find(params[:chat_id])
chat.ask_later(params[:message])
CompleteJob.perform_later(chat.id)
end
end
class ApprovalsController < ApplicationController
def create
chat = Chat.find(params[:chat_id])
approved = ActiveModel::Type::Boolean.new.cast(params[:approved])
approved ? chat.approve(params[:tool_call_id]) : chat.deny(params[:tool_call_id])
CompleteJob.perform_later(chat.id)
end
end
class CompleteJob < ApplicationJob
def perform(chat_id)
SupportAgent.find(chat_id).complete
end
end
A double-submitted approval enqueues two jobs, and that is safe: only one process runs a chat at a time, so the second job finds the chat running or the call answered and returns without running it. Approving a call again changes nothing. Denying a call that has started raises RubyLLM::ToolCallRunningError, because the tool is already doing its work:
class ApprovalsController < ApplicationController
rescue_from RubyLLM::ToolCallRunningError do
redirect_back fallback_location: root_path, alert: "That action is already running."
end
end
A tool call record’s running? is true while a process runs it, so a message’s approval cards can show progress:
<% message.ruby_llm_tool_calls.each do |tool_call| %>
<%= tool_call.name %>: <%= tool_call.running? ? "Running…" : tool_call.approval %>
<% end %>
Write approval-gated tools so running them twice is safe. A tool execution can die after its side effect succeeds but before the result is persisted, and the call runs again once its claim expires. See Durable Agents.
Durable Agents covers jobs, restarts, and cancellation. A call approved with remember: true is recorded as remembered on its tool call record, so later calls to that tool run without asking in every process.
Concurrent Tool Execution
When a model returns multiple tool calls in one response, RubyLLM executes them sequentially by default. For I/O-bound tools, opt in to concurrent execution:
chat.with_tools(Weather, StockPrice, Currency).with_tool_options(concurrency: true)
concurrency: true uses Ruby threads and requires no extra dependencies. You can also choose a mode explicitly:
chat.with_tools(Weather, StockPrice, Currency).with_tool_options(concurrency: :threads)
chat.with_tools(Weather, StockPrice, Currency).with_tool_options(concurrency: :fibers)
The :fibers mode uses the optional async gem:
gem "async", ">= 2.0"
Enable concurrent tool execution globally:
RubyLLM.configure do |config|
config.tool_concurrency = true
end
Use :threads, :fibers, true, or false.
Override it per chat when needed:
chat.with_tool_options(concurrency: false)
Rails chat records use the same settings.
With concurrency enabled, tool results are added back to the conversation as each tool finishes. RubyLLM waits for all tool results before asking the model for the next response.
In Rails, a tool call on another thread, or on another fiber when config.active_support.isolation_level is :fiber, runs as its own unit of work, as Rails expects of any work on another thread. It checks out its own database connection and doesn’t see the caller’s Current attributes. Give the tool what it needs when you create it:
class SearchDocuments < RubyLLM::Tool
description "Searches the account's documents"
parameter :query, description: "Search query"
def initialize(account:)
@account = account
end
def execute(query:)
@account.documents.search(query).map(&:title)
end
end
chat.with_tools(SearchDocuments.new(account: Current.account)).with_tool_options(concurrency: :fibers)
Accessing the Current Tool Call
A tool sometimes needs to know which invocation triggered it, for example to attribute an audit log entry or an outbound API call to the exact tool call. Declare an optional tool_call: keyword on execute and RubyLLM fills it with the executing ToolCall:
class SearchTool < RubyLLM::Tool
description "Searches the knowledge base"
parameter :query, description: "Search query"
def execute(query:, tool_call: nil)
AuditLog.create!(tool_call_id: tool_call&.id, tool_name: tool_call&.name)
KnowledgeBase.search(query)
end
end
tool_call: is reserved for RubyLLM and is not shown to the model. It also works during concurrent execution.
tool_call.id identifies one invocation. Use it to match callbacks, approvals and results. It stays the same when you reload a persisted chat or resume a pending call. If a provider reuses an ID in a later response, RubyLLM gives the new invocation its own ID and approval decision automatically.
RubyLLM stores the provider’s original identifier separately as provider_call_id when serializing or persisting the call. It uses that identifier when sending the result back to the provider.
Model Compatibility
RubyLLM will attempt to use tools with any model. If the model doesn’t support function calling, the provider will return an appropriate error when you call ask.
Monitoring Tool Calls with Callbacks
You can monitor tool execution using additive callbacks to track when tools are called and what they return.
chat = RubyLLM.chat(model: 'gpt-6.1-sol')
.with_tools(Weather)
.before_tool_call do |tool_call|
puts "Calling tool: #{tool_call.name}"
puts "Arguments: #{tool_call.arguments}"
end
.after_tool_result do |result|
puts "Tool returned: #{result}"
end
response = chat.ask "What's the weather in Paris?"
# Output:
# Calling tool: weather
# Arguments: {"latitude": "48.8566", "longitude": "2.3522"}
# Tool returned: {"temperature": 15, "conditions": "Partly cloudy"}
Reporting Progress
A tool that downloads a large file, reads a scanned document, or pages through search results can take a while. Call progress from execute to say what it is doing:
class ReadReport < RubyLLM::Tool
description "Reads a scanned report"
parameter :url, description: "Report URL"
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
end
after_tool_progress receives the tool call and a RubyLLM::Progress for each report:
chat.with_tools(ReadReport).after_tool_progress do |tool_call, progress|
puts "#{tool_call.name}: #{progress.message}"
end
progress.value and progress.total are set when the tool counts its work, and progress.fraction gives the share done. Tools from MCP servers report the server’s progress through the same callback.
The callback runs in the thread or fiber that reports, before the tool’s result. That includes threads and fibers the tool starts itself, such as a batch of downloads. With concurrent tool execution, callbacks for different tool calls can run at the same time, so keep shared state thread-safe. Tools can report as often as they like; throttle in the callback if you forward reports to a UI. Outside a chat, progress does nothing.
Limiting Tool Calls
To cap a runaway loop, give the loop a step budget instead of raising inside a callback:
chat = RubyLLM.chat(model: 'gpt-6.1-sol')
.with_tools(Weather)
.ask_later("Check weather for every major city...")
10.times do
chat.step
break if chat.complete?
end
When the budget runs out the transcript is still valid, and calling step again resumes where it stopped. See Driving the Loop Yourself.
Next Steps
- Tool Parameters - Declare flat arguments, structured schemas, and provider-specific metadata.
- Tools - The execution flow, error handling, and security overview.
- Agentic Workflows - Drive the tool loop yourself for full control.
- Chat Event Handlers - Other lifecycle callbacks on a chat.