A type-away admin command palette for Discourse. Admins open the existing
admin search (Cmd/Ctrl + /) — or the inline bar on the admin dashboard — and
type plain-language commands like suspend bob 3 weeks,
create category 'radios', or grant admin to alice. The palette matches the
command, shows a plan (an editable, pre-filled form), and only runs it once
the admin confirms. Everything is re-resolved and re-authorized on the server.
At the moment it is deterministic (no LLM): commands declare trigger words/aliases and typed params, and a parser fills those params from what you type.
type "ban bob 3 weeks"
│
├─ client matches the trigger "ban" against the cached catalog → shows the
│ "Suspend a user" command in the palette (instant, no server call)
│
├─ on select → POST /plan : the server tokenizes the text, resolves params
│ (bob → User, "3 weeks" → a date), runs the guardian + checks, and
│ returns a plan: each param as an editable field (pre-filled), a preview,
│ whether it's allowed, and any "hits"
│
├─ plan card: edit any field (user chooser, calendar, reason…), see warnings
│
└─ Confirm → POST /execute : the server RE-resolves the submitted values,
RE-checks the guardian + blocking checks, runs the command, audits it,
and returns the result (or a directive the client performs)
Core concepts:
- Command — one class per action (
lib/discourse_command_center/commands/). Declared once; from that the framework derives the matcher, the plan form, the audited execution, and (later) an LLM tool schema. servicemacro — wraps an existingService::Base(e.g.User::Suspend) and imports its contract (param names, types, required-ness) automatically.- Param types —
user,category,group,tag,duration,datetime,string, etc. Each knows how to parse text, resolve to a record, render a control, and re-coerce on execute. guardian— hard permission gate (disables Confirm).check— pre-flight "hit": a non-blocking heads-up, orblocking: true(e.g. "already suspended") which disables Confirm and is enforced server-side.- directive — for actions that can't run in one request (impersonate sets
the session; grant admin needs 2FA),
executereturns a directive the client performs instead of mutating.
Cmd/Ctrl + /modal — the full palette (commands + user quick actions + core admin search results).- Admin dashboard — an inline bar at the top (
admin-dashboard-topoutlet) that floats results as a popover. - Admin → Plugins → Command center → Commands — a reference page listing every command with its triggers and params.
Backend (lib/ + app/)
| File | What it is |
|---|---|
lib/discourse_command_center/command.rb |
Base class + DSL (identifier, triggers, param, service, guardian, check, plan, execute, result_link) |
lib/discourse_command_center/param_types.rb |
Param types — parsing/resolution/coercion (incl. Duration natural-language dates) |
lib/discourse_command_center/parser.rb |
Deterministic text → command + slot-filled params |
lib/discourse_command_center/registry.rb |
Collects commands (self-registered + other plugins) |
lib/discourse_command_center/result.rb |
Execution result (success/error/directive) |
lib/discourse_command_center/commands/*.rb |
The commands (suspend, silence, create category/group, grant admin, impersonate) |
app/controllers/discourse_command_center/commands_controller.rb |
/catalog, /plan, /execute (admin-only) |
Frontend
| File | What it is |
|---|---|
assets/javascripts/discourse/connectors/admin-search-palette/command-center.gjs |
Renders the palette inside admin search (via the core admin-search-palette outlet) |
assets/javascripts/discourse/components/admin-command-plan-card.gjs |
Plan-mode card (editable pills/fields + Confirm) |
assets/javascripts/discourse/lib/command-catalog.js |
Cached catalog + client-side trigger matching |
admin/assets/javascripts/discourse/connectors/admin-dashboard-top/command-center.gjs |
Inline dashboard bar |
admin/assets/javascripts/discourse/.../command-list.{js,gjs} |
The admin reference page (route + template) |
assets/stylesheets/command-center.scss |
All styles |
Config / meta
plugin.rb,config/routes.rb,config/settings.yml,config/locales/*TESTING.md— manual test script + a seed runner (script/seed_test_scenarios.rb).claude/skills/discourse-command-center-authoring/— skill for adding commands
Note: this plugin relies on a few small core outlets (
admin-search-palette,admin-dashboard-top) and a@closeModalpassthrough in core admin search.
Add a file under lib/discourse_command_center/commands/ — it auto-registers
and auto-appears in the palette, the browse list, and the reference page. The
fastest path is the service macro. See
.claude/skills/discourse-command-center-authoring/SKILL.md for the full guide,
and commands/suspend_user.rb (service-backed) / commands/create_category.rb
(inline) as templates.
Other plugins can contribute commands without modifying this plugin. Subclass
DiscourseCommandCenter::Command (you get the same DSL — service, param,
guardian, check, plan, execute, …) and register the class with
DiscoursePluginRegistry.register_command_center_command(MyCommand, self). The
register is gated by your plugin's enabled state: the command appears in the
palette/catalog/reference page while your plugin is enabled, and disappears when
it's disabled.
# plugins/discourse-foo/lib/discourse_foo/commands/message_user.rb
module ::DiscourseFoo
module Commands
class MessageUser < ::DiscourseCommandCenter::Command
identifier :message_user
title "discourse_foo.command_center.message_user.title" # your i18n key
icon "envelope"
triggers "message", "pm"
param :user, :user, required: true
param :message, :string, required: true, labels: %w[message saying]
guardian { |g, _r| g.is_staff? }
plan { |r| "Message @#{r[:user]&.username}" }
execute do |resolved:, guardian:|
# ...send the PM...
DiscourseCommandCenter::Result.success(message: "Message sent.")
end
end
end
end# plugins/discourse-foo/plugin.rb
after_initialize do
# Only wire it up if the command center is installed.
if defined?(::DiscourseCommandCenter)
require_relative "lib/discourse_foo/commands/message_user"
DiscoursePluginRegistry.register_command_center_command(
::DiscourseFoo::Commands::MessageUser,
self, # your Plugin::Instance — gates the command on this plugin
)
end
endRegister with your plugin instance (self in plugin.rb) so the gating works —
don't rely on subclassing alone. The command then behaves exactly like a
built-in: it's matched client-side, planned, and executed (re-resolved and
re-authorized) through the same endpoints.
- Site setting:
command_center_enabled(default off). - JSON API (admin-only), mounted at
/admin/command-center:GET /catalog.json,POST /plan.json,POST /execute.json.
bin/rspec plugins/discourse-command-center/spec/lib plugins/discourse-command-center/spec/requests
bin/qunit --standalone plugins/discourse-command-center/test/javascripts