AI agents by framework / Ruby

How to add an AI agent to a Rails app

An AI agent in a Rails app is a set of tool classes that wrap your models, a background job that runs the model loop for the signed-in user's chat, and a Turbo Stream that shows the reply as it arrives. RubyLLM provides the loop, Active Record persistence and approval for risky tools; you supply the tools, authorization and limits. If the agent only needs to do what your pages already do, you can embed one instead.

What an agent in a Rails app is made of

  • Controllers: ordinary actions that create a chat, accept a message and record approvals. They run behind your authentication like every other controller, and the model API key stays in Rails credentials.
  • Tools: plain Ruby classes in app/tools, each wrapping a query or an action on your models for one user.
  • The agent loop: the model calls a tool, receives the result and decides the next step until it can answer. In Rails it runs in an Active Job, so a slow model never holds a web thread, and the transcript is saved in your database after every step.
  • A chat view: an ERB page that subscribes to a Turbo Stream, so text appears as the job receives it, with forms for messages and approvals.
  • Authorization and approval: chats belong to a user, tools act as that user, and actions that change data wait for the user to approve them.

Ways to add an agent to Ruby on Rails

ApproachGood forWhat you take on
RubyLLM with Active Record chatsA custom agent whose conversations, tool calls and approvals live in your database, with any major model provider.Tool definitions, authorization in every tool, the chat UI, rate and cost limits.
An official provider SDK (the anthropic or openai gem)Using one provider's newest API features directly.The tool loop, persistence, streaming and approval, plus everything in the row above.
An agent framework (for example Active Agent)Many agents organized like Rails controllers, across several model providers.Another framework's conventions to learn, plus tools, authorization and limits.
Hosted answer-only chat widgetQuestions about your docs or help center.Keeping its content current. It does not act in your app.
Embedded in-app agent (Bunkhouse)Letting signed-in users get tasks done through the pages and API you already have.A script tag, an identity endpoint and hints for operations discovery cannot see.

Build an agent with RubyLLM

RubyLLM is a Ruby library for chats, tools and agents across many model providers, with Rails persistence through acts_as_chat. The examples below use RubyLLM 2.1 on Rails 8.1 with the sessions from bin/rails generate authentication, so Current.user is the signed-in user. Install it, create the chat tables and give each chat an owner:

Terminal
bundle add ruby_llm --version 2.1.1
bin/rails generate ruby_llm:install
bin/rails generate migration AddUserToChats user:references
bin/rails db:migrate
bin/rails ruby_llm:load_models

Add belongs_to :user to the generated Chat model and has_many :chats to User. Then set the key for your provider in the generated initializer. The examples use the model "claude-sonnet-5-5" from Anthropic; RubyLLM's model registry lists the IDs it knows for each provider.

config/initializers/ruby_llm.rb
RubyLLM.configure do |config|
  config.anthropic_api_key = Rails.application.credentials.dig(:anthropic, :api_key)
end

1. Tools that act as the user

A RubyLLM tool is a class with a description, declared parameters and an execute method. Each tool here receives the user in its constructor and queries through user.orders, so the model can choose an order ID but never whose orders it reads or cancels. Recoverable problems go back to the model as an error hash.

app/tools/list_orders.rb
class ListOrders < RubyLLM::Tool
  description "List the signed-in user's recent orders"
  parameter :status, description: "Only orders with this status: open, shipped or cancelled", required: false

  def initialize(user:)
    @user = user
  end

  def execute(status: nil)
    orders = @user.orders.order(created_at: :desc).limit(20)
    orders = orders.where(status:) if status.in?(%w[open shipped cancelled])
    orders.as_json(only: %i[id status total created_at])
  end
end

The macro requires_approval makes the loop stop before cancel_order runs; the call is saved with the chat and waits until the user approves or denies it. A job can be retried after a tool has run, so write tools that change data so that running them twice is safe, as the durable agents guide advises.

app/tools/cancel_order.rb
class CancelOrder < RubyLLM::Tool
  description "Cancel one of the signed-in user's open orders"
  parameter :order_id, description: "ID of the order to cancel"
  requires_approval

  def initialize(user:)
    @user = user
  end

  def execute(order_id:)
    order = @user.orders.find_by(id: order_id, status: "open")
    return { error: "The user has no open order #{order_id}" } unless order

    order.update!(status: "cancelled")
    { cancelled: order.id }
  end
end

2. The agent and its job

A RubyLLM agent declares the model, instructions and tools in one class. With chat_model Chat, the agent loads and creates your Active Record chats. The tools block runs for each chat and builds the tools from chat.user, not from Current.user, because the loop runs in a job where no request, and no Current attribute, exists.

app/agents/orders_agent.rb
class OrdersAgent < RubyLLM::Agent
  chat_model Chat
  model "claude-sonnet-5-5", provider: :anthropic
  instructions "You help the signed-in user manage their orders. Use the tools; never guess order data."
  tools { [ListOrders.new(user: chat.user), CancelOrder.new(user: chat.user)] }
end

The job drives the loop one step at a time: a model response or a round of tool calls. It stops when the model has answered, when a call waits for approval, or after eight steps. Text chunks are appended to the page through Turbo Streams, HTML-escaped because model output is untrusted. When the job ends, a page refresh broadcast makes the browser reload the chat from the database, which also renders any approval buttons with the user's own CSRF token.

app/jobs/agent_turn_job.rb
class AgentTurnJob < ApplicationJob
  MAX_STEPS = 8

  def perform(chat_id)
    chat = OrdersAgent.find(chat_id)
    MAX_STEPS.times do
      chat.step do |chunk|
        next if chunk.content.blank?

        Turbo::StreamsChannel.broadcast_append_to(chat, target: [chat, :stream], html: ERB::Util.html_escape(chunk.content))
      end
      break if chat.complete? || chat.waiting?
    end
  ensure
    Turbo::StreamsChannel.broadcast_refresh_to(chat) if chat
  end
end

3. Routes, controllers and the view

Every lookup starts from Current.user.chats, so a user can only post to, read or approve calls in their own chats; anything else raises a not-found error. The Authentication concern that the generator includes in ApplicationController already requires a signed-in user for these actions. The rate_limit call limits each user to 20 messages a minute. A new message while an approval is pending raises RubyLLM::PendingToolCallsError, so the user is asked to decide first.

config/routes.rb (excerpt)
resources :chats, only: %i[create show] do
  resources :messages, only: :create
  resources :approvals, only: :create
end
app/controllers/chats_controller.rb, messages_controller.rb, approvals_controller.rb
class ChatsController < ApplicationController
  def create
    redirect_to OrdersAgent.create!(user: Current.user)
  end

  def show
    @chat = Current.user.chats.find(params[:id])
    @pending = OrdersAgent.new(chat: @chat, persist_instructions: false).pending_approvals
  end
end

class MessagesController < ApplicationController
  rate_limit to: 20, within: 1.minute, by: -> { Current.user.id }

  def create
    chat = Current.user.chats.find(params[:chat_id])
    chat.ask_later(params.expect(:content))
    AgentTurnJob.perform_later(chat.id)
    redirect_to chat
  rescue RubyLLM::PendingToolCallsError
    redirect_to chat, alert: "Approve or deny the pending action first."
  end
end

class ApprovalsController < ApplicationController
  def create
    chat = Current.user.chats.find(params[:chat_id])
    if params[:approved] == "true"
      chat.approve(params[:tool_call_id])
    else
      chat.deny(params[:tool_call_id])
    end
    AgentTurnJob.perform_later(chat.id)
    redirect_to chat
  end
end

The view subscribes with turbo_stream_from, renders the saved user and assistant messages, an empty element the job streams into, and one approval form per pending call. The forms come from button_to and form_with, so they carry Rails' authenticity token.

app/views/chats/show.html.erb
<%= turbo_stream_from @chat %>

<% @chat.messages.where(role: %w[user assistant]).where.not(content: [nil, ""]).each do |message| %>
  <p><strong><%= message.role %>:</strong> <%= message.content %></p>
<% end %>
<p id="<%= dom_id(@chat, :stream) %>"></p>

<% @pending.each do |call| %>
  <p>Cancel order <%= call.arguments["order_id"] %>?</p>
  <%= button_to "Approve", chat_approvals_path(@chat), params: { tool_call_id: call.tool_call_id, approved: true } %>
  <%= button_to "Deny", chat_approvals_path(@chat), params: { tool_call_id: call.tool_call_id, approved: false } %>
<% end %>

<%= form_with url: chat_messages_path(@chat) do |form| %>
  <%= form.text_field :content, required: true, maxlength: 4000, aria: { label: "Message" } %>
  <%= form.submit "Send" %>
<% end %>

Production checklist for Ruby on Rails

  • Scope everything to the user. Find chats through Current.user.chats, build tools from the chat's owner, and never take a user ID from params or from the model. Calling allow_unauthenticated_access in these controllers would expose them.
  • Keep CSRF protection on. ActionController::Base verifies the authenticity token on every non-GET request, and Turbo sends the token from csrf_meta_tags with its form submissions. Do not add skip_forgery_protection to the agent controllers, and do not broadcast forms from jobs: they render without the user's session. See Securing Rails Applications.
  • Treat tool input and tool results as untrusted. The model chooses tool arguments, and data a tool returns (user-written text, emails, web pages) can contain instructions. Validate arguments in the tool, require approval for anything irreversible, and escape model output before it reaches HTML.
  • Limit cost. Cap steps per job, keep the per-user rate_limit (it needs a shared cache store such as Solid Cache when you run several servers), and set spending limits with your model provider. RubyLLM records token usage and cost for every request.
  • Run the loop in jobs. A job backend such as Solid Queue keeps Puma threads free while the model works, and Action Cable (Solid Cable by default in Rails 8) must be configured in production for the stream to reach the browser. Streaming straight from a controller with ActionController::Live also works, but it holds a server thread for as long as the reply takes.
  • Keep secrets in credentials. The model key belongs in config/credentials.yml.enc or a server environment variable, never in views, importmap JavaScript or the repository in plain text.

Option: embed an in-app agent with Bunkhouse

If the agent's job is to help signed-in users do work your Ruby on Rails app already supports, you may not need to build the tools, agent loop and chat UI yourself. Bunkhouse adds an Assistant pane to an existing web app with one script tag. When a user opens it, the assistant works with what the page already offers: buttons, links, form fields and tables, the API requests your app makes, a hints file you publish for operations it cannot see, and WebMCP tools your page declares.

The model writes small programs that run in a sandbox on the embed origin, not in your page. They can only call capabilities the assistant has discovered and your configuration allows. Write actions are shown to the user for review before they run, the model never sees credentials, and your Ruby on Rails backend keeps enforcing its own authorization on every request.

In Rails, render the loader in the layout's <head> before javascript_importmap_tags, with data-turbo-track="reload" so a Turbo visit never adds a second copy, and with the CSP nonce if you use a Content Security Policy. For signed-in users, add a GET /api/agent-identity action that signs a short-lived token for Current.user and answers 401 when nobody is signed in; the signing secret stays in a server environment variable.

app/views/layouts/application.html.erb (excerpt)
<head>
  <%= csrf_meta_tags %>
  <%= csp_meta_tag %>
  <%= javascript_include_tag "agent_identity", "data-turbo-track": "reload" %>
  <script src="https://embed.bunkhouse.ai/embed.js" nonce="<%= content_security_policy_nonce %>"
    data-public-key="pk_YOUR_SITE_PUBLIC_KEY" data-turbo-track="reload"><\/script>
  <%= javascript_importmap_tags %>
</head>

The tested Ruby on Rails integration guide has the complete code for each session mode (signed identity token, server mint and anonymous), CSRF headers and Content Security Policy settings.

When Bunkhouse fits, and when to build it yourself

  • Bunkhouse fits when users want tasks done inside your existing pages, such as filtering, editing or submitting through the controls and API you already have, and you would rather not write and maintain a tool definition for each action, a sandbox for model-written code, an approval UI and usage metering.
  • Build it yourself when the agent must run without a browser page (background jobs, email, chat platforms, scheduled work), when you need to choose the model provider, use your own keys or run inference on your own infrastructure, or when the conversation must be part of your own UI components.
  • Plan for the limits: discovery cannot see server-only routes, workflows the user has not visited, closed shadow roots or cross-origin frames (describe those in a hints file); some controls need a real user gesture; password, payment and file fields are never read, and you can mark other content private.
  • Cost: production use is $49 per month, including $20 of usage credit per paid billing period, then $1 per million input tokens and $3 per million output tokens. See billing.

Try it on your Ruby on Rails app first

A coding agent such as Claude Code, Codex or Cursor can run a free, disposable agent trial on a dummy page of your Ruby on Rails app. No account, card or model API key is needed: your coding agent acts as the assistant's model. A trial lasts 24 hours and allows 100 turns. Give your agent this instruction, changing the address if your app runs elsewhere:

Agent trialPrompt for your coding agent
Read https://api.bunkhouse.ai/agent-trial.md and follow it to run a Bunkhouse agent trial on my dummy Ruby on Rails page at http://localhost:3000.

Use a page with disposable data: everything the assistant reads is sent to your agent.

The trial evaluation checklist shows how to test a read, a rejected write and an approved change, and what a trial does not prove about production use.

Add an agent to your Ruby on Rails app.