العربية • Deutsch • English • Español • Français • Italiano • 日本語 • 한국어 • Nederlands • Polski • Português (BR) • Русский • Türkçe • 简体中文
Escalated for Rails
A full-featured, embeddable support ticket system for Rails. Drop it into any app — get a complete helpdesk with SLA tracking, escalation rules, agent workflows, and a customer portal. No external services required.
escalated.dev — Learn more, view demos, and compare Cloud vs Self-Hosted options.
Three hosting modes. Run entirely self-hosted, sync to a central cloud for multi-app visibility, or proxy everything to the cloud. Switch modes with a single config change.
Features
- Ticket lifecycle — Create, assign, reply, resolve, close, reopen with configurable status transitions
- SLA engine — Per-priority response and resolution targets, business hours calculation, automatic breach detection
- Escalation rules — Condition-based rules that auto-escalate, reprioritize, reassign, or notify
- Agent dashboard — Ticket queue with filters, bulk actions, internal notes, canned responses
- Customer portal — Self-service ticket creation, replies, and status tracking
- Admin panel — Manage departments, SLA policies, escalation rules, tags, and view reports
- File attachments — Drag-and-drop uploads with configurable storage and size limits
- Activity timeline — Full audit log of every action on every ticket
- Email notifications — Configurable per-event notifications with webhook support
- Department routing — Organize agents into departments with auto-assignment (round-robin)
- Tagging system — Categorize tickets with colored tags
- Guest tickets — Anonymous ticket submission with magic-link access via guest token
- Inbound email — Create and reply to tickets via email (Mailgun, Postmark, AWS SES, IMAP)
-
Inertia.js + Vue 3 UI — Shared frontend via
@escalated-dev/escalated - Ticket splitting — Split a reply into a new standalone ticket while preserving the original context
-
Ticket snooze — Snooze tickets with presets (1h, 4h, tomorrow, next week);
rake escalated:wake_snoozed_ticketsauto-wakes them on schedule - Saved views / custom queues — Save, name, and share filter presets as reusable ticket views
-
Embeddable support widget — Lightweight
<script>widget with KB search, ticket form, and status check -
Email threading — Outbound emails include proper
In-Reply-ToandReferencesheaders for correct threading in mail clients - Branded email templates — Configurable logo, primary color, and footer text for all outbound emails
- Real-time broadcasting — Opt-in broadcasting via ActionCable with automatic polling fallback
- Knowledge base toggle — Enable or disable the public knowledge base from admin settings
Requirements
- Ruby 3.1+
- Rails 7.1+
- Node.js 18+ (for frontend assets)
Quick Start
bundle add escalated
npm install @escalated-dev/escalated
rails generate escalated:install
rails db:migrateAdd the Ticketable concern to your User model:
class User < ApplicationRecord
include Escalated::Ticketable
endDefine authorization in your ApplicationController or an initializer:
# config/initializers/escalated.rb
Escalated.configure do |config|
config.admin_check = ->(user) { user.admin? }
config.agent_check = ->(user) { user.agent? || user.admin? }
endVisit /support — you're live.
Ticket subjects
A ticket has a requester (who raised it) and a subject line (free text). You can also attach host-app entities the ticket is about — a Project, Customer, asset, and so on — so agents see context and can jump into your app.
Include Escalated::PresentsAsTicketSubject on any attachable model and override presentation methods as needed:
class Project < ApplicationRecord
include Escalated::PresentsAsTicketSubject
def ticket_subject_subtitle
"Project · #{customer.name}"
end
def ticket_subject_url
project_path(self)
end
def ticket_subject_color
'#2563eb'
end
def ticket_subject_icon
'folder'
end
endAttach, detach, or sync subjects on a ticket (several allowed):
ticket.attach_subject(project, role: 'project')
ticket.attach_subject(customer, role: 'account')
ticket.sync_subjects([[project, 'primary'], customer])
ticket.detach_subject(project)Each subject is serialized on the ticket as { type, id, role, title, subtitle, url, color, icon, missing }. subject_id is stored as a string so integer, UUID, and string host keys all work.
Allow agent/admin attach endpoints by listing permitted models in configuration (empty list disables the API; programmatic attach still works when empty):
Escalated.configure do |config|
config.ticket_subject_types = [
'Project',
'Customer',
# morph alias => class
# 'project' => 'Project',
]
endEndpoints: POST /support/agent/tickets/:id/subjects and DELETE .../subjects/:subject_id (admin routes mirror under /support/admin/). Pass type, subject_id, and optional role in the body, or nest them under subject (useful when you need Laravel-style id without colliding with the ticket route param).
Frontend Setup
Escalated uses Inertia.js with Vue 3. The frontend components are provided by the @escalated-dev/escalated npm package.
Tailwind Content
Add the Escalated package to your Tailwind content config so its classes aren't purged:
// tailwind.config.js
content: [
// ... your existing paths
'./node_modules/@escalated-dev/escalated/src/**/*.vue',
],Page Resolver
Add the Escalated pages to your Inertia page resolver:
// app/javascript/entrypoints/application.js
import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'
createInertiaApp({
resolve: name => {
if (name.startsWith('Escalated/')) {
const escalatedPages = import.meta.glob(
'../../../node_modules/@escalated-dev/escalated/src/pages/**/*.vue',
{ eager: true }
)
const pageName = name.replace('Escalated/', '')
return escalatedPages[`../../../node_modules/@escalated-dev/escalated/src/pages/${pageName}.vue`]
}
const pages = import.meta.glob('../pages/**/*.vue', { eager: true })
return pages[`../pages/${name}.vue`]
},
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el)
},
})Theming (Optional)
Register the EscalatedPlugin to render Escalated pages inside your app's layout — no page duplication needed:
import { EscalatedPlugin } from '@escalated-dev/escalated'
import AppLayout from '@/layouts/AppLayout.vue'
createInertiaApp({
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.use(EscalatedPlugin, {
layout: AppLayout,
theme: {
primary: '#3b82f6',
radius: '0.75rem',
}
})
.mount(el)
},
})Your layout component must accept a #header slot and a default slot. Escalated will render its sub-navigation in the header and page content in the default slot. Without the plugin, Escalated uses its own standalone layout.
See the @escalated-dev/escalated README for full theming documentation and CSS custom properties.
Hosting Modes
Self-Hosted (default)
Everything stays in your database. No external calls. Full autonomy.
Escalated.configure do |config|
config.mode = :self_hosted
endSynced
Local database + automatic sync to cloud.escalated.dev for unified inbox across multiple apps. If the cloud is unreachable, your app keeps working — events queue and retry.
Escalated.configure do |config|
config.mode = :synced
config.hosted_api_url = "https://cloud.escalated.dev/api/v1"
config.hosted_api_key = ENV["ESCALATED_API_KEY"]
endAgent actions taken in the cloud portal come back through a signed webhook. Set the site's signing secret (shown on the connected site in the cloud dashboard) and give the cloud this URL:
config.hosted_signing_secret = ENV["ESCALATED_CLOUD_SIGNING_SECRET"]
# Webhook URL for the connected site: https://your-app.example.com/support/cloud/webhookThe receiver verifies X-Escalated-Signature (HMAC-SHA256 of the raw body), ignores replayed
event_ids for 24 hours, and applies subject, description, priority and status from
ticket.updated / ticket.status_changed to the local ticket whose reference matches the
projection's external_id. Changes run through the local driver, so your listeners fire, and
are not echoed back to the cloud.
Cloud
All ticket data proxied to the cloud API. Your app handles auth and renders UI, but storage lives in the cloud.
Escalated.configure do |config|
config.mode = :cloud
config.hosted_api_url = "https://cloud.escalated.dev/api/v1"
config.hosted_api_key = ENV["ESCALATED_API_KEY"]
endAll three modes share the same controllers, UI, and business logic. The driver pattern handles the rest.
Configuration
Create or edit config/initializers/escalated.rb:
Escalated.configure do |config|
config.mode = :self_hosted
config.user_class = "User"
config.table_prefix = "escalated_"
config.route_prefix = "support"
config.default_priority = :medium
# Host user key type for the engine's user-referencing FK columns.
# :auto (default) introspects your `user_class` primary key; override with
# :bigint | :uuid | :string if your User model uses a UUID/string PK. Run
# `escalated:install:migrations` AFTER setting this so the generated
# migrations emit matching column types.
config.user_id_type = :auto
# Middleware
config.middleware = [:authenticate_user!]
config.admin_middleware = nil
# Tickets
config.allow_customer_close = true
config.auto_close_resolved_after_days = 7
config.max_attachments_per_reply = 5
config.max_attachment_size_kb = 10240
# SLA
config.sla = {
enabled: true,
business_hours_only: true,
business_hours: {
start: 9, end: 17,
timezone: "UTC",
working_days: [1, 2, 3, 4, 5]
}
}
# Notifications
config.notification_channels = [:email]
config.webhook_url = nil
config.webhook_secret = nil # signs requests to webhook_url (X-Escalated-Signature)
config.allow_private_webhook_urls = false # let admin webhooks reach private addresses
# Storage (ActiveStorage)
config.storage_service = :local
endScheduling
Add these to your scheduler for SLA and escalation automation:
# config/schedule.rb (whenever gem) or use solid_queue/sidekiq-cron
every 1.minute do
runner "Escalated::CheckSlaJob.perform_now"
end
every 5.minutes do
runner "Escalated::EvaluateEscalationsJob.perform_now"
end
every 1.day do
runner "Escalated::CloseResolvedJob.perform_now"
end
every 1.week do
runner "Escalated::PurgeActivitiesJob.perform_now"
endRoutes
Routes are automatically mounted when the engine loads. By default they mount at /support.
| Route | Method | Description |
|---|---|---|
/support |
GET | Customer ticket list |
/support/create |
GET | New ticket form |
/support/{ticket} |
GET | Ticket detail |
/support/agent |
GET | Agent dashboard |
/support/agent/tickets |
GET | Agent ticket queue |
/support/agent/tickets/{ticket} |
GET | Agent ticket view |
/support/admin/reports |
GET | Admin reports |
/support/admin/departments |
GET | Department management |
/support/admin/sla-policies |
GET | SLA policy management |
/support/admin/escalation-rules |
GET | Escalation rule management |
/support/admin/tags |
GET | Tag management |
/support/admin/canned-responses |
GET | Canned response management |
/support/agent/tickets/bulk |
POST | Bulk actions on multiple tickets |
/support/agent/tickets/{ticket}/follow |
POST | Follow/unfollow a ticket |
/support/agent/tickets/{ticket}/macro |
POST | Apply a macro to a ticket |
/support/agent/tickets/{ticket}/presence |
POST | Update presence on a ticket |
/support/agent/tickets/{ticket}/pin/{reply} |
POST | Pin/unpin an internal note |
/support/{ticket}/rate |
POST | Submit satisfaction rating |
Events
Connect to ticket lifecycle events via ActiveSupport::Notifications:
ActiveSupport::Notifications.subscribe("escalated.ticket_created") do |event|
ticket = event.payload[:ticket]
# Handle new ticket
endCustom Ticket Actions
Host applications can add custom buttons to the agent ticket screen and handle clicks with a normal notification subscriber. Register actions in the initializer:
Escalated.configure do |config|
config.ticket_actions = [
{
key: 'sync-crm',
label: 'Sync CRM',
variant: 'primary', # primary | secondary | danger
confirmation: 'Sync this ticket to the CRM?',
metadata: { icon: 'refresh-cw' },
# visible / enabled may be a value or a ->(ticket, user) callable
enabled: ->(ticket, _user) { !ticket.metadata['crm_synced'] }
}
]
endVisible actions are exposed on the agent ticket show page as customActions and
on the API ticket detail response as custom_actions (each with a url and
method). Triggering one (POST /support/agent/tickets/:id/actions/:action_key
or the API equivalent) validates the action is visible (404) and enabled (403),
then dispatches the custom_action_triggered notification:
ActiveSupport::Notifications.subscribe('escalated.notification.custom_action_triggered') do |event|
payload = event.payload
next unless payload[:action_key] == 'sync-crm'
# payload[:ticket], payload[:user], payload[:payload], payload[:metadata]
endEscalated also records an internal note on the ticket whenever an action fires, for auditability.
Inbound Email
Create and reply to tickets from incoming emails. Supports Mailgun, Postmark, AWS SES webhooks, and IMAP polling.
Enable
# config/initializers/escalated.rb
Escalated.configure do |config|
config.inbound_email_enabled = true
config.inbound_email_adapter = :mailgun
config.inbound_email_address = "support@yourapp.com"
# Signs the Reply-To on outbound mail (reply+{id}.{hmac8}@domain). Once set,
# only that signed address links an inbound email to a ticket.
config.email_domain = "support.yourapp.com"
config.email_inbound_secret = ENV["ESCALATED_EMAIL_INBOUND_SECRET"]
# Mailgun
config.mailgun_signing_key = ENV["ESCALATED_MAILGUN_SIGNING_KEY"]
# Postmark
config.postmark_inbound_token = ENV["ESCALATED_POSTMARK_INBOUND_TOKEN"]
# AWS SES
config.ses_region = "us-east-1"
config.ses_topic_arn = ENV["ESCALATED_SES_TOPIC_ARN"]
# IMAP
config.imap_host = ENV["ESCALATED_IMAP_HOST"]
config.imap_port = 993
config.imap_encryption = :ssl
config.imap_username = ENV["ESCALATED_IMAP_USERNAME"]
config.imap_password = ENV["ESCALATED_IMAP_PASSWORD"]
config.imap_mailbox = "INBOX"
endWebhook URLs
| Provider | URL |
|---|---|
| Mailgun | POST /support/inbound/mailgun |
| Postmark | POST /support/inbound/postmark |
| AWS SES | POST /support/inbound/ses |
IMAP Polling
Schedule Escalated::PollImapJob with Solid Queue, Sidekiq, or whenever:
# config/recurring.yml (Solid Queue)
poll_imap:
class: Escalated::PollImapJob
schedule: every minuteFeatures
- Thread detection via the signed Reply-To address when
email_inbound_secretis set; without a secret, via ourIn-Reply-To/ReferencesMessage-IDs and the subject reference - Replies accepted only from the ticket's requester (the guest email or the requester's email, case-insensitive) and posted as that requester. Staff identity is never taken from the
Fromheader, so agents reply in the app. Any other sender gets a new ticket of their own - Guest tickets for unknown senders with auto-derived display names
- Auto-reopen resolved/closed tickets when the requester replies by email
- Duplicate detection via
Message-IDheaders - Attachment handling with configurable size and count limits
- Audit logging of every inbound email
- All settings configurable from admin panel with env fallback
Plugin SDK
Escalated supports framework-agnostic plugins built with the Plugin SDK. Plugins are written once in TypeScript and work across all Escalated backends.
Requirements
- Node.js 20+
-
@escalated-dev/plugin-runtimeinstalled in your project
Installing Plugins
npm install @escalated-dev/plugin-runtime
npm install @escalated-dev/plugin-slack
npm install @escalated-dev/plugin-jiraEnabling SDK Plugins
# config/initializers/escalated.rb
Escalated.configure do |config|
# ... existing config ...
config.sdk_plugins_enabled = true
endHow It Works
SDK plugins run as a long-lived Node.js subprocess managed by @escalated-dev/plugin-runtime, communicating with Rails over JSON-RPC 2.0 via stdio. The subprocess is spawned lazily on first use and automatically restarted with exponential backoff if it crashes. Every ticket lifecycle event is dual-dispatched to both Rails event handlers and the plugin runtime.
Building Your Own Plugin
import { definePlugin } from '@escalated-dev/plugin-sdk'
export default definePlugin({
name: 'my-plugin',
version: '1.0.0',
actions: {
'ticket.created': async (event, ctx) => {
ctx.log.info('New ticket!', event)
},
},
})Resources
- Plugin SDK — TypeScript SDK for building plugins
- Plugin Runtime — Runtime host for plugins
- Plugin Development Guide — Full documentation
Internationalization
Translations are sourced from the central escalated-locale
RubyGem, which is the canonical source of truth for every Escalated
plugin in the portfolio. The engine appends the gem's locales/*.yml
files to I18n.load_path automatically — you do not need to require
anything in your host app.
Override order (last wins):
-
escalated-localegem — central, portfolio-wide -
config/locales/*.ymlinside this engine — plugin-local overrides -
config/locales/overrides/*.ymlinside this engine — host-specific tweaks that should not be upstreamed - The host application's own
config/locales/*.yml
Missing keys in the lower layers fall through to the central gem via
I18n.fallbacks.
Also Available For
- Escalated for Laravel — Laravel Composer package
- Escalated for Rails — Ruby on Rails engine (you are here)
- Escalated for Django — Django reusable app
- Escalated for AdonisJS — AdonisJS v6 package
- Escalated for Filament — Filament v3 admin panel plugin
- Shared Frontend — Vue 3 + Inertia.js UI components
Same architecture, same Vue UI, same three hosting modes — for every major backend framework.
Newsletters (optional, disabled by default)
Admin-only broadcast feature for sending Markdown emails to contacts. Off by default — when disabled, no dispatcher tick runs.
Escalated.configure do |config|
config.enable_newsletters = true
config.app_url = 'https://support.example.com'
config.newsletter_default_from = 'hi@example.com'
config.newsletter_default_theme = 'default'
config.newsletter_brand_physical_address = 'Acme Inc. · 123 Main St · Springfield USA'
# Register a Markdown renderer (CommonMarker recommended; Redcarpet works too):
require 'commonmarker'
config.newsletter_markdown_renderer = ->(md) { Commonmarker.to_html(md) }
endSchedule the dispatcher every minute (cron, sidekiq-cron, whatever your stack uses):
Escalated::Newsletter::Dispatcher.new.dispatch_batchCustom themes go in app/views/escalated/newsletter_themes/<slug>.html.erb. Each theme receives subject, body (pre-rendered HTML), unsubscribe_url, view_in_browser_url, and brand (hash with :name, :accent, :logo_url, :physical_address).
Testing
bundle exec rspecDatabase connection
By default Escalated's tables live on your application's primary database. Name a different one to keep them somewhere else — a schema shared with a legacy system, a multi-tenant split, a separate reporting store, or simply out of your primary database:
# config/initializers/escalated.rb
Escalated.configure do |config|
config.database_connection = :support
end:support is an entry in your config/database.yml. Hosts already using
Rails' role-based multiple databases can pass the mapping instead, and the
reading/writing split is preserved:
config.database_connection = { writing: :support_primary, reading: :support_replica }Every Escalated model inherits Escalated::ApplicationRecord, so one setting
moves all of them together. A spec enumerates app/models/escalated and fails
if a model is ever added that does not inherit it.
Your user table does not move. It belongs to your application, and Escalated stores host user ids as plain unconstrained columns precisely so the two can live on different connections — there is no foreign key that would have to span them.
Migrations
Rails decides which database a migration runs against by which migration path it lives in, so install Escalated's into the path for that database rather than the primary one:
# config/database.yml
support:
adapter: postgresql
database: support
migrations_paths: db/support_migratebin/rails escalated:install:migrations MIGRATIONS_PATH=db/support_migrate
bin/rails db:migrate:supportSetting database_connection on an existing install does not move existing
data. Migrate the tables and copy the rows across before pointing Escalated at
the new database.
License
MIT - Copyright (c) Escalated.dev. See LICENSE.