Rails Application¶
Full Rails integration with Action Cable and background jobs.
Overview¶
This example demonstrates integrating RobotLab into a Rails application with real-time streaming via Action Cable, background job processing, and persistent conversation history.
Setup¶
1. Add to Gemfile¶
2. Run Generator¶
This creates:
config/initializers/robot_lab.rbapp/robots/directoryapp/tools/directory- Database migrations for conversation history
3. Run Migrations¶
Configuration¶
RobotLab uses MywayConfig for configuration. There is no RobotLab.configure block. Instead, configuration is loaded automatically from multiple sources in priority order:
- Bundled defaults (
lib/robot_lab/config/defaults.yml) - Environment-specific overrides (development, test, production)
- XDG user config (
~/.config/robot_lab/config.yml) - Project config (
./config/robot_lab.yml) - Environment variables (
ROBOT_LAB_*prefix)
Config File¶
# config/robot_lab.yml
defaults:
ruby_llm:
model: claude-sonnet-4
anthropic_api_key: <%= ENV['ANTHROPIC_API_KEY'] %>
development:
ruby_llm:
log_level: :debug
production:
ruby_llm:
request_timeout: 180
max_retries: 5
Environment Variables¶
# Provider API keys
ROBOT_LAB_RUBY_LLM__ANTHROPIC_API_KEY=sk-ant-...
ROBOT_LAB_RUBY_LLM__OPENAI_API_KEY=sk-...
# Model configuration
ROBOT_LAB_RUBY_LLM__MODEL=claude-sonnet-4
ROBOT_LAB_RUBY_LLM__REQUEST_TIMEOUT=120
Accessing Configuration¶
# Read configuration values at runtime
RobotLab.config.ruby_llm.model #=> "claude-sonnet-4"
RobotLab.config.ruby_llm.request_timeout #=> 120
# The logger defaults to Rails.logger when running in Rails
RobotLab.config.logger #=> Rails.logger
Rails Engine and Railtie¶
RobotLab provides both a Rails Engine (RobotLab::Rails::Engine) and a Railtie (RobotLab::Rails::Railtie). These are loaded automatically when Rails is detected. The Engine isolates the RobotLab namespace and adds app/robots and app/tools to the autoload paths. The Railtie loads rake tasks and generators.
Models¶
# app/models/conversation_thread.rb
class ConversationThread < ApplicationRecord
belongs_to :user
has_many :messages, class_name: "ConversationMessage", dependent: :destroy
validates :external_id, presence: true, uniqueness: true
def self.find_or_create_for(user:, external_id: nil)
external_id ||= SecureRandom.uuid
find_or_create_by!(user: user, external_id: external_id)
end
end
# app/models/conversation_message.rb
class ConversationMessage < ApplicationRecord
belongs_to :thread, class_name: "ConversationThread"
validates :role, presence: true
validates :content, presence: true
scope :ordered, -> { order(:position) }
end
Robot Definitions¶
Robots are built using RobotLab.build with named parameters. Tools are Ruby classes that inherit from RubyLLM::Tool.
# app/tools/get_user_info_tool.rb
class GetUserInfoTool < RubyLLM::Tool
description "Get information about the current user"
param :user_id, type: :integer, desc: "The user ID to look up"
def execute(user_id:)
user = User.find(user_id)
{
name: user.name,
email: user.email,
plan: user.subscription&.plan || "free",
member_since: user.created_at.to_date.to_s
}
rescue ActiveRecord::RecordNotFound
{ error: "User not found" }
end
end
# app/tools/get_orders_tool.rb
class GetOrdersTool < RubyLLM::Tool
description "Get user's recent orders"
param :user_id, type: :integer, desc: "The user ID"
param :limit, type: :integer, desc: "Number of orders to return", default: 5
def execute(user_id:, limit: 5)
orders = Order.where(user_id: user_id)
.order(created_at: :desc)
.limit(limit)
orders.map do |order|
{
id: order.external_id,
status: order.status,
total: order.total.to_f,
created_at: order.created_at.iso8601
}
end
end
end
# app/tools/create_ticket_tool.rb
class CreateTicketTool < RubyLLM::Tool
description "Create a support ticket"
param :user_id, type: :integer, desc: "The user ID"
param :subject, type: :string, desc: "Ticket subject"
param :description, type: :string, desc: "Ticket description"
param :priority, type: :string, desc: "Priority level", enum: %w[low medium high]
def execute(user_id:, subject:, description:, priority: "medium")
ticket = SupportTicket.create!(
user_id: user_id,
subject: subject,
description: description,
priority: priority
)
{
success: true,
ticket_id: ticket.external_id,
message: "Ticket created successfully"
}
rescue => e
{ success: false, error: e.message }
end
end
# app/robots/support_robot.rb
class SupportRobot
def self.build(user_id:)
RobotLab.build(
name: "support",
system_prompt: <<~PROMPT,
You are a helpful customer support assistant for our company.
Be friendly, professional, and thorough in your responses.
If you need to look up information, use the available tools.
The current user ID is #{user_id}.
PROMPT
local_tools: [GetUserInfoTool, GetOrdersTool, CreateTicketTool]
)
end
end
Network Configuration¶
Networks use create_network with a block DSL that defines tasks and their dependencies:
# app/robots/support_network.rb
class SupportNetwork
def self.build(user_id:)
support = SupportRobot.build(user_id: user_id)
RobotLab.create_network(name: "support_network") do
task :support, support, depends_on: :none
end
end
end
Service Object¶
# app/services/chat_service.rb
class ChatService
def initialize(user:, thread_id: nil)
@user = user
@thread_id = thread_id
end
def call(message:)
robot = SupportRobot.build(user_id: @user.id)
result = robot.run(message)
{
response: result.last_text_content,
has_tool_calls: result.has_tool_calls?
}
end
end
Controller¶
# app/controllers/api/chats_controller.rb
module Api
class ChatsController < ApplicationController
before_action :authenticate_user!
def create
service = ChatService.new(
user: current_user,
thread_id: params[:thread_id]
)
result = service.call(message: params[:message])
render json: {
response: result[:response]
}
end
end
end
Action Cable Integration¶
# app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
def subscribed
stream_for current_user
end
def receive(data)
ChatJob.perform_later(
user_id: current_user.id,
thread_id: data["thread_id"],
message: data["message"]
)
end
end
# app/jobs/chat_job.rb
class ChatJob < ApplicationJob
queue_as :default
def perform(user_id:, thread_id:, message:)
user = User.find(user_id)
robot = SupportRobot.build(user_id: user.id)
result = robot.run(message)
ChatChannel.broadcast_to(
user,
type: "complete",
content: result.last_text_content
)
end
end
Frontend (Stimulus)¶
// app/javascript/controllers/chat_controller.js
import { Controller } from "@hotwired/stimulus"
import { createConsumer } from "@rails/actioncable"
export default class extends Controller {
static targets = ["messages", "input", "response"]
connect() {
this.consumer = createConsumer()
this.channel = this.consumer.subscriptions.create("ChatChannel", {
received: (data) => this.handleMessage(data)
})
}
disconnect() {
this.channel?.unsubscribe()
}
send() {
const message = this.inputTarget.value.trim()
if (!message) return
this.appendMessage("user", message)
this.inputTarget.value = ""
// Create response container
this.currentResponse = document.createElement("div")
this.currentResponse.className = "message assistant"
this.messagesTarget.appendChild(this.currentResponse)
this.channel.send({
message: message,
thread_id: this.threadId
})
}
handleMessage(data) {
switch (data.type) {
case "complete":
this.currentResponse.textContent = data.content
break
}
}
appendMessage(role, content) {
const div = document.createElement("div")
div.className = `message ${role}`
div.textContent = content
this.messagesTarget.appendChild(div)
}
}
View¶
<!-- app/views/chats/show.html.erb -->
<div data-controller="chat">
<div class="messages" data-chat-target="messages">
<!-- Messages appear here -->
</div>
<form data-action="submit->chat#send">
<input type="text"
data-chat-target="input"
placeholder="Type a message..."
autocomplete="off">
<button type="submit">Send</button>
</form>
</div>
Running¶
# Install dependencies
bundle install
yarn install
# Setup database
rails db:migrate
# Set API key (or configure via config/robot_lab.yml)
export ANTHROPIC_API_KEY="your-key"
# Start server
bin/dev
Key Concepts¶
- Robot Factory:
RobotLab.build(name:, system_prompt:, local_tools:, ...)creates robot instances - MywayConfig: Configuration via YAML files and environment variables, not a configure block
robot.run("message"): Send a message as a positional string argumentresult.last_text_content: Extract the response text from aRobotResult- Memory: Robots have
robot.memoryfor key-value storage; networks share memory - Tools: Ruby classes inheriting from
RubyLLM::Tool, passed vialocal_tools: - Action Cable: Real-time streaming to browser
- Background Jobs: Non-blocking processing