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

> Source: <https://dev.to/gkosmo/ai-agents-in-rails-for-a-business-that-actually-has-customers-131e>
> Published: 2026-09-10 09:34:55+00:00

*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.
