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
| Approach | Good for | What you take on |
|---|---|---|
| RubyLLM with Active Record chats | A 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 widget | Questions 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:
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_modelsAdd 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.
RubyLLM.configure do |config|
config.anthropic_api_key = Rails.application.credentials.dig(:anthropic, :api_key)
end1. 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.
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
endThe 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.
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
end2. 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.
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)] }
endThe 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.
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
end3. 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.
resources :chats, only: %i[create show] do
resources :messages, only: :create
resources :approvals, only: :create
endclass 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
endThe 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.
<%= 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. Callingallow_unauthenticated_accessin these controllers would expose them. - Keep CSRF protection on.
ActionController::Baseverifies the authenticity token on every non-GET request, and Turbo sends the token fromcsrf_meta_tagswith its form submissions. Do not addskip_forgery_protectionto 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::Livealso 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.encor 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.
<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:
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.
Related guides
- How to add AI to your website: chat assistants, in-app agents and custom builds compared
- MCP vs WebMCP: choosing where outside agents act on your site
- AI agents in other Ruby frameworks: Sinatra