March 12, 2026 · 15 min read
How to Add AI Features to Your Rails 8 SaaS with Active Agent in 2026
Jeronim Morina
Founder, Omaship
Every SaaS in 2026 needs AI features. Not because AI is a buzzword -- because your users expect them. Smart search, content generation, automated support, document analysis. The question is not whether to add AI, but how to add it without turning your codebase into a mess of API calls, prompt strings, and JSON parsing scattered across controllers.
Rails has always been about convention over configuration. But until recently, there was no Rails-native convention for AI. You cobbled together ruby-openai calls in service objects, stored prompts as string constants, and parsed JSON responses by hand. It worked. It was also the kind of code that makes you wince six months later.
Active Agent changes that. It is an open-source framework that brings a familiar Rails abstraction to AI: agents are to LLMs what mailers are to email. Define actions, use callbacks, render prompt templates with ERB, run generations synchronously or in the background with Active Job. If you know how to write a mailer, you already know 80% of Active Agent.
This guide covers how to add AI features to a Rails 8 SaaS using Active Agent -- from installation to production patterns. No toy examples. Real SaaS use cases: smart support, content generation, data extraction, and AI-powered onboarding.
Why Active Agent instead of raw API calls
You can call OpenAI directly. Most Rails apps start there. But the same argument applies as it did for Action Mailer vs. raw SMTP: you can send email with Net::SMTP, but you do not, because Action Mailer gives you structure, testability, and separation of concerns.
Here is what you get with Active Agent that you do not get with raw API calls:
- Prompt templates as views: System instructions and per-action prompts live in
app/agents/as ERB templates. Change your prompt without touching Ruby code. Use partials for shared instructions. Interpolate variables with<%= %>like any other view. - Action-based architecture: Each AI capability is a method on an agent class.
SupportAgent#help,ContentAgent#summarize,OnboardingAgent#suggest_name. Clear, discoverable, testable. - Callbacks:
before_generation,after_generation,around_generation. Validate inputs before hitting the API. Log token usage after. Retry on failure. Same pattern as Active Record callbacks. - Background generation:
generate_laterenqueues the AI call as an Active Job. Your web request returns immediately. Process the result in a callback -- email it, store it, broadcast it over Turbo Streams. - Provider-agnostic: Switch between OpenAI, Anthropic, Ollama (local), and OpenRouter with one line of config. No code changes. Test locally with Ollama, deploy with GPT-4o or Claude.
- Structured output: Define JSON schemas for responses. Get typed, validated data back instead of parsing free-text. Essential for form filling, data extraction, and API integrations.
- Tool calling: Let the AI call Ruby methods to fetch data, perform actions, or make decisions. The agent defines which tools are available; the LLM decides when to use them.
Installation and setup
Add Active Agent and your provider gem to your Gemfile:
# Gemfile
gem "activeagent"
# Pick your provider (or multiple)
gem "openai" # OpenAI (GPT-4o, GPT-4o-mini)
gem "anthropic" # Anthropic (Claude 4, Sonnet)
# gem "openai" # Also works for Ollama and OpenRouter
Run the install generator:
bundle install
rails generate active_agent:install
This creates two files:
config/active_agent.yml-- provider configuration (API keys, default model)app/agents/application_agent.rb-- your base agent class, likeApplicationController
Configure your provider:
# config/active_agent.yml
openai:
service: "OpenAI"
access_token: <%= ENV["OPENAI_API_KEY"] %>
model: "gpt-4o-mini"
anthropic:
service: "Anthropic"
access_token: <%= ENV["ANTHROPIC_API_KEY"] %>
model: "claude-sonnet-4-20250514"
Set up your base agent:
# app/agents/application_agent.rb
class ApplicationAgent < ActiveAgent::Base
generate_with :openai
# Keep system instructions in <agent>/instructions.text
default instructions: { template: :instructions }
# Keep prompt views next to agent classes
prepend_view_path Rails.root.join("app/agents")
delegate :response, to: :generation_provider
end
Pattern 1: AI-powered support agent
The most common AI feature in SaaS: a support agent that answers user questions based on your documentation. Here is how to build one that actually works in production.
# Generate the agent
rails generate active_agent:agent Support answer
# app/agents/support_agent.rb
class SupportAgent < ApplicationAgent
after_generation :log_interaction
def answer
@question = params[:question]
@user = params[:user]
@context = params[:context] # relevant docs, FAQ entries, etc.
prompt
end
private
def log_interaction
SupportInteraction.create!(
user: params[:user],
question: params[:question],
answer: response.message.content,
tokens_used: response.usage&.total_tokens
)
end
end
The prompt template does the heavy lifting. This is where you tune the AI's behavior without changing Ruby code:
<!-- app/agents/support_agent/instructions.text -->
You are a helpful support agent for a SaaS application.
RULES:
- Answer questions using ONLY the provided context
- If the answer is not in the context, say so honestly
- Be concise and direct
- Never make up features or capabilities
- If the question requires human support, say so
<!-- app/agents/support_agent/answer.text.erb -->
User: <%= @user.name %> (plan: <%= @user.plan %>)
Relevant documentation:
<%= @context %>
Question: <%= @question %>
Use it from a controller:
# app/controllers/support_controller.rb
class SupportController < ApplicationController
def create
context = fetch_relevant_docs(params[:question])
result = SupportAgent.with(
question: params[:question],
user: Current.user,
context: context
).answer.generate_now
render json: { answer: result.message.content }
end
private
def fetch_relevant_docs(question)
# Your retrieval logic: full-text search, vector similarity, etc.
Doc.search(question).limit(5).pluck(:content).join("\n\n")
end
end
Pattern 2: Content generation with background processing
Content generation -- blog posts, product descriptions, email drafts -- takes time. You do not want to block a web request while the LLM generates 500 words. Use generate_later and broadcast the result when it is ready.
# app/agents/content_agent.rb
class ContentAgent < ApplicationAgent
after_generation :deliver_content
def draft_description
@product = params[:product]
@tone = params[:tone] || "professional"
@audience = params[:audience] || "general"
prompt
end
private
def deliver_content
product = params[:product]
content = response.message.content
product.update!(ai_description: content)
# Broadcast to the user's browser via Turbo Streams
Turbo::StreamsChannel.broadcast_replace_to(
"product_#{product.id}",
target: "ai_description",
html: content
)
end
end
<!-- app/agents/content_agent/draft_description.text.erb -->
Write a product description for:
Product: <%= @product.name %>
Category: <%= @product.category %>
Key features: <%= @product.features.join(", ") %>
Tone: <%= @tone %>
Target audience: <%= @audience %>
Requirements:
- 2-3 paragraphs
- Include a compelling opening line
- Mention specific features naturally
- End with a clear value proposition
# app/controllers/products_controller.rb
def generate_description
@product = Current.user.products.find(params[:id])
ContentAgent.with(
product: @product,
tone: params[:tone]
).draft_description.generate_later
head :accepted # Return immediately, result arrives via Turbo Stream
end
The user clicks "Generate description," the page shows a loading indicator, and the generated content appears when it is ready -- no page refresh, no polling. This is Hotwire and Active Agent working together the way Rails is meant to work.
Pattern 3: Structured data extraction
One of the most underrated AI use cases in SaaS: extracting structured data from unstructured input. Parse invoices, extract entities from emails, classify support tickets, or analyze user feedback. Active Agent's structured output support makes this clean.
# app/agents/extraction_agent.rb
class ExtractionAgent < ApplicationAgent
def classify_ticket
@ticket = params[:ticket]
prompt(
response_format: {
type: "json_schema",
json_schema: {
name: "ticket_classification",
strict: true,
schema: {
type: "object",
required: %w[category priority sentiment suggested_tags],
properties: {
category: {
type: "string",
enum: %w[billing technical feature_request bug account]
},
priority: {
type: "string",
enum: %w[low medium high urgent]
},
sentiment: {
type: "string",
enum: %w[positive neutral negative frustrated]
},
suggested_tags: {
type: "array",
items: { type: "string" }
}
},
additionalProperties: false
}
}
}
)
end
end
# Usage in a webhook or background job
result = ExtractionAgent.with(ticket: ticket).classify_ticket.generate_now
classification = JSON.parse(result.message.content)
ticket.update!(
category: classification["category"],
priority: classification["priority"],
sentiment: classification["sentiment"],
tags: classification["suggested_tags"]
)
With structured output and a strict JSON schema, the LLM returns valid, typed data every time. No regex parsing. No "sometimes it returns JSON, sometimes it doesn't." This is the pattern that turns AI from a toy into infrastructure.
Pattern 4: Tool calling -- let the AI take action
The most powerful pattern: give the AI access to Ruby methods and let it decide when to use them. This is how you build agents that actually do things -- look up order status, check subscription details, trigger workflows.
# app/agents/assistant_agent.rb
class AssistantAgent < ApplicationAgent
def chat
@message = params[:message]
@user = params[:user]
prompt
end
# Define tools the AI can call
def tools
[
{
type: "function",
function: {
name: "lookup_order",
description: "Look up order details by order number",
parameters: {
type: "object",
required: ["order_number"],
properties: {
order_number: { type: "string", description: "The order ID" }
}
}
}
},
{
type: "function",
function: {
name: "check_subscription",
description: "Check the user's current subscription plan and status",
parameters: { type: "object", properties: {} }
}
}
]
end
# Implement the tool methods
def lookup_order(order_number:)
order = params[:user].orders.find_by(number: order_number)
return "Order not found" unless order
{ status: order.status, total: order.total, created_at: order.created_at }.to_json
end
def check_subscription
sub = params[:user].subscription
return "No active subscription" unless sub
{ plan: sub.plan, status: sub.status, renews_at: sub.renews_at }.to_json
end
end
When a user asks "What's the status of my order #1234?", the AI recognizes it needs data, calls lookup_order, gets the result, and formulates a natural response. No if/else chains. No intent classification. The LLM handles the routing.
Pattern 5: AI-enhanced onboarding
Use AI to personalize the onboarding experience. Ask new users what they are building, and use an agent to suggest a configuration, generate starter content, or tailor the product tour.
# app/agents/onboarding_agent.rb
class OnboardingAgent < ApplicationAgent
def suggest_setup
@description = params[:description] # "I'm building a project management tool"
prompt(
response_format: {
type: "json_schema",
json_schema: {
name: "onboarding_suggestion",
strict: true,
schema: {
type: "object",
required: %w[suggested_name tagline recommended_features industry],
properties: {
suggested_name: { type: "string" },
tagline: { type: "string" },
recommended_features: {
type: "array",
items: { type: "string" }
},
industry: { type: "string" }
},
additionalProperties: false
}
}
}
)
end
end
A user types "I'm building a fitness tracking app for personal trainers" and gets back suggested app name, tagline, recommended feature flags to enable, and industry classification -- all structured, all actionable. This is the kind of personalization that turns a generic onboarding into a "how did they know?" moment.
Testing your agents
Untested AI code is a liability. Active Agent supports testing with fixtures and VCR cassettes, so you do not hit the API in your test suite.
# test/agents/support_agent_test.rb
require "test_helper"
class SupportAgentTest < ActiveSupport::TestCase
test "answer generates a response for a user question" do
VCR.use_cassette("support_agent_answer") do
result = SupportAgent.with(
question: "How do I reset my password?",
user: users(:one),
context: "To reset your password, go to Settings > Security > Reset Password."
).answer.generate_now
assert result.message.content.present?
assert_includes result.message.content.downcase, "password"
end
end
test "classify_ticket returns valid structured output" do
VCR.use_cassette("extraction_agent_classify") do
result = ExtractionAgent.with(
ticket: "I can't log in and I've been trying for 2 hours! This is ridiculous!"
).classify_ticket.generate_now
classification = JSON.parse(result.message.content)
assert_includes %w[billing technical feature_request bug account], classification["category"]
assert_includes %w[low medium high urgent], classification["priority"]
assert_includes %w[positive neutral negative frustrated], classification["sentiment"]
end
end
end
For faster tests that do not need real API responses, you can use a test provider that returns canned responses. This keeps your CI fast while still testing the agent's logic.
Production considerations
Shipping AI features to production is different from shipping CRUD features. Here is what catches most founders off guard:
Cost management
- Track token usage per user. Log every generation with the user, action, and token count. Set up alerts when usage spikes. Use the
after_generationcallback to record this automatically. - Use the cheapest model that works. GPT-4o-mini handles 90% of SaaS AI features. Reserve GPT-4o or Claude for complex reasoning. Active Agent makes model switching per-agent easy via
generate_with. - Cache aggressively. If the same question gets the same answer (FAQ lookups, classification of known patterns), cache the response. A simple
Rails.cache.fetcharound your generation call can cut costs by 50% or more. - Set token limits. Configure
max_tokensin your provider settings. A runaway prompt that generates 4,000 tokens when you expected 200 will blow your budget fast.
Error handling and resilience
- API calls fail. OpenAI has outages. Anthropic rate-limits you. Network timeouts happen. Active Agent supports automatic retries with exponential backoff. Configure it in your base agent or per-agent.
- Have a fallback. When the AI is down, what does the user see? A "try again later" message is better than a 500 error. For critical features like support, fall back to a search-based answer or a contact form.
- Validate outputs. Even with structured output, verify the response makes sense. A classification agent that returns "urgent" for "How do I change my avatar?" needs guardrails.
Rate limiting
# app/controllers/concerns/ai_rate_limited.rb
module AiRateLimited
extend ActiveSupport::Concern
private
def check_ai_rate_limit!
key = "ai_rate_limit:#{Current.user.id}"
count = Rails.cache.increment(key, 1, expires_in: 1.hour)
if count > Current.user.ai_requests_per_hour
render json: { error: "AI rate limit exceeded. Try again later." },
status: :too_many_requests
end
end
end
Streaming for better UX
For user-facing features where the user watches the output appear, Active Agent supports streaming with callbacks. Stream tokens to the browser via Turbo Streams or Server-Sent Events for that "typing" effect users expect from AI interfaces.
When to use Active Agent vs. raw API calls
Active Agent is not always the right tool. Here is a decision framework:
Use Active Agent when:
- You have multiple AI features with different prompts and behaviors
- You need background generation (generate_later)
- Your prompts are complex enough to benefit from templates
- You want provider-agnostic code (test with Ollama, deploy with OpenAI)
- You care about testability and separation of concerns
Use raw API calls when:
- You have a single, simple AI feature (e.g., one summarization endpoint)
- You need maximum control over the HTTP request (custom headers, retries)
- You are building a real-time chat interface with complex conversation management
For most SaaS apps adding their first 2-5 AI features, Active Agent is the right choice. It gives you structure from the start without forcing you into a complex architecture.
The competitive advantage of AI-ready architecture
Here is the part most guides skip: AI features are becoming table stakes. Within 12 months, every SaaS in every category will have AI-powered search, AI-generated content, and AI-assisted onboarding. The competitive advantage is not having AI features -- it is being able to ship them fast.
That means your architecture matters. If adding a new AI feature requires a new service object, new background job class, new error handling, and new tests every time, you will ship slowly. If it requires a new agent class with a prompt template -- like Active Agent gives you -- you will ship in hours instead of days.
This is why starting with a structured foundation matters. Not because the first feature is hard, but because the tenth feature should be as easy as the first.
AI-ready from day one
Omaship ships with an AI-agent optimized Rails 8 foundation -- structured for AI coding agents and ready for you to add AI features to your product. Stop setting up infrastructure and start shipping.