Implements real-time features with Action Cable and WebSockets. Use when adding live updates, chat features, notifications, real-time dashboards, or when user mentions Action Cable, WebSockets, channels, or real-time.
Install with Tessl CLI
npx tessl i github:ThibautBaissac/rails_ai_agents --skill action-cable-patterns90
Does it follow best practices?
If you maintain this skill, you can automatically optimize it using the tessl CLI to improve its score:
npx tessl skill review --optimize ./path/to/skillValidation for skill structure
Action Cable integrates WebSockets with Rails:
Action Cable is included in Rails by default. Configure it:
# config/cable.yml
development:
adapter: async
test:
adapter: test
production:
adapter: solid_cable # Rails 8 default
# OR
adapter: redis
url: <%= ENV.fetch("REDIS_URL") %>app/
├── channels/
│ ├── application_cable/
│ │ ├── connection.rb # Authentication
│ │ └── channel.rb # Base channel
│ ├── notifications_channel.rb
│ ├── events_channel.rb
│ └── chat_channel.rb
├── javascript/
│ └── channels/
│ ├── consumer.js
│ ├── notifications_channel.js
│ └── events_channel.js
spec/channels/
├── notifications_channel_spec.rb
└── events_channel_spec.rb# 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
# Using Rails 8 authentication
if session_token = cookies.signed[:session_token]
if session = Session.find_by(token: session_token)
session.user
else
reject_unauthorized_connection
end
else
reject_unauthorized_connection
end
end
end
end# app/channels/notifications_channel.rb
class NotificationsChannel < ApplicationCable::Channel
def subscribed
stream_for current_user
end
def unsubscribed
# Cleanup when user disconnects
end
# Class method to broadcast
def self.notify(user, notification)
broadcast_to(user, {
type: "notification",
id: notification.id,
title: notification.title,
body: notification.body,
created_at: notification.created_at.iso8601
})
end
end// app/javascript/channels/notifications_channel.js
import consumer from "./consumer"
consumer.subscriptions.create("NotificationsChannel", {
connected() {
console.log("Connected to notifications")
},
disconnected() {
console.log("Disconnected from notifications")
},
received(data) {
if (data.type === "notification") {
this.showNotification(data)
}
},
showNotification(notification) {
// Show toast or update notification badge
const event = new CustomEvent("notification:received", { detail: notification })
window.dispatchEvent(event)
}
})# app/channels/events_channel.rb
class EventsChannel < ApplicationCable::Channel
def subscribed
@event = Event.find(params[:event_id])
# Authorization check
if authorized?
stream_for @event
else
reject
end
end
def unsubscribed
# Cleanup
end
# Broadcast update to all subscribers
def self.broadcast_update(event)
broadcast_to(event, {
type: "update",
html: render_event(event)
})
end
def self.broadcast_comment(event, comment)
broadcast_to(event, {
type: "new_comment",
html: render_comment(comment)
})
end
private
def authorized?
EventPolicy.new(current_user, @event).show?
end
def self.render_event(event)
ApplicationController.renderer.render(
partial: "events/event",
locals: { event: event }
)
end
def self.render_comment(comment)
ApplicationController.renderer.render(
partial: "comments/comment",
locals: { comment: comment }
)
end
end// app/javascript/channels/events_channel.js
import consumer from "./consumer"
const eventId = document.querySelector("[data-event-id]")?.dataset.eventId
if (eventId) {
consumer.subscriptions.create(
{ channel: "EventsChannel", event_id: eventId },
{
connected() {
console.log(`Connected to event ${eventId}`)
},
received(data) {
switch(data.type) {
case "update":
this.handleUpdate(data)
break
case "new_comment":
this.handleNewComment(data)
break
}
},
handleUpdate(data) {
const container = document.getElementById("event-details")
if (container) {
container.innerHTML = data.html
}
},
handleNewComment(data) {
const comments = document.getElementById("comments")
if (comments) {
comments.insertAdjacentHTML("beforeend", data.html)
}
}
}
)
}# app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
def subscribed
@room = ChatRoom.find(params[:room_id])
if authorized?
stream_for @room
broadcast_presence(:join)
else
reject
end
end
def unsubscribed
broadcast_presence(:leave) if @room
end
def speak(data)
message = @room.messages.create!(
user: current_user,
body: data["body"]
)
self.class.broadcast_message(@room, message)
end
def typing
self.class.broadcast_to(@room, {
type: "typing",
user: current_user.name
})
end
def self.broadcast_message(room, message)
broadcast_to(room, {
type: "message",
html: render_message(message),
message_id: message.id
})
end
private
def authorized?
@room.users.include?(current_user)
end
def broadcast_presence(action)
self.class.broadcast_to(@room, {
type: "presence",
action: action,
user: current_user.name,
timestamp: Time.current.iso8601
})
end
def self.render_message(message)
ApplicationController.renderer.render(
partial: "messages/message",
locals: { message: message }
)
end
end// app/javascript/channels/chat_channel.js
import consumer from "./consumer"
export function connectToChat(roomId) {
return consumer.subscriptions.create(
{ channel: "ChatChannel", room_id: roomId },
{
connected() {
console.log("Connected to chat")
},
disconnected() {
console.log("Disconnected from chat")
},
received(data) {
switch(data.type) {
case "message":
this.handleMessage(data)
break
case "typing":
this.handleTyping(data)
break
case "presence":
this.handlePresence(data)
break
}
},
speak(body) {
this.perform("speak", { body: body })
},
typing() {
this.perform("typing")
},
handleMessage(data) {
const messages = document.getElementById("messages")
messages.insertAdjacentHTML("beforeend", data.html)
messages.scrollTop = messages.scrollHeight
},
handleTyping(data) {
const indicator = document.getElementById("typing-indicator")
indicator.textContent = `${data.user} is typing...`
setTimeout(() => indicator.textContent = "", 2000)
},
handlePresence(data) {
const status = document.getElementById("presence-status")
status.textContent = `${data.user} ${data.action}ed`
}
}
)
}# app/channels/dashboard_channel.rb
class DashboardChannel < ApplicationCable::Channel
def subscribed
stream_for current_user.account
end
def self.broadcast_stats(account)
stats = DashboardStatsQuery.new(account: account).call
broadcast_to(account, {
type: "stats_update",
stats: stats
})
end
def self.broadcast_activity(account, activity)
broadcast_to(account, {
type: "new_activity",
html: render_activity(activity)
})
end
private
def self.render_activity(activity)
ApplicationController.renderer.render(
partial: "activities/activity",
locals: { activity: activity }
)
end
end# app/services/events/update_service.rb
module Events
class UpdateService
def call(event, params)
event.update!(params)
# Broadcast to all viewers
EventsChannel.broadcast_update(event)
# Update dashboard stats
DashboardChannel.broadcast_stats(event.account)
success(event)
end
end
end# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :event
belongs_to :user
after_create_commit :broadcast_to_channel
private
def broadcast_to_channel
EventsChannel.broadcast_comment(event, self)
end
end# app/models/comment.rb
class Comment < ApplicationRecord
after_create_commit -> {
broadcast_append_to(
[event, "comments"],
target: "comments",
partial: "comments/comment"
)
}
after_destroy_commit -> {
broadcast_remove_to([event, "comments"])
}
end<%# app/views/events/show.html.erb %>
<%= turbo_stream_from @event, "comments" %>
<div id="comments">
<%= render @event.comments %>
</div># spec/channels/notifications_channel_spec.rb
require "rails_helper"
RSpec.describe NotificationsChannel, type: :channel do
let(:user) { create(:user) }
before do
stub_connection(current_user: user)
end
describe "#subscribed" do
it "successfully subscribes" do
subscribe
expect(subscription).to be_confirmed
end
it "streams for the current user" do
subscribe
expect(subscription).to have_stream_for(user)
end
end
describe ".notify" do
let(:notification) { create(:notification, user: user) }
it "broadcasts to the user" do
expect {
described_class.notify(user, notification)
}.to have_broadcasted_to(user).with(hash_including(type: "notification"))
end
end
end# spec/channels/events_channel_spec.rb
require "rails_helper"
RSpec.describe EventsChannel, type: :channel do
let(:user) { create(:user) }
let(:event) { create(:event, account: user.account) }
let(:other_event) { create(:event) }
before do
stub_connection(current_user: user)
end
describe "#subscribed" do
context "with authorized event" do
it "subscribes successfully" do
subscribe(event_id: event.id)
expect(subscription).to be_confirmed
expect(subscription).to have_stream_for(event)
end
end
context "with unauthorized event" do
it "rejects subscription" do
subscribe(event_id: other_event.id)
expect(subscription).to be_rejected
end
end
end
end# spec/system/chat_spec.rb
require "rails_helper"
RSpec.describe "Chat", type: :system, js: true do
let(:user) { create(:user) }
let(:room) { create(:chat_room, users: [user]) }
before { sign_in user }
it "sends and receives messages in real-time" do
visit chat_room_path(room)
fill_in "message", with: "Hello, world!"
click_button "Send"
expect(page).to have_content("Hello, world!")
end
end// app/javascript/controllers/chat_controller.js
import { Controller } from "@hotwired/stimulus"
import consumer from "../channels/consumer"
export default class extends Controller {
static targets = ["messages", "input", "typingIndicator"]
static values = { roomId: Number }
connect() {
this.channel = consumer.subscriptions.create(
{ channel: "ChatChannel", room_id: this.roomIdValue },
{
received: this.received.bind(this),
connected: this.connected.bind(this),
disconnected: this.disconnected.bind(this)
}
)
}
disconnect() {
this.channel?.unsubscribe()
}
connected() {
this.element.classList.remove("disconnected")
}
disconnected() {
this.element.classList.add("disconnected")
}
received(data) {
if (data.type === "message") {
this.messagesTarget.insertAdjacentHTML("beforeend", data.html)
this.scrollToBottom()
}
}
send(event) {
event.preventDefault()
const body = this.inputTarget.value.trim()
if (body) {
this.channel.perform("speak", { body })
this.inputTarget.value = ""
}
}
typing() {
this.channel.perform("typing")
}
scrollToBottom() {
this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight
}
}# config/initializers/action_cable.rb
Rails.application.config.action_cable.max_connections_per_server = 1000# Only broadcast to connected users
def self.broadcast_if_subscribed(user, data)
return unless ActionCable.server.connections.any? { |c| c.current_user == user }
broadcast_to(user, data)
end# app/services/broadcast_service.rb
class BroadcastService
def self.debounced_broadcast(key, data, wait: 1.second)
Rails.cache.fetch("broadcast:#{key}", expires_in: wait) do
yield
true
end
end
end15fdeaf
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.