Omaship

March 12, 2026 · 15 min read

How to Add AI Features to Your Rails 8 SaaS with Active Agent in 2026

Jeronim Morina

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_later enqueues 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, like ApplicationController

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_generation callback 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.fetch around your generation call can cut costs by 50% or more.
  • Set token limits. Configure max_tokens in 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.

Continue reading

We use analytics and session recordings to learn which parts of Omaship help and which need work. Accept all, or customize what you share.

Privacy policy