livechat

Gem Version Downloads CI License: MIT

In-app support messaging for Rails. Add a chat button so users can ask for help from the page where they got stuck. Your team answers from a mounted inbox. No SaaS account, no third-party script, no separate app, no customer data leaving your database.

Use it when email is too detached, Intercom is too much, and Chatwoot is another app you do not want to deploy.

The livechat widget open over a running Rails app

Install

# Gemfile
gem "livechat"
bundle install
bin/rails generate livechat:install
bin/rails db:migrate
<%# app/views/layouts/application.html.erb %>
<%= livechat_tag %>

That's it. Visit any page, click the bubble, say hi. Answer yourself at /livechat.

Optional demo data:

bin/rails livechat:seed_demo

It creates two idempotent sample conversations: one open thread with an unread visitor reply, and one resolved thread. Running the task again refreshes those demo messages instead of duplicating conversations.

[!IMPORTANT] The inbox defaults to development only. Set authorize_agent before you deploy — see Configure.

Ruby >= 3.2 · Rails >= 7.1, < 9 · Active Storage only if you want file attachments.

Installing with a coding agent? Point it at AGENTS.md — the same steps in the order an agent needs them, plus the gates it tends to get wrong and the things it should not do. It ships inside the gem, so cat "$(bundle show livechat)/AGENTS.md" works from any app that bundles it.

What you get

| | | | ------------- | ------------------------------------------------------------------------ | | Widget | Chat bubble, expandable panel, drafts preserved, unread badges | | Inbox | Open / resolved tabs, search across everything, who-worked-what column | | Team | Any teammate answers any thread; every reply signed with its author | | Email | Both directions — one per unread stretch, not one per message | | Files | Images inline, documents as links. Served through the engine, never a blob URL | | Realtime | Polling by default (no Redis, no Action Cable). Push is opt-in | | Threads | One per visitor — a conversation, not tickets. Writing again reopens it | | Deps | None. Plain JS — no Tailwind, no Stimulus, no importmap, no build step | | Auth | Lambdas over the raw request — Devise, Rails 8 auth, anything | | i18n | 26 languages, RTL included | | Turbo/CSP | Turbo Drive and strict nonce-based CSP out of the box |

Why a gem

livechat Hosted chat SaaS
Cost Free, MIT Per-seat, per-month
Where conversations live Your database The vendor's
To deploy bundle add livechat A script tag, or a second app
Visitor identity Server-side, from your session Whatever the visitor types
Page weight One <script>, no CDN Third-party bundle
Infrastructure None. Polling by default Theirs, or your own Redis + Cable
"Powered by" badge Never Usually, until you pay

How it works

  1. Add <%= livechat_tag %> to your layout. A bubble appears bottom-right — or open the panel from any element with data-livechat-open, or window.Livechat.open().
  2. A visitor writes. The message lands in livechat_conversations in your database, and — if you configured it — in your team's email.
  3. Your team answers at /livechat. Several people can work the same thread; resolve it when done. A visitor writing again reopens it.
  4. The visitor sees the reply in the widget, or by email when they're gone.

Realtime is polling, on purpose: ~4s while the panel is open, ~30s in the background, nothing at all for visitors who never wrote. No Action Cable, no Redis, no infrastructure. At support-chat volume you will not notice; your ops person will notice there is nothing new to run. Already running Action Cable and want instant delivery? config.action_cable = true — polling stays the fallback.

The inbox

Open and resolved filters, unread badges, search (visitor name, email, and everything anyone wrote), and a two-column desktop layout with the chat list on the left and the selected thread on the right. Reply with Cmd/Ctrl+Enter, resolve, reopen. The inbox keeps itself fresh while you watch — and never reloads over a half-written reply or search.

Desktop inbox Mobile thread
The inbox: conversation list on the left and selected thread on the right The mobile thread view with reply composer

Every reply carries its author and time, and resolving is recorded in the thread. Gated by authorize_agent.

Configure

Everything is optional — a fresh install works with zero config. In config/initializers/livechat.rb:

Option Default What it does
authorize_agent development only Who can read the inbox. Override before deploying
base_controller_class ActionController::Base Controller the inbox inherits — name your admin's and it adopts its layout, helpers and auth
enabled everyone Who sees the widget. false hides it and rejects posts
current_user nil Identify the visitor. Receives the request
app_name Rails app name Shown in the widget header
greeting localized default First message visitors see
reply_time_text localized default "We usually reply within a few hours"
launcher_label localized default Text on the bubble
avatar_url nil Customer-facing avatar in the widget header; URL or per-request callable
accent_color nil One hex restyles launcher, header, bubbles, send button
show_launcher true false hides the bubble — bring your own entry point
visitor_label name, else email How a visitor is labelled in the inbox
agent_label name, else email How an agent is labelled internally
agent_display_name the full label What visitors see — trim it or anonymise it
mailer_from nil Required for any email
agent_emails nil Who gets notified of new visitor messages
on_visitor_message no-op Runs after a visitor writes — Slack, etc.
on_agent_message no-op Runs after an agent replies
attach_files true File attachments (needs Active Storage)
storage_service nil Named Active Storage service for chat attachments
max_attachments 5 Per message
max_attachment_size 10.megabytes Enforced server-side
allowed_attachment_types nil (any) Or an allowlist, e.g. %w[image/png application/pdf]
action_cable false Opt into push delivery
action_cable_url "/cable" Match your mount ActionCable...
rate_limit { to: 30, within: 1.minute } Per-IP throttle (Rails 7.2+). nil disables
mount_path "/livechat" Keep in sync with mount in routes.rb

A typical initializer:

Livechat.configure do |config|
  config.current_user     = ->(request) { request.env["warden"]&.user }
  config.authorize_agent  = ->(request) { request.env["warden"]&.user&.admin? }
  config.mailer_from      = "chat@example.com"
  config.agent_emails     = -> { User.where(admin: true).pluck(:email) }
  config.reply_time_text  = "We usually reply within an hour."
  config.avatar_url       = "/support-avatar.png"
  config.accent_color     = "#7c3aed"
end

avatar_url can also choose branding from the current request:

config.avatar_url = ->(request) { request.env["current_account"]&.support_avatar_url }

Prefer a same-origin image. If the URL uses another host, allow that host in your application's img-src Content Security Policy.

Gates receive the raw request, so they work with any auth:

# Devise / Warden
config.current_user = ->(request) { request.env["warden"]&.user }

# Rails 8 built-in auth (bin/rails generate authentication)
config.current_user = lambda do |request|
  token = request.cookies["session_token"]
  Session.find_signed(token)&.user if token
end
Who visitors talk to

Replies are signed. What visitors see is up to you:

config.agent_display_name = ->(label) { label.split.first }   # "Ada"
config.agent_display_name = ->(_label) { "Support team" }     # anonymous
Email, both directions

When a visitor writes and nobody has read it, the team gets one email — one per unread stretch, not one per message. When an agent replies and the visitor is away, the visitor gets one email (signed-in visitors automatically, guests once they leave an address — the widget asks, gently).

Requires mailer_from; team notifications also need agent_emails. For anything else, hook in:

config.on_visitor_message = ->(message) { SlackNotifier.ping(message) }
File attachments

On by default wherever the app has Active Storage. If you don't already:

bin/rails active_storage:install && bin/rails db:migrate

Visitors get a paperclip in the composer; agents get a file field on the reply form. Safe image formats render inline, other files as download links. Every file is served through the engine's own route and gated the same way the chat is — an agent, or the visitor who owns that conversation — so nothing leaks through a guessable or long-lived blob URL. Responses are private and no-store; audio supports byte ranges for playback and seeking.

Where Active Storage isn't installed, the widget quietly stays text-only.

Set config.storage_service to route chat uploads to a dedicated Active Storage service from your app's config/storage.yml:

config.storage_service = :livechat_uploads

Privacy, retention, and deletion

Livechat stores conversation identity and context, message bodies and attribution, read/status timestamps, and optional attachments in the host application. Captured page URLs are limited to HTTP(S) and stored without credentials, query strings, or fragments. The engine does not send data to a Livechat service, but configured notification emails and message hooks can send message content, visitor context, and attachment filenames wherever the host chooses.

Every authorized agent can read every conversation in this one shared inbox. Visitors can read only the conversation resolved from their signed-in id or guest cookie. Livechat does not provide tenant-isolated agent inboxes in 1.x; do not use one mount for mutually isolated tenant support teams.

No record is deleted automatically. Delete one conversation (including its messages), all conversations for a user, or resolved history past a host-chosen cutoff explicitly:

Livechat::Conversation.find(id).destroy!
Livechat::Conversation.where(visitor_id: user.id.to_s).find_each(&:destroy!)
Livechat::Conversation.purge_resolved(older_than: 90.days.ago)

Use destroy!, not delete_all, when attachments must follow Rails' Active Storage lifecycle. Confirm object-storage jobs, replicas, backups, notification emails, hook destinations, logs, and exports separately: deleting live database rows cannot retract those copies. allowed_attachment_types is an upload allowlist, not malware scanning; choose a restrictive list and storage policy for your application. See SECURITY.md for the full boundary.

Realtime with Action Cable

Polling is the default and needs nothing from your app. If you already run Action Cable, turn on push so a reply appears the instant it's sent:

config.action_cable = true
config.action_cable_url = "/cable" # match your `mount ActionCable... => ...`

A new message nudges the widget and the inbox to refresh at once; polling stays the fallback, so a dropped socket or a proxy that blocks WebSockets never means a missed message. The widget speaks the Action Cable protocol over a plain WebSocket — no @rails/actioncable, no build step — and only ever subscribes to a stream the server signed for it. Under a strict CSP, allow the socket with connect-src 'self'.

Widget API

| | | | --- | --- | | window.Livechat.open() / .close() | open("Hi, I need help with…") prefills the box — never over a visitor's draft | | data-livechat-open | Any element opens the panel on click | | data-livechat-message="…" | Prefill from that element — great for contextual buttons | | <%= livechat_button %> | A plain, unstyled opener button | | <%= livechat_button("Ask about this order", message: "I need help with order ##{@order.id}") %> | Open and prefill in one helper | | config.show_launcher = false | Hide the bubble entirely |

While replies are unread, every data-livechat-open element carries a small count badge — so hiding the launcher never hides the answer.

Contextual buttons are where a Rails app beats a generic support widget:

<%= livechat_button("Ask about this order",
      message: "I need help with order ##{@order.id}") %>

<%= livechat_button("Ask about this invoice",
      message: "I need help with invoice ##{@invoice.number}") %>

<button data-livechat-open
        data-livechat-message="I need help with project <%= @project.name %>">
  Contact support
</button>

Compatibility and public API

The following are the model and integration contracts that 1.x will keep stable under semantic versioning:

  • Livechat::Conversation, including STATUSES, TYPING_TTL, its persisted fields, messages, recent_first, for_visitor, claim!, posting and read/status methods, display_name, and purge_resolved.
  • Livechat::Message, including AUTHOR_TYPES, EVENTS, MAX_BODY_LENGTH, its persisted fields, optional files, documented scopes, author/read predicates, public_label, attached_files, and preview.
  • Livechat.configure, mount_livechat, livechat_tag, livechat_button, window.Livechat.open/close, data-livechat-open, and data-livechat-message.
  • config.on_visitor_message and config.on_agent_message, each called with the saved Livechat::Message documented by the initializer.

Conversation and Message are intentionally distinct and will not become a generic Post during 1.x. Engine controllers, partials, CSS classes, generated HTML, and widget implementation objects are private. Incompatible changes to the public list above wait for a new major version; a deprecation normally ships first.

Upgrading from 0.x

There is no migration, initializer change, constant alias, or model rename. Stored page URLs become query-free on the next visitor message, attachment responses stay on the same engine URL but now stream privately instead of being buffered, and Rails 9 is excluded until a compatible release is tested.

Use cases

  • SaaS apps: answer billing, onboarding and account questions from inside the app.
  • Customer portals: let users ask about orders, invoices, documents or bookings.
  • Marketplaces: keep buyer, seller and admin support tied to the current page.
  • Internal tools: give non-technical teammates a direct line from admin screens.
  • Course and member apps: handle access, lesson and subscription questions in context.

What it doesn't do

No AI bots, no canned responses, no omnichannel (WhatsApp, Messenger…), no visitor tracking, no "powered by" badge. If you need a support platform, Chatwoot is excellent. If you need your users to be able to reach you from inside your Rails app — this is a gem's worth of exactly that.

Development

bundle exec rake test
bundle exec rake test:system
bundle exec rubocop

One family

Five Rails engines built on the same backbone, so adopting a second one is mostly muscle memory:

Gem What it does
testimonials Testimonials, reviews and NPS — text and video, collected in your own app
ideasbugs In-app bug reports and feature requests, with a triage queue
livechat (this gem) Live chat between your visitors and your agents, self-hosted
product_tours Product tours and video tutorials, shown in-app at the right moment
i18n_proofreading In-context translation fixes suggested by your own users

They share the install shape (generate <gem>:install, mount, one initializer), the same host hooks (base_controller_class to inherit your admin's controller, agent_layout for just the shell), one dashboard design system — the same two-pane layout, colour tokens and components in all five, scoped so it cannot touch your own CSS — and migrations that follow your app's primary_key_type.

License

MIT.