# How to write a skill description that triggers at the right time

> Write the description around the words users type and the files they edit, then score it on ten fixed prompts beside a Cursor rule and a Copilot path glob.

Source: https://voxgig.com/howto/write-a-skill-description-that-triggers

- Audience: agent-builder
- Level: intermediate
- Verified: 2026-10-21
- Published: 2026-10-19

## Short answer

Write the `description` in the third person around what the skill does and the words a user types: the task nouns, the file names, and the commands. Keep the trigger sentence first, because Claude Code's listing shows at most 1,536 characters of it. Then score it on ten prompts, five that should load the skill and five that should not, and count misses and false fires separately. A glob on `applyTo` or `globs` lets the path decide instead.

---
## You will need

Node 22 or later, a skill or rule you own, and ten prompts from real sessions. Verified 2026-10-21 against Node 22.22.0. The skill follows the
[Agent Skills specification](https://agentskills.io/specification), which caps a description at
1,024 characters and asks it to say what the skill does and when to use it.[^1] The sample runs
offline and calls no model: the description check is a lexical proxy, and the glob check is exact.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A Copilot instructions file with `applyTo`](https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/add-custom-instructions/add-repository-instructions) | Rules about a kind of file, where the path says enough | A rule that loads only when a matching file is in the request, so a task phrased without a file never sees it | The trigger is in what the user asked, not in which file is open |
| [A Cursor `.mdc` rule with a description and `alwaysApply` false](https://cursor.com/docs/rules) | Cursor users, and a rule the agent should pull in when it judges it relevant | A Cursor-only format, and a description the agent reads on every turn to decide, the same cost as a skill | The team uses more than one agent, where the skill format is the portable one |
| [The SKILL.md `description` field](https://agentskills.io/specification) | Any agent that reads the Agent Skills format, and a procedure users ask for in words | A listing entry on every turn, a cut at 1,536 characters in Claude Code, and a miss whenever the words differ from yours | The rule must hold whenever a file is touched, whether or not anyone asked |

The glob and the description answer different questions. A glob fires when a matching file is in
the request, so it cannot see a prompt with no file open, and it cannot tell an edit to
`CHANGELOG.md` from a release. A description is read by the model on every turn, so it fires on
intent and costs a listing entry per skill per turn.

## Write the description from the prompts, not from the procedure

The skill is a release procedure the team calls Lantern, and its first description said so.

```yaml title="skills/before/release-notes/SKILL.md"
---
name: release-notes
description: Lantern, the Northwind shipping procedure. Use when running Lantern.
---
```

No word in that sentence is one a user types. The rewrite starts from the prompts: what the skill
does, then the nouns people use when they want it, then the file it edits.

```yaml title="skills/after/release-notes/SKILL.md"
---
name: release-notes
description: Drafts changelog entries and release notes from conventional commits, and bumps the version. Use when the user asks for a changelog, release notes, a version bump, or a release, or edits CHANGELOG.md.
---
```

[Anthropic's skill authoring guidance](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices)
asks for the third person, for both what the skill does and when to use it, and for specific key
terms. The description is how a model chooses among what may be a hundred skills.[^2] The
[Claude Code skills reference](https://code.claude.com/docs/en/skills) adds that the combined
`description` and `when_to_use` text is cut at 1,536 characters in the listing, so the key use case
goes first. The body stays out of the description. An agent that has decided to load the skill
reads the body anyway, and an agent that has not would pay for every word of it on every turn.

## Score it on ten prompts, five that should fire and five that should not

The ten prompts are fixed, five that should load the skill and five that should not. Each carries
the files that were open at the time, because the glob approaches read nothing else.

```json title="prompts.json"
  { "text": "write the changelog entry for the 2.4 release", "files": ["CHANGELOG.md"], "expect": true },
  { "text": "draft release notes from the commits since v2.3", "files": [], "expect": true },
  { "text": "bump the api package to 2.4.0 and update the changelog", "files": ["packages/api/package.json", "CHANGELOG.md"], "expect": true },
```

The harness decides three ways. For a description it checks whether the prompt and the description
share a content word, a crude stand-in for the model's reading and the one property the author
controls. For a glob it matches the open files with Node's
[`path.matchesGlob`](https://nodejs.org/api/path.html). For an always-on rule it fires.

```js title="trigger.mjs"
export const byDescription = (description) => {
  const vocabulary = terms(description)
  return (prompt) => [...terms(prompt.text)].some((w) => vocabulary.has(w))
}

export const byGlob = (patterns) => {
  const globs = patterns.split(',').map((g) => g.trim()).filter(Boolean)
  return (prompt) => prompt.files.some((f) => globs.some((g) => matchesGlob(f, g)))
}

export const always = () => true
```

Each approach is scored on fires, misses, false fires, and the characters of context it consumed
across the ten prompts. That cost is a listing entry on every turn, plus the body on every turn it
fired.

```js title="trigger.mjs"
export function evaluate({ decide, listing = '', body }, prompts) {
  const fired = prompts.filter(decide)
  const misses = prompts.filter((p) => p.expect && !decide(p))
  const falseFires = prompts.filter((p) => !p.expect && decide(p))
  const chars = listing.length * prompts.length + body.length * fired.length
  return { fired: fired.length, misses, falseFires, chars }
}
```

```bash
node compare.mjs
```

```text output
approach                                fires   misses  false   chars
Copilot applyTo glob                    4       2       1       4932
Cursor alwaysApply true                 10      0       5       12330
Cursor description, alwaysApply false   5       0       0       8165
SKILL.md description, internal names    0       5       0       830
SKILL.md description, user words        5       0       0       8315

Copilot applyTo glob: missed "draft release notes from the commits since v2.3" (no file in context)
Copilot applyTo glob: missed "what should go in the release notes for this pull request"
Copilot applyTo glob: fired on "add lodash as a dependency of the api package"
```

The internal-name description fires on nothing. The rewrite fires on the five positives only, and
the Cursor `.mdc` carrying the same description scores the same, because the mechanism is the same
and only the file format differs. The proxy is deliberately crude. It reads vocabulary, and it
cannot tell `write the changelog` from `fix the typo in the changelog`, which a model can. To
measure the model, the [`claude plugin eval` command](https://code.claude.com/docs/en/skills) runs
prompts against a plugin and grades each on whether the Skill tool was used.

## Let the path decide where the file says enough

Two of the approaches never read the prompt. Copilot's `.instructions.md` carries an `applyTo` glob
and joins a request when a file it matches is part of it, as the
[repository instructions documentation](https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/add-custom-instructions/add-repository-instructions)
describes.

```yaml title="repo/.github/instructions/release-notes.instructions.md"
---
applyTo: "**/CHANGELOG.md,packages/*/package.json"
---
```

The glob row fires on four prompts, misses two, and fires once where it should not. The misses are
the prompts with no matching file open: a request for release notes with nothing open, and one
asked while editing a source file. The false fire is a dependency added to `package.json`, which
looks exactly like a version bump from the path's point of view. A glob is exact and cheap, with no
listing entry at all, but it reads the path and never the request.

Cursor puts both mechanisms in one file format and lets the frontmatter choose between them.

```yaml title="repo/.cursor/rules/release-notes.mdc"
---
description: Drafts changelog entries and release notes from conventional commits, and bumps the version. Use when the user asks for a changelog, release notes, a version bump, or a release, or edits CHANGELOG.md.
alwaysApply: false
---
```

[Cursor's rules documentation](https://cursor.com/docs/rules) reads it as a table. `alwaysApply:
true` loads the rule on every chat. `globs` without a description attaches it when a matching file
is in context. A description without `globs`, as here, hands the decision to the agent, which is
the skill mechanism under another name.[^3] Claude Code offers the reverse inside the skill format:
a `paths` field on `SKILL.md` limits automatic loading to work on matching files.

## Check it worked

The third test pins the glob's behavior, because its misses and its false fire are the ones a
reader is most likely to doubt.

```js title="trigger.test.mjs"
test('a glob misses the prompts with no matching file in context and fires on an edit to a matching file', () => {
  const r = row('Copilot applyTo glob')
  assert.deepEqual(r.misses.map((p) => p.text), [
    'draft release notes from the commits since v2.3',
    'what should go in the release notes for this pull request',
  ])
  assert.deepEqual(r.falseFires.map((p) => p.text), ['add lodash as a dependency of the api package'])
})
```

```bash
node --test trigger.test.mjs
```

```text output
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 163.348006
```

The other tests pin the internal-name description at zero fires, the rewrite at zero misses and
zero false fires, and the always-on rule at `5` false fires.

## When it goes wrong

The trigger sentence is in the description, but the skill never loads. The description
explains the procedure's history first and runs to 1,624 characters, over the specification's limit
of 1,024. Claude Code shows the first 1,536 characters of an entry in its listing, so the sentence
that says when to use the skill is the one that gets cut.[^4]

```bash
node pitfall.mjs
```

```text output
description: 1624 characters, the listing shows the first 1536
trigger sentence survives the cut: false
full description: fires 5, misses 0, false fires 0
what the listing shows: fires 0, misses 5, false fires 0
```

The same words in a different order go from five hits to none. Put the use case in the first
sentence, the history nowhere, and the whole description inside the specification's 1,024
characters.

The skill loads on every turn. The rule was marked `alwaysApply: true`, or its description claims
every task in the repository. The comparison prices that: five false fires, and 12,330 characters
of context across ten prompts against 8,315 for the description that fires on half of them. Those
characters sit where the code the agent was asked to read would otherwise be.

The frontmatter does not parse. Claude Code then loads the skill with no fields set, and the listing
falls back to the body's first line, `# Release notes`, for a description. `/release-notes` still
works, so the failure looks like a wording problem, but it is a YAML one. A colon followed by a
space inside an unquoted description breaks it; quote the value.

## When not to do this

Do not write the description for the people who built the skill. They know what Lantern is; the
agent is reading a stranger's prompt. Every noun in the description should be one that appeared in
a real request.

Do not reach for `alwaysApply: true` because a rule matters. A rule that loads on every turn costs
its length on every turn, and a rule that must hold belongs in a hook that runs whether or not the
agent agrees.

Do not use a glob for a procedure users ask for in words. Release notes are requested, not edited
into being, and a glob on `CHANGELOG.md` sees the edit after the fact.

Do not treat the lexical check as the measurement. It tells you whether the words are there.
Whether the model loads the skill is a question for a run of the agent with the skill in place and
the same ten prompts, counted the same way.

## Related how-tos

- [Organize agent skills across many related repositories](/howto/organise-skills-across-a-multi-package-ecosystem)

- [Install an API skill and check a coding agent uses it](/howto/install-an-api-skill-for-a-coding-agent)

- [Name tools so an agent picks the right one from fifty](/howto/name-tools-so-agents-pick-the-right-one)

## Last verified

Verified 2026-10-21 against Node 22.22.0. Every output block is what the command preceding it
printed. The description check is a lexical proxy over a fixed prompt set, not a model's decision,
and no agent was run; the glob matching uses Node's own matcher and is exact. Each tool's loading
rule is taken from its documentation.

[^1]: The [specification](https://agentskills.io/specification) counts in three units. A name is
at most 64 characters and a description at most 1,024. The body of `SKILL.md` should stay under
5,000 tokens and the file under 500 lines. The name and description of every installed skill,
budgeted at about 100 tokens together, are loaded at startup for all of them. Whichever unit the
reader counts in, one of the limits is written in it.

[^2]: The [authoring guidance](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices)
gives a reason for the third person that has nothing to do with style. The description is injected
into the system prompt, and an inconsistent point of view can cause discovery problems. It also
publishes a list of descriptions to avoid, among them `Helps with documents`, `Processes data`, and
`Does stuff with files`, a list that reads as collected rather than invented.

[^3]: Cursor's [rules page](https://cursor.com/docs/rules) defines a project rule by its
frontmatter. A plain `.md` file in `.cursor/rules` is ignored because it has no `description`,
`globs`, or `alwaysApply` to read, and the page points anyone who wants plain markdown at
`AGENTS.md` instead. A rule, in other words, is a file whose first lines say when it applies; the
rest is content.

[^4]: The [Claude Code skills reference](https://code.claude.com/docs/en/skills) gives the listing
a budget of 1% of the model's context window. When the entries overflow it, the listing drops
descriptions starting with the skills invoked least, so the skills in use keep their text. Each entry is capped
at 1,536 characters regardless. Both numbers are settings, `skillListingBudgetFraction` and
`skillListingMaxDescChars`, so both were chosen rather than derived.