Skip to content

feat(composer): slash commands to invoke enabled skills - #1090

Merged
philmerrell merged 1 commit into
developfrom
feature/skill-slash-commands
Sep 13, 2026
Merged

feat(composer): slash commands to invoke enabled skills#1090
philmerrell merged 1 commit into
developfrom
feature/skill-slash-commands

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

Typing /web-research in the composer invokes that skill for that message. It is the sibling of the @-mention (Marketplace D11): same menu shape, same keyboard, same "rides one turn, does not bind the conversation" semantics. The menu's last row is Browse skills →, which goes to Customize → Skills.

Spec: docs/specs/skill-slash-commands.md

Scope is the skills the user has turned on

That is the decision the whole design rests on. Because the invoked skill is already in enabled_skills, a slash command changes nothing about the cacheable prefix — the system prompt, toolConfig and <available_skills> block are byte-identical whether or not a command was used. The entire cost is one short directive appended to the turn's user message.

Offering a switched-off skill would have meant either widening the disclosure on the fly (a 30k–150k-token prefix rewrite at the cache-write premium, triggered by a keystroke) or showing a command that names a skill the model cannot see. Turning a skill on stays on the Customize page, which the menu links to.

The text is the binding

Unlike the @ menu there is no remembered pick — the invoked set is derived from the composer text on every keystroke. A slug is a single unambiguous token (an Agent name is not; it contains spaces, which is why the @ menu has to remember). Deriving is therefore exact, and it buys two things: a hand-typed command works identically to a menu pick, and the chip cannot disagree with what gets sent. The chip's removes the /slug from the text, because that is the only place the binding lives.

The token rule

/ is ordinary punctuation, so the rule keeps the menu shut far more often than it opens it. A command must start a word and must not be followed by another /:

Input Result
/web-research … command
use /web-research, then … command
and/or, 24/7 prose — does not start a word
https://x.com/docs, src/app/docx prose — same reason
/usr/bin/env prose — starts a word, excluded by the trailing-slash half
/not-a-skill prose — only slugs the user can invoke resolve

That second-to-last row is the one that matters: an absolute path starts a word exactly like a command does, so without the trailing-slash clause a skill slugged usr would be invoked silently. The rule is implemented three times (composer token, findSkillCommands, thread renderer) and all three must agree, or a message would render as something different from what it sent.

Backend

  • GET /skills/ now serves the runtime activation slug, computed with slugify_skill_name — the same function that produces the Skill.name the AgentSkills plugin injects. The SPA never re-implements the rule. It is optional in the SPA's UserSkill: the two deploy independently and in no enforced order, so a client that lands first degrades to "no slash commands", not a menu of /undefined.
  • invoked_skills on InvocationRequest is intersected against the turn's effective skill set — the same narrow-never-grant rule _apply_enabled_skills_filter applies, re-run because an Agent's skill bindings can still replace that set afterwards. Ordered by the effective set, not the request, so two turns naming the same skills produce byte-identical text.
  • The directive is appended last, after every prepended note, and rides original_message so the thread shows only what the user typed while the note stays an honest part of persisted history.

A directive rather than a server-side pre-load because the only activation path is the plugin's own skills tool, which the model calls; pre-loading would duplicate its response formatting and bypass its activation-state tracking to save one tool call.

Contrast — and a token trap worth knowing

The first draft measured 4.15:1 for the chip label (12px/500), under the 4.5 AA bar, with a 1.05:1 border. The cause is general to the app:

--color-primary-50 is not a tint. It is oklch(from #0033a0 calc(l + 0.4) c h) — lightness offset only, full chroma retained — so it resolves to rgb(118, 179, 255), a saturated mid-blue. The state-* scales are real tints (state-success-50 = rgb(240, 253, 244)); primary is the exception and the naming hides it.

Chip and menu tile now use neutral surfaces with the brand blue in the text:

Element Light Dark Bar
Chip label (12px, 500) 10.60 4.74 4.5 (AA normal text)
Chip glyph 4.84 3.96 3.0 (UI component)
Menu /slug 16.13 10.30 4.5

On gray-700, primary-200 measures 3.42 and primary-100 4.02 — both fail; primary-50 (4.74) is the only step that clears AA on that surface.

A follow-up task is queued to sweep the rest of the app for the same mid-blue-as-fill pattern.

Gating

None of its own — it rides SKILLS_ENABLED. With skills off, GET /skills/ 404s, the command list is empty and the menu never opens. The two embedded previews pass [showSkillCommands]="false" for the same reason they pass [showAgentMentions]="false".

A queued follow-up carrying a slash command is never armed for mid-turn steering: a steer lands on the tool-result message of a turn whose skills were already resolved, so it flushes as a normal turn — same as one with an attachment or an @-mention.

Testing

  • Backend: 1166 passed (tests/architecture, tests/apis/inference_api, tests/apis/app_api, skills bundle). New test_skill_slash_commands.py covers narrowing, slug ordering, and the directive.
  • SPA: 2892 passed / 241 files. New specs for findSkillCommands / removeSkillCommand, the / menu keyboard path, and splitSkillCommands.
  • Verified live against the local stack (SPA :4200 → app-api :8000 → inference-api :8001): menu opens and filters, Enter/Tab commits, chip appears from a hand-typed command, edits the text, "Browse skills" navigates, prose with slashes never triggers, and the wire payload carried invoked_skills: ["web_research"] as a strict subset of enabled_skills. The model replied "I'll activate the web-research skill…", called the skills tool, and ran it. Contrast measured in both themes with both theme levers.

🤖 Generated with Claude Code

Typing `/web-research` in the composer invokes that skill for that message —
the sibling of the `@`-mention, with the same menu shape, keyboard and
"rides one turn" semantics. The menu's last row is "Browse skills →".

Scope is the skills the user already has turned on, which is what makes it
cheap: the invoked skill is already in `enabled_skills`, so the system
prompt, toolConfig and `<available_skills>` block are byte-identical whether
or not a command was used. The cacheable prefix is untouched; the whole cost
is one line appended to the turn's user message.

The text is the binding. Unlike the `@` menu there is no remembered pick —
the invoked set is derived from the composer text, so a hand-typed command
works like a menu pick and the chip cannot disagree with what is sent. The
chip's ✕ edits the text, because that is where the binding lives.

`/` is ordinary punctuation, so a command must start a word AND not be
followed by another `/`. That second clause is what keeps `/usr/bin/env`
prose: an absolute path starts a word exactly like a command does. The rule
is implemented three times (composer token, `findSkillCommands`, thread
renderer) and all three must agree.

Backend: `GET /skills/` now serves the runtime activation `slug` rather than
letting the SPA re-derive it; `invoked_skills` on the invocation request is
intersected against the turn's effective set (re-run after Agent bindings
can replace it) and becomes a directive appended last, riding
`original_message` so the thread shows only what the user typed.

Contrast: the chip and menu tile use neutral surfaces with the brand blue in
the text. `bg-primary-50` is not a tint — the primary scale offsets lightness
only and keeps full chroma, so it resolves to rgb(118,179,255). Measured at
10.60/4.74 (light/dark) for the label, all elements above their AA bar.

Spec: docs/specs/skill-slash-commands.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@philmerrell
philmerrell merged commit 6ac4e4d into develop Sep 13, 2026
6 checks passed
@philmerrell
philmerrell deleted the feature/skill-slash-commands branch September 13, 2026 17:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant