The premise
I use three AI tools in a normal week. Claude in the terminal as a daily driver. Copilot inside JetBrains for line-by-line autocomplete. ChatGPT for one-shot questions where I can't be bothered to open anything else. Each of them has its own version of skills, markdown files that teach the tool how you want a thing done, and that fire on their own when they're relevant.
The names are different. The shape is the same. And almost all of the ones I see people write shouldn't exist.
Nobody tells you that bit during the demo. The mechanism is easy. Writing one that earns its place in your toolchain is much harder, and most of what gets called a skill or a rule or a custom instruction is somebody's pet prompt in a markdown costume.
What follows is what I worked out by getting it wrong a few times, then getting annoyed enough to read the actual contracts.
What a skill actually is, across tools
Strip the marketing off each one and you're looking at the same thing in different wrappers.
- Claude Code.
.claude/skills/<name>/SKILL.md with YAML frontmatter, name, description, optional allowed-tools. Claude reads the description and decides whether to invoke the skill.
- Cursor.
.cursor/rules/<name>.mdc with frontmatter description, globs, alwaysApply. The model auto-loads rules whose globs match files in scope, or whose description Cursor decides is relevant.
- GitHub Copilot.
.github/copilot-instructions.md for repo-wide rules, plus .github/instructions/<name>.instructions.md with an applyTo glob for path-scoped ones.
- ChatGPT and custom GPTs. The Instructions field on a custom GPT, or per-project instructions. No glob, so it fires whenever you talk to that GPT.
Three of the four are the same object, a markdown file with metadata that says when to fire and a body that says what to do. ChatGPT is the simplest because it drops the routing entirely. You pick the GPT, the rules fire. The others each do their own version of scanning what's available and deciding what applies right now.
Decides is the important word. You aren't loading these by hand. The tool decides whether your skill is relevant, from the trigger you wrote, so the trigger is the most important thing in the file by a wide margin.
A skill with a vague trigger never fires and you never find out why. The louder failure is the one that fires constantly, dragging context into unrelated requests and making the model slower, dumber and more expensive.
What they're useful for
Not as much as you'd hope. The things they are good at, they're very good at.
Codifying a repeatable workflow you keep re-explaining. How a missing [Authorize] attribute is not actually an issue despite everything telling you it is because our infrastructure deals with it.
Local conventions that aren't in the code itself. "Use this logger, not the framework one." "Don't add new exceptions to that namespace." "All IQueryable extensions go through this helper." Things that would normally live in a CONTRIBUTING.md no one reads, but actually take effect because the tool reads it every time it's relevant.
Domain mappings. Internal acronyms, the difference between the three things called Account in your codebase, which DB column is the canonical user ID. I built a domain-glossary rule for a client and watched a junior dev's onboarding drop from a fortnight to a long afternoon, because the model could answer the dumb questions and they could focus on the real ones.
Tool wrappers. A skill that says "when the user wants to query staging Postgres, here's the SSO dance and here's the read-only role to assume". Bounded, predictable, and on tools that support it you can control which other tools the skill is allowed to call.
Path-scoped rules where the tool supports them. Cursor's globs and Copilot's applyTo are useful. A rule that only fires inside infrastructure/*/.tf can be brutally specific because it doesn't have to disclaim everything else.
What they're not useful for
This is the longer list, and the one most people skip.
Personality and style preferences. "Be concise." "Don't apologise." "Use British English." Those belong in your top-level user instructions, not a per-skill file. Skills cost context every time they fire, and a file that exists to stop the model saying "Certainly!" is not worth what it costs to load.
General programming knowledge. "How to write good Python." "What dependency injection is." "Use SOLID principles." The model knows. A skill that re-explains it is noise sitting where useful tokens should be.
Things that change often. Skills are static markdown. If yours needs the current state of the database, the open incidents, or what's in JIRA right now, you don't want a skill. You want a tool, an MCP server, or the model going to look for itself.
Wikis. This is the failure mode I see most often. Someone writes a 2,000-line SKILL.md or .cursorrules titled engineering-standards with sixteen H1s and the entire internal API reference inlined. Then they're surprised that it makes the model slower, more expensive, and worse at the actual task. Long skills are a smell. If yours is over ~150 lines, it almost certainly wants to be split.
Things that should be in code or tests. If the rule is "never use Newtonsoft.Json", you don't need a skill, you need a Roslyn analyzer. Better still, a build break. Skills replace reminders, not enforcement.
How to write one
There's a discipline to this that took me a few tries to internalise. It's the same on every tool I've used.
Start from the trigger, not the content. Before you write a line of the body, write the description and, where the tool supports them, the globs. If you can't name when this fires and when it doesn't, you don't have a skill yet. You have a feeling.
Here's a bad description.
description: Helps with .NET stuff.
And a good one.
description: |
Use when migrating a .NET project between major versions (e.g. .NET 8 → 10).
Handles target framework moniker, SDK pin, deprecated API rewrites, and
CI workflow updates. Run this BEFORE editing csproj files manually.
One of those is doing real work. The other is a coin flip.
Write the body for a competent contractor, not a beginner. The model isn't your junior. It knows what an HTTP request is. It does not know that your service expects a custom X-Tenant header and will 500 with a misleading error if you forget. Skip the basics, hammer the specifics.
Use progressive disclosure where the tool lets you. Almost nobody does this and it's the difference between a skill that costs you nothing and one you resent. On Claude, and on Cursor with .mdc references, keep the top-level file lean, under 100 lines as a healthy target, and link out to sibling files for the heavy material.
.claude/skills/release-cut/
├── SKILL.md (the brain - 60 lines)
├── reference.md (full changelog format, label semantics)
├── examples/
│ ├── happy-path.md
│ └── hotfix.md
└── scripts/
└── tag-and-push.sh
Inside SKILL.md, that looks like this.
> For the canonical changelog format and a worked example, read reference.md. > For hotfix scenarios (out-of-band tags, no-changelog commits), read examples/hotfix.md.
The model pulls those in when the task needs them. You get the depth without paying for it on every fire. Copilot and ChatGPT don't do progressive disclosure, so trimming is the only lever you have there.
Be explicit about ordering and stopping conditions. "Do A, then B, then C. If C fails, stop and surface the error." Models plough forward unless told otherwise. A good skill has spine.
Narrow the blast radius where you can. On Cursor, narrow globs. On Copilot, narrow applyTo. On Claude, the field that actually restricts is disallowed-tools, which takes tools out of the pool while the skill is active. allowed-tools reads like a whitelist and isn't one, it pre-approves the listed tools so they run without a permission prompt while leaving everything else callable. Worth getting the two the right way round before you rely on either.
A worked example
Below is the smallest skill that earns its keep, ripped from one I actually use. Shown in Claude format, but the same content maps cleanly to a Cursor .mdc or a Copilot applyTo-scoped instructions file.
---
name: csharp-pr-prep
version: 1.4.0
owner: "@matthewxbird"
description: |
Use when the user asks to prepare a PR for a C#/.NET change. Verifies
the diff respects our conventions (no exceptions for control flow, no
Newtonsoft, public APIs documented), runs `dotnet format`, and writes
a PR body in our standard template.
allowed-tools:
- Read
- Edit
- Grep
- Bash(dotnet --version)
- Bash(dotnet format:*)
- Bash(git diff:*)
- Bash(git status)
- Bash(git add:*)
disallowed-tools:
- Bash(git push:*)
- Bash(rm:*)
---
<!--
Changelog
1.4.0 - Add Newtonsoft.Json blocker, narrow Bash allowlist.
1.3.0 - Stop on first failure (was running all checks then summarising).
1.2.0 - Initial path-scoped version.
-->
## Non-goals
- Does **not** push to remote.
- Does **not** touch files outside `src/` or `tests/`.
- Does **not** edit CI workflows. If CI needs changing, surface and stop.
## Preconditions
- Branch is not main/master.
- `dotnet --version` resolves and matches `global.json`.
## Checks (in order, stop on first failure)
1. Run `dotnet format --verify-no-changes`. If it fails, run `dotnet format`
and stage the result.
2. Grep the diff for `throw new ` inside `try`/`catch` pairs that aren't
in `*Exception.cs` files. Surface any matches - these usually mean
control-flow-by-exception. See `reference.md` for our rule.
3. Grep the diff for `Newtonsoft.Json`. We use `System.Text.Json`. Any
match is a blocker.
4. For every new `public` symbol in `src/`, confirm there's an XML doc
comment. Missing docs are a blocker.
## Dry-run
Before any `Edit` or `git add`, list the files you will modify and stop
for confirmation. Continue only after the user replies `yes`.
## PR body
Use the template in `templates/pr.md`. Title format: `<area>: <verb-phrase>`,
no Conventional Commit prefixes.
Sixty lines. It routes precisely, it tells the model where to read more, and it cannot push to remote even if asked nicely, because disallowed-tools takes git push out of the pool rather than politely asking it not to. That's the shape.
Two caveats on that frontmatter. version and owner aren't part of the schema, and Claude Code ignores keys it doesn't know, but the packaging tools for the Skills API validate the key list and will reject them, so keep them in a comment if you plan to publish. The same goes for Cursor, where the file is still under git, so tag the version in the body's changelog comment instead.
---
description: Prepare a C#/.NET PR per our conventions.
globs:
- "src/**/*.cs"
- "tests/**/*.cs"
alwaysApply: false
---
<!-- version: 1.4.0 - owner: @matthewxbird -->
(same body)
And in Copilot, where the only metadata is the path scope.
---
applyTo: "src/**/*.cs,tests/**/*.cs"
---
<!-- csharp-pr-prep · version: 1.4.0 · owner: @matthewxbird -->
(same body)
The metadata schema changes. The discipline doesn't.
Other best practices
A handful of habits that took me a while to settle on, all earned the hard way.
Version the file. A version: x.y.z in a changelog comment does two things. Your test harness can pin against a known-good version, and you have to decide whether a change is meaningful before you bump it. Bump when the trigger or behaviour could shift, not for a typo.
Declare an owner. When a skill misfires at 11pm during a release, whoever is on call needs to know who to wake up. owner: "@team-platform", a person, a Slack handle, anything. It saves a thread of "does anyone know who wrote this", and anonymous skills rot.
Be granular with tool access. Bash on its own is a blank cheque, and Claude Code takes patterns like Bash(dotnet format:), so use them. List the exact commands in allowed-tools so the skill runs without prompting you for each one, and put the things that must never happen in disallowed-tools, Bash(git push:) and Bash(rm:*) being the obvious pair. Cursor and Copilot have no metadata for this, so the body has to do the policing, which is weaker than a restriction the tool enforces.
Write the non-goals down. A ## Non-goals heading is the highest-leverage two lines in any skill. Models expand scope to be helpful unless told not to. "Does not push to remote. Does not touch CI." prevents three classes of incident.
Add a dry-run phase for anything that writes. "Before any Edit or destructive Bash, list what you will change and stop." Costs nothing, prevents the most embarrassing failures. Skip it only for skills that are read-only by construction.
Cap context. "Read at most three sibling files, do not list directories." Without a ceiling, models read every file they're allowed to and bill you for it. Put a number on it.
Make steps idempotent where you can, and label them where you can't. Re-running shouldn't duplicate commits, double-add sections or re-tag releases. When a step can't be idempotent, "run database migration 0042" being the classic, say so loudly and gate it behind the dry-run.
Keep a changelog comment at the top of the body. Three lines beats git log for someone reading the skill cold.
<!--
1.4.0 - Add Newtonsoft.Json blocker, narrow Bash allowlist.
1.3.0 - Stop on first failure.
-->
Name skills by trigger, not by content. release-cut is good. our-release-process-and-changelog-format-v2 is a cry for help. The filename is the first thing a teammate sees in a directory listing, so make it earn the space.
Co-locate fixtures and tests. If a skill has a test suite, and serious ones should, put it next to the skill rather than in a far-off tests/ tree. When you edit the skill the tests are right there, and when you delete the skill they go with it.
Testing them (and accepting they'll never be fully deterministic)
Nobody does this part, and it's the part that separates a skill from a wish.
You cannot make an LLM call deterministic. Same prompt, same model, same temperature, different output. What you can do is squeeze the variance down until the skill is reliable enough to depend on, then check it stays there when you edit the file, swap models or change the tools around it. My targets are that it fires when it should on at least 95% of runs, follows the steps on at least 90%, and never calls a tool it isn't allowed to. Pick your own numbers. Having numbers at all is the thing.
Three categories of test, and you need all three.
1. Trigger tests. Does it fire when it should, and stay quiet when it shouldn't? The most important kind, and the one everyone skips. Write ten prompts that should activate it and ten that look similar but shouldn't, run them, and measure the hit rate and the false-positive rate. If either is under 90% your description is wrong, so fix the trigger rather than the body.
2. Behaviour tests. When it fires, does it do the right things in the right order? Take each canonical scenario, happy path, hotfix, malformed input, missing precondition, run it ten times and read the trace. Assert on tool calls. Did it run dotnet format before checking for Newtonsoft.Json, did it Edit anything outside src/, did it stop when check 2 failed. Tool-call assertions hold up far better than assertions on prose.
3. Negative tests. Give it something that looks like the trigger and isn't, or an environment where the precondition fails, and assert it refuses cleanly. "Surface the error and stop" is worth nothing until you've watched it happen.
The harness can be embarrassingly simple. For Claude, the Agent SDK lets you script a session and inspect the tool calls it made. Forty lines is enough.
import json, subprocess
from collections import Counter
PROMPTS = json.load(open("tests/csharp-pr-prep.json")) # {prompt, expect_skill, expect_tools, must_not_call}
results = Counter()
for case in PROMPTS:
for _ in range(10): # 10 runs per case
trace = run_claude(case["prompt"]) # returns parsed tool calls
fired = "csharp-pr-prep" in trace.skills
tools = [c["tool"] for c in trace.calls]
if fired != case["expect_skill"]: results["trigger_miss"] += 1
if any(t in case.get("must_not_call", []) for t in tools): results["forbidden_tool"] += 1
if not set(case["expect_tools"]).issubset(tools): results["missing_step"] += 1
print(results)
assert results["forbidden_tool"] == 0, "Skill called a tool it shouldn't"
assert results["trigger_miss"] / total < 0.05
assert results["missing_step"] / total < 0.10
Crude, but it's the difference between "the skill works on my laptop" and "the skill works 9 times out of 10 across 30 scenarios, and CI will tell me when that changes".
A few rules I've settled on.
- Pin the model version. "Latest Sonnet" is not something you can test against. Pin it, bump it deliberately, re-run the suite.
- Run on every skill edit. Small wording changes in a description move trigger rates a long way, and you only find out if you look.
- Assert on tool calls, not prose. "Did it write Done. in the response" is a flaky test. "Did it call
Bash with dotnet format" is not.
- Use a smaller model as judge for the fuzzy bits. To score whether a PR body is coherent, hand the prompt and the output to a cheaper model with a rubric. It's reliable enough to catch drift, which is all you need it for.
- Keep the suite small. Thirty well-chosen scenarios catch more regressions than three hundred lazy ones, and they'll actually run in CI.
Cursor and Copilot have no first-class SDK for this yet, which is why my serious skills live in Claude. The best I've managed for Cursor is a shell script that opens a temp project, triggers a rule through the CLI and greps the chat log. Ugly, and it still caught a regression where a tiny wording change took the trigger rate from 96% to 41%.
Where I've seen them fail
A team I worked with last year wrote a single .cursorrules file that was effectively the company's entire engineering handbook in markdown. Six thousand lines. Triggered on every file. Their token bill tripled and the actual code suggestions got worse, because the model was now drowning in context and couldn't tell what was load-bearing.
We deleted it and replaced it with seven path-scoped rules, each under 80 lines with a tight trigger. csharp-review.mdc globbing */.cs, sql-review.mdc globbing */.sql, terraform-review.mdc globbing infra/*/.tf, and so on. Same content, organised by trigger. The bill came down and the suggestions got sharper.
A skill isn't a place to put knowledge. It's a place to put knowledge that should travel with one specific trigger. When two skills want the same rule, that rule is usually the codebase's problem rather than the skill's.
When to not write the skill at all
Things I've watched people turn into skills that should have been something else.
- One-shot tasks → just ask the model directly.
- "Things I always forget" → that's a personal note, not a skill.
- "Things the whole team always forgets" → fix the codebase, the docs, or the CI.
- Style preferences → top-level user instructions.
- "Reminders" → if it has to fire every time, it's a prompt, not a skill.
- Anything that needs live data → MCP server, or just let the model go and look.
- Anything that's actually a lint rule → make it a lint rule.
The test I keep coming back to is whether I'd be annoyed if it fired half the times it shouldn't and stayed quiet half the times it should. If the answer is yes, it doesn't earn its place, because that's what a vague trigger does to you.
The tool-by-tool reality check
A short, opinionated take on each one I use.
- Claude Code has the cleanest model. The frontmatter does real routing, progressive disclosure works, and the tool fields are enforced by the runtime rather than by asking nicely. Lowest bar to writing a good one.
- Cursor is fine if you use
globs properly and resist writing a giant alwaysApply: true rule. The legacy single-file .cursorrules is a trap, so move to the .cursor/rules/ directory and break it up.
- Copilot's repo-wide
copilot-instructions.md earns its keep for team conventions and works in both the web version and the editor extensions. The per-path instructions/*.instructions.md is newer and less mature, but it's the right shape.
- ChatGPT custom GPTs are the bluntest of the bunch, one Instructions field and no routing at all. Treat one as a single skill with no off-switch. Good for a narrow persona, bad for anything conditional.
If you find yourself writing the same rule into every tool you have open, it wants to be in the code instead. A lint rule, an analyzer, a pre-commit hook, and a one-line reminder in the AI tools pointing at it. These files are for the things you can't enforce.
Closing
Skills do one narrow job well. They carry the when of how your team works so nobody has to type it out again, and used that way they save real time in whatever assistant you have open. Used as wikis they cost real money in every tool you installed them in.
Write the trigger first. If you can't write a good one, you don't have a skill yet.