{"id":"3a87dc7f-eb5a-4f5d-8623-903235272580","shortId":"fPn2pv","kind":"skill","title":"chat-widget","tagline":"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 live chat, customer support chat, real-time messaging, or in-app support.","description":"# Live Support Chat Widget\n\nBuild a real-time support chat system with a floating widget for users and an admin dashboard for support staff.\n\n## When to Use This Skill\n\nUse when the user wants to:\n- Add a live chat widget to their app\n- Build customer support chat functionality\n- Create real-time messaging between users and admins\n- Add an in-app support channel\n\n## Architecture Overview\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│                        FRONTEND                                 │\n├─────────────────────────────┬───────────────────────────────────┤\n│   User Widget               │   Admin Dashboard                 │\n│   - Floating chat button    │   - Chat list (active/archived)   │\n│   - Message panel           │   - Conversation view             │\n│   - Unread badge            │   - Archive/restore controls      │\n│   - Connection indicator    │   - User info display             │\n└─────────────┬───────────────┴───────────────┬───────────────────┘\n              │                               │\n              │     WebSocket + REST API      │\n              ▼                               ▼\n┌─────────────────────────────────────────────────────────────────┐\n│                        BACKEND                                  │\n├─────────────────────────────────────────────────────────────────┤\n│   Channels                  │   Controllers                     │\n│   - ChatChannel (per chat)  │   - User: get/create chat         │\n│   - AdminChannel (global)   │   - Admin: list, view, archive    │\n├─────────────────────────────┼───────────────────────────────────┤\n│   Models                    │   Jobs                            │\n│   - Chat (1 per user)       │   - Email notification (delayed)  │\n│   - Message (many per chat) │                                   │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n## Implementation Guide\n\n### Step 1: Data Models\n\nCreate two tables: `support_chats` and `support_messages`.\n\n**support_chats**\n```\nid              - primary key (UUID recommended)\nuser_id         - foreign key to users (UNIQUE - one chat per user)\nlast_message_at - timestamp (for sorting chats by recency)\nadmin_viewed_at - timestamp (tracks when admin last viewed)\narchived_at     - timestamp (null = active, set = archived)\ncreated_at\nupdated_at\n```\n\n**support_messages**\n```\nid              - primary key (UUID recommended)\nchat_id         - foreign key to support_chats\ncontent         - text (required)\nsender_type     - enum: 'user' | 'admin'\nread_at         - timestamp (null = unread)\ncreated_at\nupdated_at\n```\n\n**Key indexes:**\n- `support_chats.user_id` (unique)\n- `support_chats.last_message_at` (for sorting)\n- `support_chats.archived_at` (for filtering)\n- `support_messages.chat_id`\n- `support_messages.(chat_id, created_at)` (composite, for ordering)\n\n**Model relationships:**\n```\nUser has_one SupportChat\nSupportChat belongs_to User\nSupportChat has_many SupportMessages\nSupportMessage belongs_to SupportChat\n```\n\n**Model methods to implement:**\n\nChat model:\n```pseudo\nfunction touch_last_message()\n  update last_message_at = now()\n\nfunction unread_for_admin?()\n  return exists message where sender_type = 'user'\n    and created_at > admin_viewed_at\n\nfunction mark_viewed_by_admin()\n  update admin_viewed_at = now()\n\nfunction archive()\n  update archived_at = now()\n\nfunction unarchive()\n  update archived_at = null\n\nfunction archived?()\n  return archived_at != null\n```\n\nMessage model:\n```pseudo\nafter_create:\n  chat.touch_last_message()\n  if sender_type == 'user' and chat.archived?:\n    chat.unarchive()  // Auto-reactivate on new user message\n\nafter_create_commit:\n  broadcast_to_chat_channel(message_data)\n  if sender_type == 'user':\n    broadcast_to_admin_notification_channel(message_data, chat_info)\n  if sender_type == 'admin':\n    schedule_email_notification(delay: 5.minutes)\n```\n\n### Step 2: API Endpoints\n\n**User-facing:**\n```\nGET  /support_chat       - Get or create user's chat with messages\nPATCH /support_chat/mark_read - Mark admin messages as read\n```\n\n**Admin-facing:**\n```\nGET  /admin/chats              - List chats (query: archived=true/false)\nGET  /admin/chats/:id          - Get chat with messages\nPOST /admin/chats/:id/archive  - Archive chat\nPOST /admin/chats/:id/unarchive - Restore chat\n```\n\n**Controller logic:**\n\nUser GET /support_chat:\n```pseudo\nfunction show()\n  chat = current_user.support_chat || create_chat(user: current_user)\n  return {\n    id: chat.id,\n    messages: chat.messages.map(m => serialize_message(m))\n  }\n```\n\nAdmin GET /admin/chats:\n```pseudo\nfunction index()\n  chats = SupportChat\n    .where(archived_at: params.archived ? not_null : null)\n    .includes(:user, :messages)\n    .order(last_message_at: desc)\n\n  return chats.map(c => {\n    id: c.id,\n    user_email: c.user.email,\n    last_message_preview: c.messages.last?.content.truncate(100),\n    last_message_sender: c.messages.last?.sender_type,\n    message_count: c.messages.count,\n    unread: c.unread_for_admin?,\n    archived: c.archived?\n  })\n```\n\n### Step 3: WebSocket Channels\n\nCreate two channels for real-time communication.\n\n**ChatChannel** (specific to each chat):\n```pseudo\nclass ChatChannel\n  on_subscribe(chat_id):\n    chat = find_chat(chat_id)\n    if not authorized(chat):\n      reject()\n      return\n    stream_from \"support_chat:#{chat_id}\"\n\n  function authorized(chat):\n    return chat.user_id == current_user.id OR current_user.is_admin\n\n  action send_message(content):\n    if content.blank: return\n    sender_type = current_user.is_admin ? 'admin' : 'user'\n    chat.messages.create(content: content, sender_type: sender_type)\n```\n\n**AdminNotificationChannel** (global for all admins):\n```pseudo\nclass AdminNotificationChannel\n  on_subscribe:\n    if not current_user.is_admin:\n      reject()\n      return\n    stream_from \"admin_support_notifications\"\n```\n\n**Broadcasting (from Message model):**\n```pseudo\nfunction broadcast_message():\n  message_data = {\n    id: id,\n    content: content,\n    sender_type: sender_type,\n    read_at: read_at,\n    created_at: created_at\n  }\n\n  // Broadcast to chat subscribers (user + any viewing admins)\n  broadcast(\"support_chat:#{chat.id}\", {\n    type: \"new_message\",\n    message: message_data\n  })\n\n  // Notify all admins when user sends message\n  if sender_type == 'user':\n    broadcast(\"admin_support_notifications\", {\n      type: \"new_user_message\",\n      chat_id: chat.id,\n      user_email: chat.user.email,\n      message: message_data\n    })\n```\n\n### Step 4: Frontend - User Widget\n\nCreate a floating chat widget with these components:\n\n**Component structure:**\n```\nChatWidget (root container)\n├── ChatButton (fixed position, bottom-right)\n│   ├── Icon (message bubble when closed, X when open)\n│   └── UnreadBadge (shows count, caps at \"9+\")\n└── ChatPanel (slides up when open)\n    ├── Header (title + connection status dot)\n    ├── MessageList (scrollable)\n    │   └── MessageBubble (styled by sender_type)\n    └── InputArea\n        ├── Textarea (auto-expanding)\n        └── SendButton\n```\n\n**State management hook:**\n```pseudo\nfunction useSupportChat():\n  state:\n    chat: Chat | null\n    connected: boolean\n    loading: boolean\n\n  refs:\n    consumer: WebSocketConsumer\n    subscription: ChannelSubscription\n    seenMessageIds: Set<string>  // For deduplication\n\n  on_mount:\n    fetch('/support_chat')\n      .then(data => {\n        chat = data\n        seenMessageIds.addAll(data.messages.map(m => m.id))\n      })\n\n  when chat.id changes:\n    subscription = consumer.subscribe('ChatChannel', { chat_id: chat.id })\n    subscription.on_received(data => {\n      if data.type == 'new_message':\n        if seenMessageIds.has(data.message.id): return  // Dedupe\n        seenMessageIds.add(data.message.id)\n        chat.messages.push(data.message)\n        if data.message.sender_type == 'admin':\n          play_notification_sound()\n    })\n    subscription.on_connected(() => connected = true)\n    subscription.on_disconnected(() => connected = false)\n\n  on_unmount:\n    subscription.unsubscribe()\n\n  function sendMessage(content):\n    subscription.perform('send_message', { content: content.trim() })\n\n  function markAsRead():\n    fetch('/support_chat/mark_read', { method: 'PATCH' })\n    // Update local state to mark admin messages as read\n\n  return { chat, connected, loading, sendMessage, markAsRead }\n```\n\n**Widget behavior:**\n- Show floating button at bottom-right corner (fixed position)\n- Display unread count badge (count messages where sender_type='admin' and read_at=null)\n- Toggle panel open/closed on button click\n- Auto-call markAsRead() when panel opens\n- Auto-scroll to bottom when new messages arrive\n- Show connection status indicator (green dot = connected)\n- Keyboard: Enter to send, Shift+Enter for newline\n\n**Message styling:**\n- User messages: right-aligned, primary color background\n- Admin messages: left-aligned, secondary/muted background\n- Show timestamp on each message\n\n### Step 5: Frontend - Admin Dashboard\n\nCreate two pages: chat list and chat detail.\n\n**Chat List Page:**\n```\nHeader: \"Support Chats\"\nTabs: [Active] [Archived]\n\nChat cards (sorted by last_message_at desc):\n┌─────────────────────────────────────────┐\n│ [Unread indicator] user@example.com     │\n│ Last message preview text...            │\n│ 5 messages · 2 minutes ago              │\n└─────────────────────────────────────────┘\n```\n\nFeatures:\n- Tab filtering (active vs archived)\n- Unread indicator (highlight border or badge)\n- Click to navigate to detail\n- Show \"You: \" prefix if last message was from admin\n\n**Chat Detail Page:**\n```\nHeader: user@example.com [Archive/Restore button]\nBack link\n\nMessages (grouped by date):\n──── Monday, January 29 ────\n[User bubble]  Message content\n               10:30 AM\n\n          [Admin bubble] Reply content\n                         10:35 AM\n\nInput area (same as widget)\n```\n\nFeatures:\n- Group messages by date with dividers\n- User messages left, admin messages right (opposite of user widget)\n- Show sender label (\"You\" for admin, user email/name for user)\n- Archive/restore toggle button\n- Same WebSocket subscription as user widget for real-time updates\n- Call mark_viewed_by_admin() when page loads (server-side)\n\n### Step 6: Email Notifications\n\nSend email to user when admin replies and user hasn't seen it.\n\n**Job/worker:**\n```pseudo\nclass SupportReplyNotificationJob\n  perform(message):\n    if message.sender_type != 'admin': return\n    if message.read_at != null: return  // Already read, skip\n\n    send_email(\n      to: message.chat.user.email,\n      subject: \"New reply from Support\",\n      body: \"You have a new message from our support team...\"\n    )\n```\n\n**Scheduling:**\n- Schedule job with 5-minute delay when admin sends message\n- This gives user time to see message in-app before email\n- Job checks if still unread before sending\n\n### Step 7: TypeScript Types\n\n```typescript\ninterface SupportMessage {\n  id: string\n  content: string\n  sender_type: 'user' | 'admin'\n  read_at: string | null  // ISO8601\n  created_at: string      // ISO8601\n}\n\ninterface SupportChat {\n  id: string\n  messages: SupportMessage[]\n}\n\ninterface SupportChatListItem {\n  id: string\n  user_id: string\n  user_email: string\n  last_message_at: string | null\n  last_message_preview: string | null\n  last_message_sender: 'user' | 'admin' | null\n  message_count: number\n  unread: boolean\n  archived: boolean\n}\n\ninterface AdminSupportChat {\n  id: string\n  user_id: string\n  user_email: string\n  archived: boolean\n  messages: SupportMessage[]\n}\n\n// WebSocket message types\ninterface ChatChannelMessage {\n  type: 'new_message'\n  message: SupportMessage\n}\n\ninterface AdminNotificationMessage {\n  type: 'new_user_message'\n  chat_id: string\n  user_email: string\n  message: SupportMessage\n}\n```\n\n## Key Design Decisions\n\n1. **One chat per user** - Simplifies UX, user always has same conversation history\n2. **Soft-delete via archiving** - Preserves history, allows restore\n3. **Auto-unarchive** - When user sends message to archived chat, reactivate it\n4. **Delayed email notifications** - 5 min delay prevents spam for rapid replies\n5. **Message deduplication** - Track seen IDs to prevent duplicates from send + broadcast echo\n6. **Separate admin channel** - Allows future features like global unread count, desktop notifications\n\n## Testing Checklist\n\nAfter implementation:\n- [ ] User can open widget and send message\n- [ ] Admin sees message in real-time on dashboard\n- [ ] Admin can reply and user sees it instantly\n- [ ] Unread badge shows correct count\n- [ ] Badge clears when widget opens\n- [ ] Connection indicator reflects actual status\n- [ ] Archive/restore works correctly\n- [ ] Auto-unarchive triggers on user message\n- [ ] Email sends after 5 min if message unread\n- [ ] Email does NOT send if user already read message\n- [ ] Messages appear in chronological order\n- [ ] No duplicate messages appear\n\n## Common Pitfalls\n\n1. **Forgetting deduplication** - Messages sent by current user echo back via broadcast\n2. **Race conditions on read status** - Use database transactions\n3. **WebSocket auth** - Verify user can access the specific chat\n4. **Stale connection status** - Handle reconnection gracefully\n5. **Missing indexes** - Add composite index on (chat_id, created_at)\n6. **Email timing** - Use background job, not synchronous send\n\n---\n\n## Framework-Specific Guidance\n\n### Ruby on Rails\n\n**Models:**\n```ruby\n# app/models/support_chat.rb\nclass SupportChat < ApplicationRecord\n  belongs_to :user\n  has_many :support_messages, dependent: :destroy\n\n  scope :active, -> { where(archived_at: nil) }\n  scope :archived, -> { where.not(archived_at: nil) }\n  scope :recent_first, -> { order(last_message_at: :desc) }\n\n  def touch_last_message\n    update_column(:last_message_at, Time.current)\n  end\n\n  def unread_for_admin?\n    support_messages.where(sender_type: :user)\n      .where(\"created_at > ?\", admin_viewed_at || Time.at(0)).exists?\n  end\n\n  def archive!\n    update_column(:archived_at, Time.current)\n  end\n\n  def unarchive!\n    update_column(:archived_at, nil)\n  end\nend\n\n# app/models/support_message.rb\nclass SupportMessage < ApplicationRecord\n  belongs_to :support_chat\n  enum :sender_type, { user: 0, admin: 1 }\n  validates :content, presence: true\n\n  after_create :update_chat_timestamp\n  after_create :auto_unarchive, if: :user?\n  after_create_commit :broadcast_message\n  after_create_commit :schedule_notification, if: :admin?\n\n  private\n\n  def broadcast_message\n    ActionCable.server.broadcast(\"support_chat:#{support_chat_id}\", {\n      type: \"new_message\",\n      message: { id:, content:, sender_type:, read_at:, created_at: }\n    })\n  end\n\n  def schedule_notification\n    SupportReplyNotificationJob.set(wait: 5.minutes).perform_later(self)\n  end\nend\n```\n\n**Channel:**\n```ruby\n# app/channels/support_chat_channel.rb\nclass SupportChatChannel < ApplicationCable::Channel\n  def subscribed\n    @chat = SupportChat.find(params[:chat_id])\n    reject unless @chat.user_id == current_user.id || current_user.admin?\n    stream_from \"support_chat:#{@chat.id}\"\n  end\n\n  def send_message(data)\n    @chat.support_messages.create!(\n      content: data[\"content\"],\n      sender_type: current_user.admin? ? :admin : :user\n    )\n  end\nend\n```\n\n**Migration:**\n```ruby\ncreate_table :support_chats, id: :uuid do |t|\n  t.references :user, type: :uuid, null: false, foreign_key: true, index: { unique: true }\n  t.datetime :last_message_at\n  t.datetime :admin_viewed_at\n  t.datetime :archived_at\n  t.timestamps\nend\n\ncreate_table :support_messages, id: :uuid do |t|\n  t.references :support_chat, type: :uuid, null: false, foreign_key: true\n  t.text :content, null: false\n  t.integer :sender_type, default: 0\n  t.datetime :read_at\n  t.timestamps\nend\nadd_index :support_messages, [:support_chat_id, :created_at]\n```\n\n### React (with any backend)\n\n**Hook:**\n```typescript\n// hooks/useSupportChat.ts\nimport { useEffect, useState, useRef, useCallback } from 'react'\n\nexport function useSupportChat(websocketUrl: string) {\n  const [chat, setChat] = useState<Chat | null>(null)\n  const [connected, setConnected] = useState(false)\n  const wsRef = useRef<WebSocket | null>(null)\n  const seenIds = useRef(new Set<string>())\n\n  useEffect(() => {\n    fetch('/api/support_chat').then(r => r.json()).then(data => {\n      setChat(data)\n      data.messages.forEach((m: Message) => seenIds.current.add(m.id))\n    })\n  }, [])\n\n  useEffect(() => {\n    if (!chat?.id) return\n    const ws = new WebSocket(`${websocketUrl}?chat_id=${chat.id}`)\n    wsRef.current = ws\n\n    ws.onopen = () => setConnected(true)\n    ws.onclose = () => setConnected(false)\n    ws.onmessage = (event) => {\n      const data = JSON.parse(event.data)\n      if (data.type === 'new_message' && !seenIds.current.has(data.message.id)) {\n        seenIds.current.add(data.message.id)\n        setChat(prev => prev ? { ...prev, messages: [...prev.messages, data.message] } : prev)\n      }\n    }\n    return () => ws.close()\n  }, [chat?.id])\n\n  const sendMessage = useCallback((content: string) => {\n    wsRef.current?.send(JSON.stringify({ action: 'send_message', content }))\n  }, [])\n\n  return { chat, connected, sendMessage }\n}\n```\n\n**Widget Component:**\n```tsx\n// components/ChatWidget.tsx\nexport function ChatWidget() {\n  const [isOpen, setIsOpen] = useState(false)\n  const { chat, connected, sendMessage } = useSupportChat('/ws/chat')\n  const [input, setInput] = useState('')\n  const messagesEndRef = useRef<HTMLDivElement>(null)\n\n  const unreadCount = chat?.messages.filter(\n    m => m.sender_type === 'admin' && !m.read_at\n  ).length ?? 0\n\n  useEffect(() => {\n    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })\n  }, [chat?.messages])\n\n  const handleSend = () => {\n    if (!input.trim()) return\n    sendMessage(input.trim())\n    setInput('')\n  }\n\n  return (\n    <div className=\"fixed bottom-4 right-4 z-50\">\n      {isOpen ? (\n        <div className=\"w-80 h-96 bg-white rounded-lg shadow-xl flex flex-col\">\n          <header className=\"p-3 border-b flex justify-between items-center\">\n            <span>Support Chat</span>\n            <span className={`w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-gray-400'}`} />\n          </header>\n          <div className=\"flex-1 overflow-y-auto p-3 space-y-2\">\n            {chat?.messages.map(m => (\n              <div key={m.id} className={`p-2 rounded ${m.sender_type === 'user' ? 'bg-blue-100 ml-auto' : 'bg-gray-100'}`}>\n                {m.content}\n              </div>\n            ))}\n            <div ref={messagesEndRef} />\n          </div>\n          <div className=\"p-3 border-t flex gap-2\">\n            <input value={input} onChange={e => setInput(e.target.value)}\n              onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSend()}\n              className=\"flex-1 border rounded px-2\" placeholder=\"Type a message...\" />\n            <button onClick={handleSend} className=\"px-3 py-1 bg-blue-500 text-white rounded\">Send</button>\n          </div>\n        </div>\n      ) : (\n        <button onClick={() => setIsOpen(true)} className=\"w-14 h-14 bg-blue-500 rounded-full text-white relative\">\n          💬\n          {unreadCount > 0 && (\n            <span className=\"absolute -top-1 -right-1 bg-red-500 text-xs w-5 h-5 rounded-full flex items-center justify-center\">\n              {unreadCount > 9 ? '9+' : unreadCount}\n            </span>\n          )}\n        </button>\n      )}\n    </div>\n  )\n}\n```\n\n### Next.js (App Router)\n\n**API Route:**\n```typescript\n// app/api/support-chat/route.ts\nimport { getServerSession } from 'next-auth'\nimport { prisma } from '@/lib/prisma'\n\nexport async function GET() {\n  const session = await getServerSession()\n  if (!session?.user) return Response.json({ error: 'Unauthorized' }, { status: 401 })\n\n  let chat = await prisma.supportChat.findUnique({\n    where: { userId: session.user.id },\n    include: { messages: { orderBy: { createdAt: 'asc' } } }\n  })\n\n  if (!chat) {\n    chat = await prisma.supportChat.create({\n      data: { userId: session.user.id },\n      include: { messages: true }\n    })\n  }\n\n  return Response.json(chat)\n}\n```\n\n**WebSocket with Pusher/Ably (serverless-friendly):**\n```typescript\n// For serverless, use Pusher, Ably, or similar\nimport Pusher from 'pusher'\nconst pusher = new Pusher({ appId, key, secret, cluster })\n\n// When message is created:\nawait pusher.trigger(`support-chat-${chatId}`, 'new-message', messageData)\n\n// Client-side with pusher-js:\nconst channel = pusher.subscribe(`support-chat-${chatId}`)\nchannel.bind('new-message', (data) => { /* update state */ })\n```\n\n### PHP/Laravel\n\n**Models:**\n```php\n// app/Models/SupportChat.php\nclass SupportChat extends Model\n{\n    protected $casts = ['last_message_at' => 'datetime', 'archived_at' => 'datetime'];\n\n    public function user() { return $this->belongsTo(User::class); }\n    public function messages() { return $this->hasMany(SupportMessage::class); }\n\n    public function scopeActive($query) { return $query->whereNull('archived_at'); }\n    public function scopeArchived($query) { return $query->whereNotNull('archived_at'); }\n\n    public function isUnreadForAdmin(): bool {\n        return $this->messages()\n            ->where('sender_type', 'user')\n            ->where('created_at', '>', $this->admin_viewed_at ?? '1970-01-01')\n            ->exists();\n    }\n}\n\n// app/Models/SupportMessage.php\nclass SupportMessage extends Model\n{\n    protected static function booted() {\n        static::created(function ($message) {\n            $message->supportChat->update(['last_message_at' => now()]);\n            broadcast(new NewSupportMessage($message))->toOthers();\n\n            if ($message->sender_type === 'admin') {\n                SendSupportReplyNotification::dispatch($message)->delay(now()->addMinutes(5));\n            }\n        });\n    }\n}\n```\n\n**Broadcasting Event:**\n```php\n// app/Events/NewSupportMessage.php\nclass NewSupportMessage implements ShouldBroadcast\n{\n    public function __construct(public SupportMessage $message) {}\n\n    public function broadcastOn() {\n        return new PrivateChannel('support-chat.' . $this->message->support_chat_id);\n    }\n\n    public function broadcastAs() { return 'new-message'; }\n}\n```\n\n### Vue.js\n\n**Composable:**\n```typescript\n// composables/useSupportChat.ts\nimport { ref, onMounted, onUnmounted } from 'vue'\n\nexport function useSupportChat() {\n  const chat = ref<Chat | null>(null)\n  const connected = ref(false)\n  let ws: WebSocket | null = null\n  const seenIds = new Set<string>()\n\n  onMounted(async () => {\n    const res = await fetch('/api/support-chat')\n    chat.value = await res.json()\n    chat.value?.messages.forEach(m => seenIds.add(m.id))\n\n    ws = new WebSocket(`/ws/chat?id=${chat.value?.id}`)\n    ws.onopen = () => connected.value = true\n    ws.onclose = () => connected.value = false\n    ws.onmessage = (e) => {\n      const data = JSON.parse(e.data)\n      if (data.type === 'new_message' && !seenIds.has(data.message.id)) {\n        seenIds.add(data.message.id)\n        chat.value?.messages.push(data.message)\n      }\n    }\n  })\n\n  onUnmounted(() => ws?.close())\n\n  const sendMessage = (content: string) => {\n    ws?.send(JSON.stringify({ action: 'send_message', content }))\n  }\n\n  return { chat, connected, sendMessage }\n}\n```\n\n---\n\n## Database Recommendations\n\n### PostgreSQL (Recommended)\n- Use UUID primary keys for security (non-guessable IDs)\n- Use `timestamptz` for all datetime columns\n- Add GIN index on content for full-text search (optional)\n\n### MySQL\n- Use `CHAR(36)` or `BINARY(16)` for UUIDs\n- Use `DATETIME(6)` for microsecond precision\n- Consider `utf8mb4` charset for emoji support\n\n### SQLite (Development/Small Scale)\n- Works fine for prototyping\n- Store UUIDs as TEXT\n- No native datetime type, store as ISO8601 strings\n\n### MongoDB (Document Store)\n- Embed messages in chat document if message count is bounded\n- Or use separate collection with chat_id reference\n- Use TTL index on archived chats for auto-cleanup (optional)\n\n---\n\n## Email Processing Recommendations\n\n### Transactional Email Services\n- **Postmark** - Best deliverability, simple API\n- **SendGrid** - Good free tier, robust\n- **AWS SES** - Cheapest at scale\n- **Resend** - Modern DX, React email templates\n\n### Implementation Pattern\n```pseudo\n// Always use background jobs for email\nJob: SendSupportReplyNotification\n  delay: 5 minutes after admin message\n\n  perform(message_id):\n    message = find_message(message_id)\n\n    // Guard clauses - don't send if:\n    if message.sender_type != 'admin': return\n    if message.read_at != null: return        // Already read\n    if message.chat.archived?: return         // Chat archived\n\n    send_email(\n      to: message.chat.user.email,\n      template: 'support_reply',\n      data: { message_preview: message.content.truncate(200) }\n    )\n```\n\n### Email Template Tips\n- Include message preview (truncated)\n- Add direct link to open chat (if web app)\n- Keep subject simple: \"New reply from [App] Support\"\n- Include unsubscribe link for compliance\n\n---\n\n## Real-Time Technology Options\n\n| Technology | Best For | Serverless? |\n|------------|----------|-------------|\n| ActionCable (Rails) | Rails apps | No |\n| Socket.IO | Node.js apps | No |\n| Pusher | Any stack | Yes |\n| Ably | Any stack | Yes |\n| Supabase Realtime | Supabase users | Yes |\n| Firebase RTDB | Firebase users | Yes |\n| Server-Sent Events | Simple one-way | Yes |\n\n### Fallback Strategy\nIf WebSocket unavailable, implement polling:\n```pseudo\n// Poll every 5 seconds when disconnected\nif (!websocket.connected) {\n  setInterval(() => {\n    fetch('/api/support-chat/messages?since=' + lastMessageTime)\n      .then(newMessages => appendMessages(newMessages))\n  }, 5000)\n}\n```\n\n## Limitations\n- Use this skill only when the task clearly matches the scope described above.\n- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.\n- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.","tags":["chat","widget","antigravity","awesome","skills","sickn33","agent-skills","agentic-skills","ai-agent-skills","ai-agents","ai-coding","ai-workflows"],"capabilities":["skill","source-sickn33","skill-chat-widget","topic-agent-skills","topic-agentic-skills","topic-ai-agent-skills","topic-ai-agents","topic-ai-coding","topic-ai-workflows","topic-antigravity","topic-antigravity-skills","topic-claude-code","topic-claude-code-skills","topic-codex-cli","topic-codex-skills"],"categories":["antigravity-awesome-skills"],"synonyms":[],"warnings":[],"endpointUrl":"https://skills.sh/sickn33/antigravity-awesome-skills/chat-widget","protocol":"skill","transport":"skills-sh","auth":{"type":"none","details":{"cli":"npx skills add sickn33/antigravity-awesome-skills","source_repo":"https://github.com/sickn33/antigravity-awesome-skills","install_from":"skills.sh"}},"qualityScore":"0.700","qualityRationale":"deterministic score 0.70 from registry signals: · indexed on github topic:agent-skills · 34882 github stars · SKILL.md body (26,009 chars)","verified":false,"liveness":"unknown","lastLivenessCheck":null,"agentReviews":{"count":0,"score_avg":null,"cost_usd_avg":null,"success_rate":null,"latency_p50_ms":null,"narrative_summary":null,"summary_updated_at":null},"enrichmentModel":"deterministic:skill-github:v1","enrichmentVersion":1,"enrichedAt":"2026-04-24T12:50:44.669Z","embedding":null,"createdAt":"2026-04-18T21:34:10.174Z","updatedAt":"2026-04-24T12:50:44.669Z","lastSeenAt":"2026-04-24T12:50:44.669Z","tsv":"'-01':2279,2280 '-1':2036,2052 '-14':2068,2070 '-2':1978,1980,2001,2040 '-3':2050 '/admin/chats':443,450,457,462,493 '/api/support-chat':2392 '/api/support-chat/messages':2731 '/api/support_chat':1842 '/lib/prisma':2104 '/support_chat':423,470,794 '/support_chat/mark_read':433,857 '/ws/chat':1935,2404 '0':1585,1617,1783,1955,2083 '1':156,169,1304,1459,1619 '10':1048,1055 '100':527,2009,2016 '16':2486 '1970':2278 '2':416,999,1317,1471 '200':2638 '29':1043 '3':544,1327,1480 '30':1049 '35':1056 '36':2483 '4':708,1340,1490 '400':1992 '401':2121 '5':961,997,1174,1344,1352,1434,1497,2318,2591,2723 '5.minutes':414,1675 '500':1988,2056,2074 '5000':2738 '6':1116,1365,1508,2491 '7':1201 '9':744,2085,2086 'abli':2159,2690 'access':1486 'action':594,1910,2441 'actionc':2677 'actioncable.server.broadcast':1651 'activ':220,980,1005,1540 'active/archived':121 'actual':1419 'add':80,102,1500,1789,2469,2646 'addminut':2317 'admin':20,64,101,114,149,207,213,248,320,331,338,340,399,409,435,440,491,540,593,604,605,618,627,632,668,681,691,831,865,896,948,963,1027,1051,1073,1085,1108,1124,1141,1178,1214,1254,1367,1389,1398,1573,1581,1618,1646,1718,1749,1951,2275,2311,2594,2613 'admin-fac':439 'adminchannel':147 'adminnotificationchannel':614,621 'adminnotificationmessag':1288 'adminsupportchat':1264 'ago':1001 'align':944,952 'allow':1325,1369 'alreadi':1148,1445,2620 'alway':1312,2582 'api':137,417,2091,2562 'app':42,87,106,1190,2089,2654,2661,2680,2684 'app/api/support-chat/route.ts':2094 'app/channels/support_chat_channel.rb':1683 'app/events/newsupportmessage.php':2322 'app/models/support_chat.rb':1526 'app/models/support_message.rb':1605 'app/models/supportchat.php':2212 'app/models/supportmessage.php':2282 'appear':1449,1456 'appendmessag':2736 'appid':2170 'applicationc':1686 'applicationrecord':1529,1608 'architectur':109 'archiv':152,216,222,345,347,353,357,359,447,459,500,541,981,1007,1261,1273,1322,1336,1542,1546,1548,1589,1592,1600,1753,2223,2249,2258,2545,2626 'archive/restore':128,1033,1090,1421 'area':1059 'arriv':922 'asc':2133 'ask':2772 'async':2106,2387 'auth':1482,2100 'author':574,585 'auto':378,765,908,915,1329,1425,1631,2012,2549 'auto-cal':907 'auto-cleanup':2548 'auto-expand':764 'auto-reactiv':377 'auto-scrol':914 'auto-unarch':1328,1424 'aw':2568 'await':2111,2124,2137,2178,2390,2394 'back':1035,1468 'backend':138,1801 'background':947,954,1512,2584 'badg':127,890,1013,1407,1411 'behavior':876,1959 'belong':290,298,1530,1609 'belongsto':2231 'best':2559,2674 'bg':1986,1990,2007,2014,2054,2072 'bg-blue':2006,2053,2071 'bg-gray':1989,2013 'bg-green':1985 'binari':2485 'blue':2008,2055,2073 'bodi':1160 'bool':2263 'boolean':779,781,1260,1262,1274 'boot':2290 'border':1011,2037 'bottom':729,882,918 'bottom-right':728,881 'bound':2532 'boundari':2780 'broadcast':387,397,635,641,661,669,690,1363,1470,1638,1649,2302,2319 'broadcasta':2349 'broadcaston':2335 'bubbl':733,1045,1052 'build':4,48,88 'button':118,879,905,1034,1092,2045,2062 'c':516 'c.archived':542 'c.id':518 'c.messages.count':536 'c.messages.last':525,531 'c.unread':538 'c.user.email':521 'call':909,1104 'cap':742 'card':983 'cast':2218 'chang':805 'channel':108,139,390,401,546,549,1368,1681,1687,2196 'channel.bind':2202 'channelsubscript':786 'char':2482 'charset':2497 'chat':2,10,31,34,46,54,83,91,117,119,143,146,155,165,176,181,195,204,234,240,276,305,389,404,429,445,453,460,465,474,476,478,497,559,565,567,569,570,575,581,582,586,663,671,698,715,775,776,797,809,870,968,971,973,978,982,1028,1293,1306,1337,1489,1504,1612,1627,1653,1655,1690,1693,1704,1727,1767,1794,1818,1821,1857,1865,1900,1915,1931,1946,1961,1974,1993,2123,2135,2136,2147,2182,2200,2341,2345,2368,2370,2446,2526,2538,2546,2625,2651 'chat-widget':1 'chat.archived':375 'chat.id':484,672,700,804,811,1705,1867 'chat.messages.create':607 'chat.messages.map':486 'chat.messages.push':826 'chat.support_messages.create':1711 'chat.touch':367 'chat.unarchive':376 'chat.user':588,1697 'chat.user.email':703 'chat.value':2393,2396,2406,2428 'chatbutton':725 'chatchannel':141,555,562,808 'chatchannelmessag':1281 'chatid':2183,2201 'chatpanel':745 'chats.map':515 'chatwidget':722,1924 'cheapest':2570 'check':1194 'checklist':1379 'chronolog':1451 'clarif':2774 'class':561,620,1134,1527,1606,1684,2213,2233,2241,2283,2323 'classnam':1976,1999,2034,2048,2066 'claus':2605 'cleanup':2550 'clear':1412,2747 'click':906,1014 'client':2189 'client-sid':2188 'close':735,2433 'cluster':2173 'collect':2536 'color':946 'column':1564,1591,1599,2468 'commit':386,1637,1642 'common':1457 'communic':554 'complianc':2667 'compon':719,720,1919 'components/chatwidget.tsx':1921 'compos':2355 'composables/usesupportchat.ts':2357 'composit':280,1501 'condit':1473 'connect':130,752,778,836,837,841,871,924,929,1416,1492,1825,1916,1932,1984,2374,2447 'connected.value':2409,2412 'consid':2495 'const':1817,1824,1829,1835,1860,1878,1902,1925,1930,1936,1940,1944,1963,2109,2166,2195,2367,2373,2382,2388,2416,2434 'construct':2329 'consum':783 'consumer.subscribe':807 'contain':724 'content':241,597,608,609,647,648,848,852,1047,1054,1209,1621,1662,1712,1714,1776,1905,1913,2436,2444,2473 'content.blank':599 'content.trim':853 'content.truncate':526 'control':129,140,466 'convers':124,1315 'corner':884 'correct':1409,1423 'count':535,741,889,891,1257,1375,1410,2530 'creat':93,172,223,254,278,329,366,385,426,477,547,657,659,712,965,1220,1506,1579,1625,1630,1636,1641,1667,1724,1757,1796,2177,2272,2292 'createdat':2132 'criteria':2783 'current':480,1465 'current_user.admin':1700,1717 'current_user.id':590,1699 'current_user.is':592,603,626 'current_user.support':475 'custom':32,89 'dashboard':21,65,115,964,1397 'data':170,392,403,644,678,706,796,798,814,1710,1713,1847,1849,1879,2139,2206,2417,2634 'data.message':827,1896,2430 'data.message.id':821,825,1887,1889,2425,2427 'data.message.sender':829 'data.messages.foreach':1850 'data.messages.map':800 'data.type':816,1883,2421 'databas':1478,2449 'date':1040,1067 'datetim':2222,2225,2467,2490,2514 'decis':1303 'dedup':823 'dedupl':790,1354,1461 'def':1559,1570,1588,1596,1648,1670,1688,1707 'default':1782 'delay':161,413,1176,1341,1346,2315,2590 'delet':1320 'deliver':2560 'depend':1537 'desc':513,989,1558 'describ':2751 'design':1302 'desktop':1376 'destroy':1538 'detail':972,1018,1029 'development/small':2502 'direct':2647 'disconnect':840,2726 'dispatch':2313 'display':134,887 'div':1996,2018 'divid':1069 'document':2521,2527 'dot':754,928 'duplic':1360,1454 'dx':2575 'e':2025,2029,2415 'e.data':2419 'e.key':2030 'e.shiftkey':2032 'e.target.value':2027 'echo':1364,1467 'email':159,411,520,702,1117,1120,1152,1192,1238,1271,1297,1342,1431,1439,1509,2552,2556,2577,2587,2628,2639 'email/name':1087 'emb':2523 'emoji':2499 'end':1569,1587,1595,1603,1604,1669,1679,1680,1706,1720,1721,1756,1788 'endpoint':418 'enter':931,935,2031 'enum':246,1613 'environ':2763 'environment-specif':2762 'error':2118 'event':1877,2320,2707 'event.data':1881 'everi':2722 'exist':322,1586,2281 'expand':766 'expert':2768 'export':1812,1922,2105,2364 'extend':2215,2285 'face':421,441 'fallback':2713 'fals':842,1737,1771,1778,1828,1875,1929,2376,2413 'featur':1002,1063,1371 'fetch':793,856,1841,2391,2730 'filter':271,1004 'find':568,2600 'fine':2505 'firebas':2699,2701 'first':1553 'fix':726,885 'flex':2035 'float':14,58,116,714,878 'foreign':189,236,1738,1772 'forget':1460 'framework':1518 'framework-specif':1517 'free':2565 'friend':2153 'frontend':111,709,962 'full':1983,2077,2476 'full-text':2475 'function':92,308,317,334,344,350,356,472,495,584,640,772,846,854,1813,1923,2107,2227,2235,2243,2252,2261,2289,2293,2328,2334,2348,2365 'futur':1370 'get':422,424,442,449,452,469,492,2108 'get/create':145 'getserversess':2096,2112 'gin':2470 'give':1182 'global':148,615,1373 'good':2564 'grace':1496 'gray':1991,2015 'green':927,1987 'group':1038,1064 'guard':2604 'guessabl':2461 'guid':167 'guidanc':1520 'h':1979,2069 'handl':1494 'handlesend':1964,2033,2047 'hasmani':2239 'hasn':1128 'header':750,976,1031 'highlight':1010 'histori':1316,1324 'hook':770,1802 'hooks/usesupportchat.ts':1804 'icon':731 'id':182,188,229,235,261,273,277,451,483,517,566,571,583,589,645,646,699,810,1207,1226,1232,1235,1265,1268,1294,1357,1505,1656,1661,1694,1698,1728,1761,1795,1858,1866,1901,2346,2405,2407,2462,2539,2598,2603 'id/archive':458 'id/unarchive':463 'implement':166,304,1381,2325,2579,2718 'import':1805,2095,2101,2162,2358 'in-app':40,104,1188 'includ':506,2129,2142,2642,2663 'index':259,496,1499,1502,1741,1790,2471,2543 'indic':131,926,991,1009,1417 'info':133,405 'input':1058,1937,2021,2023,2777 'input.trim':1966,1969 'inputarea':762 'instant':1405 'interfac':1205,1224,1230,1263,1280,1287 'iso8601':1219,1223,2518 'isopen':1926,1972 'isunreadforadmin':2262 'januari':1042 'job':154,1172,1193,1513,2585,2588 'job/worker':1132 'js':2194 'json.parse':1880,2418 'json.stringify':1909,2440 'keep':2655 'key':184,190,231,237,258,1301,1739,1773,1997,2171,2456 'keyboard':930 'label':1082 'last':198,214,310,313,368,510,522,528,986,993,1023,1240,1245,1250,1555,1561,1565,1745,2219,2298 'lastmessagetim':2733 'later':1677 'left':951,1072 'left-align':950 'length':1954 'let':2122,2377 'like':1372 'limit':2739 'link':1036,2648,2665 'list':120,150,444,969,974 'live':30,44,82 'load':780,872,1111 'local':861 'logic':467 'm':487,490,801,1851,1948,1995,2398 'm.content':2017 'm.id':802,1854,1998,2400 'm.read':1952 'm.sender':1949,2003 'manag':769 'mani':163,295,1534 'mark':335,434,864,1105 'markasread':855,874,910 'match':2748 'messag':38,97,122,162,179,199,228,264,275,311,314,323,362,369,383,391,402,431,436,455,485,489,508,511,523,529,534,596,637,642,643,675,676,677,685,697,704,705,732,818,851,866,892,921,938,941,949,959,987,994,998,1024,1037,1046,1065,1071,1074,1137,1165,1180,1187,1228,1241,1246,1251,1256,1275,1278,1284,1285,1292,1299,1334,1353,1388,1391,1430,1437,1447,1448,1455,1462,1536,1556,1562,1566,1639,1650,1659,1660,1709,1746,1760,1792,1852,1885,1894,1912,1962,2044,2130,2143,2175,2186,2205,2220,2236,2266,2294,2295,2299,2305,2308,2314,2332,2343,2353,2423,2443,2524,2529,2595,2597,2599,2601,2602,2635,2643 'message.chat.archived':2623 'message.chat.user.email':1154,2630 'message.content.truncate':2637 'message.read':1144,2616 'message.sender':1139,2611 'messagebubbl':757 'messagedata':2187 'messagelist':755 'messages.filter':1947 'messages.foreach':2397 'messages.map':1994 'messages.push':2429 'messagesendref':1941,2020 'messagesendref.current':1957 'method':302,858 'microsecond':2493 'migrat':1722 'min':1345,1435 'minut':1000,1175,2592 'miss':1498,2785 'ml':2011 'ml-auto':2010 'model':153,171,283,301,306,363,638,1524,2210,2216,2286 'modern':2574 'monday':1041 'mongodb':2520 'mount':792 'mysql':2480 'nativ':2513 'navig':1016 'new':381,674,695,817,920,1156,1164,1283,1290,1658,1838,1862,1884,2168,2185,2204,2303,2337,2352,2384,2402,2422,2658 'new-messag':2184,2203,2351 'newlin':937 'newmessag':2735,2737 'newsupportmessag':2304,2324 'next':2099 'next-auth':2098 'next.js':2088 'nil':1544,1550,1602 'node.js':2683 'non':2460 'non-guess':2459 'notif':160,400,412,634,693,833,1118,1343,1377,1644,1672 'notifi':679 'null':219,252,355,361,504,505,777,900,1146,1218,1244,1249,1255,1736,1770,1777,1822,1823,1833,1834,1943,2371,2372,2380,2381,2618 'number':1258 'onchang':2024 'onclick':2046,2063 'one':194,287,1305,2710 'one-way':2709 'onkeydown':2028 'onmount':2360,2386 'onunmount':2361,2431 'open':738,749,913,1384,1415,2650 'open/closed':903 'opposit':1076 'option':2479,2551,2672 'order':282,509,1452,1554 'orderbi':2131 'output':2757 'overview':110 'p':2000 'page':967,975,1030,1110 'panel':123,902,912 'param':1692 'params.archived':502 'patch':432,859 'pattern':2580 'per':142,157,164,196,1307 'perform':1136,1676,2596 'permiss':2778 'php':2211,2321 'php/laravel':2209 'pitfal':1458 'placehold':2041 'play':832 'poll':2719,2721 'posit':727,886 'post':456,461 'postgresql':2451 'postmark':2558 'precis':2494 'prefix':1021 'presenc':1622 'preserv':1323 'prev':1891,1892,1893,1897 'prev.messages':1895 'prevent':1347,1359 'preview':524,995,1247,2636,2644 'primari':183,230,945,2455 'prisma':2102 'prisma.supportchat.create':2138 'prisma.supportchat.findunique':2125 'privat':1647 'privatechannel':2338 'process':2553 'protect':2217,2287 'prototyp':2507 'pseudo':307,364,471,494,560,619,639,771,1133,2581,2720 'public':2226,2234,2242,2251,2260,2327,2330,2333,2347 'pusher':2158,2163,2165,2167,2169,2193,2686 'pusher-j':2192 'pusher.subscribe':2197 'pusher.trigger':2179 'pusher/ably':2150 'px':2039,2049 'py':2051 'queri':446,2245,2247,2254,2256 'r':1844 'r.json':1845 'race':1472 'rail':1523,2678,2679 'rapid':1350 'react':1798,1811,2576 'reactiv':379,1338 'read':249,438,653,655,868,898,1149,1215,1446,1475,1665,1785,2621 'real':7,36,51,95,552,1101,1394,2669 'real-tim':6,35,50,94,551,1100,1393,2668 'realtim':2695 'receiv':813 'recenc':206 'recent':1552 'recommend':186,233,2450,2452,2554 'reconnect':1495 'ref':782,2019,2359,2369,2375 'refer':2540 'reflect':1418 'reject':576,628,1695 'relat':2081 'relationship':284 'repli':1053,1125,1157,1351,1400,2633,2659 'requir':243,2776 'res':2389 'res.json':2395 'resend':2573 'response.json':2117,2146 'rest':136 'restor':464,1326 'return':321,358,482,514,577,587,600,629,822,869,1142,1147,1859,1898,1914,1967,1971,2116,2145,2229,2237,2246,2255,2264,2336,2350,2445,2614,2619,2624 'review':2769 'right':730,883,943,1075 'right-align':942 'robust':2567 'root':723 'round':1982,2002,2038,2060,2076 'rounded-ful':1981,2075 'rout':2092 'router':2090 'rtdb':2700 'rubi':1521,1525,1682,1723 'safeti':2779 'scale':2503,2572 'schedul':410,1170,1171,1643,1671 'scope':1539,1545,1551,2750 'scopeact':2244 'scopearchiv':2253 'scroll':916 'scrollabl':756 'scrollintoview':1958 'search':2478 'second':2724 'secondary/muted':953 'secret':2172 'secur':2458 'see':1186,1390,1403 'seen':1130,1356 'seenid':1836,2383 'seenids.add':2399,2426 'seenids.current.add':1853,1888 'seenids.current.has':1886 'seenids.has':2424 'seenmessageid':787 'seenmessageids.add':824 'seenmessageids.addall':799 'seenmessageids.has':820 'self':1678 'send':595,684,850,933,1119,1151,1179,1199,1333,1362,1387,1432,1442,1516,1708,1908,1911,2061,2439,2442,2608,2627 'sendbutton':767 'sender':244,325,371,394,407,530,532,601,610,612,649,651,687,760,894,1081,1211,1252,1575,1614,1663,1715,1780,2268,2309 'sendgrid':2563 'sendmessag':847,873,1903,1917,1933,1968,2435,2448 'sendsupportreplynotif':2312,2589 'sent':1463,2706 'separ':1366,2535 'serial':488 'server':1113,2705 'server-s':2704 'server-sid':1112 'serverless':2152,2156,2676 'serverless-friend':2151 'servic':2557 'ses':2569 'session':2110,2114 'session.user.id':2128,2141 'set':221,788,1839,2385 'setchat':1819,1848,1890 'setconnect':1826,1871,1874 'setinput':1938,1970,2026 'setinterv':2729 'setisopen':1927,2064 'shift':934 'shouldbroadcast':2326 'show':473,740,877,923,955,1019,1080,1408 'side':1114,2190 'similar':2161 'simpl':2561,2657,2708 'simplifi':1309 'sinc':2732 'skill':73,2742 'skill-chat-widget' 'skip':1150 'slide':746 'smooth':1960 'socket.io':2682 'soft':1319 'soft-delet':1318 'sort':203,267,984 'sound':834 'source-sickn33' 'spam':1348 'span':1975 'specif':556,1488,1519,2764 'sqlite':2501 'stack':2688,2692 'staff':24,68 'stale':1491 'state':768,774,862,2208 'static':2288,2291 'status':753,925,1420,1476,1493,2120 'step':168,415,543,707,960,1115,1200 'still':1196 'stop':2770 'store':2508,2516,2522 'strategi':2714 'stream':578,630,1701 'string':1208,1210,1217,1222,1227,1233,1236,1239,1243,1248,1266,1269,1272,1295,1298,1816,1906,2437,2519 'structur':721 'style':758,939 'subject':1155,2656 'subscrib':564,623,664,1689 'subscript':785,806,1095 'subscription.on':812,835,839 'subscription.perform':849 'subscription.unsubscribe':845 'substitut':2760 'success':2782 'supabas':2694,2696 'support':9,23,33,43,45,53,67,90,107,175,178,180,227,239,274,580,633,670,692,977,1159,1168,1535,1611,1652,1654,1703,1726,1759,1766,1791,1793,1973,2181,2199,2340,2344,2500,2632,2662 'support-chat':2180,2198,2339 'support_chats.archived':268 'support_chats.last':263 'support_chats.user':260 'support_messages.chat':272 'support_messages.where':1574 'supportchat':288,289,293,300,498,1225,1528,2214,2296 'supportchat.find':1691 'supportchatchannel':1685 'supportchatlistitem':1231 'supportmessag':296,297,1206,1229,1276,1286,1300,1607,2240,2284,2331 'supportreplynotificationjob':1135 'supportreplynotificationjob.set':1673 'synchron':1515 'system':11,55 't.datetime':1744,1748,1752,1784 't.integer':1779 't.references':1732,1765 't.text':1775 't.timestamps':1755,1787 'tab':979,1003 'tabl':174,1725,1758 'task':2746 'team':1169 'technolog':2671,2673 'templat':2578,2631,2640 'test':1378,2766 'text':242,996,2058,2079,2477,2511 'text-whit':2057,2078 'textarea':763 'tier':2566 'time':8,37,52,96,553,1102,1184,1395,1510,2670 'time.at':1584 'time.current':1568,1594 'timestamp':201,210,218,251,956,1628 'timestamptz':2464 'tip':2641 'titl':751 'toggl':901,1091 'toother':2306 'topic-agent-skills' 'topic-agentic-skills' 'topic-ai-agent-skills' 'topic-ai-agents' 'topic-ai-coding' 'topic-ai-workflows' 'topic-antigravity' 'topic-antigravity-skills' 'topic-claude-code' 'topic-claude-code-skills' 'topic-codex-cli' 'topic-codex-skills' 'touch':309,1560 'track':211,1355 'transact':1479,2555 'treat':2755 'trigger':1427 'true':838,1623,1740,1743,1774,1872,2065,2144,2410 'true/false':448 'truncat':2645 'tsx':1920 'ttl':2542 'two':173,548,966 'type':245,326,372,395,408,533,602,611,613,650,652,673,688,694,761,830,895,1140,1203,1212,1279,1282,1289,1576,1615,1657,1664,1716,1734,1768,1781,1950,2004,2042,2269,2310,2515,2612 'typescript':1202,1204,1803,2093,2154,2356 'unarch':351,1330,1426,1597,1632 'unauthor':2119 'unavail':2717 'uniqu':193,262,1742 'unless':1696 'unmount':844 'unread':126,253,318,537,888,990,1008,1197,1259,1374,1406,1438,1571 'unreadbadg':739 'unreadcount':1945,2082,2084,2087 'unsubscrib':2664 'updat':225,256,312,339,346,352,860,1103,1563,1590,1598,1626,2207,2297 'use':25,71,74,1477,1511,2157,2453,2463,2481,2489,2534,2541,2583,2740 'usecallback':1809,1904 'useeffect':1806,1840,1855,1956 'user':17,28,61,77,99,112,132,144,158,187,192,197,247,285,292,327,373,382,396,420,427,468,479,481,507,519,606,665,683,689,696,701,710,940,1044,1070,1078,1086,1089,1097,1122,1127,1183,1213,1234,1237,1253,1267,1270,1291,1296,1308,1311,1332,1382,1402,1429,1444,1466,1484,1532,1577,1616,1634,1719,1733,2005,2115,2228,2232,2270,2697,2702 'user-fac':419 'user@example.com':992,1032 'useref':1808,1831,1837,1942 'userid':2127,2140 'usest':1807,1820,1827,1928,1939 'usesupportchat':773,1814,1934,2366 'utf8mb4':2496 'uuid':185,232,1729,1735,1762,1769,2454,2488,2509 'ux':1310 'valid':1620,2765 'valu':2022 'verifi':1483 'via':1321,1469 'view':125,151,208,215,332,336,341,667,1106,1582,1750,2276 'vs':1006 'vue':2363 'vue.js':2354 'w':1977,2067 'wait':1674 'want':29,78 'way':2711 'web':2653 'websocket':135,545,1094,1277,1481,1832,1863,2148,2379,2403,2716 'websocket.connected':2728 'websocketconsum':784 'websocketurl':1815,1864 'where.not':1547 'wherenotnul':2257 'wherenul':2248 'white':2059,2080 'widget':3,15,47,59,84,113,711,716,875,1062,1079,1098,1385,1414,1918 'work':1422,2504 'ws':1861,1869,2378,2401,2432,2438 'ws.close':1899 'ws.onclose':1873,2411 'ws.onmessage':1876,2414 'ws.onopen':1870,2408 'wsref':1830 'wsref.current':1868,1907 'x':736 'yes':2689,2693,2698,2703,2712","prices":[{"id":"710c65d8-e63f-4b6a-acf1-f06191ae4a14","listingId":"3a87dc7f-eb5a-4f5d-8623-903235272580","amountUsd":"0","unit":"free","nativeCurrency":null,"nativeAmount":null,"chain":null,"payTo":null,"paymentMethod":"skill-free","isPrimary":true,"details":{"org":"sickn33","category":"antigravity-awesome-skills","install_from":"skills.sh"},"createdAt":"2026-04-18T21:34:10.174Z"}],"sources":[{"listingId":"3a87dc7f-eb5a-4f5d-8623-903235272580","source":"github","sourceId":"sickn33/antigravity-awesome-skills/chat-widget","sourceUrl":"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/chat-widget","isPrimary":false,"firstSeenAt":"2026-04-18T21:34:10.174Z","lastSeenAt":"2026-04-24T12:50:44.669Z"}],"details":{"listingId":"3a87dc7f-eb5a-4f5d-8623-903235272580","quickStartSnippet":null,"exampleRequest":null,"exampleResponse":null,"schema":null,"openapiUrl":null,"agentsTxtUrl":null,"citations":[],"useCases":[],"bestFor":[],"notFor":[],"kindDetails":{"org":"sickn33","slug":"chat-widget","github":{"repo":"sickn33/antigravity-awesome-skills","stars":34882,"topics":["agent-skills","agentic-skills","ai-agent-skills","ai-agents","ai-coding","ai-workflows","antigravity","antigravity-skills","claude-code","claude-code-skills","codex-cli","codex-skills","cursor","cursor-skills","developer-tools","gemini-cli","gemini-skills","kiro","mcp","skill-library"],"license":"mit","html_url":"https://github.com/sickn33/antigravity-awesome-skills","pushed_at":"2026-04-24T06:41:17Z","description":"Installable GitHub library of 1,400+ agentic skills for Claude Code, Cursor, Codex CLI, Gemini CLI, Antigravity, and more. Includes installer CLI, bundles, workflows, and official/community skill collections.","skill_md_sha":"7060da3aab99ed438e30cce30d753ac4896c60af","skill_md_path":"skills/chat-widget/SKILL.md","default_branch":"main","skill_tree_url":"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/chat-widget"},"layout":"multi","source":"github","category":"antigravity-awesome-skills","frontmatter":{"name":"chat-widget","description":"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 live chat, customer support chat, real-time messaging, or in-app support."},"skills_sh_url":"https://skills.sh/sickn33/antigravity-awesome-skills/chat-widget"},"updatedAt":"2026-04-24T12:50:44.669Z"}}