M7.5
Skills: progressive disclosure and the skill-vs-tool judgement
1,752 words · about 11 min
Concept
A tool is a callable function the model invokes with arguments and gets a result from. A skill is a body of instructions the model loads on demand. Both are extension points; they solve different problems, and conflating them is one of the more common design errors in this field.
The distinction that actually decides it: a tool extends what the harness can do; a skill extends what the model knows how to do. If the capability requires code to execute — a network call, a filesystem write, a subprocess — it is a tool. If the capability is procedural knowledge the model can act on using tools it already has, it is a skill.
The mechanism that makes skills worth having: progressive disclosure
MechanismThe naive way to give a model procedural knowledge is to put it in the system prompt. That fails for a reason Module 4 established: context is a budget, and the system prompt is the most expensive place to spend it because it is on every request. Twenty procedures at 2,000 tokens each is 40,000 tokens of permanent overhead, of which roughly zero are relevant to any given turn.
Progressive disclosure splits the cost. The model always sees a catalog — just enough to know a skill exists and when it applies. The body loads only when invoked.
From docs/subsystems/skills.md, the catalog contract is precise and the omissions are the design:
dsh-tool-skillinjects the initial durable user-role<system-reminder>at the firstagent/pre-stepof a live session that observes a non-empty complete view. The catalog contains sorted skillnameand normalized, XML-escapeddescriptiononly; it omits bodies, paths, sources, providers, and routing hints.
catalogDescriptionMaxLength bounds the description, default 500, integer minimum 3. Sorting is not cosmetic — it makes the rendered catalog stable across runs, which is what keeps the KV-cache prefix intact (Module 4 again).
Loading is a tool call. The skill({ name }) tool "validates the kebab-case name, finds the summary in the invocation-neutral catalog, rejects it before loading unless isModelInvocable permits access, then rereads the complete definition for the calling agent cwd and rechecks the policy before returning content," yielding a result containing <skill_content name="...">, <skill_resources>, and <skill_instructions>.
Note the double policy check — once against the catalog summary, once after re-reading the definition. Skills come from the filesystem and the filesystem changes; a check against a cached summary can be stale by the time content is read. Re-checking after the read closes a TOCTOU window that would otherwise let a file edited mid-session bypass its own invocation policy.
Note also the arithmetic. Twenty skills at ~60 catalog tokens each is ~1,200 tokens permanently, plus ~2,000 tokens once, only for whatever is actually used. Against 40,000 permanent, that is the whole argument, and it is a Module 4 argument wearing an extension-points hat.
Mechanism: catalog/loader design
MechanismThe skill capability is a textbook M7.2 seam (docs/subsystems/skills.md): the Service Definition dsh-skill (ctx.skills), the local Service Provider dsh-skill-filesystem, the optional packaged provider dsh-skill-badge, and the Consumer dsh-tool-skill.
The registry API:
registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void
register(skill: SkillRegistration): () => void
async list(options: SkillViewOptions = {}): Promise<SkillSummary[]>
async snapshot(options: SkillViewOptions = {}): Promise<SkillCatalogSnapshot>
async get(name: string, options: SkillViewOptions = {}): Promise<SkillDefinition | undefined>And the provider contract:
interface SkillProvider {
readonly name: string
readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[] | SkillProviderObservation>
readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise<SkillDefinition | undefined>
}list and get being separate methods is the progressive-disclosure mechanism at the seam level. A provider can enumerate cheaply (a readdir and a frontmatter parse) and fetch expensively (a full read, or a network call for a remote provider). If the contract were a single listWithBodies(), every provider would pay full cost to render a catalog, and progressive disclosure would exist only in the consumer.
The type split mirrors it:
interface SkillSummary {
readonly name: string
readonly description: string
readonly whenToUse?: string
readonly invocation: SkillInvocationPolicy
readonly source: SkillSource
readonly provider: string
readonly resourceBase?: SkillResourceBase
}
interface SkillCandidate extends SkillSummary {
readonly rank: number
readonly locator: unknown
readonly path?: string
readonly metadata?: Readonly<Record<string, unknown>>
}
interface SkillDefinition extends SkillSummary {
readonly content: string
readonly path?: string
readonly metadata?: Readonly<Record<string, unknown>>
}content appears only on SkillDefinition. The type system enforces that a catalog render cannot accidentally include a body — you cannot leak what you do not have. locator: unknown is a provider-private handle: the registry passes a candidate back to its own provider for get() without ever interpreting it, so a remote provider can put a URL there and a database provider a row id, and neither needs a registry change.
SkillProviderObservation ({ candidates, complete }) lets a provider say "here is what I have, but I am not done" — a watcher still scanning, a network provider partially fetched. The consumer only renders a catalog "from a complete snapshot," which prevents the model seeing a skill list that shrinks and grows between steps and treating the disappearance as meaningful.
Local discovery is ranked (docs/subsystems/skills.md):
| Rank | Source | Root |
|---|---|---|
| 100 | project-dsh | <projectRoot>/.dsh/skills |
| 200 | project-agents | <projectRoot>/.agents/skills |
| 300 | custom | Config.customSkillDirs |
| 400 | user-dsh | <dshHome>/skills |
| 500 | user-agents | <agentsHome>/skills |
| 600 | bundled | Config.bundledSkillDir when configured |
Project beats user beats bundled — the standard specificity gradient, and the same shape as the profile layering in M7.3. "The project root is the nearest ancestor containing .git; without one, the current cwd is used." Names are kebab-case (^[a-z0-9]+(?:-[a-z0-9]+)*$); the local provider accepts directory bundles (<name>/SKILL.md) and flat files (<name>.md), and "nested recursive **/SKILL.md discovery is not supported."
Invocation policy is per skill, read from exact kebab-case frontmatter keys disable-model-invocation and user-invocable, both defaulting to true:
interface SkillInvocationPolicy {
readonly modelInvocable: boolean
readonly userInvocable: boolean
}Two axes, because "the model may load this" and "a human may run this" are independent. A dangerous procedure can be user-only; a purely internal one can be model-only.
Change propagation is one emit event, skills/change, declared at packages/skill/skill/src/index.ts:297 — "an unfiltered invalidation notification; consumers refetch the catalog for their own lookup options. Listener failures are contained and cannot veto the registry mutation." docs/event-producer-consumer.md shows it currently has no listeners: the consumer re-derives per step instead. Before each later step, dsh-tool-skill digests the rendered entries and "a changed digest appends a durable full replacement through agent.inject()." The consequence is worth internalising: "Body-only edits therefore change later tool calls without producing catalog messages or rewriting earlier tool results." Editing a skill's body is free; editing its description costs a durable catalog message and a KV-cache break.
The judgement: skill, tool, or hook?
Use this decision order.
Does the capability need to execute code the model cannot already reach? → Tool. Search the web, write a file, call an API. A skill cannot do it; it can only tell the model to.
Is it procedural knowledge — how to use existing capabilities well? → Skill. "How this repo's release process works." "How to write a migration in this codebase." The model has bash and file tools; what it lacks is the procedure.
Must it run whether or not the model chooses it? → Hook (or native plugin listener). A secret scanner that only runs when the model decides to invoke it is not a security control. Enforcement lives on tools/pre-execute where it cannot be declined. This is the sharpest of the three lines: skills and tools are model-elective; hooks are not.
Is it a human action that should not consume a model turn? → Command (M7.6).
Two failure modes to name explicitly:
A skill that should have been a tool. You write "how to query the metrics database" as a skill, and the model dutifully constructs psql invocations through bash — brittle, unvalidated, and unauditable. If you wanted a query capability, the tool schema is the contract; a skill is prose that a model may misread.
A tool that should have been a skill. You write run_release_checklist() that shells out to eleven steps. Now failures are opaque, partial completion is unrepresentable, and the model cannot adapt when step 7 needs judgement. Multi-step procedures requiring judgement between steps are what skills are for: the model executes each step with real tools and can react.
And the anti-pattern that motivated progressive disclosure in the first place: a skill that should have been in the system prompt — if it is needed on literally every turn, the catalog indirection costs a tool call for nothing. Progressive disclosure wins when relevance is sparse.
Tradeoffs
Skills spend a tool call and a round trip to load. For a model that needed the skill, that is cheap. For a model that loads three skills before starting, it is real latency. Skills are also unvalidated: a tool has a schema and a rejection path, while a skill is text that the model may follow, partially follow, or ignore — there is no runtime check that a skill was obeyed. And discovery quality rests entirely on the description: a badly described skill is invisible, and the catalog gives the model nothing else to go on.
More broadly, whenToUse exists in SkillSummary but the catalog "omits … routing hints." Whether richer routing metadata would improve selection enough to justify its permanent token cost is an open empirical question, and the current answer is a judgement call rather than a measured result.
How others differ
Claude Code popularised this shape — SKILL.md files with frontmatter, discovered from project and user directories, surfaced by name and description, loaded on demand — and dsh's project-agents / user-agents roots and SKILL.md convention are deliberate compatibility with it. Where they differ is architectural: in dsh skills are a capability seam with a provider registry, so a skill can come from a database or a service; Claude Code's are filesystem-and-plugin sourced. That is more machinery than most deployments need, and it is the right call only if you actually intend non-filesystem providers.
MCP has a "prompts" primitive that is close in spirit — server-provided instruction templates the client can list and fetch — but it is user-selected rather than model-selected in most clients, which changes the design completely: no catalog pressure, no routing problem, no progressive-disclosure argument.
Practice here is young. The skill/tool boundary is being worked out live, and anyone claiming a settled taxonomy is ahead of the evidence.
Check yourself
4 questions on M7.5