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. Originally published on gkosmo.eu https://www.gkosmo.eu/blogs/ai-agents-in-rails-for-a-business-that-actually-has-customers . 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 https://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. Gemfile gem "rcrewai" gem "rcrewai-rails" config/initializers/rcrewai.rb 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. app/tools/order lookup tool.rb 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. app/services/ticket triage.rb 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. app/jobs/triage ticket job.rb 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 app/models/support ticket.rb 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 config/routes.rb 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 https://github.com/gkosmo/rcrewAI and rcrewai-rails https://github.com/gkosmo/rcrewai-rails . Issues welcome, especially the ones that say I'm wrong.