Persistence with acts_as
Persist application-owned chats and messages while RubyLLM manages its supporting records.
After reading this guide, you will know:
- How to add RubyLLM to your
ChatandMessagemodels. - Which records belong to your application and which RubyLLM manages internally.
- How persisted usage, tool calls, models, and batches behave.
- How to use custom or namespaced chat and message classes.
Two Application Models
Your application owns the conversation: chats and messages. Those are the records where application concerns such as users, permissions, titles, content, retention, and attachments belong.
# app/models/chat.rb
class Chat < ApplicationRecord
acts_as_chat
belongs_to :user, optional: true
end
# app/models/message.rb
class Message < ApplicationRecord
acts_as_message
validates :role, presence: true
end
That is the complete application-facing persistence setup. There is no Model, ToolCall, UsageEntry, or Batch model to add to app/models.
RubyLLM owns six supporting tables under the ruby_llm_ prefix:
ruby_llm_modelsfor the model registry.ruby_llm_tool_callsfor provider tool requests and their result links.ruby_llm_usagesfor retry- and cancellation-safe cost and usage tracking.ruby_llm_batchesfor provider-side batch state.ruby_llm_mcp_credentialsfor encrypted MCP OAuth credentials.ruby_llm_provider_filesfor the provider uploads of stored attachments.
They are regular tables installed by migrations, not a Rails engine. Their record classes are implementation details; use the public RubyLLM APIs described below.
Their shared abstract base, RubyLLM::ActiveRecord::Record, inherits the default database connection. If your chats and messages use a secondary database, configure the shared connection so all related tables stay together.
Do not validate message content as always present. Streaming creates an empty assistant message before content arrives, and a valid assistant message can contain tool calls without text.
Working with Persisted Chats
An acts_as_chat record exposes the regular chat API and persists transcript changes automatically:
chat = Chat.create!(model: 'gpt-6.1-sol', user: current_user)
response = chat.ask("What is the capital of France?")
response.content # => "The capital of France is Paris."
chat.messages.last.content
chat.messages.count # => 2
The flow saves the user message, creates an assistant message, performs the provider operation, and fills the assistant message on success. If the operation fails before producing a useful message, RubyLLM removes the empty assistant record. Usage already incurred by a retry or cancellation remains in the usage ledger.
Use add_message to append an existing message without making a provider request. It accepts an attributes Hash, a RubyLLM::Message, or a persisted message record and returns a new record in this chat:
chat.add_message(role: :user, content: "Keep this context for the next request.")
chat.add_message(previous_chat.messages.first)
The original record remains in its conversation. Copying transcript content does not create a new provider usage entry.
System instructions are messages too:
chat.with_instructions("You are a concise Ruby expert.")
chat.with_instructions("Use short bullet points.", append: true)
chat.messages.where(role: :system)
Runtime-only settings such as fallbacks, tools, temperature, and callbacks live on the memoized to_llm chat. Reapply them after loading a fresh record, or define them in an Agent.
chat.with_fallbacks("gpt-4.1-mini", "claude-haiku-4-5")
chat.with_temperature(0.2)
chat.ask("Summarize this conversation.")
Model Storage
The chat belongs to a model record in RubyLLM’s internal registry. The familiar readers still expose the provider-facing identity:
chat = Chat.create!(model: 'gpt-6.1-sol')
chat.model_id # => "gpt-6.1-sol"
chat.provider # => "openai"
chat.model # => #<RubyLLM::ActiveRecord::Model ...>
chat.model.context_window
The registry itself is persisted internally. Query and refresh it through RubyLLM.models:
RubyLLM.models.find('gpt-6.1-sol', provider: :openai)
RubyLLM.models.by_provider(:anthropic)
RubyLLM.models.refresh
There is no application-owned Model class or configurable registry model class. acts_as_chat defines the model association against RubyLLM’s private record, preserving referential integrity without putting provider metadata in your application models.
On a message, model returns the ID from the last successful provider attempt. model_info looks up that ID in the registry:
response.model # => "gpt-6.1-sol"
response.model_info.name
Both readers return nil when the message has no recorded model. model_info also returns nil if the model is no longer in the registry.
If your message table has a finish_reason column, the record also exposes stopped?, max_tokens?, tool_call_stop?, and content_filtered?, with the same meanings as a plain message:
response.reload.max_tokens? # => true if a token limit stopped the response
Cost and Usage Tracking
Usage belongs to provider work, not to the transcript. RubyLLM therefore persists one normalized entry for every physical attempt, including automatic retries and cancelled streams that never produced a message.
response = chat.ask("Explain Ruby fibers.")
response.tokens.input # aggregate of the attempts that produced it
response.tokens.output
response.cost.total
chat.tokens.input # includes retries and cancelled attempts
chat.cost.total # includes retries and cancelled attempts
An entry links to the message it produced when there is one. A failed retry or a cancelled generation links only to the chat. Destroying a chat or a message keeps its entries and clears the link, so spend you sum from the ledger stays the same. acts_as_chat and acts_as_message add a ruby_llm_usages association over those rows, with provider, model, status, the token buckets, and total_cost, so you can sum spend in SQL. Each attempt also fires a usage.ruby_llm event. chat.tokens and chat.cost remain the value objects.
Token buckets and cost components are numeric columns in ruby_llm_usages; cost details are not stored as JSON. This freezes the price calculated when an attempt finishes and keeps the ledger suitable for database aggregation. The 2.0 runtime reads this ledger only; the 2.0 upgrade moves 1.16 message tokens into it before removing the old columns.
Attribute each chat attempt to a record with RubyLLM.with_usage_owner. Its usage rows keep both the chat and owner associations. One-shot operations write to the same ledger, with no chat, and also accept owner:. Query an owner’s spend through its own association:
RubyLLM.with_usage_owner(current_user) do
chat.ask("Summarize the latest reviews.")
RubyLLM.transcribe("memo.m4a")
RubyLLM.paint("a paper boat in the rain", owner: current_user.account)
end
class User < ApplicationRecord
has_many :ruby_llm_usages, as: :owner, class_name: "RubyLLM::ActiveRecord::Usage", dependent: :nullify
end
current_user.ruby_llm_usages.sum(:total_cost)
See Cost and Usage Tracking for owner rules, entry statuses, incomplete totals, cancellation behavior, and instrumentation.
Tools
Tools remain ordinary Ruby classes:
class Weather < RubyLLM::Tool
description "Gets current weather for a city"
parameter :city
def execute(city:)
"Sunny in #{city}"
end
end
chat.with_tools(Weather)
chat.ask("What is the weather in Berlin?")
RubyLLM persists provider tool requests internally and exposes public RubyLLM::ToolCall values from the message:
message = chat.messages.find(&:tool_call?)
tool_call = message.tool_calls.values.first
tool_call.id
tool_call.name
tool_call.arguments
message.tool_results
The tool result itself is an application-owned message with the tool role. The internal tool-call row only preserves the provider request and links it to that result message.
Batches
Submitting persisted chats automatically stores the provider batch state internally:
chats = tickets.map do |ticket|
Chat.create!(model: "claude-haiku-4-5").ask_later(ticket.body)
end
batch = RubyLLM.batch(chats)
BatchPollJob.perform_later(batch.id)
Look the batch up with the public value API from another process:
batch = RubyLLM::Batch.find(batch_id)
batch.refresh
if batch.complete?
batch.messages # appends each result to its persisted chat
end
No application Batch model is required. See Batch Processing for polling and multi-round tool workflows.
Attachments and Structured Output
acts_as_message declares has_many_attached :attachments when Active Storage is loaded. Files passed to ask are persisted with the user message. In a controller action, check that an upload parameter is a file before passing it to ask:
uploaded_file = params[:uploaded_file]
return head :bad_request unless uploaded_file.is_a?(ActionDispatch::Http::UploadedFile)
chat.ask("What is in this file?", with: uploaded_file)
An upload field can arrive as a String, Array, or Hash. Strong parameters permit Strings, so permitting the field does not prove that it contains a file. Validate every entry when accepting multiple uploads. See Attachment Security for how RubyLLM handles file paths and URLs.
You can also pass stored attachments that the current user is authorized to access:
chat.ask("Compare these", with: project.documents)
Stored files stay in Active Storage until a request needs their bytes. Loading a chat, checking awaiting_approval?, or listing pending_approvals downloads nothing, and each file downloads once, when a request first sends it. RubyLLM takes the file’s type from the blob’s content type.
Files too large to send inline are uploaded to the provider, and RubyLLM records each upload of a blob in ruby_llm_provider_files. A chat loaded later, in any process, sends the provider’s copy instead of downloading and uploading the file again. The first time a process reuses a copy, RubyLLM asks the provider whether it still has it, and uploads the file again when it is missing or expired. Uploads belong to the account that made them, so a chat that switches to another API key, project, or storage bucket uploads its own copy. Purging a blob deletes its records.
Structured output is stored as JSON text in the application message content:
class PersonSchema < Schematist::Schema
string :name
integer :age
end
response = chat.with_schema(PersonSchema).ask("Generate a person")
response.parsed # => {"name" => "Ada", "age" => 36}
response.content # => '{"name":"Ada","age":36}', what the row stores
chat.messages.last.parsed # the same Hash, read back from the record
Separate User and LLM Transcripts
The association passed to acts_as_chat is the transcript RubyLLM persists and sends to providers. Point it at a separate association when your user-visible transcript has different retention, compaction, moderation, or redaction rules:
class Conversation < ApplicationRecord
has_many :messages
acts_as_chat messages: :llm_messages,
message_class: "LlmMessage"
end
Your UI can render conversation.messages while RubyLLM works with conversation.llm_messages.
Custom and Namespaced Models
Only the chat/message association needs mapping:
class Conversation < ApplicationRecord
acts_as_chat messages: :chat_messages,
message_class: "ChatMessage"
end
class ChatMessage < ApplicationRecord
acts_as_message chat: :conversation,
chat_class: "Conversation"
end
Specify the foreign key when Rails cannot infer it:
module Support
class Conversation < ApplicationRecord
acts_as_chat messages: :entries,
message_class: "Support::Entry",
messages_foreign_key: :conversation_id
end
class Entry < ApplicationRecord
acts_as_message chat: :conversation,
chat_class: "Support::Conversation",
chat_foreign_key: :conversation_id
end
end
Internal usage and tool-call references are polymorphic, so custom names and primary-key types do not require matching application models for RubyLLM’s supporting records.
Extending Your Models
Chats and messages remain normal ActiveRecord models:
class Chat < ApplicationRecord
acts_as_chat
belongs_to :user
has_many :favorites, dependent: :destroy
scope :recent, -> { order(updated_at: :desc) }
def summary
messages.last(2).map(&:content).join(" … ")
end
end
Keep transcript content and application relationships in these models. Observe provider cost and usage through usage.ruby_llm instrumentation instead of copying RubyLLM’s internal supporting models into your domain.
Next Steps
- Cost and Usage Tracking - understand attempts, retries, cancellations, and incomplete totals.
- Streaming with Hotwire/Turbo - broadcast persisted responses in real time.
- Generators and App Conventions - see exactly what installation creates.
- Model Registry - inspect and refresh the internal registry through the public API.