CtrlK
BlogDocsLog inGet started
Tessl Logo

action-cable-patterns

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-patterns
What are skills?

90

Does it follow best practices?

Validation for skill structure

SKILL.md
Review
Evals

Action Cable Patterns for Rails 8

Overview

Action Cable integrates WebSockets with Rails:

  • Real-time updates without polling
  • Server-to-client push notifications
  • Chat and messaging features
  • Live dashboards and feeds
  • Collaborative editing

Quick Start

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") %>

Project Structure

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

Connection Authentication

# 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

Channel Patterns

Pattern 1: Notifications Channel

# 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)
  }
})

Pattern 2: Resource Updates Channel

# 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)
        }
      }
    }
  )
}

Pattern 3: Chat Channel

# 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`
      }
    }
  )
}

Pattern 4: Dashboard Live Updates

# 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

Broadcasting from Services

# 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

Broadcasting from Models

# 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

Integration with Turbo Streams

# 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>

Testing Channels

Channel Spec

# 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

Channel with Authorization

# 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

Integration Test

# 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

Stimulus Controller for Channels

// 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
  }
}

Performance Considerations

Connection Limits

# config/initializers/action_cable.rb
Rails.application.config.action_cable.max_connections_per_server = 1000

Selective Broadcasting

# 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

Debouncing Broadcasts

# 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
end

Checklist

  • Connection authentication configured
  • Channel authorization implemented
  • Client-side subscription set up
  • Broadcasting from services/models
  • Channel specs written
  • Error handling in place
  • Reconnection logic on client
  • Performance limits configured
Repository
ThibautBaissac/rails_ai_agents
Last updated
Created

Is this your skill?

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.