Copilot custom instructions are, for me, the difference between “Copilot guesses” and “Copilot knows our repo”. Many teams create a .github/copilot-instructions.md at some point, put twenty rules in it and then wonder why the review still nitpicks formatting and the agent writes code in the CDK stack as if it were a Lambda handler. The reason is almost always the same: everything sits in one file, and everything applies everywhere.
Since Copilot also reads path-specific instructions, you can do better. You place small files under .github/instructions/ and use glob patterns to say which files they apply to. In this article I set this up step by step for a fictional TypeScript repo of a bike rental, including a small check script that finds broken patterns before they cost you a review.
In short
With files like
.github/instructions/infra.instructions.mdand anapplyTopattern, Copilot only gets the rules that match the changed file. WithexcludeAgentyou separate rules for code review from rules for the agent. The biggest pitfalls are glob patterns that silently match nothing and the fact that Copilot reads the instructions from the pull request branch.
What you will learn in this article
- Which kinds of Copilot instructions exist and which tools read them
- How to write path-specific files with applyTo and excludeAgent
- A complete set of instructions for a TypeScript repo with CDK
- How a small script finds wrong glob patterns automatically
- Which mistakes teams make most often with instruction files
What are Copilot custom instructions?
Copilot custom instructions are short rules in natural language that you store as Markdown files in the repository. Copilot attaches the custom instructions to its requests automatically. So you don’t have to repeat them in every chat, and code review knows them without anyone having to remember. Inside a repository, GitHub distinguishes three kinds:
- Repository-wide:
.github/copilot-instructions.mdalways applies, no matter which file is involved. - Path-specific: any number of
NAME.instructions.mdfiles under.github/instructions/, including subfolders. They only kick in when theirapplyTopattern matches the file. - Agent instructions:
AGENTS.mdanywhere in the repo. Alternatively, GitHub accepts a singleCLAUDE.mdorGEMINI.md, but only in the root directory.
On top of that there are personal instructions and organization-wide guidance. The order matters: personal instructions have the highest priority, then the repository, and the organization comes last. Copilot still receives all matching sets at the same time. Priority does not mean “the others get dropped”. That’s why GitHub explicitly advises against contradictory instructions.
Personal, repository and organization instructions reach Copilot together.
One more term, because it will show up constantly: a glob pattern is a wildcard expression for file paths. * stands for any characters inside one folder, ** for any number of folder levels. So infra/**/*.ts matches every TypeScript file anywhere under infra/.
Which files work where?
This is where it gets confusing, because not every Copilot surface reads all Copilot custom instructions. GitHub’s official support overview looks like this for the most important environments (as of November 2026):
| Environment | Repository-wide | Path-specific | AGENTS.md & co. |
|---|---|---|---|
| GitHub.com: Chat | yes | no | no |
| GitHub.com: code review | yes | yes | yes (plus REVIEW.md) |
| GitHub.com: cloud agent | yes | yes | yes |
| VS Code: Chat | yes | yes | AGENTS.md only |
| VS Code: code review | yes | no | no |
| JetBrains: Chat | yes | yes | no |
| Copilot CLI | yes | yes | yes |
In practice this means, for example: path-specific rules work in code review on GitHub.com and for the agent, but not in Chat on GitHub.com. If you test there whether your CDK rules apply, you draw the wrong conclusion. Also, VS Code Chat only reads AGENTS.md, not CLAUDE.md. Teams that use Claude Code and Copilot side by side therefore get the widest coverage with AGENTS.md.
How path-specific Copilot custom instructions are structured
Path-specific Copilot custom instructions consist of a frontmatter block and regular Markdown. Frontmatter is the small YAML header between two lines with ---. It holds at most two keys that GitHub evaluates. After that come the actual rules.
What applyTo controls
applyTo is required and contains one or more glob patterns, separated by commas. If the pattern matches the file Copilot is working on, or a file changed by a pull request, Copilot gets this file in addition to the repository-wide instructions.
--- applyTo: "**/*.ts,**/*.tsx" --- # TypeScript and React - No `any`. Glob patterns that often miss
The docs list a few examples worth remembering, because they explain mistakes nobody notices otherwise. A pattern without ** stays in exactly one folder:
-
*.tsonly matches files in the root directory, notsrc/app.ts. -
src/*.tsmatchessrc/app.ts, but notsrc/lib/app.ts. -
**/*.tsmatches every TypeScript file in the whole repo. -
**/handlers/**/*.tsmatches everyhandlersfolder at any depth.
The tricky part: a wrong pattern doesn’t produce an error. The file is simply never used. That’s exactly why I build the check script further down.
Rules only for review or only for the agent
According to the docs, the optional key excludeAgent takes two values: "code-review" or "cloud-agent". If it is missing, both code review and the cloud agent use the file. This is handy because the roles differ. The agent should know how to write tests. Code review, on the other hand, should know what to look for regarding security, instead of attaching a lecture about test names to every pull request.
🎬 How Copilot code review picks the matching instructions – this animated diagram only runs on my site: watch it on sascha-ms.de
Step by step: instructions for a TypeScript repo with CDK
The example is a made-up bike rental: Lambda handlers under services/, infrastructure with AWS CDK under infra/, tests next to the code. You can copy the files into your own repo one to one and then adapt them.
Prerequisites
- A GitHub repository and a Copilot plan with code review or the cloud agent.
- In the repository under Settings → Copilot → Code review, “Use custom instructions when reviewing pull requests” is enabled. According to the docs, this is the default.
- For the check script: Node.js 22 and npm.
Step 1: Create the structure
In the end the repo looks like this. You can choose the file names before .instructions.md freely; the suffix is required.
bike-rental/ ├── AGENTS.md ├── .github/ │ ├── copilot-instructions.md │ └── instructions/ │ ├── typescript.instructions.md │ ├── infra.instructions.md │ ├── handlers.instructions.md │ ├── tests.instructions.md │ └── review-security.instructions.md ├── infra/lib/rental-stack.ts └── services/rentals/src/handlers/ ├── create-rental.ts └── create-rental.test.ts Step 2: Keep the repository-wide file short
.github/copilot-instructions.md should only contain what truly applies to every file: what the project is about, which commands exist and two or three hard rules. Everything specific moves into separate files. GitHub itself recommends path-specific files for exactly this purpose, so the repository-wide file doesn’t get overloaded.
<!-- .github/copilot-instructions.md --> # Bike rental: guidance for Copilot This repository contains a bike rental service on AWS: Lambda handlers in TypeScript under `services/`, infrastructure with AWS CDK under `infra/`. - Node.js 22, TypeScript in strict mode, ES modules. - Build: `npm run build`, tests: `npm test`, lint: `npm run lint`. - Never put secrets, account IDs or real customer data in code or tests. - Prefer small, pure functions and keep AWS SDK calls at the edges. Step 3: TypeScript rules for all .ts files
<!-- .github/instructions/typescript.instructions.md --> --- applyTo: "**/*.ts" --- # TypeScript - No `any`. Use `unknown` and narrow the type. - Validate external input (API events, queue messages) with zod before using it. - Use named exports, no default exports. - Use async/await instead of `.then()` chains. Step 4: CDK rules for infra/ only
These rules would make no sense in a Lambda handler. With applyTo: "infra/**/*.ts", Copilot only sees them when infrastructure code is involved.
<!-- .github/instructions/infra.instructions.md --> --- applyTo: "infra/**/*.ts" --- # AWS CDK - Use L2 constructs. Use L1 (`Cfn*`) only with a comment explaining why. - Every Lambda function uses `NodejsFunction`, ARM64 and a log group with retention. - Grant permissions with `grant*` methods instead of hand-written IAM policies. - Stateful resources (tables, buckets) get `RemovalPolicy.RETAIN`. Step 5: Rules for Lambda handlers
<!-- .github/instructions/handlers.instructions.md --> --- applyTo: "services/**/src/handlers/**/*.ts" --- # Lambda handlers - Create SDK clients outside the handler so they are reused. - Log with the Powertools Logger as JSON and never log complete events. - Handlers must be idempotent: the same event may arrive twice. - Throw only for errors that are worth a retry; return everything else as a typed result. If you want to go deeper into idempotency: the idea behind the third rule is a topic of its own, which I cover in detail in the article on idempotency in AWS Lambda.
Step 6: Test rules for the agent only
With excludeAgent: "code-review", only the cloud agent gets this file. It writes tests following your conventions, and the review stays lean.
<!-- .github/instructions/tests.instructions.md --> --- applyTo: "**/*.test.ts" excludeAgent: "code-review" --- # Tests - Use vitest with describe/it. Test names describe behavior. - Mock AWS SDK calls with aws-sdk-client-mock and never call real AWS. - Put each test file next to its source file. Step 7: Security focus for review only
Conversely, this file only applies in code review. The last line is there on purpose: a review that comments on formatting burns attention that is missing for real problems.
<!-- .github/instructions/review-security.instructions.md --> --- applyTo: "infra/**/*.ts,services/**/*.ts" excludeAgent: "cloud-agent" --- # Review focus: security - Flag IAM statements with "*" in actions or resources. - Flag public S3 buckets, open security groups and missing encryption. - Flag log statements that could contain personal data (names, emails, addresses). - Do not comment on formatting. Prettier takes care of that. Step 8: AGENTS.md for commands
AGENTS.md is not a Copilot format but a convention that several AI tools read. I put in it what an agent should run before finishing. If a subfolder contains another AGENTS.md, the nearest one takes precedence according to GitHub.
# AGENTS.md ## Setup npm ci ## Before you finish npm run lint && npm test && npx cdk synth ## Conventions See .github/copilot-instructions.md and the files in .github/instructions/. Step 9: Check whether the instructions work
- Open a pull request that changes a file under
infra/and request Copilot as a reviewer. Comments aboutCfn*constructs without a justification show thatinfra.instructions.mdis applied. - Change an instruction in the same pull request as a test. Copilot reads the files from the head branch, the branch with the changes. This lets you try out new rules before they land in
main. - In VS Code, start a new chat about a file under
infra/and expand the References in the response. The matching.instructions.mdshould be listed there.
Expected result: review comments on a CDK stack revolve around IAM, encryption and constructs, comments on a handler around logging and idempotency. Formatting remarks should disappear.
Check instruction files automatically
Because a wrong glob pattern is ignored silently, I like to check Copilot custom instructions in CI. The script reads all *.instructions.md files, checks the frontmatter and tests every pattern against the real files in the repo. For matching I use picomatch. Its behavior agrees with the examples from the GitHub docs: *.ts stays in the root directory, ** goes deep.
Set up the project
{ "name": "copilot-instructions-check", "version": "1.0.0", "private": true, "type": "module", "scripts": { "check:instructions": "tsx scripts/check-instructions.ts", "test": "vitest run --dir test", "typecheck": "tsc --noEmit", "check:example": "cd example && tsx ../scripts/check-instructions.ts" }, "dependencies": { "picomatch": "^4.0.2" }, "devDependencies": { "@types/node": "^22.10.0", "@types/picomatch": "^3.0.1", "tsx": "^4.19.2", "typescript": "^5.7.2", "vitest": "^3.0.0" } } { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "noEmit": true }, "include": ["scripts", "test"] } npm install The check script
A missing applyTo or an undocumented value in excludeAgent is an error, because the file then behaves differently than intended. A pattern without matches is a warning, since the folder might simply not exist yet. The 4,000-character limit is my own rule of thumb. GitHub names no fixed limit but recommends short, self-contained instructions.
// scripts/check-instructions.ts import { readdirSync, readFileSync, existsSync } from "node:fs"; import { join, relative, sep } from "node:path"; import picomatch from "picomatch"; export type Finding = { file: string; level: "error" | "warning"; message: string }; const ALLOWED_AGENTS = ["code-review", "cloud-agent"]; const SOFT_LIMIT = 4000; // own rule of thumb, not a GitHub limit const IGNORED_DIRS = new Set([".git", "node_modules", "cdk.out", "dist", "coverage"]); // Reads the frontmatter block between the first two "---" lines. export function parseFrontmatter(text: string): Record<string, string> | null { const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text); if (!match) return null; const result: Record<string, string> = {}; for (const line of match[1].split(/\r?\n/)) { const pair = /^([A-Za-z]+):\s*(.*)$/.exec(line.trim()); if (pair) result[pair[1]] = pair[2].replace(/^["']|["']$/g, ""); } return result; } // "a/**/*.ts, b/**/*.ts" becomes ["a/**/*.ts", "b/**/*.ts"] export function splitPatterns(applyTo: string): string[] { return applyTo.split(",").map((p) => p.trim()).filter(Boolean); } export function checkInstructionFile(file: string, text: string, repoFiles: string[]): Finding[] { const findings: Finding[] = []; const fm = parseFrontmatter(text); if (!fm) { return [{ file, level: "error", message: "frontmatter block is missing" }]; } if (!fm.applyTo) { findings.push({ file, level: "error", message: "applyTo is missing" }); } else { for (const pattern of splitPatterns(fm.applyTo)) { const isMatch = picomatch(pattern, { dot: true }); const hits = repoFiles.filter((f) => isMatch(f)).length; if (hits === 0) { findings.push({ file, level: "warning", message: `pattern "${pattern}" matches no file` }); } } } if (fm.excludeAgent && !ALLOWED_AGENTS.includes(fm.excludeAgent)) { findings.push({ file, level: "error", message: `excludeAgent "${fm.excludeAgent}" is not a documented value` }); } if (text.length > SOFT_LIMIT) { findings.push({ file, level: "warning", message: `${text.length} characters, please shorten` }); } return findings; } export function listFiles(root: string, dir = root): string[] { const files: string[] = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { if (IGNORED_DIRS.has(entry.name)) continue; const full = join(dir, entry.name); if (entry.isDirectory()) files.push(...listFiles(root, full)); else files.push(relative(root, full).split(sep).join("/")); } return files; } export function checkRepository(root: string): Finding[] { const repoFiles = listFiles(root); const findings: Finding[] = []; if (!existsSync(join(root, ".github", "copilot-instructions.md"))) { findings.push({ file: ".github/copilot-instructions.md", level: "warning", message: "repository-wide file is missing" }); } const instructionFiles = repoFiles.filter( (f) => f.startsWith(".github/instructions/") && f.endsWith(".instructions.md"), ); for (const file of instructionFiles) { findings.push(...checkInstructionFile(file, readFileSync(join(root, file), "utf8"), repoFiles)); } return findings; } if (import.meta.url === `file://${process.argv[1]}`) { const findings = checkRepository(process.cwd()); for (const f of findings) console.log(`${f.level.toUpperCase()} ${f.file}: ${f.message}`); const errors = findings.filter((f) => f.level === "error").length; console.log(`${findings.length} finding(s), ${errors} error(s)`); process.exit(errors > 0 ? 1 : 0); } Tests
// test/check-instructions.test.ts import { describe, expect, it } from "vitest"; import { checkInstructionFile, parseFrontmatter, splitPatterns } from "../scripts/check-instructions.js"; const repoFiles = [ "infra/lib/rental-stack.ts", "services/rentals/src/handlers/create-rental.ts", "services/rentals/src/handlers/create-rental.test.ts", "README.md", ]; describe("parseFrontmatter", () => { it("reads applyTo and excludeAgent", () => { const fm = parseFrontmatter('---\napplyTo: "infra/**/*.ts"\nexcludeAgent: "code-review"\n---\n# CDK'); expect(fm).toEqual({ applyTo: "infra/**/*.ts", excludeAgent: "code-review" }); }); it("returns null without frontmatter", () => { expect(parseFrontmatter("# Only text")).toBeNull(); }); }); describe("splitPatterns", () => { it("splits comma-separated globs", () => { expect(splitPatterns("**/*.ts, **/*.tsx")).toEqual(["**/*.ts", "**/*.tsx"]); }); }); describe("checkInstructionFile", () => { it("accepts a valid file", () => { const text = '---\napplyTo: "services/**/src/handlers/**/*.ts"\n---\nUse Powertools Logger.Source:https://dev.to/sascha-ms/path-specific-copilot-custom-instructions-a-guide-54lgRelated posts
OpenCode desktop app: official app, web UI and GUI options
同一次导链改动,macOS job 上次 15 分钟拿不到 runner、这次 8 秒就拿到了
Guaranteed Deadlines for Safety-Critical RTOS: Scheduling, WCET, and Validation
Your AI agent wrote the tests too
Make risograph films and posters with Claude Code and the riso-windowseat kit

