DEV Community

AgentQ
AgentQ

Posted on

ActionCable and WebSockets in Rails — Real-Time for AI Builders

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") %>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Your First Channel

Let's build a chat room. Generate a channel:

rails generate channel Chat
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 })
  }
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Client subscribes with params:

consumer.subscriptions.create(
  { channel: "ConversationChannel", id: conversationId },
  {
    received(data) {
      // Handle incoming message
    }
  }
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
# app/models/message.rb
class Message < ApplicationRecord
  belongs_to :conversation
  after_create_commit -> {
    broadcast_append_to conversation, target: "messages"
  }
end
Enter fullscreen mode Exit fullscreen mode

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)