4.9 KiB
Custom slash commands
User-defined /command shortcuts that expand a prompt template into a normal
user message on the interactive session. They let you package a long, reusable
instruction behind a short command (/deeploop, /review, …) — the template is
interpolated with whatever the user typed and handed to the model as if the user had
written it out in full.
Because the expansion becomes an ordinary user turn on the main session, a command
is fully interactive: after it fires the model can ask follow-up questions,
iterate with the user, write files, and dispatch sub-agents — exactly like any other
turn. A command is not a one-off request.
File layout (mirrors agents/)
Each command is a directory under commands/:
commands/<name>/meta.json # manifest
commands/<name>/COMMAND.md # the prompt template
<name>is the directory name and the token typed after/(e.g.commands/echo/→/echo). Use lowercase[a-z0-9_-].meta.json:description(required) — one line, shown in/helpand the composer autocomplete.enabled(optional, defaulttrue) — setfalseto hide a command without deleting it.
COMMAND.md— the template body.{{args}}(or{{prompt}}) is replaced with the user's arguments. If neither placeholder appears, the arguments are appended after a---separator (opencode-like default).
Files are read at request time, so edits to meta.json / COMMAND.md take effect
without a restart (same as agents/*/AGENT.md). There is no DB table and no CRUD API —
you create a command by adding files, just like an agent. A missing commands/
directory is fine (no custom commands).
Precedence
Hard-coded system commands (/help, /clear, /new, /model, /models,
/context, /cost, /compact, /resettools, /sethome, /stop) always win: they
are matched first in each surface's command handler (the WS handler for web/mobile,
the Telegram plugin handler), so a custom command with a colliding name is
unreachable and is filtered out of /help and the autocomplete. An unrecognised /…
is rejected ("Unknown command") and never forwarded to the LLM.
Dual view (typed command vs. expanded template)
The history row's content stores the expanded template (what the model replays);
the UI shows the typed command (/echo ciao), never the template. This is carried
by MessageMetadata.command (CommandRef { name, display }), persisted on the user
turn next to attachments:
- The backend substitutes
content = command.displaywhen emitting theuser_messageecho (emitter.user_message, both call sites inllm_loop.rs/session/handler/mod.rs) and when serialising history (api/sessions.rs). - The frontend is telnet-style: custom commands are not echoed optimistically on
send — the bubble appears only when the backend re-emits the
user_messagecarrying the typed form. Only system commands (which reply with aDoneand never echo back) are rendered optimistically. SeeSYSTEM_SLASH_COMMANDSinweb/lib/chat-session.js.
Composer autocomplete
Typing / in the copilot composer opens a dropdown: built-in system commands first,
then custom commands fetched from GET /api/commands. Filter by prefix, navigate with
↑/↓, accept with Enter/Tab (inserts /name ), dismiss with Esc.
(web/components/copilot.js; styles in web/css/copilot-input.css.)
Implementation map
| Concern | Location |
|---|---|
| Capability trait + DTOs + expansion | crates/core-api/src/command.rs (CommandApi, CommandInfo, ResolvedCommand, expand_template) |
| Discovery / resolve / expand | src/core/command/mod.rs (LlmCommandManager: CommandApi, owned by Skald as Arc<…>) |
| Metadata dual-view type | crates/core-api/src/message_meta.rs (CommandRef, MessageMetadata.command) |
Matching + dynamic /help (web + mobile) |
src/frontend/api/ws.rs (dynamic_help, custom-command interposition) |
Matching + dynamic /help (Telegram) |
crates/plugin-telegram-bot/src/handlers.rs (help_text, fallback-command arm; resolves via ctx.command) |
| Plugin wiring | PluginContext.command (crates/core-api/src/plugin.rs) → TgShared.command |
Echo content = display |
src/core/session/handler/{llm_loop.rs, mod.rs}, src/frontend/api/sessions.rs (applies to every surface: web, mobile, Telegram) |
| Listing endpoint | GET /api/commands → src/frontend/api/commands.rs |
| Autocomplete + telnet gate | web/components/copilot.js, web/lib/chat-session.js |
Example
commands/echo/meta.json:
{ "description": "Test command — echoes your input", "enabled": true }
commands/echo/COMMAND.md:
Repeat the user's input back verbatim.
User input:
{{args}}
Typing /echo hello world runs a turn where the model receives
Repeat the user's input back verbatim.\n\nUser input:\nhello world, while the chat
bubble shows /echo hello world.