cd /news/ai-agents/ai-agents-in-rails-for-a-business-th… · home topics ai-agents article
[ARTICLE · art-125610] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=↑ positive

AI agents in Rails, for a business that actually has customers

A developer has released rcrewai and rcrewai-rails, open-source Ruby gems that let Rails applications run AI agents as background jobs for tasks like support ticket triage. The setup uses scoped, read-only tools such as an order lookup that injects the customer context rather than letting the model request it, and pairs a drafting agent with a reviewer agent that enforces policy before a human sends the reply. The author maintains both gems and runs them in production at nakyma.io.

by read6 min views1 publishedSep 10, 2026

Originally published on gkosmo.eu.

An agent is a background job that gets to call a few methods

I have been putting this post off for months. Partly because "AI agents" is a phrase that makes me tired, and partly because most of what I read about it is either a demo that writes haikus or an architecture diagram with eleven boxes.

Here is what I actually run. It's small. You can paste it into a Rails app this afternoon.

The gems are rcrewai and rcrewai-rails. I maintain both and I use them on nakyma.io, so this is not a neutral review. It is the thing I reach for.

A shop. Customers open support tickets. Somebody in the shop reads each one, looks up the customer's orders, decides if it's a refund, a shipping question or something else, and writes a reply.

That last person is the bottleneck. We are going to replace the reading and the drafting, and keep the sending.

That line matters: agents draft, humans send. Every time I've skipped this rule I regretted it within a week.

gem "rcrewai"
gem "rcrewai-rails"
RCrewAI.configure do |config|
  config.llm_provider = :anthropic
  config.anthropic_api_key = ENV.fetch("ANTHROPIC_API_KEY")
  config.anthropic_model = ENV.fetch("ANTHROPIC_MODEL", "claude-sonnet-4-5")
  config.temperature = 0.1
end

Temperature low. This is a support desk, not a poetry slam.

This is the part people get wrong, so it comes first.

A tool is a Ruby object the model is allowed to call. Not "the database". Not "ActiveRecord". One method, scoped to one customer, read-only, returning a string.

class OrderLookupTool < RCrewAI::Tools::Base
  tool_name "order_lookup"
  description "Recent orders for the customer who opened this ticket. " \
              "Returns number, status, total and shipped_at."

  param :limit, type: :integer, required: false, default: 5,
        description: "How many recent orders to return (max 10)"

  def initialize(customer:)
    super()
    @customer = customer
  end

  def execute(limit: 5)
    @customer.orders
             .order(created_at: :desc)
             .limit(limit.clamp(1, 10))
             .map { |o| o.slice(:number, :status, :total, :shipped_at) }
             .to_json
  end
end

The param DSL turns into a JSON schema the model sees. The customer: is injected by us, not chosen by the model. The model cannot ask for someone else's orders because there is no parameter for that. Security by not having the argument. It's the cheapest kind.

The gem ships with WebSearch, SqlDatabase, FileWriter and friends, and the Rails engine adds an ActiveRecordTool. I don't use the generic ones in production. A tool that can run any query is a tool I have to explain to a customer later.

Two agents, two tasks, run in order. The first drafts, the second checks and formats.

class TicketTriage
  class Failed < StandardError; end

  OUTPUT = {
    type: "object",
    properties: {
      category:         { type: "string" },  # refund | shipping | other
      refund_requested: { type: "boolean" },
      reply:            { type: "string" }
    },
    required: %w[category refund_requested reply]
  }.freeze

  def initialize(ticket)
    @ticket = ticket
  end

  def call
    support = RCrewAI::Agent.new(
      name: "support",
      role: "Support agent at a small online shop",
      goal: "Answer the customer accurately using their real order data",
      backstory: "You never invent order details. If the tool has no " \
                 "matching order, say so and ask for the order number.",
      tools: [OrderLookupTool.new(customer: @ticket.customer)],
      max_iterations: 6
    )

    reviewer = RCrewAI::Agent.new(
      name: "reviewer",
      role: "Support lead",
      goal: "Make sure replies follow policy and are short",
      backstory: "Policy: refunds within 30 days of delivery, no questions. " \
                 "After 30 days, offer store credit. Never promise a date " \
                 "we did not get from the order data."
    )

    draft = RCrewAI::Task.new(
      name: "draft",
      agent: support,
      description: <<~TXT,
        Customer #{@ticket.customer.name} wrote:

        #{@ticket.body}

        Look up their orders, then write a reply.
      TXT
      expected_output: "A plain-text reply of at most 120 words."
    )

    review = RCrewAI::Task.new(
      name: "review",
      agent: reviewer,
      context: [draft],
      description: "Check the draft against policy. Fix it if needed. " \
                   "Classify the ticket.",
      expected_output: "JSON with category, refund_requested and reply.",
      output_schema: OUTPUT
    )

    crew = RCrewAI::Crew.new("ticket_triage")
    crew.add_agent(support)
    crew.add_agent(reviewer)
    crew.add_task(draft)
    crew.add_task(review)

    result = crew.execute
    raise Failed, result[:results].map { _1[:result] }.join("\n") if result[:failed_tasks] > 0

    review.structured_output
  end
end

Things to notice.

The ticket text goes straight into the task description. There is no template engine, no {placeholder} interpolation. It's a Ruby heredoc. The crew is built per ticket and thrown away. Objects are cheap; I stopped trying to make crews reusable and my code got shorter.

context: [draft] is how the reviewer sees the support agent's output. That's the whole "multi-agent" story. One task's result becomes the next task's input. Nothing more mystical than a pipeline.

output_schema makes the last task return parsed JSON, validated, with retries when the model rambles. review.structured_output is a Hash with string keys. No regex on model output. If you are writing JSON.parse(response[/\{.*\}/m]) somewhere, this is the fix.

class TriageTicketJob < ApplicationJob
  queue_as :agents
  retry_on RCrewAI::Error, TicketTriage::Failed,
           wait: :polynomially_longer, attempts: 3

  def perform(ticket)
    out = TicketTriage.new(ticket).call

    ticket.update!(
      category:         out["category"],
      refund_requested: out["refund_requested"],
      draft_reply:      out["reply"],
      triaged_at:       Time.current
    )
  end
end
class SupportTicket < ApplicationRecord
  belongs_to :customer
  after_create_commit -> { TriageTicketJob.perform_later(self) }
end

That's it. A ticket arrives, thirty seconds later it has a category and a draft reply sitting in the admin, and a human clicks send or rewrites it. Give the agents queue its own worker so a slow model call doesn't sit in front of your password reset emails.

Everything above is the core gem. It's plain Ruby, it runs in a rake task, in a test, anywhere.

The Rails engine is for the day after it works, when someone asks "why did it tell Mrs Dupont she'd get a refund?".

rails generate rcrewai:rails:install
rails db:migrate
js
mount RcrewAI::Rails::Engine => "/rcrewai"

You get tables for crews, agents, tasks and executions, an ActiveJob that records every run with its inputs, output, duration and error, and a span tree of every prompt and tool call underneath. There is a dashboard at /rcrewai. On nakyma.io that is how I find out what a run cost and where it looped.

If you want your crews to live in the database instead of in a service object, the engine has a CrewBuilder mixin and RcrewAI::Rails::Crew#execute_async. I use it for the crews that don't change per request. For the per-ticket kind, the PORO above is simpler and I'd start there.

Two agents is plenty. I've never needed a third that a better task description couldn't replace.

Put the policy in the backstory, not in a vector database. Thirty days, store credit after, don't promise dates. That fits in a sentence. Retrieval is for when it doesn't fit in a page.

Make the tool the security boundary. The model gets a method with the arguments you choose. Nothing else.

Never let the agent's output reach a customer without a draft_ prefix on the column. The day you remove the prefix should be a decision, not an accident.

And run it as a job. An agent is a background job that gets to call a few methods. Once I stopped thinking of it as anything more, it fit into Rails the way everything else does.

Code for both gems: rcrewAI and rcrewai-rails. Issues welcome, especially the ones that say I'm wrong.

── more in #ai-agents 4 stories · sorted by recency
── more on @rcrewai 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/ai-agents-in-rails-f…] indexed:0 read:6min 2026-09-10 ·