# 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//meta.json # manifest commands//COMMAND.md # the prompt template ``` - `` 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 `/help` and the composer autocomplete. - `enabled` (optional, default `true`) — set `false` to 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.display` when emitting the `user_message` echo (`emitter.user_message`, both call sites in `llm_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_message` carrying the typed form. Only system commands (which reply with a `Done` and never echo back) are rendered optimistically. See `SYSTEM_SLASH_COMMANDS` in `web/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`: ```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`.