To configure AI skills properly you need three things: a correctly structured SKILL.md, an entry file that registers it, and a maintenance routine that catches drift. A skill is a markdown file with a name, a description and instructions, and the model only opens it when something it has already loaded, meaning CLAUDE.md, AGENTS.md or .github/copilot-instructions.md, points at the file and says when to use it. The description is the router: it should contain the phrases people actually type, not a summary of the product. Keep one job per skill, keep the always-loaded layer small, and audit the setup on a schedule, because a broken AI config never throws an error.
TL;DR
- A skill is a kebab-case folder with a SKILL.md whose frontmatter name matches the folder; the description is a router built from the phrases people actually type.
- Nothing scans your repo for you. Every skill needs an entry in CLAUDE.md, AGENTS.md and copilot-instructions.md with a trigger and a path, or it does not exist.
- One job per skill, a thin always-loaded layer (about 70% of a good config loads on demand), and a ten-minute audit on a schedule, because a broken config never throws.
What does a correctly structured skill look like?
Thousands of open-source skills are one download away, and dropping one into a repo takes ten seconds. That does not make it work. I once opened a project with 200+ skills in it, all downloaded, all near-identical, and not one configured entry file. The AI did not know a single one of them existed. Everything below is the part that owner skipped.
Strip away the hype first. A skill is a markdown file with a name, a description and a set of instructions. That is it. The power is not in the file; it is in whether your AI can find it and knows when to open it. Four rules make that possible, and none of them is optional.
Name the file SKILL.md. Nothing else: not skill.md, not seo-writer.md, not README.md. Tooling looks for that exact name, and on a case-sensitive filesystem a lowercase variant simply does not exist.
One skill, one folder, and the folder name is the identifier:
.github/skills/
next-intl-add-language/SKILL.md
next-intl-writer/SKILL.md
seo-writer/SKILL.md
web-design-reviewer/SKILL.mdKebab-case: lowercase, hyphens, no spaces, no underscores, no version numbers. seo-writer-v2-final is how a 200-skill graveyard starts.
Make the frontmatter name match the folder name. Every SKILL.md opens with frontmatter:
---
name: web-design-reviewer
description: 'This skill enables visual inspection of websites running
locally or remotely to identify and fix design issues. Triggers on
requests like "review website design", "check the UI", "fix the
layout", "find design problems". Detects issues with responsive
design, accessibility, visual consistency, and layout breakage,
then performs fixes at the source code level.'
---If the folder says web-design-reviewer and the frontmatter says Web Design Reviewer, one skill now has two identities. Some tools key off the folder, some off the frontmatter, and your registry points at a third thing. Folder name, frontmatter name and registry entry must be the same string. It is a five-second check across the whole repo:
for f in .github/skills/*/SKILL.md; do
folder=$(basename "$(dirname "$f")")
name=$(grep -m1 '^name:' "$f" | sed 's/name: *//; s/["'"'"']//g')
[ "$folder" = "$name" ] || echo "MISMATCH: $folder != $name"
donePut the skills in .github/skills/. Not because a spec demands it, but because .github/ is the one directory every AI tool, every vendor and every teammate already expects to be shared. Claude, Copilot and a human reviewer all find it in the same place.
Write the description as a router, not a summary
This is the part almost everyone gets wrong. The body of a skill is only read after the model has decided to open it, and the description is what it uses to make that decision. So the description is not a summary of the product. It is a router. Compare two from the same repo.
Weak, because it describes the product:
seo-writer:
"Write high-converting, SEO-optimized website copy,
landing pages, and marketing content."Strong, because it contains the words people type:
web-design-reviewer:
"...Triggers on requests like 'review website design',
'check the UI', 'fix the layout', 'find design problems'..."The second one wins, and not because it is longer. It contains the literal phrases a human being types. When someone says "check the UI", the match is exact. Write descriptions with the words your users will actually use, not the words a product manager would use.
A good description also states the boundary. The next-intl-writer description ends with: "After writing translations, always wires them into the components using useTranslations or getTranslations, replacing every hardcoded string." That one sentence stops the skill from halting halfway and leaving a JSON file with no components touched.
Two more habits keep descriptions honest. Never mention a vendor: the moment a skill says "as Claude, you should", it stops being portable. And keep the examples true to the project: a skill whose examples talk about en.json, az.json and ru.json still works in a project that ships en.json and fr.json, but every run makes the model read about two locales that do not exist and work out that they are illustrative. That is friction paid on every task, for no reason.
How does the AI know which skill to use?
The AI does not scan your repo. It reads what you point it at.
Dropping a SKILL.md somewhere in the repo registers it nowhere. Each tool scans one or two folders of its own and ignores the rest, and even there all it loads at the start of a session is the name and the description. If nothing in the model's loaded context mentions the file, the model never opens it, never knows it exists and never behaves differently because of it. A skill without a pointer is a text file. The entry files are not paperwork. They are the wiring.
Three entry points exist, and every tool reads one of them automatically:
File | Read by | Loaded |
|---|---|---|
| Claude Code | Automatically, every session |
| Vendor-neutral, read by most modern agents | Automatically |
| GitHub Copilot | Automatically, repo-wide |
Everything else in a setup gets loaded because one of these three told the model to load it.
Inside the entry file, the registration is a routing table. In CLAUDE.md and AGENTS.md it can be a plain markdown table:
## Skills
When the user's request matches an available skill, read its
`SKILL.md` and follow its instructions.
| Trigger | Skill |
|---|---|
| Generating translation keys and wiring i18n strings into components, replacing hardcoded strings | `next-intl-writer` - `.github/skills/next-intl-writer/SKILL.md` |
| Adding a new language / locale | `next-intl-add-language` - `.github/skills/next-intl-add-language/SKILL.md` |
| Writing SEO/marketing copy, landing-page text, or high-converting content | `seo-writer` - `.github/skills/seo-writer/SKILL.md` |
| Reviewing UI, checking the design, or fixing layout/responsive/visual issues | `web-design-reviewer` - `.github/skills/web-design-reviewer/SKILL.md` |Three pieces of information in two columns: when to reach for it, what it is called, where it lives. Write the trigger column in task language, not skill language, because the model matches it against a user request, not against a filename.
For Copilot the same registry is a block inside copilot-instructions.md:
<skills>
<skill>
<name>web-design-reviewer</name>
<description>This skill enables visual inspection of websites...</description>
<file>.github/skills/web-design-reviewer/SKILL.md</file>
</skill>
</skills>Different syntax, identical job: name, trigger, path. Every registry format on earth is those three fields. Missing the path, the model cannot open the file. Missing the trigger, it cannot decide to. Missing the name, you cannot debug it.
Register every skill in every entry file, not just your favourite one. The moment you add a skill and update two registries out of three, the third tool is working from a different catalogue than the other two, and it will not tell you.
When are two skills one too many?
Two skills in one domain is fine. Two skills for one job is not. Nothing in a skill file expresses priority, so when two skills could match one request the model reads both, averages them and hands you a codebase built from two half-followed standards.
The test is sharper than "never have two similar skills". Take the two i18n skills above: next-intl-writer and next-intl-add-language sit in the same domain and touch the same folder, and they do not collide, because their jobs do not overlap. next-intl-writer: I built a component, now generate keys for it and replace every hardcoded string. next-intl-add-language: I want the entire app available in a new locale. Different intent, different trigger, different outcome.
The question is not whether two skills sound alike. It is whether a single user request would match both. If yes, one of them goes, or the triggers get rewritten until only one can match.
The same logic sets the size of your collection. Nobody needs 200 skills. Four that each earn their place beat two hundred that share three triggers between them. If you cannot say out loud which request fires a given skill, it does not belong in the repo.
Can Claude and Copilot share one set of skills?
Yes, and without maintaining two sets of anything. The layout that makes it work: content lives in .github/, pointers live in the entry files.
.github/
skills/*/SKILL.md <- the actual skills
instructions/*.md <- deep domain standards (a11y, Next.js)
prompts/*.prompt.md <- reusable task recipes
agents/*.agent.md <- specialised personas
copilot-instructions.md <- Copilot's entry point
CLAUDE.md <- Claude's entry point
AGENTS.md <- vendor-neutral rulebook
.claude/commands/*.md <- thin Claude wrappers, no contentNothing in .github/skills/, .github/instructions/ or .github/prompts/ is vendor-specific. Not one word. Those files describe the work, and that is what makes them shareable. Three tricks do the rest.
The thin wrapper. Copilot picks up .github/prompts/*.prompt.md as slash commands, Claude Code picks up .claude/commands/*.md, and the obvious move is to copy the file into both. Do not. This is the entire .claude/commands/implement-feature.md:
---
description: Implement or update a feature, strictly matching
existing architecture, conventions, and patterns
argument-hint: <feature description, or paste/reference a .md,
code, or design>
---
Follow the feature-implementation guidelines below as the rules
for this task.
**My request:** $ARGUMENTS
@.github/prompts/implement-feature.prompt.mdTen lines. The last one is an @ file reference that pulls in the real prompt at runtime. The 374-word feature-implementation standard exists in exactly one file; Copilot reads it as /implement-feature, Claude reads it as /implement-feature, and it gets edited once. The wrapper carries the vendor syntax. The target carries the content.
Let each vendor load shared files its own way. Instruction files carry applyTo globs in their frontmatter:
---
description: 'Next.js + Tailwind development standards'
applyTo: '**/*.tsx, **/*.ts, **/*.jsx, **/*.js, **/*.css'
---Copilot uses the glob to auto-attach the file whenever a matching file is being edited. Claude does not read applyTo, so CLAUDE.md points at the same files explicitly:
## Related Instruction Files
For deeper, tool-agnostic guidance, also follow:
- `.github/instructions/nextjs.instructions.md`
- `.github/instructions/nextjs-tailwind.instructions.md`
- `.github/instructions/a11y.instructions.md`
- `AGENTS.md` - full project rulebook shared across AI agents.Same three files, two completely different loading mechanisms, zero duplicated content. When a WCAG rule changes, one edit reaches both tools.
Collapse the top rulebook too. The honest counterweight: in my repo CLAUDE.md, AGENTS.md and copilot-instructions.md still restate the same core rules (components under 300 lines, next/image only, isLoading on every store, Loader2 with a label, never next/dynamic with ssr: false), and three copies of one rule will drift. The endgame is one rulebook and two pointers:
<!-- CLAUDE.md -->
# Project rules
@AGENTS.md
<!-- .github/copilot-instructions.md -->
# Project rules
See AGENTS.md for the full project rulebook. Follow it exactly.If you are setting this up from scratch today, start with AGENTS.md as the single source of truth and keep the other two thin from day one.
Keep the always-loaded layer small
Context is a budget, and skills are the mechanism for spending it only when needed. Here is what the split looks like in a real setup, in words:
Layer | Words | Loaded |
|---|---|---|
Entry files ( | 4,498 | every session |
Skills, instructions, prompts, agents | 11,744 | only when relevant |
Roughly 70% of the configuration is never in context until something needs it. The accessibility standard alone is 3,987 words of WCAG 2.2 criteria: invaluable while building a form, pure noise while fixing a webhook payload. So it lives in .github/instructions/a11y.instructions.md and gets pulled in by glob, not pasted into CLAUDE.md.
This is also the real argument against the 200-skill repo. Even if all 200 had been registered, every session would have opened with a wall of conflicting instructions and the model would have had to guess which mattered. Whatever sits in front of the model every session has to stay small, or it stops being read: the same lesson as the 407-line work log in Claude Code + Obsidian without the flat log.
Two consequences for your setup. Put only rules that apply to every task in the entry files, and everything domain-specific behind a skill, an instruction file or a glob. And delete skills the moment they stop being true: a stale skill is worse than no skill, because the model follows it with full confidence, and every path a skill mentions is a promise that the path exists.
What about rules that only apply for a while?
Sometimes a project enters a phase where the normal rules do not apply: a migration, a redesign, a hardening sprint. The usual fix is to edit rules in place and hope to remember to change them back. A better one is a temporary block at the top of CLAUDE.md with two things nothing else in the file has, an explicit precedence declaration and an expiry condition:
## ACTIVE: UI Redesign Implementation Contract (temporary)
This section governs the `redesign/ui` branch. Where it conflicts
with any rule below, this section wins. Delete this section once
the redesign ships.
**Scope: visual layer only.** A valid edit changes what the user
sees, never what the code fetches, stores, decides, or where it
navigates.
### Frozen (never touch)
...Two sentences do all the work. "Where it conflicts with any rule below, this section wins" solves the priority problem skills cannot express on their own. "Delete this section once the redesign ships" means the block carries its own removal instruction, so it does not quietly become permanent. Ordering your rules is not enough. State the precedence, and state the expiry.
Audit your setup in ten minutes
A broken AI config fails silently. It does not throw; the model just quietly does something slightly wrong, and you blame the model. That is why the setup needs a scheduled check, and why the check has to be mechanical. Four checks cover most of the rot, and every one of them found something in my own repo before this was published.
Check 1: is every skill on disk registered everywhere?
# every skill folder on disk
ls -1 .github/skills/
# every skill mentioned in your entry files
grep -o 'skills/[a-z-]*' CLAUDE.md AGENTS.md \
.github/copilot-instructions.md | sort -uThe two lists must match for every entry file. What it catches: a skill present in two registries and missing from the third, which means one tool is running from a different catalogue than the other two.
Check 2: does every path a skill mentions still exist? Skills go stale in one specific way: they name files, and files move.
# real paths
grep -ohE 'src/[a-zA-Z0-9/._-]+' .github/skills/*/SKILL.md \
.github/instructions/*.md CLAUDE.md AGENTS.md \
| sort -u | while read -r p; do
[ -e "$p" ] || echo "MISSING: $p"
done
# alias paths - do not skip this half, it is where the rot hides
grep -ohE '@/[a-zA-Z0-9/._-]+' .github/skills/*/SKILL.md \
.github/instructions/*.md .github/agents/*.md \
CLAUDE.md AGENTS.md .github/copilot-instructions.md \
| sed 's/[.,`)]*$//' | sort -u | while read -r p; do
real="src/${p#@/}"
[ -e "$real" ] || [ -e "$real.ts" ] || [ -e "$real.tsx" ] \
|| echo "MISSING: $p"
doneRun both halves. The first grep only sees real src/ paths; if your rules use the @/ alias, the second half is where the rot hides. Expect false positives: illustrative paths inside code samples show up as missing. Read the list, do not just count it. What it catches: a skill sending the model to src/components/language-toggle.tsx when the file has lived at src/components/animated/language-toggle.tsx for months, and a rule that says "always use the utilities from @/lib/utils/scroll" for a module that was never written.
That second case has a sting. A vaguer version of the same rule with no path at all, "use a shared scroll utility", sounds safer. It is worse. A wrong path fails loudly the moment the model tries to open it. A vague pointer just makes the model invent something and move on, and nobody notices until code review.
Check 3: does the skill still describe this project? Read the examples in every skill against the current codebase: locale files, folder names, component names. Nothing breaks when they drift, which is exactly why they drift.
Check 4: is the same fact written down twice? Any string that appears in two files will eventually appear differently in two files. Skill descriptions are the usual offender: they live in the SKILL.md frontmatter and again in the Copilot <skills> block, and registries need them for routing, so the duplication is hard to avoid. Put it on the list of things you deliberately keep in sync rather than things you forgot were duplicated.
The audit, condensed:
- Every
SKILL.mdis named exactlySKILL.md - Folder name, frontmatter name and registry entry are identical strings
- Every skill on disk is registered in every entry file, not just your favourite one
- Every registry entry points at a path that exists
- Every path mentioned inside a skill still exists
- No two skills could match the same user request
- Descriptions contain the phrases users actually type
- No skill mentions a vendor name
- The always-loaded layer is small, and the heavy files load on demand
Your AI is not lazy and it is not stupid. It is reading exactly what you gave it, and nothing you did not. Open your repo now, list the skill folders, grep the entry files. Whatever disagrees between those two lists is the part your AI has been quietly working around.
A skill nobody points at is just a text file.
Ogtay Iskandarov
Designer and full-stack developer running klauzzdcode, a one-person studio in Baku. Freelance since 2023, I ship products from Figma to deploy and write down what survives contact with production.