Upgrade to 2.2
Move a RubyLLM 2.1 application to 2.2, one release at a time.
After reading this guide, you will know:
- How RubyLLM upgrades move from one release to the next.
- How to upgrade a 2.1 application and its Rails schema to 2.2.
- How persisted chats now run in one process at a time.
- What changes for a crashed job, a denial, pending approvals, an MCP input error, a document URL, and a deploy that mixes versions.
This guide covers 2.1 to 2.2. Coming from 2.0? Follow the 2.1 upgrade guide with RubyLLM 2.1 first.
One Release at a Time
Each release ships the upgrade steps for the changes since the previous release. When a release changes the Rails schema, bin/rails generate ruby_llm:upgrade generates the migrations that take your database from the previous release to the current one. The next release replaces that generator with its own.
To move across several releases, upgrade to each one in turn. Deploy it, run its upgrade, and resolve its deprecation warnings before you continue. Each release’s upgrade guide stays in the repository at that release’s tag.
Finish the 2.1 Upgrade
2.2 does not include the 2.1 upgrade generator. Run it while your application still runs 2.1, in every environment, and keep the migration it generated in db/migrate.
Update the Gem
Require 2.2 in your Gemfile, so the update stops at this release:
gem "ruby_llm", "~> 2.2.0"
Then update it in your development branch:
bundle update ruby_llm
Plain Ruby applications need no changes. Rails applications upgrade their schema and read the changes to persisted chats below.
Upgrade the Rails Schema
Rails applications generate and run the 2.2 upgrade:
bin/rails generate ruby_llm:upgrade
bin/rails db:migrate
If your chat model is not Chat, name it the way you did for the install generator, for example bin/rails generate ruby_llm:upgrade chat:Conversation.
It adds claim_token and claimed_until columns to your chats table and a claimed_until column to ruby_llm_tool_calls. They record which process runs a chat and its tool calls. It also adds a nullable provider_call_id column to tool calls so RubyLLM can distinguish invocations when a provider reuses an ID, a nullable phase column to your messages table, which keeps the label OpenAI’s Responses API gives each answer so later requests send it back, a nullable continuation text column to your messages table, which keeps the token an unfinished long response resumes from, and creates the ruby_llm_queued_messages table, which holds queued messages until a chat takes them, and the ruby_llm_chat_files table, which holds the files of chats that use with_file_store. It adds nullable workflow_id, workflow_name, workflow_step_id, and workflow_step_name columns to ruby_llm_usages, which record the workflow each attempt ran in (see Usage Rows Record Their Workflow). The migration changes no existing data. The generator also creates any of RubyLLM’s conventional directories your application lacks, such as app/skills and app/sandboxes, and an app/agents/application_agent.rb that points at your chat model, unless you already have one. New agents from ruby_llm:agent inherit from it.
The generator also removes the broadcasting code and the has_many_attached :attachments line that ruby_llm:chat_ui wrote into your message model, because acts_as_message now provides both, and turns on config.broadcast_messages when it removes that broadcasting code. It installs Active Storage if your application lacks it. Broadcasting code you wrote yourself stays untouched.
A chat UI generated by 2.1 still uses that broadcasting code: its job calls broadcast_append_chunk and its page subscribes to the stream the model names. While your application has either, the generator leaves the message model and config.broadcast_messages alone, and the chat UI keeps working as it did. Rerun ruby_llm:chat_ui to move the job, the views and the model to RubyLLM’s broadcasting together. The 2.2 chat UI also queues messages sent while a reply runs instead of refusing them.
Run the migration before deploying 2.2.
The upgrade also adds a nullable namespace column to ruby_llm_tool_calls, which keeps the tool namespace of each call, such as a member of a native client toolset. Existing calls have no namespace. Remembered approvals apply to tools with the same namespace and name.
If you already ran an unreleased 2.2 upgrade without this column, add it in a new migration before updating:
add_column :ruby_llm_tool_calls, :namespace, :string
If you already ran an unreleased 2.2 migration that created provider_id on ruby_llm_tool_calls, rename that column in a new migration before updating:
rename_column :ruby_llm_tool_calls, :provider_id, :provider_call_id
Tool Call IDs Belong to RubyLLM
New tool calls receive a RubyLLM ID rather than exposing the provider’s ID. Continue using tool_call.id for callbacks, approvals and results. Do not compare it with an ID in a raw provider response. Existing persisted calls keep their IDs, and newly persisted calls keep theirs across reloads and job retries.
Restart every worker on 2.2 before resuming these conversations. A 2.1 worker does not read the saved provider ID.
Each new invocation has its own ID, even when a provider repeats an earlier ID or requests the same arguments again. A one-time approval applies only to that invocation. remember: true continues to approve later calls to the same tool.
For chats with tool calls saved before 2.2, batch redelivery checks still rely on those older provider IDs. Start a new chat for new batches if your provider reuses them.
Messages Can Broadcast With Turbo
An application that broadcasts its messages itself needs no change: RubyLLM’s broadcasting is off unless you turn it on.
RubyLLM.configure do |config|
config.broadcast_messages = true
end
With it on, and turbo-rails and Action Cable loaded, acts_as_message broadcasts a chat’s messages to the chat record’s stream, turbo_stream_from @chat. It appends an empty assistant reply when it is created, replaces a message when it is saved, removes it when it is destroyed, and refreshes the chat’s page after every change and at the end of each run. Streamed chunks know their record, so a job streams with chat.complete(&:broadcast). See Streaming with Hotwire/Turbo.
Broadcasting renders each message with its partial, chosen by to_partial_path, so an application that turns it on needs messages/_user, messages/_assistant, messages/_tool, and messages/_tool_calls. A model’s broadcasts: option overrides the configuration.
Usage Rows Record Their Workflow
Usage rows written inside RubyLLM.workflow store the workflow and step, so you can total the cost of a piece of work, including guardrails, embeddings, and other calls outside its chat:
RubyLLM.workflow("Support reply", id: "chat-#{chat.id}") do |workflow|
workflow.step("Retrieve") { RubyLLM.embed(question) }
workflow.step("Answer") { chat.ask(question) }
end
RubyLLM::ActiveRecord::Usage.where(workflow_id: "chat-#{chat.id}").sum(:total_cost)
Rows written before the upgrade have no workflow. Until you run the migration, RubyLLM writes rows without one. See Usage by Workflow.
Messages Declare Their Attachments
acts_as_message now declares has_many_attached :attachments, so you can remove that line from your message model. Without Active Storage, the model boots without attachments and RubyLLM logs how to add them; storing an attachment then raises RubyLLM::ConfigurationError. To add Active Storage, run bin/rails active_storage:install and bin/rails db:migrate.
The Default Model Is GPT-6.1 Sol
RubyLLM.chat without a model, and a persisted chat created without one, now use gpt-6.1-sol instead of gpt-5.6. To keep the previous model, set it in your configuration:
RubyLLM.configure do |config|
config.default_model = "gpt-5.6"
end
One Process Runs a Chat
In 2.1, two processes that completed the same persisted chat at the same time could both run an approved tool call and both ask the model for a reply, for example when a double-submitted approval enqueued two jobs. In 2.2, complete, step, generate, run_tools, and compact claim the chat before they call the model or run a tool, and each tool call is claimed before it runs. A process that finds the chat claimed returns without doing anything:
completereturns the latest message, andstep,generate, andcompactreturnnil.waiting?istrue, so loops that stop oncomplete? || waiting?stop.chat.running?and a tool call record’srunning?aretrue.
The process that holds the claim continues with approvals and questions recorded while it ran. See One Process at a Time.
Crashed Runs Resume After Their Claim Expires
In 2.1, complete ran the unfinished tool calls of a crashed job right away. In 2.2, a crashed job keeps its claims for config.max_run_time, ten minutes by default, and complete in another process returns until they expire. A job retried within that time finds the chat running? and does nothing. Check running? after complete and enqueue the job again later, as Resuming a Crashed Run shows.
Give tools that legitimately run longer than ten minutes their own limit with max_run_time, and raise config.max_run_time above your slowest model request without streaming. A run that outlasts its claim counts as crashed and may run again.
Rescue Denials of Running Calls
deny on a persisted tool call that has started running now raises RubyLLM::ToolCallRunningError; in 2.1 it recorded a denial the running tool never saw. approve on a running call and either decision on an answered call change nothing, where 2.1 overwrote the recorded decision. Rescue the error where you record denials:
rescue_from RubyLLM::ToolCallRunningError do
redirect_back fallback_location: root_path, alert: "That action is already running."
end
Pending Approvals Read the Database
In 2.1, pending_approvals on a persisted chat built the chat’s tools to find the calls that need a decision, which connected to every MCP server the chat uses, starting stdio servers in the web process. In 2.2, the process that saves a call records whether it waits for approval on the call’s record, and pending_approvals reads only the database. The schema does not change, but the approval column of ruby_llm_tool_calls now holds pending for a call that waits for a decision, where 2.1 left it empty. If your code reads that column and treats an empty value as undecided, treat pending the same way:
RubyLLM::ActiveRecord::ToolCall.where(approval: [nil, "pending"], result_id: nil)
Calls that were already waiting for approval when you deploy have no such record, so pending_approvals leaves them out. Load the chat through its agent class, so its tools and MCP servers are registered, list those calls the 2.1 way, which still connects to the chat’s servers, and decide them by ID:
chat = ChatAgent.find(chat_id)
chat.to_llm.pending_approvals.map(&:id) # => ["call_1a2b3c"]
chat.approve("call_1a2b3c")
Rescue Input Errors as Input Errors
Tools can now ask the user something, the way MCP servers do, so RubyLLM::MCP::InputRequiredError now inherits from the new RubyLLM::InputRequiredError instead of RubyLLM::MCP::Error. A rescue RubyLLM::MCP::Error no longer catches it. Rescue the general error, which also catches a tool’s own unanswered questions:
begin
tool.call(**arguments)
rescue RubyLLM::InputRequiredError => e
e.requests.each { |request| puts request.message }
end
RubyLLM::MCP::InputRequest is now RubyLLM::InputRequest, the same class under its old name.
Providers Fetch Document URLs
In 2.1, RubyLLM downloaded every URL attachment and sent its bytes. In 2.2, PDF and Office document URLs sent through the Responses API on OpenAI, Azure, xAI, OpenRouter, and Meta go to the provider as URLs, and the provider downloads them. A URL that only your servers can reach, such as one on a private network or behind your application’s login, worked in 2.1 and fails in 2.2. Attach the file as a local path, an IO, or an Active Storage attachment instead, so RubyLLM sends its bytes. See Letting the Provider Fetch Document URLs.
Responses Keep No Request Headers
In 2.1, message.raw and error.response still carried the request headers, including the API key, and error.response also carried the request body, the whole serialized conversation. In 2.2 they keep neither, so logging or reporting them no longer leaks keys or prompts. To see what a chat sends, use render or a before_request hook, described in Advanced Request Control.
Rename Chat Columns Named After New Methods
acts_as_chat records gain code_execution, skills, file_store, with_code_execution, with_skills, with_file_store, queue, queued_messages, unqueue, after_queue, after_dequeue, after_unqueue, after_file_write, and the ruby_llm_chat_files association in 2.2. A column of your own with one of those names is hidden behind RubyLLM’s method, so reading it returns RubyLLM’s setting, usually nil, instead of your value. Rename such a column before you upgrade, for example a code_execution toggle to runs_code:
rename_column :chats, :code_execution, :runs_code
Rename Protocol Method Overrides
Applications need no change. These are internal provider and protocol methods, but a provider gem or a custom provider that overrides one must rename its override, because RubyLLM no longer calls the old name. In 2.2 every method that builds part of a request starts with render_, every method that reads part of a response starts with parse_, and the two capability predicates drop supports_:
# 2.1
def format_role(role)
role.to_s
end
def extract_thinking_text(message_data)
message_data["reasoning"]
end
def supports_provider_file_references?
false
end
# 2.2
def render_role(role)
role.to_s
end
def parse_thinking_text(message_data)
message_data["reasoning"]
end
def provider_file_references?
false
end
format_*is nowrender_*. The ones providers override most areformat_role,format_content,format_message_content,format_image,format_document,format_video,format_thinking,format_tool_calls, andformat_provider_file.extract_*is nowparse_*, for exampleextract_usage,extract_thinking_text,extract_thinking_signature,extract_raw_reasoning, andextract_server_tool_use.supports_embedding_media?is nowembedding_media?, andsupports_provider_file_references?is nowprovider_file_references?.
Calls to the shared modules change the same way, so Protocols::ChatCompletions::Media.format_content becomes Protocols::ChatCompletions::Media.render_content.
A few methods keep their names: format_cache_option_keys, format_for, extract_capabilities, extract_citations in Chat Completions, and extract_tool_calls in the Anthropic, Cohere, and Converse streams.
Deploy Without Mixing Versions
Workers on 2.1 do not claim chats, so they can still overlap with workers on 2.2 while a deploy rolls out. Run the schema upgrade first, then let the new version replace every worker before you rely on claims. Chats that run once all workers are on 2.2 are covered.
Run your tests and deploy.
Older Upgrade Guides
Use the 2.1 upgrade guide, the 2.0 upgrade guide, or the 1.16 upgrade guide for older releases. See GitHub releases for the full changelog.