Slash Command Conventions
SkillCommunicationEsposter slash command conventions — parameter definitions, execution modes, the chip-based parameter UI and its safeParse/setErrors validation, message formatting, adding new commands, and SlashCommandDefinitionMap being the inventory rather than any page that mirrors it (plus the two shapes the map cannot show — a command that posts nothing, and inline parameters versus a dialog being alternatives). Apply when writing or modifying slash commands, useExecuteSlashCommand, SlashCommandDefinitionMap, or the SlashCommandParameters components.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Slash Command Conventions skill
What this skill tells your AI
The instructions your AI receives, as published by esposter/esposter in .agents/skills/slash-commands/SKILL.md and read by ahel’s review.
Core Types
SlashCommandParameter extends Description — always has both name and description (never optional):
export interface SlashCommandParameter extends Description {
isRequired: boolean;
name: string;
}
// Shared value schema — normalize, then require non-empty
export const slashCommandParameterValueSchema = z.string().transform(normalizeString).pipe(z.string().min(1));
SlashCommand — parameters is always present (never parameters?). Default to [] for commands with no parameters:
export interface SlashCommand extends Description, ItemEntityType<SlashCommandType> {
icon: string;
parameters: SlashCommandParameter[];
title: string;
}
// no params: empty array — always present, never optional or omitted
[SlashCommandType.Roll]: { parameters: [], ... }
Message Format
Messages use markdown. Rich text applies: italic *text*, bold **text**, code `text`.
Each case only builds a plain StandardCreateMessageInput. marked.parse() and storeSendMessage are applied once, after the switch — never per-case:
if (!createMessageInput) return;
await storeSendMessage({
...createMessageInput,
message: createMessageInput.message ? marked.parse(createMessageInput.message, { async: false }) : undefined,
replyRowKey: replyRowKey.value,
});
Never call sanitizeHtml/sanitizeTextHtml here. Sanitization is declared at the Zod boundary in the base db-schema schemas — see the string-utils skill, which bans manual frontend calls.
/me — no new MessageType
/me [message] does NOT introduce MessageType.Me. Wrap the argument in *...* and post as a regular MessageType.Message:
case SlashCommandType.Me: {
const { message } = command.parameterValues;
createMessageInput = { message: `*${message}*`, roomId, type: MessageType.Message };
break;
}
Parameterized Command UI — Discord-style chips
There is no v-form, no useVRules(), no SubmitEventPromise anywhere in this feature. Parameters render as inline chips built from raw <input> elements, and validation is manual.
Components (app/components/Message/Model/Message/Input/):
| File | Role |
|---|---|
SlashCommandParameters/Index.vue | Chip row + focus orchestration (delegates submit to useSubmitSlashCommand) |
SlashCommandParameters/CommandInput.vue | Editable /command name at the head of the row |
SlashCommandParameters/Chip.vue | One parameter: bold name label + bare <input> |
SlashCommandParameters/TrailingInput.vue | Free-text tail; adds hidden parameters |
Header/SlashCommandParameters.vue | Hidden-parameter list (REQUIRED OPTIONS / OPTIONAL) + focused-param hint/error |
Validation — safeParse + setErrors, not :rules
Errors live in useSlashCommandStore as SlashCommandParameterError[] ({ id, messages }, keyed by parameter name), written via setErrors(name, messages). Chip.vue validates per keystroke and only styles its own border; the message text renders in the input header:
setErrors(
name,
isRequired && !slashCommandParameterValueSchema.safeParse($event).success ? [REQUIRED_ERROR_MESSAGE] : [],
);
REQUIRED_ERROR_MESSAGE comes from app/services/message/slashCommands/constants.ts — never inline the string.
useSubmitSlashCommand (app/composables/message/slashCommand/useSubmitSlashCommand.ts) re-validates every required parameter on submit, and if any required one is missing it reveals the hidden chip (appends to activeParameterNames) and returns instead of sending; Index.vue only calls it. Parameter mutations (createParameter, deleteParameter, collapseToText, clearPendingSlashCommand) all live in the store, not the components.
Focus model
focusedIndex in the store is the single source of truth: -1 = the command name input, 0..n-1 = chips, n = trailing input, -2 = blurred. Navigation is emit-driven (navigate:previous / navigate:next), fired from Chip.vue only when the caret sits at the very start/end of the input.
Dismissal — collapse to text, never discard
Escape (and Backspace at focusedIndex === -1) calls collapseToText(), which round-trips the pending command back into the composer via buildText() (/type name:value …) rather than dropping the user's input:
onKeyStroke("Escape", () => collapseToText());
Execution Modes
Derived from slashCommand.parameters.length > 0, not a separate mode field. SlashCommandSuggestion.ts (which contains no switch — it only routes) branches on it:
- Immediate —
parameters: []—useExecuteSlashCommand()runs straight away - Parameterized — one or more parameters —
setPendingSlashCommand(slashCommand, remainingText), which parses any already-typed text into parameter values
The Execution Switch Lives in One Place
The only switch over SlashCommandType is in app/composables/message/slashCommand/useExecuteSlashCommand.ts, closed by exhaustiveGuard(command) — so a new enum value fails typecheck until handled. It is not in SlashCommandSuggestion.ts.
Its argument is a discriminated union pairing each type with its own parameter shape, so command.parameterValues is narrowed per case:
{ [P in SlashCommandType]: { parameterValues: SlashCommandParameters<P>; type: P } }[SlashCommandType]
Always use SlashCommandType.X enum values, never "Me", "Shrug", etc.
Adding a New Command
- Add value to
SlashCommandTypeenum. - Add entry to
SlashCommandDefinitionMapwithparameters: []or required/optional params (as const satisfies Record<SlashCommandType, SlashCommand>forces this). - Add
case SlashCommandType.X:to the switch inuseExecuteSlashCommand.ts:- Posting a message: assign
createMessageInputandbreak— the shared tail parses + sends it - Opening a dialog: flip the dialog store's state (
isOpen.value = true,open(ScheduledMessageJobType.X)) - Neither: do the work inline (e.g.
Topicruns a room mutation and posts nothing)
- Posting a message: assign
- No new
MessageTypeunless rendering is structurally different (e.g. Poll, Call).
The registry is the list, not this page
The enum, the map and the switch must stay in sync — satisfies Record<SlashCommandType, SlashCommand> enforces the map and exhaustiveGuard enforces the switch — so SlashCommandDefinitionMap is the readable inventory of what exists and what each command does. Never mirror it here: a copy is one command behind from the first addition.
Two shapes are worth knowing before reading it, because neither is guessable from the map alone:
- A command need not post a message at all. Leave
createMessageInputunassigned and the shared tail sends nothing — that is how a command which only runs a mutation (setting a room topic) or only opens a dialog is written. - Inline parameters and a dialog are alternatives. A command either collects its arguments as inline chips through
parameters, or opens a dialog and declares none. Never both.
Signals
- GitHub stars
- 23
- Forks
- 3
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
slash-commands- Source
- github.com/esposter/esposter