Skip to content

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.

PackageRolectx key
packages/skill/skillService definition: layered provider registry (ctx.skills)ctx.skills
packages/skill/skill-filesystemLocal provider scanning project/user roots on diskregisters on ctx.skills
packages/skill/skill-badgeOptional bundled dsh-badge providerregisters on ctx.skills
packages/skill/tool-skillConsumer: session catalog + model-facing skill toolregisters on ctx.tools
packages/client/ui-skillWeb UI: a compact transcript row for skill loadsbrowser 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):

markdown
---
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, optional whenToUse, invocation, source, provider, resourceBase). This is what the catalog renders.
  • SkillCandidate — summary plus rank and an opaque locator (provider-owned) that the registry stores and hands back to the winning provider's get().
  • SkillDefinition — candidate plus content (the Markdown body), returned by get().

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:

RankSourceRoot
100project-dsh<projectRoot>/.dsh/skills
200project-agents<projectRoot>/.agents/skills
300customConfig.customSkillDirs
400user-dsh<dshHome>/skills (skips the .system child)
500user-agents<agentsHome>/skills
600bundledConfig.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:

text
<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:

json
{ "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

PackageKeyDefault / notes
dsh-skillcollectCacheMaxEntriesmax completed cwd/provider catalogs cached
dsh-skill-filesystemproviderName, includeDefaultRoots, dshHome, agentsHome, customSkillDirsprovider name default filesystem; roots on by default
dsh-skill-filesystemwatch*, watchUsePolling, watchStabilityThresholdMs (200), watchPollIntervalMs (100), watchMaxProjects (128), watchFollowSymlinkswatcher tuning
dsh-skill-filesystembundledSkillDirdefaults to $DSH_BUNDLED_SKILL_DIR
dsh-skill-badge(none — fixed candidate)disabled in shipped CLI
dsh-tool-skillcatalogDescriptionMaxLengthdefault 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 scopesagent/pre-step and scope-layered registries
  • Tool registry & execution pipeline — how ctx.tools definitions and post-execute hooks run
  • docs/subsystems/skills.md — the official subsystem reference and full API
  • packages/skill/skill/src/index.tsSkillRegistry service definition
  • packages/skill/tool-skill/src/index.ts — catalog injection and skill tool