This is Part 14 of the Ruby for AI series. Last time we added Stimulus for lightweight JavaScript. Now we're going real-time with ActionCable and WebSockets.
If you're building AI features, real-time is non-negotiable. Users expect streaming responses, live updates, and instant feedback. ActionCable gives you all of that with zero external dependencies.
What Is ActionCable?
ActionCable is Rails' built-in WebSocket framework. It wraps the WebSocket protocol into channels — think of them like controllers, but for persistent, bidirectional connections.
HTTP: Client asks, server answers, connection closes.
WebSocket: Connection stays open. Both sides can send messages anytime.
That's it. No polling. No SSE hacks. A real persistent connection.
Setting Up ActionCable
ActionCable comes with Rails by default. If you used rails new with the standard stack, you're ready. Check your config/cable.yml:
# config/cable.yml
development:
adapter: async
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL", "redis://localhost:6379/1") %>
The async adapter works for development. In production, you'll want Redis — it handles multiple server processes sharing the same WebSocket connections.
Mount it in your routes (usually already there):
# config/routes.rb
Rails.application.routes.draw do
mount ActionCable.server => '/cable'
end
Your First Channel
Let's build a chat room. Generate a channel:
rails generate channel Chat
This creates two files:
# app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
def subscribed
stream_from "chat_room"
end
def unsubscribed
# Cleanup when client disconnects
end
def speak(data)
ActionCable.server.broadcast("chat_room", {
message: data["message"],
sent_at: Time.current.strftime("%H:%M")
})
end
end
The subscribed method runs when a client connects. stream_from tells ActionCable which broadcast stream to listen to. The speak method is called from the client — yes, clients can call server methods directly.
The Client Side
// app/javascript/channels/chat_channel.js
import consumer from "channels/consumer"
consumer.subscriptions.create("ChatChannel", {
connected() {
console.log("Connected to chat")
},
disconnected() {
console.log("Disconnected from chat")
},
received(data) {
const messages = document.getElementById("messages")
messages.insertAdjacentHTML("beforeend",
`<div class="message">
<span class="time">${data.sent_at}</span>
<span class="body">${data.message}</span>
</div>`
)
},
speak(message) {
this.perform("speak", { message: message })
}
})
this.perform("speak", ...) calls the speak method on the server channel. received(data) fires when the server broadcasts. That's the full loop.
Broadcasting from Anywhere
The real power: you can broadcast from anywhere in your app. Models, jobs, controllers — anywhere.
# From a controller
class MessagesController < ApplicationController
def create
@message = Message.create!(message_params)
ActionCable.server.broadcast("chat_room", {
message: @message.body,
user: @message.user.name,
sent_at: @message.created_at.strftime("%H:%M")
})
head :ok
end
end
# From a background job
class AiResponseJob < ApplicationJob
def perform(conversation_id, prompt)
response = OpenAI::Client.new.chat(
parameters: { model: "gpt-4", messages: [{ role: "user", content: prompt }] }
)
ActionCable.server.broadcast(
"conversation_#{conversation_id}",
{ response: response.dig("choices", 0, "message", "content") }
)
end
end
This pattern is huge for AI features. Kick off a slow API call in a background job, then push the result to the user's browser the instant it's ready.
Dynamic Channels with Parameters
You don't want everyone in one room. Use parameters to create per-user or per-conversation streams:
# app/channels/conversation_channel.rb
class ConversationChannel < ApplicationCable::Channel
def subscribed
conversation = Conversation.find(params[:id])
stream_from "conversation_#{conversation.id}"
end
end
Client subscribes with params:
consumer.subscriptions.create(
{ channel: "ConversationChannel", id: conversationId },
{
received(data) {
// Handle incoming message
}
}
)
Authentication
ActionCable uses cookies by default. Set up the connection:
# app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
if (user = User.find_by(id: cookies.encrypted[:user_id]))
user
else
reject_unauthorized_connection
end
end
end
end
Now every channel can access current_user, just like a controller.
ActionCable + Turbo Streams
Here's where it gets beautiful. Turbo Streams can ride on ActionCable, giving you real-time DOM updates with zero custom JavaScript:
<%# app/views/conversations/show.html.erb %>
<%= turbo_stream_from @conversation %>
<div id="messages">
<%= render @conversation.messages %>
</div>
# app/models/message.rb
class Message < ApplicationRecord
belongs_to :conversation
after_create_commit -> {
broadcast_append_to conversation, target: "messages"
}
end
Create a message anywhere — controller, job, console — and it appears in every connected browser automatically. No JavaScript. No channel code. Just a model callback and a Turbo Stream tag.
What's Next
You've got real-time communication locked down. Next up: Active Job & Background Processing — Sidekiq, Solid Queue, and async patterns that'll power your AI features behind the scenes.
Part 14 of the Ruby for AI series. Code runs on Rails 8+.
Top comments (0)