TurboChat
TurboChat is a mountable Rails chat engine for server-rendered applications.
Status: actively maintained.
What You Get
- Turbo Stream chat UI with two layout styles (bounded card or full-viewport).
- Role-based permissions with a swappable adapter.
- Mentions, invitations, inline message editing, and typing signals.
- Moderation: mute, timeout, ban, and message deletion.
- System messages for membership lifecycle events.
- Browser and server-side event hooks.
Basic Setup
Add the gem:
# Gemfile
gem "turbo_chat"Install and migrate:
bundle install
bin/rails generate turbo_chat:install
bin/rails db:migrateMount the engine:
# config/routes.rb
mount TurboChat::Engine => "/", as: "turbo_chat"This mounts TurboChat at your app root.
Because the engine defines root as chats#index, both of these resolve to chat index:
//chats
Route helpers:
-
turbo_chat.root_path->/ -
turbo_chat.chats_path->/chats -
turbo_chat.chat_path(chat)->/chats/:id -
turbo_chat_path-> the mount point (/)
Host App Requirements
1. Mark your participant model
class User < ApplicationRecord
acts_as_chat_participant
endThis adds has_many :chat_memberships and the associations TurboChat needs to resolve participants in chats.
2. Resolve the current participant
If your app exposes current_user and it returns a model using acts_as_chat_participant, TurboChat works out of the box.
Resolution order:
- Host
ApplicationController#current_chat_participant(if defined) -
config.chat.current_participant_resolver(if configured) -
current_user(if available) - Raise
NotImplementedError
For non-current_user auth:
TurboChat.configure do |config|
config.chat.current_participant_resolver = ->(controller) { controller.send(:current_member) }
endFirst Working Example
chat = TurboChat::Chat.create!(title: "Support")
TurboChat::ChatMembership.create!(chat: chat, participant: current_user, role: :admin)<%= link_to "Open chat", turbo_chat.chat_path(chat) %>Configuration
The installer generates a full initializer at config/initializers/turbo_chat.rb with every option documented. Start with the defaults and change only what you need.
Config is organized into scoped namespaces:
TurboChat.configure do |config|
# Limits
config.chat.max_chat_participants = 10
config.chat_message.max_message_length = 1000
# Features
config.chat_message.enable_mentions = true
config.chat_message.blocked_words = []
config.chat_message.blocked_words_action = :reject # or :scramble
# Layout
config.style.chat_style = "chat_style_bounded" # or "chat_style_unbounded"
config.chat_message.message_insert_position = "append_end" # or "append_start"
# Events (all opt-in, all false by default)
config.events.emit_typing_events = true
config.events.emit_message_events = true
endAvailable scopes: config.chat, config.chat_message, config.style, config.events, config.moderation, config.signals. Flat aliases (e.g. config.max_message_length) still work for backward compatibility.
Chat Modes
Chats default to chat_mode: :standard.
chat = TurboChat::Chat.create!(title: "Assistant", chat_mode: :assistant)Built-in assistant-mode defaults make the chat simpler for 1:1 assistant use cases:
max_chat_participants = 2- members panel and invite UI hidden
- mentions disabled
- system messages disabled
- chat close action hidden
- invitation, mention, lifecycle, and moderation events disabled
You can override assistant-mode defaults without changing standard chats:
TurboChat.configure do |config|
config.mode(:assistant).show_members = true
config.mode(:assistant).enable_mentions = true
endRate Limiting
TurboChat does not include built-in rate limiting. For production, add request throttling using middleware such as rack-attack.
Roles and Permissions
Built-in roles
| Role | Rank | Key capabilities |
|---|---|---|
member |
0 | View, post, mention members, edit own messages |
moderator |
1 | + invite, @all/@ROLE mentions, mute/timeout/ban, delete messages |
admin |
2 | + close/reopen chats, change member roles |
Higher-rank roles can act on lower-rank members but not on peers or above.
Custom roles
TurboChat.configure do |config|
config.add_role(
:support_agent,
name: "Support Agent",
rank: 1,
permissions: %i[view_chat post_message delete_message]
)
endPermission adapter
All access checks go through config.chat.permission_adapter (default: TurboChat::Permission). To customize, create a class that implements the same public interface:
class CustomPermission < TurboChat::Permission
def can_post_message?
super && !participant.suspended?
end
end
TurboChat.configure do |config|
config.chat.permission_adapter = CustomPermission
endThe adapter must respond to: can_view_chat?, can_create_chat?, can_post_message?, can_invite_member?, can_grant_member_permissions?, can_mute_member?, can_timeout_member?, can_ban_member?, can_delete_message?, can_edit_message?, can_close_chat?, can_reopen_chat?.
Invitations
Admins and moderators can invite participants through the members panel. The invited participant sees pending invitations on the chat index with Accept/Decline buttons.
Programmatic invitation:
TurboChat::ChatMembership.create!(
chat: chat,
participant: invitee,
role: :member,
invitation_accepted: false
)Accept/decline routes are built in: PATCH /chats/:id/accept and PATCH /chats/:id/decline.
Message Ingest API
Post messages as a specific participant:
TurboChat::Messages.send_message_as(
current_user,
chat,
body: "Internal note",
source: :app
)External ingest with idempotency (chat_id + source + external_id):
TurboChat::Messages.ingest_external!(
chat: chat,
participant: current_user,
body: "Hello from WhatsApp",
source: :whatsapp,
external_id: webhook_payload.fetch("message_id"),
sent_at: webhook_payload["sent_at"]
)Duplicate webhook deliveries with the same external_id resolve to the existing message.
Source labels shown in message badges are configurable:
config.chat_message.message_source_labels = {
"app" => "In App",
"whatsapp" => "WhatsApp",
"sms_gateway" => "SMS"
}Moderation API
TurboChat::Moderation.mute_member!(actor: moderator, membership: membership)
TurboChat::Moderation.unmute_member!(actor: moderator, membership: membership)
TurboChat::Moderation.timeout_member!(actor: moderator, membership: membership, until_time: 30.minutes.from_now)
TurboChat::Moderation.ban_member!(actor: moderator, membership: membership)
TurboChat::Moderation.delete_message!(actor: moderator, message: message)
TurboChat::Moderation.close_chat!(actor: admin, chat: chat)
TurboChat::Moderation.reopen_chat!(actor: admin, chat: chat)All actions are permission-checked and generate system messages in the chat timeline. Mute, timeout, ban, and role changes are visible to all participants as system messages (disable with config.chat.system_messages = false).
Raises TurboChat::Moderation::AuthorizationError or TurboChat::Moderation::InvalidActionError.
Signals API
TurboChat::Signals.start!(chat: chat, participant: user, signal_type: :typing)
TurboChat::Signals.start!(chat: chat, participant: user, signal_type: :thinking)
TurboChat::Signals.start!(chat: chat, participant: user, signal_type: :planning)
TurboChat::Signals.custom!(chat: chat, participant: user, signal_text: "Reviewing your request")
TurboChat::Signals.clear!(chat: chat, participant: user)Block form that auto-clears when the work finishes:
TurboChat::Signals.with(chat: chat, participant: user, signal_type: :thinking) do
# long-running operation
endSignal lifetime is configurable via config.signals.signal_ttl_seconds (default: 60).
Events
All event emissions are opt-in.
Browser events (CustomEvent on document)
config.events.emit_typing_events = true # turbo-chat:typing-started, turbo-chat:typing-ended
config.events.emit_message_events = true # turbo-chat:message-sent
config.events.emit_mention_events = true # turbo-chat:mention
config.events.emit_invitation_events = true # turbo-chat:invitation-accepted
config.events.emit_chat_lifecycle_events = true # turbo-chat:chat-invited, chat-joined, chat-declined,
# chat-left, chat-closed, chat-reopeneddocument.addEventListener("turbo-chat:message-sent", function (event) {
console.log(event.detail); // { chatId: "123" }
});Server-side events (ActiveSupport::Notifications)
config.moderation.emit_moderation_events = true # turbo_chat.moderation.member_muted, member_banned, ...
config.moderation.emit_blocked_words_events = true # turbo_chat.blocked_words.detected, rejected, scrambledActiveSupport::Notifications.subscribe(/turbo_chat\.moderation\./) do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
Rails.logger.info("[TurboChat] #{event.name} #{event.payload.inspect}")
endTheming
TurboChat ships a default theme built on CSS custom properties. Override them in your app stylesheet:
:root {
--chat-text: #12263f;
--chat-muted: #5f738a;
--chat-border: #c9d5e3;
--chat-surface: #ffffff;
--chat-surface-soft: #f6f9fd;
--chat-primary: #1f6edc;
--chat-primary-dark: #1452ac;
--chat-primary-soft: #e7f1ff;
--chat-danger: #b42318;
--chat-danger-soft: #fff1f3;
--chat-success: #1f7a40;
--chat-success-soft: #ecfdf3;
--chat-mention-highlight-color: #b42318;
--chat-shadow: 0 20px 48px rgba(16, 36, 58, 0.12);
}Additional style config:
config.style.own_message_hex_color = "#e7f1ff"
config.style.other_message_hex_color = "#ffffff"
config.style.mention_mark_hex_color = "#b42318"
config.style.composer_placeholder_text = "Type a message..."Upgrade
bin/rails turbo_chat:install:migrations
bin/rails db:migrateKnown Limitations
- Stream subscriptions after member removal. Turbo Stream subscriptions persist until page reload. A removed member may see new messages until they navigate away. Stream names are cryptographically signed so this is not a security issue.
- Blocked word filtering. Word-boundary matching can be bypassed with Unicode homoglyphs, zero-width characters, or leetspeak. Treat it as a first line of defense.
- Invitable participants cap. The invite picker returns at most 100 non-member participants. Larger user bases should provide a custom search endpoint.
Dependencies
- Ruby
>= 3.1 - Rails
>= 7.0,< 8.0 -
turbo-rails>= 1.4,< 3.0