Builds real-time support chat with floating user widget and admin dashboard using Rails ActionCable channels, WebSockets, REST API, and chat/message models.
From antigravity-awesome-skillsnpx claudepluginhub sickn33/antigravity-awesome-skills --plugin antigravity-awesome-skillsThis skill uses the workspace's default tool permissions.
Designs and optimizes AI agent action spaces, tool definitions, observation formats, error recovery, and context for higher task completion rates.
Enables AI agents to execute x402 payments with per-task budgets, spending controls, and non-custodial wallets via MCP tools. Use when agents pay for APIs, services, or other agents.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
Build a real-time support chat system with a floating widget for users and an admin dashboard for support staff.
Use when the user wants to:
┌─────────────────────────────────────────────────────────────────┐
│ FRONTEND │
├─────────────────────────────┬───────────────────────────────────┤
│ User Widget │ Admin Dashboard │
│ - Floating chat button │ - Chat list (active/archived) │
│ - Message panel │ - Conversation view │
│ - Unread badge │ - Archive/restore controls │
│ - Connection indicator │ - User info display │
└─────────────┬───────────────┴───────────────┬───────────────────┘
│ │
│ WebSocket + REST API │
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ BACKEND │
├─────────────────────────────────────────────────────────────────┤
│ Channels │ Controllers │
│ - ChatChannel (per chat) │ - User: get/create chat │
│ - AdminChannel (global) │ - Admin: list, view, archive │
├─────────────────────────────┼───────────────────────────────────┤
│ Models │ Jobs │
│ - Chat (1 per user) │ - Email notification (delayed) │
│ - Message (many per chat) │ │
└─────────────────────────────────────────────────────────────────┘
Create two tables: support_chats and support_messages.
support_chats
id - primary key (UUID recommended)
user_id - foreign key to users (UNIQUE - one chat per user)
last_message_at - timestamp (for sorting chats by recency)
admin_viewed_at - timestamp (tracks when admin last viewed)
archived_at - timestamp (null = active, set = archived)
created_at
updated_at
support_messages
id - primary key (UUID recommended)
chat_id - foreign key to support_chats
content - text (required)
sender_type - enum: 'user' | 'admin'
read_at - timestamp (null = unread)
created_at
updated_at
Key indexes:
support_chats.user_id (unique)support_chats.last_message_at (for sorting)support_chats.archived_at (for filtering)support_messages.chat_idsupport_messages.(chat_id, created_at) (composite, for ordering)Model relationships:
User has_one SupportChat
SupportChat belongs_to User
SupportChat has_many SupportMessages
SupportMessage belongs_to SupportChat
Model methods to implement:
Chat model:
function touch_last_message()
update last_message_at = now()
function unread_for_admin?()
return exists message where sender_type = 'user'
and created_at > admin_viewed_at
function mark_viewed_by_admin()
update admin_viewed_at = now()
function archive()
update archived_at = now()
function unarchive()
update archived_at = null
function archived?()
return archived_at != null
Message model:
after_create:
chat.touch_last_message()
if sender_type == 'user' and chat.archived?:
chat.unarchive() // Auto-reactivate on new user message
after_create_commit:
broadcast_to_chat_channel(message_data)
if sender_type == 'user':
broadcast_to_admin_notification_channel(message_data, chat_info)
if sender_type == 'admin':
schedule_email_notification(delay: 5.minutes)
User-facing:
GET /support_chat - Get or create user's chat with messages
PATCH /support_chat/mark_read - Mark admin messages as read
Admin-facing:
GET /admin/chats - List chats (query: archived=true/false)
GET /admin/chats/:id - Get chat with messages
POST /admin/chats/:id/archive - Archive chat
POST /admin/chats/:id/unarchive - Restore chat
Controller logic:
User GET /support_chat:
function show()
chat = current_user.support_chat || create_chat(user: current_user)
return {
id: chat.id,
messages: chat.messages.map(m => serialize_message(m))
}
Admin GET /admin/chats:
function index()
chats = SupportChat
.where(archived_at: params.archived ? not_null : null)
.includes(:user, :messages)
.order(last_message_at: desc)
return chats.map(c => {
id: c.id,
user_email: c.user.email,
last_message_preview: c.messages.last?.content.truncate(100),
last_message_sender: c.messages.last?.sender_type,
message_count: c.messages.count,
unread: c.unread_for_admin?,
archived: c.archived?
})
Create two channels for real-time communication.
ChatChannel (specific to each chat):
class ChatChannel
on_subscribe(chat_id):
chat = find_chat(chat_id)
if not authorized(chat):
reject()
return
stream_from "support_chat:#{chat_id}"
function authorized(chat):
return chat.user_id == current_user.id OR current_user.is_admin
action send_message(content):
if content.blank: return
sender_type = current_user.is_admin ? 'admin' : 'user'
chat.messages.create(content: content, sender_type: sender_type)
AdminNotificationChannel (global for all admins):
class AdminNotificationChannel
on_subscribe:
if not current_user.is_admin:
reject()
return
stream_from "admin_support_notifications"
Broadcasting (from Message model):
function broadcast_message():
message_data = {
id: id,
content: content,
sender_type: sender_type,
read_at: read_at,
created_at: created_at
}
// Broadcast to chat subscribers (user + any viewing admins)
broadcast("support_chat:#{chat.id}", {
type: "new_message",
message: message_data
})
// Notify all admins when user sends message
if sender_type == 'user':
broadcast("admin_support_notifications", {
type: "new_user_message",
chat_id: chat.id,
user_email: chat.user.email,
message: message_data
})
Create a floating chat widget with these components:
Component structure:
ChatWidget (root container)
├── ChatButton (fixed position, bottom-right)
│ ├── Icon (message bubble when closed, X when open)
│ └── UnreadBadge (shows count, caps at "9+")
└── ChatPanel (slides up when open)
├── Header (title + connection status dot)
├── MessageList (scrollable)
│ └── MessageBubble (styled by sender_type)
└── InputArea
├── Textarea (auto-expanding)
└── SendButton
State management hook:
function useSupportChat():
state:
chat: Chat | null
connected: boolean
loading: boolean
refs:
consumer: WebSocketConsumer
subscription: ChannelSubscription
seenMessageIds: Set<string> // For deduplication
on_mount:
fetch('/support_chat')
.then(data => {
chat = data
seenMessageIds.addAll(data.messages.map(m => m.id))
})
when chat.id changes:
subscription = consumer.subscribe('ChatChannel', { chat_id: chat.id })
subscription.on_received(data => {
if data.type == 'new_message':
if seenMessageIds.has(data.message.id): return // Dedupe
seenMessageIds.add(data.message.id)
chat.messages.push(data.message)
if data.message.sender_type == 'admin':
play_notification_sound()
})
subscription.on_connected(() => connected = true)
subscription.on_disconnected(() => connected = false)
on_unmount:
subscription.unsubscribe()
function sendMessage(content):
subscription.perform('send_message', { content: content.trim() })
function markAsRead():
fetch('/support_chat/mark_read', { method: 'PATCH' })
// Update local state to mark admin messages as read
return { chat, connected, loading, sendMessage, markAsRead }
Widget behavior:
Message styling:
Create two pages: chat list and chat detail.
Chat List Page:
Header: "Support Chats"
Tabs: [Active] [Archived]
Chat cards (sorted by last_message_at desc):
┌─────────────────────────────────────────┐
│ [Unread indicator] user@example.com │
│ Last message preview text... │
│ 5 messages · 2 minutes ago │
└─────────────────────────────────────────┘
Features:
Chat Detail Page:
Header: user@example.com [Archive/Restore button]
Back link
Messages (grouped by date):
──── Monday, January 29 ────
[User bubble] Message content
10:30 AM
[Admin bubble] Reply content
10:35 AM
Input area (same as widget)
Features:
Send email to user when admin replies and user hasn't seen it.
Job/worker:
class SupportReplyNotificationJob
perform(message):
if message.sender_type != 'admin': return
if message.read_at != null: return // Already read, skip
send_email(
to: message.chat.user.email,
subject: "New reply from Support",
body: "You have a new message from our support team..."
)
Scheduling:
interface SupportMessage {
id: string
content: string
sender_type: 'user' | 'admin'
read_at: string | null // ISO8601
created_at: string // ISO8601
}
interface SupportChat {
id: string
messages: SupportMessage[]
}
interface SupportChatListItem {
id: string
user_id: string
user_email: string
last_message_at: string | null
last_message_preview: string | null
last_message_sender: 'user' | 'admin' | null
message_count: number
unread: boolean
archived: boolean
}
interface AdminSupportChat {
id: string
user_id: string
user_email: string
archived: boolean
messages: SupportMessage[]
}
// WebSocket message types
interface ChatChannelMessage {
type: 'new_message'
message: SupportMessage
}
interface AdminNotificationMessage {
type: 'new_user_message'
chat_id: string
user_email: string
message: SupportMessage
}
After implementation:
Models:
# app/models/support_chat.rb
class SupportChat < ApplicationRecord
belongs_to :user
has_many :support_messages, dependent: :destroy
scope :active, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) }
scope :recent_first, -> { order(last_message_at: :desc) }
def touch_last_message
update_column(:last_message_at, Time.current)
end
def unread_for_admin?
support_messages.where(sender_type: :user)
.where("created_at > ?", admin_viewed_at || Time.at(0)).exists?
end
def archive!
update_column(:archived_at, Time.current)
end
def unarchive!
update_column(:archived_at, nil)
end
end
# app/models/support_message.rb
class SupportMessage < ApplicationRecord
belongs_to :support_chat
enum :sender_type, { user: 0, admin: 1 }
validates :content, presence: true
after_create :update_chat_timestamp
after_create :auto_unarchive, if: :user?
after_create_commit :broadcast_message
after_create_commit :schedule_notification, if: :admin?
private
def broadcast_message
ActionCable.server.broadcast("support_chat:#{support_chat_id}", {
type: "new_message",
message: { id:, content:, sender_type:, read_at:, created_at: }
})
end
def schedule_notification
SupportReplyNotificationJob.set(wait: 5.minutes).perform_later(self)
end
end
Channel:
# app/channels/support_chat_channel.rb
class SupportChatChannel < ApplicationCable::Channel
def subscribed
@chat = SupportChat.find(params[:chat_id])
reject unless @chat.user_id == current_user.id || current_user.admin?
stream_from "support_chat:#{@chat.id}"
end
def send_message(data)
@chat.support_messages.create!(
content: data["content"],
sender_type: current_user.admin? ? :admin : :user
)
end
end
Migration:
create_table :support_chats, id: :uuid do |t|
t.references :user, type: :uuid, null: false, foreign_key: true, index: { unique: true }
t.datetime :last_message_at
t.datetime :admin_viewed_at
t.datetime :archived_at
t.timestamps
end
create_table :support_messages, id: :uuid do |t|
t.references :support_chat, type: :uuid, null: false, foreign_key: true
t.text :content, null: false
t.integer :sender_type, default: 0
t.datetime :read_at
t.timestamps
end
add_index :support_messages, [:support_chat_id, :created_at]
Hook:
// hooks/useSupportChat.ts
import { useEffect, useState, useRef, useCallback } from 'react'
export function useSupportChat(websocketUrl: string) {
const [chat, setChat] = useState<Chat | null>(null)
const [connected, setConnected] = useState(false)
const wsRef = useRef<WebSocket | null>(null)
const seenIds = useRef(new Set<string>())
useEffect(() => {
fetch('/api/support_chat').then(r => r.json()).then(data => {
setChat(data)
data.messages.forEach((m: Message) => seenIds.current.add(m.id))
})
}, [])
useEffect(() => {
if (!chat?.id) return
const ws = new WebSocket(`${websocketUrl}?chat_id=${chat.id}`)
wsRef.current = ws
ws.onopen = () => setConnected(true)
ws.onclose = () => setConnected(false)
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.type === 'new_message' && !seenIds.current.has(data.message.id)) {
seenIds.current.add(data.message.id)
setChat(prev => prev ? { ...prev, messages: [...prev.messages, data.message] } : prev)
}
}
return () => ws.close()
}, [chat?.id])
const sendMessage = useCallback((content: string) => {
wsRef.current?.send(JSON.stringify({ action: 'send_message', content }))
}, [])
return { chat, connected, sendMessage }
}
Widget Component:
// components/ChatWidget.tsx
export function ChatWidget() {
const [isOpen, setIsOpen] = useState(false)
const { chat, connected, sendMessage } = useSupportChat('/ws/chat')
const [input, setInput] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const unreadCount = chat?.messages.filter(
m => m.sender_type === 'admin' && !m.read_at
).length ?? 0
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [chat?.messages])
const handleSend = () => {
if (!input.trim()) return
sendMessage(input.trim())
setInput('')
}
return (
<div className="fixed bottom-4 right-4 z-50">
{isOpen ? (
<div className="w-80 h-96 bg-white rounded-lg shadow-xl flex flex-col">
<header className="p-3 border-b flex justify-between items-center">
<span>Support Chat</span>
<span className={`w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-gray-400'}`} />
</header>
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{chat?.messages.map(m => (
<div key={m.id} className={`p-2 rounded ${m.sender_type === 'user' ? 'bg-blue-100 ml-auto' : 'bg-gray-100'}`}>
{m.content}
</div>
))}
<div ref={messagesEndRef} />
</div>
<div className="p-3 border-t flex gap-2">
<input value={input} onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSend()}
className="flex-1 border rounded px-2" placeholder="Type a message..." />
<button onClick={handleSend} className="px-3 py-1 bg-blue-500 text-white rounded">Send</button>
</div>
</div>
) : (
<button onClick={() => setIsOpen(true)} className="w-14 h-14 bg-blue-500 rounded-full text-white relative">
💬
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-xs w-5 h-5 rounded-full flex items-center justify-center">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
)}
</div>
)
}
API Route:
// app/api/support-chat/route.ts
import { getServerSession } from 'next-auth'
import { prisma } from '@/lib/prisma'
export async function GET() {
const session = await getServerSession()
if (!session?.user) return Response.json({ error: 'Unauthorized' }, { status: 401 })
let chat = await prisma.supportChat.findUnique({
where: { userId: session.user.id },
include: { messages: { orderBy: { createdAt: 'asc' } } }
})
if (!chat) {
chat = await prisma.supportChat.create({
data: { userId: session.user.id },
include: { messages: true }
})
}
return Response.json(chat)
}
WebSocket with Pusher/Ably (serverless-friendly):
// For serverless, use Pusher, Ably, or similar
import Pusher from 'pusher'
const pusher = new Pusher({ appId, key, secret, cluster })
// When message is created:
await pusher.trigger(`support-chat-${chatId}`, 'new-message', messageData)
// Client-side with pusher-js:
const channel = pusher.subscribe(`support-chat-${chatId}`)
channel.bind('new-message', (data) => { /* update state */ })
Models:
// app/Models/SupportChat.php
class SupportChat extends Model
{
protected $casts = ['last_message_at' => 'datetime', 'archived_at' => 'datetime'];
public function user() { return $this->belongsTo(User::class); }
public function messages() { return $this->hasMany(SupportMessage::class); }
public function scopeActive($query) { return $query->whereNull('archived_at'); }
public function scopeArchived($query) { return $query->whereNotNull('archived_at'); }
public function isUnreadForAdmin(): bool {
return $this->messages()
->where('sender_type', 'user')
->where('created_at', '>', $this->admin_viewed_at ?? '1970-01-01')
->exists();
}
}
// app/Models/SupportMessage.php
class SupportMessage extends Model
{
protected static function booted() {
static::created(function ($message) {
$message->supportChat->update(['last_message_at' => now()]);
broadcast(new NewSupportMessage($message))->toOthers();
if ($message->sender_type === 'admin') {
SendSupportReplyNotification::dispatch($message)->delay(now()->addMinutes(5));
}
});
}
}
Broadcasting Event:
// app/Events/NewSupportMessage.php
class NewSupportMessage implements ShouldBroadcast
{
public function __construct(public SupportMessage $message) {}
public function broadcastOn() {
return new PrivateChannel('support-chat.' . $this->message->support_chat_id);
}
public function broadcastAs() { return 'new-message'; }
}
Composable:
// composables/useSupportChat.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useSupportChat() {
const chat = ref<Chat | null>(null)
const connected = ref(false)
let ws: WebSocket | null = null
const seenIds = new Set<string>()
onMounted(async () => {
const res = await fetch('/api/support-chat')
chat.value = await res.json()
chat.value?.messages.forEach(m => seenIds.add(m.id))
ws = new WebSocket(`/ws/chat?id=${chat.value?.id}`)
ws.onopen = () => connected.value = true
ws.onclose = () => connected.value = false
ws.onmessage = (e) => {
const data = JSON.parse(e.data)
if (data.type === 'new_message' && !seenIds.has(data.message.id)) {
seenIds.add(data.message.id)
chat.value?.messages.push(data.message)
}
}
})
onUnmounted(() => ws?.close())
const sendMessage = (content: string) => {
ws?.send(JSON.stringify({ action: 'send_message', content }))
}
return { chat, connected, sendMessage }
}
timestamptz for all datetime columnsCHAR(36) or BINARY(16) for UUIDsDATETIME(6) for microsecond precisionutf8mb4 charset for emoji support// Always use background jobs for email
Job: SendSupportReplyNotification
delay: 5 minutes after admin message
perform(message_id):
message = find_message(message_id)
// Guard clauses - don't send if:
if message.sender_type != 'admin': return
if message.read_at != null: return // Already read
if message.chat.archived?: return // Chat archived
send_email(
to: message.chat.user.email,
template: 'support_reply',
data: { message_preview: message.content.truncate(200) }
)
| Technology | Best For | Serverless? |
|---|---|---|
| ActionCable (Rails) | Rails apps | No |
| Socket.IO | Node.js apps | No |
| Pusher | Any stack | Yes |
| Ably | Any stack | Yes |
| Supabase Realtime | Supabase users | Yes |
| Firebase RTDB | Firebase users | Yes |
| Server-Sent Events | Simple one-way | Yes |
If WebSocket unavailable, implement polling:
// Poll every 5 seconds when disconnected
if (!websocket.connected) {
setInterval(() => {
fetch('/api/support-chat/messages?since=' + lastMessageTime)
.then(newMessages => appendMessages(newMessages))
}, 5000)
}