[FEATURE] Skill groups: a `group` frontmatter field to collapse N related skills into one system-prompt entry

Open 💬 0 comments Opened Jun 16, 2026 by Loukas922

Problem

A single CLI can ship many skills. For example, one Lark CLI I use ships ~26 skills (lark-doc, lark-calendar, lark-im, lark-sheets, ...). With that pack plus a few other CLIs, it's easy to pass 50+ installed skills.

Each skill's name + description sits in the system prompt on every turn, even when the conversation never touches those tools. At a rough ~50–100 tokens per entry, 50 skills cost on the order of a few thousand tokens of fixed overhead (rough estimate). And from my reading of the source it appears the listing has a fixed budget: the constants are SKILL_BUDGET_CONTEXT_PERCENT = 0.01 and DEFAULT_CHAR_BUDGET = 8000 in src/tools/SkillTool/prompt.ts. Note this budget is measured in characters, not tokens — getCharBudget() computes contextWindowTokens * CHARS_PER_TOKEN (4) * 0.01, i.e. roughly 1% of the context window, falling back to 8000 chars. As skill count grows, descriptions get truncated to fit that budget — which can hurt trigger-match precision.

I'd love a way for a CLI to expose a whole family of skills as one listing entry.

How it works today (brief)

Based on reading the publicly-restored source (as of the version I inspected), skills appear to load progressively: (1) name + description go into the system-prompt listing; (2) on trigger, the full SKILL.md body is injected; (3) references/ are read on demand. Existing mitigations don't seem to cover this case:

  • Conditional skills (paths) genuinely remove a skill from the listing, but only work for skills tied to file paths. API-oriented skills (Lark, etc.) have no path association. (More precisely: from my reading, unactivated conditional skills are withheld from getSkillDirCommands() and held in a separate map; once a path matches, activateConditionalSkillsForPaths moves them into the dynamic-skills set and they rejoin the listing.)
  • user-invocable: false only sets isHidden (which hides from slash completion). From my reading, getSkillToolCommands() doesn't filter on isHidden, so the skill still appears in the listing.
  • Description trimming is palliative — N skills still cost N entries.

Proposal: a group frontmatter field

  • Skills with a group are filtered out of the system-prompt listing.
  • A group-entry skill (no group) appears as usual; its SKILL.md carries a routing table of the sub-skills.
  • When the entry skill triggers, the model picks the right sub-skill and invokes it via the Skill tool. The sub-skill's body is then injected the normal way, landing at the same level as any directly-triggered skill.

Net effect: 26 Lark skills cost one listing entry; the other 25 stay installed but surface on demand through the entry skill.

# lark/SKILL.md  (entry skill — appears in the listing)
---
name: lark
description: Lark/Feishu suite. Route to the matching sub-skill below.
---
# Routing table
# - docs / wiki  -> invoke skill `lark-doc`
# - calendar     -> invoke skill `lark-calendar`
# - chat / IM    -> invoke skill `lark-im`
# lark-doc/SKILL.md  (sub-skill — hidden from the listing)
---
name: lark-doc
description: Read and edit Lark docs.
group: lark
---

Why it should be backward-compatible & low-risk

Skills without a group would behave exactly as today; the load/trigger/execute path stays untouched. From my reading, the change is a small one spread across a handful of files:

<details>
<summary>Likely touch points (source-derived, may be off)</summary>

  • src/types/command.ts: add group?: string to Command.
  • src/utils/frontmatterParser.ts: parse group.
  • src/skills/loadSkillsDir.ts: this is more than "carry group onto the Command." From my reading, parseSkillFrontmatterFields() returns a fully typed object and createSkillCommand() takes a fully destructured/typed parameter list — so the new field has to be threaded through both (added to the return type of parseSkillFrontmatterFields and to createSkillCommand's param list), not just attached ad hoc.
  • src/commands.ts getSkillToolCommands() (~563): add if (cmd.group) return false;.
  • src/commands.ts getSlashCommandToolSkills() (~586): this is a separate filter feeding slash-completion and skill counts. It would still surface group sub-skills unless it's filtered too.
  • src/tools/SkillTool/SkillTool.ts SAFE_SKILL_PROPERTIES (~875): this is a hard allowlist gating permission auto-allow. From my reading, a new group property that isn't added here would fail skillHasOnlySafeProperties() and flip every group sub-skill from auto-allow to requiring a permission prompt — which would contradict the "no behavior change" goal. So group needs to be added to this set.

Key constraint: filter only at the listing level (getSkillToolCommands() / getSlashCommandToolSkills()), not at getSkillDirCommands(). From my reading, findCommand(commandName, commands) resolves over the array returned by getCommands(), so a skill absent from getCommands() is unresolvable — meaning sub-skills must remain in getCommands() or the Skill-tool invocation can't resolve them. This is roughly the opposite of unactivated conditional skills, which are withheld from the dir-level list until a path activates them. The listing in attachments.ts calls getSkillToolCommands() (and applies the incremental sentSkillNames delta), so sub-skills would be excluded there with no extra work.
</details>

On trust level / how the model weighs the injected body

This is my own inference, not a property I see the code asserting. From my reading, when a sub-skill is invoked through the Skill tool, its SKILL.md body is injected as a meta user message (the code path constructs it with isMeta: true), distinct from a Bash tool_result. My expectation is that a model tends to weight a user message differently from a tool_result (the latter being external data that's reasonable to discount for prompt-injection reasons). For SOPs with hard rules ("MUST query the index first"), I'd expect that difference to matter — but I'm describing model behavior, not a label set by the framework.

Alternatives considered

A CLI can already expose a skills read/embed command and have the entry skill Bash the sub-skill's SOP into context (some packs do this). A framework-side group seems preferable because:

  • How the body arrives: an SOP fetched via Bash arrives as a tool_result, whereas a group-injected SOP arrives as a (meta) user message — see the caveat above; this is my inference about model weighting, not a code-asserted guarantee.
  • Coupling/cost: the CLI-read approach requires every CLI to implement list/read commands and spawns a Bash process (plus a permission prompt) per fetch. group would work for any skill, with no backing CLI, as one in-framework Skill-tool call.

Happy to attempt a PR if this seems worth pursuing. Thanks for considering — and apologies if I've misread any of the internals.

---

This is based on reading the publicly-restored/minified source as of the version I inspected; these aren't documented public APIs and may change. The maintainers are the source of truth.

View original on GitHub ↗