{"slug": "ai-agents-in-rails-for-a-business-that-actually-has-customers", "title": "AI agents in Rails, for a business that actually has customers", "summary": "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.", "body_md": "*Originally published on [gkosmo.eu](https://www.gkosmo.eu/blogs/ai-agents-in-rails-for-a-business-that-actually-has-customers).*\n\nAn agent is a background job that gets to call a few methods\n\nI 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.\n\nHere is what I actually run. It's small. You can paste it into a Rails app this afternoon.\n\nThe 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.\n\nA 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.\n\nThat last person is the bottleneck. We are going to replace the reading and the drafting, and keep the sending.\n\nThat line matters: agents draft, humans send. Every time I've skipped this rule I regretted it within a week.\n\n```\n# Gemfile\ngem \"rcrewai\"\ngem \"rcrewai-rails\"\n# config/initializers/rcrewai.rb\nRCrewAI.configure do |config|\n  config.llm_provider = :anthropic\n  config.anthropic_api_key = ENV.fetch(\"ANTHROPIC_API_KEY\")\n  config.anthropic_model = ENV.fetch(\"ANTHROPIC_MODEL\", \"claude-sonnet-4-5\")\n  config.temperature = 0.1\nend\n```\n\nTemperature low. This is a support desk, not a poetry slam.\n\nThis is the part people get wrong, so it comes first.\n\nA 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.\n\n```\n# app/tools/order_lookup_tool.rb\nclass OrderLookupTool < RCrewAI::Tools::Base\n  tool_name \"order_lookup\"\n  description \"Recent orders for the customer who opened this ticket. \" \\\n              \"Returns number, status, total and shipped_at.\"\n\n  param :limit, type: :integer, required: false, default: 5,\n        description: \"How many recent orders to return (max 10)\"\n\n  def initialize(customer:)\n    super()\n    @customer = customer\n  end\n\n  def execute(limit: 5)\n    @customer.orders\n             .order(created_at: :desc)\n             .limit(limit.clamp(1, 10))\n             .map { |o| o.slice(:number, :status, :total, :shipped_at) }\n             .to_json\n  end\nend\n```\n\nThe `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.\n\nThe 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.\n\nTwo agents, two tasks, run in order. The first drafts, the second checks and formats.\n\n```\n# app/services/ticket_triage.rb\nclass TicketTriage\n  class Failed < StandardError; end\n\n  OUTPUT = {\n    type: \"object\",\n    properties: {\n      category:         { type: \"string\" },  # refund | shipping | other\n      refund_requested: { type: \"boolean\" },\n      reply:            { type: \"string\" }\n    },\n    required: %w[category refund_requested reply]\n  }.freeze\n\n  def initialize(ticket)\n    @ticket = ticket\n  end\n\n  def call\n    support = RCrewAI::Agent.new(\n      name: \"support\",\n      role: \"Support agent at a small online shop\",\n      goal: \"Answer the customer accurately using their real order data\",\n      backstory: \"You never invent order details. If the tool has no \" \\\n                 \"matching order, say so and ask for the order number.\",\n      tools: [OrderLookupTool.new(customer: @ticket.customer)],\n      max_iterations: 6\n    )\n\n    reviewer = RCrewAI::Agent.new(\n      name: \"reviewer\",\n      role: \"Support lead\",\n      goal: \"Make sure replies follow policy and are short\",\n      backstory: \"Policy: refunds within 30 days of delivery, no questions. \" \\\n                 \"After 30 days, offer store credit. Never promise a date \" \\\n                 \"we did not get from the order data.\"\n    )\n\n    draft = RCrewAI::Task.new(\n      name: \"draft\",\n      agent: support,\n      description: <<~TXT,\n        Customer #{@ticket.customer.name} wrote:\n\n        #{@ticket.body}\n\n        Look up their orders, then write a reply.\n      TXT\n      expected_output: \"A plain-text reply of at most 120 words.\"\n    )\n\n    review = RCrewAI::Task.new(\n      name: \"review\",\n      agent: reviewer,\n      context: [draft],\n      description: \"Check the draft against policy. Fix it if needed. \" \\\n                   \"Classify the ticket.\",\n      expected_output: \"JSON with category, refund_requested and reply.\",\n      output_schema: OUTPUT\n    )\n\n    crew = RCrewAI::Crew.new(\"ticket_triage\")\n    crew.add_agent(support)\n    crew.add_agent(reviewer)\n    crew.add_task(draft)\n    crew.add_task(review)\n\n    result = crew.execute\n    raise Failed, result[:results].map { _1[:result] }.join(\"\\n\") if result[:failed_tasks] > 0\n\n    review.structured_output\n  end\nend\n```\n\nThings to notice.\n\nThe 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.\n\n`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.\n\n`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.\n\n```\n# app/jobs/triage_ticket_job.rb\nclass TriageTicketJob < ApplicationJob\n  queue_as :agents\n  retry_on RCrewAI::Error, TicketTriage::Failed,\n           wait: :polynomially_longer, attempts: 3\n\n  def perform(ticket)\n    out = TicketTriage.new(ticket).call\n\n    ticket.update!(\n      category:         out[\"category\"],\n      refund_requested: out[\"refund_requested\"],\n      draft_reply:      out[\"reply\"],\n      triaged_at:       Time.current\n    )\n  end\nend\n# app/models/support_ticket.rb\nclass SupportTicket < ApplicationRecord\n  belongs_to :customer\n  after_create_commit -> { TriageTicketJob.perform_later(self) }\nend\n```\n\nThat'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.\n\nEverything above is the core gem. It's plain Ruby, it runs in a rake task, in a test, anywhere.\n\nThe Rails engine is for the day after it works, when someone asks \"why did it tell Mrs Dupont she'd get a refund?\".\n\n```\nrails generate rcrewai:rails:install\nrails db:migrate\njs\n# config/routes.rb\nmount RcrewAI::Rails::Engine => \"/rcrewai\"\n```\n\nYou 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.\n\nIf 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.\n\nTwo agents is plenty. I've never needed a third that a better task description couldn't replace.\n\nPut 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.\n\nMake the tool the security boundary. The model gets a method with the arguments you choose. Nothing else.\n\nNever 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.\n\nAnd 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.\n\nCode 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.", "url": "https://wpnews.pro/news/ai-agents-in-rails-for-a-business-that-actually-has-customers", "canonical_source": "https://dev.to/gkosmo/ai-agents-in-rails-for-a-business-that-actually-has-customers-131e", "published_at": "2026-09-10 09:34:55+00:00", "updated_at": "2026-09-10 09:52:32.400825+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-products"], "entities": ["rcrewai", "rcrewai-rails", "Rails", "Ruby", "nakyma.io", "Anthropic", "Claude Sonnet"], "alternates": {"html": "https://wpnews.pro/news/ai-agents-in-rails-for-a-business-that-actually-has-customers", "markdown": "https://wpnews.pro/news/ai-agents-in-rails-for-a-business-that-actually-has-customers.md", "text": "https://wpnews.pro/news/ai-agents-in-rails-for-a-business-that-actually-has-customers.txt", "jsonld": "https://wpnews.pro/news/ai-agents-in-rails-for-a-business-that-actually-has-customers.jsonld"}}