Generators and App Conventions
Scaffold chats, a chat UI, agents, tools, and schemas with RubyLLM’s Rails generators and conventions.
After reading this guide, you will know:
- What the
ruby_llm:installandruby_llm:chat_uigenerators create for you. - How RubyLLM’s conventional app directory structure is organized.
- How to generate agents, tools, schemas, MCP servers, skills, sandboxes, evaluations, and judges.
- How the generated chat UI renders messages, tool calls, tool results, approvals, and attachments.
- How to customize model names and set up ActiveStorage.
RubyLLM’s generators write models, migrations, controllers, jobs, and views into your application. Start with persistence, then add the chat UI or generate individual agents, tools, and schemas as you need them.
Quick Setup with Generator
Run the install generator:
bin/rails generate ruby_llm:install
The generator:
- Creates application migrations and models for Chat and Message
- Creates one migration for RubyLLM’s internal model, tool-call, usage, and batch tables
- Adds the
acts_as_chatandacts_as_messagedeclarations - Installs Active Storage, which
acts_as_messageuses for attachments - Creates an initializer for provider configuration
- Creates the conventional AI app directories:
app/agents,app/tools,app/schemas,app/prompts,app/skills,app/sandboxes,app/mcp,app/evals, andapp/judges - Creates
app/agents/application_agent.rb, the parent of your agents, pointed at your chat model
After running the generator:
bin/rails db:migrate
bin/rails ruby_llm:load_models
You can now create a persisted chat with Chat.create! and send a message with chat.ask.
Install Generator Options
The generator uses Rails-like syntax for custom model names:
# Default - creates the application Chat and Message models
bin/rails generate ruby_llm:install
bin/rails generate ruby_llm:install chat:Conversation message:ChatMessage
bin/rails generate ruby_llm:install chat:Discussion message:DiscussionMessage
The name:ClassName syntax follows Rails conventions - specify only what you want to customize.
The generated models are one line each:
class Message < ApplicationRecord
acts_as_message
end
acts_as_message declares the message’s attachments and, when config.broadcast_messages is on, broadcasts the chat’s messages with Turbo. ruby_llm:chat_ui turns that setting on in your RubyLLM initializer. See Streaming with Hotwire/Turbo.
Adding a Chat UI
Run the chat UI generator to add controllers, views, and a background job:
bin/rails generate ruby_llm:chat_ui
The generator creates:
- Agent:
ChatAgent, which the controllers and job create and load chats through - Controllers: Create chats, save messages and enqueue their replies, queue and unqueue messages sent during a reply, and record approval decisions
- Views: Render messages, their attachments, queued messages, and approval cards, and update them with Turbo Streams
- Jobs: Request AI responses in the background and continue the conversation after a decision or with queued messages
- Routes: RESTful routes for chats, messages, queued messages, and approvals
Start your server and visit http://localhost:3000/chats.
The controllers save the user’s message with chat.ask_later before they enqueue the job, so the message shows right away and a job that runs again after a failure does not repeat it. The generated job streams each reply with the chunks’ own broadcast. ChatsController#create enqueues a new chat’s first reply a second later, so the new page has subscribed to the chat’s stream before the reply starts:
class ChatResponseJob < ApplicationJob
def perform(chat_id)
chat = ChatAgent.find(chat_id)
chat.complete(&:broadcast)
self.class.perform_later(chat_id) if chat.queued_messages.any? && !chat.waiting?
end
end
Replies stream into the page chunk by chunk, and the finished message replaces the streamed text once when it is saved. New messages, approval cards, and the form appear through page refreshes, so the page always matches what a reload would show. A Stimulus controller, app/javascript/controllers/chat_message_controller.js, puts the chunks in order and renders assistant replies as Markdown, sanitized with DOMPurify. Images in a reply show as text, so a prompt injection cannot make the browser fetch a URL that leaks the chat, while attachments still show. The generator pins its two dependencies, marked and dompurify, with bin/importmap pin at exact versions. In an app that bundles its JavaScript, it prints the yarn add command and bin/rails stimulus:manifest:update instead. Without JavaScript, replies show as plain text. See Message Ordering Issues. While the job runs the chat, an empty reply shows “Thinking…” and the message form stays. A message sent in the meantime is queued: MessagesController calls chat.queue while chat.running?, and the message shows under the transcript, faded, with a Cancel button that posts to QueuedMessagesController#destroy, which calls chat.unqueue. The running job takes it at its next step, so the reply goes on to answer it, and the page refresh then shows it in the transcript. chat.running? reads the chat’s claim, so a job that dies frees the chat once its claim expires.
The UI generator also supports custom model names:
bin/rails generate ruby_llm:chat_ui chat:Conversation message:ChatMessage
Approvals and Attachments
When a tool requires approval, the chat page shows a card for each call in chat.pending_approvals, with its arguments and Approve and Deny buttons. A run_code call that asks for a wider sandbox shows its reason, its code, and what that sandbox allows. Remote tool calls, such as those from MCP servers, get the same cards. While a call waits, the transcript hides its tool call entry so the call shows only as a card, and the message form gives way to a line asking for a decision. MessagesController redirects a message sent in the meantime back to the chat.
Each button posts to ApprovalsController#create, which records the decision with chat.approve or chat.deny, replaces the cards with a Turbo Stream, and enqueues ChatResponseJob to continue the conversation. Decided calls show as ordinary tool calls with their results. When the job finishes or pauses for the next approval, the page refreshes with the new cards and the message form.
The chat UI talks to ChatAgent in app/agents/chat_agent.rb. The controllers and the job create chats with ChatAgent.create! and load them with ChatAgent.find, so whatever you add to it reaches the UI. Give it tools, instructions, or code execution, and tools that require approval get their cards:
class ChatAgent < ApplicationAgent
instructions "You help the finance team with invoices."
tools SendInvoiceTool
code_execution sandbox: ReadOnlySandbox, escalate_to: [WorkspaceSandbox]
end
The agent is named after your chat model: chat:Conversation gives ConversationAgent, and chat:Llm::Chat gives Llm::ChatAgent in app/agents/llm/chat_agent.rb.
Messages show their Active Storage attachments: images inline, and other files as download links with their size. That includes the files a code run returns, such as charts.
Upgrading an Existing Integration
When a release changes the Rails schema, it ships ruby_llm:upgrade for applications on the previous release. The generator covers only the changes since that release, so upgrade one release at a time. See Upgrading for the current steps.
An application on RubyLLM 1.16 first installs 2.0 and follows the 2.0 upgrade guide.
Conventional Directory Structure
RubyLLM’s Rails generators establish a default app structure:
app/
|-- agents/ agent classes
|-- tools/ tool classes
|-- schemas/ structured output schemas
|-- prompts/ ERB instructions, one folder per agent
|-- skills/ Agent Skills folders, one per skill
|-- sandboxes/ sandbox classes for code execution
|-- mcp/ MCP server classes
|-- evals/ evaluations and their datasets
`-- judges/ judge classes
The install generator creates these directories with .gitkeep files so teams start from one shared convention, and the upgrade generator adds any an existing application lacks.
These are conventions, not hard requirements:
- Agents, tools, schemas, sandboxes, MCP servers, and judges can live anywhere in your app autoload paths.
with_skillsand theskillsmacro find skills inapp/skillsunless you add other roots.- Evaluations find their dataset next to the class in
app/evals. - Prompt lookup convention: named agents automatically resolve
instructions.txt.erbfrom the agent class name when the file exists.
For prompt lookup, RubyLLM uses class name conventions:
WorkAssistant->app/prompts/work_assistant/instructions.txt.erbAdmin::SupportAgent->app/prompts/admin/support_agent/instructions.txt.erb
See the Agents guide for how agent instruction rendering works, or Prompt Rendering for direct RubyLLM.render_prompt usage.
Generators
Alongside ruby_llm:install and ruby_llm:chat_ui, every building block has a generator that puts it where RubyLLM looks for it:
bin/rails generate ruby_llm:agent Support
bin/rails generate ruby_llm:tool Weather
bin/rails generate ruby_llm:schema Product
bin/rails generate ruby_llm:mcp Linear https://mcp.linear.app/mcp
bin/rails generate ruby_llm:skill invoices "Bills an order to a customer as a PDF."
bin/rails generate ruby_llm:sandbox Workspace
bin/rails generate ruby_llm:evaluation Support
bin/rails generate ruby_llm:judge Urgency
What each generator creates:
ruby_llm:agent:app/agents/support_agent.rb, a subclass ofApplicationAgent, andapp/prompts/support_agent/instructions.txt.erb. In an app withoutApplicationAgent, the agent inherits fromRubyLLM::Agentand names its chat model itself.ruby_llm:tool:app/tools/weather_tool.rbplus tool-specific chat UI partials underapp/views/messages/tool_callsandapp/views/messages/tool_resultsruby_llm:schema:app/schemas/product_schema.rbruby_llm:mcp:app/mcp/linear.rb, connecting to the URL you give. For a server that runs on your machine, pass--commandinstead:bin/rails generate ruby_llm:mcp Files --command "npx -y @modelcontextprotocol/server-filesystem ."ruby_llm:skill:app/skills/invoices/SKILL.mdwith the name and description. Names use lowercase letters, digits, and hyphens, as the Agent Skills format requires.ruby_llm:sandbox:app/sandboxes/workspace_sandbox.rb, anMXC::Sandboxwith a two-minute timeout, andgem "mxc"in your Gemfile when it is missingruby_llm:evaluation:app/evals/support_evaluation.rband its dataset,support_evaluation.yml, with one case to fill in. It runsSupportAgentwhen you have one.ruby_llm:judge:app/judges/urgency_judge.rb
ruby_llm:chat_ui and ruby_llm:tool accept --ui scaffold or --ui tailwind. The default, --ui auto, picks Tailwind when your app has it. An existing app follows Upgrading instead of running ruby_llm:install.
If your chat UI uses a custom message model, pass the same mapping you gave ruby_llm:chat_ui so the partials land where the UI looks for them:
bin/rails generate ruby_llm:tool Weather message:ChatMessage
Chat UI View Conventions
The generated chat UI follows one convention: each message partial uses the local that matches its partial name.
messages/_user.html.erbgetsusermessages/_assistant.html.erbgetsassistantmessages/_system.html.erbgetssystemmessages/_tool.html.erbgetstoolmessages/_tool_calls.html.erbgetstool_calls
This comes from Rails partial rendering: render @chat.messages calls to_partial_path, and Rails injects a local named after that partial.
For compatibility with model broadcasts (broadcasts_to), generated message partials also accept a message local as a fallback.
Shared partials take explicit locals: messages/_attachments.html.erb gets message, chats/_approvals.html.erb gets chat, and approvals/_approval.html.erb gets approval and chat.
Tool Call and Tool Result Partials
Tool-specific partials are generated under app/views/messages/tool_calls and app/views/messages/tool_results:
app/views/messages/
|-- tool_calls/
| |-- _default.html.erb
| `-- _your_tool.html.erb
`-- tool_results/
|-- _default.html.erb
`-- _your_tool.html.erb
Locals passed to those partials:
messages/tool_calls/_your_tool.html.erbreceivesmessageandtool_callmessages/tool_results/_your_tool.html.erbreceivestool
ruby_llm:tool creates _your_tool.html.erb files with the correct names so custom rendering hooks up automatically.
Using fixed locals keeps the templates dumb and predictable.
Turbo Stream templates used by the generated chat UI:
messages/create.turbo_stream.erbresets the message form forMessagesController#create.approvals/create.turbo_stream.erbreplaces the approval cards forApprovalsController#create.
Setting Up Active Storage
acts_as_message stores attachments with Active Storage, and the install generator installs it. Without it, the message model boots without attachments and RubyLLM logs how to add them. An application that does not have it yet runs:
bin/rails active_storage:install
bin/rails db:migrate
The ActiveStorage attachments you pass to with: from your own models can use any name.
Next Steps
- Persistence with acts_as - what the generated models do once they
acts_as_chat. - Streaming with Hotwire/Turbo - the streaming flow behind the generated chat UI.
- Agents - build on the generated agent and prompt conventions.
- Advanced Rails Configuration - provider overrides and custom contexts for the generated app.