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
has_many_attached :attachments
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 four 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.
They are regular tables installed by migrations, not a Rails engine. Their record classes are implementation details; use the public RubyLLM APIs described below.
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-5.6-luna', 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-5.6')
chat.model_id # => "gpt-5.6"
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-5.6', 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-5.6"
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. 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 upgrade generator moves 1.16 message tokens into it before removing the old columns.
See Cost and Usage Tracking for 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
With Active Storage on your message model, files passed to ask are persisted with the user message:
chat.ask("What is in this file?", with: params[:uploaded_file])
chat.ask("Compare these", with: project.documents)
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.