Skills are reusable, task-specific instruction bundles that a harness can load on demand so the model does not have to carry every procedure in its context window. A skill is not code, a session event, or a World State fact — it is a Markdown instruction body that starts empty and is injected into a live agent turn only when invoked. The whole pipeline lives under packages/skill/* and is deliberately kept outside the core agent-loop spine, so providers can be local, embedded, or remote without changing what the model sees.
| Package | Role | ctx key |
|---|---|---|
packages/skill/skill | Service definition: layered provider registry (ctx.skills) | ctx.skills |
packages/skill/skill-filesystem | Local provider scanning project/user roots on disk | registers on ctx.skills |
packages/skill/skill-badge | Optional bundled dsh-badge provider | registers on ctx.skills |
packages/skill/tool-skill | Consumer: session catalog + model-facing skill tool | registers on ctx.tools |
packages/client/ui-skill | Web UI: a compact transcript row for skill loads | browser half only |
The official subsystem reference lives at docs/subsystems/skills.md; the source is packages/skill/{skill,skill-filesystem,skill-badge,tool-skill}/src/index.ts.
What a skill is and how it is declared
A skill is a Markdown file with YAML frontmatter. Two on-disk shapes are accepted by the filesystem provider (packages/skill/skill-filesystem/src/index.ts):
- directory bundle —
<skill-name>/SKILL.md; - flat file —
<skill-name>.md(single Markdown file).
Skill names must be kebab-case (^[a-z0-9]+(?:-[a-z0-9]+)*$). Recursive **/SKILL.md discovery is not supported — the provider scans only one level.
The frontmatter contract is minimal (missing YAML, or a missing name/description, causes the file to be skipped with a warning):
---
name: my-skill
description: Short routing description shown in the catalog.
# optional:
whenToUse: Extra routing guidance (shown by consumers that render it).
disable-model-invocation: false # modelInvocable
user-invocable: true # user can trigger with /<name>
metadata-key: any-extra-field # kept as opaque metadata
---
The actual instruction body goes here (Markdown).Two frontmatter keys map onto invocation policy (SkillInvocationPolicy): disable-model-invocation (default false → model-invocable) and user-invocable (default true). Both are read as exact kebab-case booleans; legacy camelCase keys (disableModelInvocation, modelInvocable, userInvocable) are rejected with a message telling you the canonical key. Setting disable-model-invocation: true and user-invocable: false keeps a skill reachable only through trusted ctx.skills.get() callers.
The registry: ctx.skills
SkillRegistry (packages/skill/skill/src/index.ts) is the provider-neutral catalog. Providers register synchronously during apply(); discovery is awaited inside list(). The registry is layered host + per-scope: a registration files into the layer of its calling context's scope, so global rows and repository plugins land in the global layer while a plugin mounted by an agent preset lands in that preset's layer. A read merges the global layer with the viewing agent's chain — the nearest layer's entry wins a duplicate name outright.
Within one layer, duplicate skill names resolve by rank, then provider order, then local order. The registry's public IDs:
registerProvider(create) register(skill)
list(options) -> Promise<SkillSummary[]>
snapshot(options) -> Promise<SkillCatalogSnapshot>
get(name, options) -> Promise<SkillDefinition | undefined>The three result shapes grow from summary → candidate → definition:
SkillSummary— invocation-neutral metadata (name,description, optionalwhenToUse,invocation,source,provider,resourceBase). This is what the catalog renders.SkillCandidate— summary plusrankand an opaquelocator(provider-owned) that the registry stores and hands back to the winning provider'sget().SkillDefinition— candidate pluscontent(the Markdown body), returned byget().
Snapshot distinguishes authoritative absence from transient failure: complete is true only when every registered provider finished without a concurrent catalog revision, and incomplete snapshots are never cached. Lookup is cwd-sensitive (workspace-local skills) and abortable via signal. The registry emits the unfiltered skills/change invalidation event after any provider/runtime mutation — it carries no diff, so consumers refetch snapshot().
Local discovery priority
dsh-skill-filesystem scans roots in rank order. The project root is the nearest ancestor containing .git (probed through ctx.fs when present, so sandboxed/remote workspaces stay correct), else the cwd:
| Rank | Source | Root |
|---|---|---|
| 100 | project-dsh | <projectRoot>/.dsh/skills |
| 200 | project-agents | <projectRoot>/.agents/skills |
| 300 | custom | Config.customSkillDirs |
| 400 | user-dsh | <dshHome>/skills (skips the .system child) |
| 500 | user-agents | <agentsHome>/skills |
| 600 | bundled | Config.bundledSkillDir when configured |
dshHome resolves to $DSH_HOME or ~/.dsh; agentsHome to $DSH_AGENTS_HOME or ~/.agents. Chokidar watches existing roots for flat/bundle entry additions, removals, and direct entry changes; project-scoped watchers use a bounded LRU (watchMaxProjects, default 128). Model-facing write/edit observations synchronously invalidate the provider, while the host watcher covers IDE/Git/external mutations. Watcher failures mark the observation incomplete without hiding readable candidates from direct loads.
skill-badge
dsh-skill-badge is a provider, not a registry plugin with behavior — it registers exactly one immutable bundled candidate named dsh-badge (it adds the official "powered by dsh" attribution badge to docs, PR/MR descriptions, and other produced content, per its helper description) at BUNDLED_SKILL_RANK = 600, exposing its packaged assets/ directory as resourceBase. The shipped CLI declares the plugin disabled, so enabling the composition row is an explicit opt-in. get() reads the body from ../assets/dsh-badge.md.
Session catalog and the skill tool
dsh-tool-skill injects the initial catalog as a durable <system-reminder> user message at the first agent/pre-step of a live session that observes a non-empty, complete view:
<system-reminder>
A skill is a reusable set of task-specific instructions. The following skills are available in this session:
<available_skills>
- `skill-name`: description
</available_skills>
If the user names a skill ... call the `skill` tool with the exact skill name before taking task actions...
</system-reminder>These catalogs are session history, not World State — they enter as source.kind === 'skill-catalog' messages (a catalog-form message that also records its exact entries for non-model consumers). Before each later step the plugin digests the rendered entries (SHA-256 over [name, description] JSON per entry); a changed digest appends a full replacement catalog to the step's enter decision (another skill-catalog message), and deleting every skill appends an explicit empty replacement. The catalog carries model-invocable name + length-bounded description only — never bodies, paths, or providers. This is the part that feeds system-prompt assembly: skills are surfaced as callable instructions alongside the system prompt rather than blended into it.
The model-facing skill tool (name: 'skill', one parameter name) validates the kebab-case name, looks it up in the invocation-neutral catalog, and refuses unless isModelInvocable(skill). It then re-reads the current definition for the calling agent's cwd and returns:
{ "name": "...", "provider": "...",
"resourceBase": {"kind": "directory", "path": "..."},
"content": "the full markdown body" }Loading happens on every call — definitions are never cached by the registry, so body-only edits change later calls without producing catalog messages. resourceBase resolves relative scripts/assets only as needed. There is a second entry point: a user message containing a whitespace-bounded /<skill-name> token anywhere in the text (matching a user-invocable skill) is a deterministic load gesture — the body is injected after all other injections, closest to the answer. The catalog and skill tool never see disable-model-invocation skills; the user gesture is their only path.
Configuration summary
| Package | Key | Default / notes |
|---|---|---|
dsh-skill | collectCacheMaxEntries | max completed cwd/provider catalogs cached |
dsh-skill-filesystem | providerName, includeDefaultRoots, dshHome, agentsHome, customSkillDirs | provider name default filesystem; roots on by default |
dsh-skill-filesystem | watch*, watchUsePolling, watchStabilityThresholdMs (200), watchPollIntervalMs (100), watchMaxProjects (128), watchFollowSymlinks | watcher tuning |
dsh-skill-filesystem | bundledSkillDir | defaults to $DSH_BUNDLED_SKILL_DIR |
dsh-skill-badge | (none — fixed candidate) | disabled in shipped CLI |
dsh-tool-skill | catalogDescriptionMaxLength | default 500, min 3 |
The SkillConfig bundle (forwarded from the config catalog) binds enabled, registry, filesystem, and tool sub-configs into one toggle.
UI: packages/client/ui-skill
The client package is a pure UI plugin — its Node apply() is empty; its browser half ships via the package exports["./client"]. SkillRow.tsx registers a compact transcript row over the toolview hole: it derives display state (running/ok/error/stopped) from the durable call slice alone (never the live catalog), shows the skill name from the call arguments, and keeps the exact loaded output in a bounded disclosure card. Locale strings live under the skill namespace (client/locales.ts).
Further reading
- System prompt assembly — where catalogs and reminders are woven into the model context
- Agent loop and scopes —
agent/pre-stepand scope-layered registries - Tool registry & execution pipeline — how
ctx.toolsdefinitions and post-execute hooks run docs/subsystems/skills.md— the official subsystem reference and full APIpackages/skill/skill/src/index.ts—SkillRegistryservice definitionpackages/skill/tool-skill/src/index.ts— catalog injection andskilltool