Persistence with acts_as

Persist chats, messages, tool calls, and model metadata with ActiveRecord using the acts_as helpers.

Table of contents

  1. Setting Up Models with acts_as Helpers
    1. With Model Registry (Default for new apps)
  2. Working with Chats
    1. Basic Chat Operations
    2. Model Fallbacks
    3. Separate User and LLM Transcripts
    4. Token Usage and Costs
      1. Freezing Costs at Completion
    5. Database Model Registry
    6. System Instructions
    7. Using Tools
    8. File Attachments
    9. Structured Output
  3. Customizing Models
    1. Using Custom Model Names
      1. With Model Registry
      2. Namespaced Models Example
    2. Common Customizations
  4. Advanced Topics
    1. Handling Edge Cases
      1. Automatic Cleanup
      2. Provider Content Restrictions
    2. Customizing the Persistence Flow
  5. Next Steps

After reading this guide, you will know:

  • How to add RubyLLM capabilities to your models with the acts_as helpers.
  • How to ask questions, track tokens and costs, and add system instructions on persisted chats.
  • How to use tools, file attachments, and structured output with ActiveRecord-backed chats.
  • How to customize model and association names, including for namespaced models.
  • How to override the persistence flow when you need content validations.

The acts_as helpers turn ordinary ActiveRecord models into RubyLLM chats and messages. Once a model acts_as_chat, it gains the full chat API - ask, with_tools, with_schema, and the rest - while persisting every message to your database automatically. Start by declaring the helpers, then everything else in this guide builds on those four models.

Setting Up Models with acts_as Helpers

Add RubyLLM capabilities to your models:

With Model Registry (Default for new apps)

# app/models/chat.rb
class Chat < ApplicationRecord
  # New API style - uses Rails association names as primary parameters
  acts_as_chat # Defaults: messages: :messages, model: :model

  # Or with custom associations:
  # acts_as_chat messages: :chat_messages,
  #              model: :ai_model

  belongs_to :user, optional: true
end

# app/models/message.rb
class Message < ApplicationRecord
  # New API style - uses Rails association names
  acts_as_message # Defaults: chat: :chat, tool_calls: :tool_calls, model: :model

  # Or with custom associations:
  # acts_as_message chat: :conversation,
  #                 tool_calls: :function_calls

  # Note: Do NOT add "validates :content, presence: true"
  validates :role, presence: true
  validates :chat, presence: true
end

# app/models/tool_call.rb
class ToolCall < ApplicationRecord
  acts_as_tool_call # Defaults: message: :message, result: :result
end

# app/models/model.rb
class Model < ApplicationRecord
  acts_as_model # Defaults: chats: :chats
end

Working with Chats

Basic Chat Operations

The acts_as_chat helper provides all standard chat methods:

chat_record = Chat.create!(model: 'gpt-5-nano', user: current_user)

# Ask a question - the persistence flow runs automatically
begin
  # This saves the user message, then calls complete() which:
  # 1. Creates an empty assistant message
  # 2. Makes the API call
  # 3. Updates the message on success, or destroys it on failure
  response = chat_record.ask "What is the capital of France?"

  assistant_message_record = chat_record.messages.last
  puts assistant_message_record.content # => "The capital of France is Paris."
rescue RubyLLM::Error => e
  puts "API Call Failed: #{e.message}"
  # The empty assistant message is automatically cleaned up on failure
end

chat_record.ask "Tell me more about that city"

puts "Conversation length: #{chat_record.messages.count}" # => 4

Model Fallbacks

Persisted chats expose the same fallback API as plain RubyLLM::Chat:

chat_record = Chat.find(params[:id])

chat_record.with_fallbacks("gpt-4.1-mini", "claude-haiku-4-5")
chat_record.before_fallback do |fallback|
  Rails.logger.info "Falling back from #{fallback.from.id} to #{fallback.to.id}"
end

chat_record.ask("Summarize this support conversation.")

Fallback configuration is applied to the in-memory to_llm chat for that record. It is not stored in the chats table, so reapply it when you load the record again, or use an Agent to centralize fallback configuration.

When a fallback response is persisted, RubyLLM associates that assistant message with the fallback model. During streaming, if the primary model already produced partial content, the fallback response starts a new assistant message row rather than overwriting the partial primary row.

Separate User and LLM Transcripts

The acts_as_chat association is the transcript RubyLLM persists and sends to the provider. The optional ruby_llm:chat_ui generator renders that same association by default.

If your app needs separate user-visible and model-visible transcripts, point acts_as_chat at the model-visible association. This is useful for chat compaction, moderation, redaction, or any workflow where the LLM should see a different transcript from the user. In this setup, messages is whatever you show the user, while llm_messages is whatever you show the LLM:

For plain Ruby chats, see Replacing the LLM Transcript.

class Conversation < ApplicationRecord
  has_many :messages

  acts_as_chat messages: :llm_messages,
               message_class: "LlmMessage"
end

Your custom UI renders conversation.messages. RubyLLM persists and sends conversation.llm_messages:

conversation.llm_messages.destroy_all
conversation.llm_messages.create!(role: :system, content: summary)
conversation.llm_messages.create!(role: :user, content: latest_user_message)

conversation.complete

Token Usage and Costs

Persisted chats and messages expose the same normalized token and cost helpers as regular RubyLLM objects:

message = chat_record.messages.last

message.tokens.input       # Standard input tokens
message.tokens.output      # Billable output tokens
message.tokens.cache_read  # Prompt cache reads
message.tokens.cache_write # Prompt cache writes
message.finish_reason      # Provider-reported reason the model stopped

message.cost.total
message.cost.thinking # When the model has distinct reasoning-token pricing
chat_record.cost.total

The cache_read_tokens and cache_write_tokens helpers read the columns of the same names. The v2.0 upgrade generator renames the v1.9 cached_tokens and cache_creation_tokens columns for you.

RubyLLM normalizes provider-specific cache accounting before persisting token counts. See Token Usage and Cost for the provider comparison table.

Freezing Costs at Completion

New installs add two optional message columns, and the v2.0 upgrade generator adds them to existing apps:

  • total_cost, a decimal(16, 10) holding the response cost in USD.
  • cost_details, a JSON column holding the per-component breakdown (input, output, cache read, cache write, thinking, total).

When these columns exist, RubyLLM records each assistant message’s cost at completion using the pricing in effect at that moment. message.cost then returns the recorded cost instead of recomputing it, so a later RubyLLM.models.refresh! that changes registry pricing does not rewrite historical costs. chat_record.cost sums the recorded per-message costs.

message.total_cost   # => 0.00234 (numeric column, SQL-aggregatable)
message.cost_details # => {"input" => 0.0012, "output" => 0.00114, "total" => 0.00234}
message.cost.total   # => 0.00234 (frozen at completion, not recomputed)

chat_record.messages.sum(:total_cost) # aggregate in SQL

Because total_cost is a plain numeric column, you can aggregate spend directly in the database:

Chat.joins(:messages).where(messages: { role: 'assistant' }).sum('messages.total_cost')

If the columns are absent, message.cost computes live from the associated model record’s current pricing, as it always has. Freezing history means stale registry pricing only affects new messages, so refresh the registry on a schedule. See Model Costs for the recommended refresh job.

finish_reason is persisted when your message table has a string column with that name. New installs include it, and the upgrade generator adds it for existing apps.

Database Model Registry

When using the Model registry (created by default by the generator), your chats and messages get associations to model records:

# String automatically resolves to Model record
chat = Chat.create!(model: 'gpt-5.4')
chat.model # => #<Model model_id: "gpt-5.4", provider: "openai">
chat.model.name # => "GPT-5.4"
chat.model.context_window # => 1050000
chat.model.supports_vision # => true

# Populate/refresh models from models.json (v1.13+)
bin/rails ruby_llm:load_models

Chat.joins(:model).where(models: { provider: 'anthropic' })
Model.left_joins(:chats).group(:id).order('COUNT(chats.id) DESC')

Model.where(supports_functions: true)
Model.where(supports_vision: true)

If the model registry table is empty (or not available yet), RubyLLM falls back to models.json for lookups (v1.13+).

System Instructions

System prompts are persisted as messages with the system role:

chat_record = Chat.create!(model: 'gpt-5-nano')

chat_record.with_instructions("You are a Ruby expert.")

# By default, with_instructions replaces the active system instruction
chat_record.with_instructions("You are a concise Ruby expert.")

# Append only when you intentionally want multiple system prompts
chat_record.with_instructions("Use short bullet points.", append: true)

system_message = chat_record.messages.find_by(role: :system)
puts system_message.content # => "You are a concise Ruby expert."

Using Tools

Tools are Ruby classes that the AI can call. While the tool classes themselves aren’t persisted, the tool calls and their results are saved as messages:

class Weather < RubyLLM::Tool
  description "Gets current weather for a location"
  parameter :city, description: "City name"

  def execute(city:)
    "The weather in #{city} is sunny and 22°C."
  end
end

chat_record = Chat.create!(model: 'gpt-5-nano')
chat_record.with_tools(Weather)

response = chat_record.ask("What's the weather in Paris?")

# Check persisted messages:
# 1. User message: "What's the weather in Paris?"
# 2. Assistant message with tool_calls (the AI's decision to use the tool)
# 3. Tool result message (the output from Weather#execute)
puts chat_record.messages.count # => 3

# The tool call details are stored in the ToolCall table
tool_call = chat_record.messages.second.tool_calls.first
puts tool_call.name # => "Weather"
puts tool_call.arguments # => {"city" => "Paris"}

File Attachments

Send files to AI models using ActiveStorage:

chat_record = Chat.create!(model: 'claude-sonnet-4-6')

# Send a single file - type automatically detected
chat_record.ask("What's in this file?", with: "app/assets/images/diagram.png")

# Send multiple files of different types - all automatically detected
chat_record.ask("What are in these files?", with: [
  "app/assets/documents/report.pdf",
  "app/assets/images/chart.jpg",
  "app/assets/text/notes.txt",
  "app/assets/audio/recording.mp3"
])

# Works with file uploads from forms
chat_record.ask("Analyze this file", with: params[:uploaded_file])

# Works with existing ActiveStorage attachments
chat_record.ask("What's in this document?", with: user.profile_document) # has_one_attached
chat_record.ask("Compare these documents", with: project.documents)      # has_many_attached

File types are automatically detected from extensions or MIME types.

Structured Output

Generate and persist structured responses:

class PersonSchema < RubyLLM::Schema
  string :name
  integer :age
  string :city, required: false
end

chat_record = Chat.create!(model: 'gpt-5-nano')
response = chat_record.with_schema(PersonSchema).ask("Generate a person from Paris")

# The structured response is automatically parsed as a Hash
puts response.content # => {"name" => "Marie", "age" => 28, "city" => "Paris"}

# But it's stored as JSON in the database
message = chat_record.messages.last
puts message.content # => "{\"name\":\"Marie\",\"age\":28,\"city\":\"Paris\"}"
puts JSON.parse(message.content) # => {"name" => "Marie", "age" => 28, "city" => "Paris"}

Schemas work in multi-turn conversations:

chat_record.with_schema(PersonSchema)
person = chat_record.ask("Generate a French person")

# Remove the schema for analysis
chat_record.without_schema
analysis = chat_record.ask("What's interesting about this person?")

puts chat_record.messages.count # => 4

Customizing Models

The acts_as helpers integrate seamlessly with standard Rails patterns. Add associations, validations, scopes, and callbacks as needed.

Using Custom Model Names

If your application uses different model names, you can configure the acts_as helpers accordingly:

With Model Registry

# app/models/conversation.rb (instead of Chat)
class Conversation < ApplicationRecord
  acts_as_chat messages: :chat_messages,  # Association name
               model: :ai_model

  belongs_to :user, optional: true
end

# app/models/chat_message.rb (instead of Message)
class ChatMessage < ApplicationRecord
  acts_as_message chat: :conversation,  # Association name
                  tool_calls: :ai_tool_calls,
                  model: :ai_model
end

# app/models/ai_tool_call.rb (instead of ToolCall)
class AiToolCall < ApplicationRecord
  acts_as_tool_call message: :chat_message,
                    result: :result
end

# app/models/ai_model.rb (instead of Model)
class AiModel < ApplicationRecord
  acts_as_model chats: :conversations
end

The new API follows Rails association inference: the association name determines the default foreign key, and the *_class options only change the class name. For example, tool_calls: :ai_tool_calls uses ai_tool_call_id, while tool_call_class: 'AiToolCall' by itself still uses tool_call_id.

Namespaced Models Example

For namespaced models, you’ll need to specify class names explicitly:

# app/models/admin/bot_chat.rb
module Admin
  class BotChat < ApplicationRecord
    acts_as_chat messages: :bot_messages,
                 message_class: 'Admin::BotMessage'  # Required for namespace
  end
end

# app/models/admin/bot_message.rb
module Admin
  class BotMessage < ApplicationRecord
    acts_as_message chat: :bot_chat,
                    chat_class: 'Admin::BotChat',
                    tool_calls: :bot_tool_calls,
                    tool_call_class: 'Admin::BotToolCall'
  end
end

# app/models/admin/bot_tool_call.rb
module Admin
  class BotToolCall < ApplicationRecord
    acts_as_tool_call message: :bot_message,
                      message_class: 'Admin::BotMessage'
  end
end

If you choose prefixed association names such as llm_tool_calls, configure the reverse association the same way you would in Rails:

class Llm::ToolCall < ApplicationRecord
  acts_as_tool_call message: :llm_message,
                    message_class: 'Llm::Message',
                    result_foreign_key: :llm_tool_call_id
end

Common Customizations

Extend your models with standard Rails patterns:

# app/models/chat.rb
class Chat < ApplicationRecord
  acts_as_chat

  belongs_to :user
  has_many :favorites, dependent: :destroy

  scope :recent, -> { order(updated_at: :desc) }
  scope :with_responses, -> { joins(:messages).where(messages: { role: 'assistant' }).distinct }

  def summary
    messages.last(2).map(&:content).join(' ... ')
  end

  after_create :notify_administrators

  private

  def notify_administrators
    # Custom logic
  end
end

Advanced Topics

Handling Edge Cases

Automatic Cleanup

RubyLLM automatically cleans up empty assistant messages when API calls fail. This prevents orphaned records that could cause issues with providers that reject empty content.

Provider Content Restrictions

Some providers (like Gemini) reject conversations with empty message content. RubyLLM’s automatic cleanup ensures this isn’t an issue during normal operation.

Customizing the Persistence Flow

For applications requiring content validations, override the default persistence methods:

# app/models/chat.rb
class Chat < ApplicationRecord
  acts_as_chat

  private

  def persist_new_message
    # Create a new message object but don't save it yet
    @message = messages.new(role: :assistant)
  end

  def persist_message_completion(message)
    return unless message

    # Fill in attributes and save once we have content
    @message.assign_attributes(
      content: message.content,
      model: Model.find_by(model_id: message.model),
      input_tokens: message.tokens.input,
      output_tokens: message.tokens.output,
      cache_read_tokens: message.tokens.cache_read,
      cache_write_tokens: message.tokens.cache_write
    )

    @message.save!

    persist_tool_calls(message.tool_calls) if message.tool_calls.present?
  end

  def persist_tool_calls(tool_calls)
    tool_calls.each_value do |tool_call|
      attributes = tool_call.to_h
      attributes[:tool_call_id] = attributes.delete(:id)
      @message.tool_calls.create!(**attributes)
    end
  end
end

# app/models/message.rb
class Message < ApplicationRecord
  acts_as_message

  # Now you can safely add this validation
  validates :content, presence: true
end

This approach trades streaming UI updates for content validation support:

  • ✅ Content validations work
  • ✅ No empty messages in database
  • ❌ No DOM target for streaming before API response

Next Steps