feat(composer): slash commands to invoke enabled skills - #1090
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Typing
/web-researchin 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.mdScope 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,toolConfigand<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/slugfrom 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/:/web-research …use /web-research, then …and/or,24/7https://x.com/docs,src/app/docx/usr/bin/env/not-a-skillThat 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
usrwould 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 activationslug, computed withslugify_skill_name— the same function that produces theSkill.nametheAgentSkillsplugin injects. The SPA never re-implements the rule. It is optional in the SPA'sUserSkill: 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_skillsonInvocationRequestis intersected against the turn's effective skill set — the same narrow-never-grant rule_apply_enabled_skills_filterapplies, 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.original_messageso 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
skillstool, 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-50is not a tint. It isoklch(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. Thestate-*scales are real tints (state-success-50=rgb(240, 253, 244));primaryis the exception and the naming hides it.Chip and menu tile now use neutral surfaces with the brand blue in the text:
✕glyph/slugOn
gray-700,primary-200measures 3.42 andprimary-1004.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
tests/architecture,tests/apis/inference_api,tests/apis/app_api, skills bundle). Newtest_skill_slash_commands.pycovers narrowing, slug ordering, and the directive.findSkillCommands/removeSkillCommand, the/menu keyboard path, andsplitSkillCommands.✕edits the text, "Browse skills" navigates, prose with slashes never triggers, and the wire payload carriedinvoked_skills: ["web_research"]as a strict subset ofenabled_skills. The model replied "I'll activate the web-research skill…", called theskillstool, and ran it. Contrast measured in both themes with both theme levers.🤖 Generated with Claude Code