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.';
expect(checkInstructionFile("handlers.instructions.md", text, repoFiles)).toEqual([]);
});
it("warns when a pattern matches no file", () => {
const text = '---\napplyTo: "*.ts"\n---\nRoot only.';
const [finding] = checkInstructionFile("root.instructions.md", text, repoFiles);
expect(finding.level).toBe("warning");
expect(finding.message).toMatch(/matches no file/);
});
it("reports a missing applyTo as an error", () => {
const text = "---\ndescription: Tests\n---\nUse vitest.";
const [finding] = checkInstructionFile("tests.instructions.md", text, repoFiles);
expect(finding).toMatchObject({ level: "error", message: "applyTo is missing" });
});
it("rejects unknown excludeAgent values", () => {
const text = '---\napplyTo: "**/*.ts"\nexcludeAgent: "chat"\n---\nText';
const findings = checkInstructionFile("x.instructions.md", text, repoFiles);
expect(findings.some((f) => f.message.includes("not a documented value"))).toBe(true);
});
});
Run it and expected result
npm test
npm run check:example
The tests pass with seven green cases, and the example repo reports 0 finding(s), 0 error(s). A broken file is more interesting. With applyTo: "src/*.ts" and excludeAgent: "chat", the output looks like this:
WARNING .github/instructions/broken.instructions.md: pattern "src/*.ts" matches no file
ERROR .github/instructions/broken.instructions.md: excludeAgent "chat" is not a documented value
2 finding(s), 1 error(s)
Run it in CI
In GitHub Actions, a small job that runs on changes under .github/instructions/ is enough. Exit code 1 on errors makes the check fail.
# .github/workflows/instructions.yml
name: Check Copilot instructions
on:
pull_request:
paths:
- ".github/instructions/**"
- ".github/copilot-instructions.md"
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm run check:instructions
What belongs in the files and what doesn’t?
The best rule in Copilot custom instructions is one that a new colleague would phrase the same way after a week on the team. Good candidates are a project overview, commands, libraries in use and rules you can’t see in the code, for example “handlers must be idempotent”. GitHub also lists things that tend to work poorly: references to external resources, requests about the tone and style of answers, and requests about the level of detail. “Answer like a friendly colleague” therefore doesn’t belong in there.
I’d add two rules of my own. First: don’t write anything into the files that a linter or formatter can enforce. That’s cheaper, deterministic and doesn’t start a debate. Second: instruction files are code. They go through review, get an owner and are deleted when they are no longer true.
Common mistakes with Copilot custom instructions
Patterns that match nothing
applyTo: "*.ts" sounds like “all TypeScript files” but only means the root directory. The check script catches this.
Testing in the wrong tool
According to the support overview, path-specific files don’t apply in Chat on GitHub.com, nor in code review in VS Code and Visual Studio. So test where the rules are supposed to work: in the pull request or in your IDE’s chat.
Contradictions between the levels
If the organization says “tabs” and the repo says “two spaces”, Copilot gets both. The result is a matter of luck. Clean up such conflicts instead of relying on priority.
Mistaking instructions for a security boundary
Since Copilot reads the instructions from the pull request branch, the same pull request can weaken the review rules that are supposed to check it. That’s handy for experimenting but a reason for caution when it comes to security. I would add .github/instructions/ and AGENTS.md to CODEOWNERS and treat the AI review as an additional opinion, never as the only approval. How I set up such rules in a team is described in AI code review: team rules for Copilot approvals.
CLAUDE.md in a subfolder
GitHub accepts CLAUDE.md and GEMINI.md only as a single file in the root directory. For per-folder instructions, AGENTS.md is the right way.
My take
I consider path-specific Copilot custom instructions one of the most underrated Copilot features. They cost nothing, take an hour to set up and make the review noticeably calmer, because Copilot no longer applies every rule to every file. Especially in serverless repos, where infrastructure, handlers and tests live side by side, the model fits very well.
I am skeptical about two things. On the one hand, the zoo of formats keeps growing: copilot-instructions.md, *.instructions.md, AGENTS.md, CLAUDE.md, REVIEW.md, plus skills. If you don’t organize this deliberately, you’ll have contradictory rules in five places a year from now. On the other hand, custom instructions are wishes, not guarantees. GitHub itself says that Copilot won’t find every problem in a pull request. If you want to describe recurring workflows rather than rules, agent skills are often the better tool.
Custom instructions steer Copilot in the editor and in reviews. If you want an agent to react to new issues automatically, take a look at GitHub Agentic Workflows for issue triage.
FAQ
Where do I put Copilot custom instructions?
Repository-wide rules go into .github/copilot-instructions.md. Path-specific rules live as NAME.instructions.md under .github/instructions/, subfolders included.
What happens when several instruction files match?
Copilot gets all matching files together: the repository-wide one plus every path-specific file whose pattern matches. That’s why the files shouldn’t contradict each other.
Does Copilot read CLAUDE.md?
On GitHub.com in the cloud agent and in code review, yes, but only a single file in the root directory. According to the support overview, Chat in VS Code only reads AGENTS.md.
Is there a length limit for instructions?
The current docs name no fixed limit. GitHub recommends short, self-contained statements. I keep every file at a few hundred words.
If your team uses Claude Code, Codex or Gemini CLI alongside Copilot, a shared file for all agents pays off. I show the AGENTS.md setup, including the trap where Claude Code silently ignores it, in a separate article.
Your next step
Take your existing copilot-instructions.md and mark every rule that only applies to part of the repo. Move those rules into separate .instructions.md files with a matching applyTo, run the check script and open a small pull request to compare the review. Which rule had the biggest effect for you, and which one did Copilot stubbornly ignore? Let me know in the comments.
All code from this article, ready to run: smirowstanitzok/sascha-ms-examples
Sources
- Adding repository custom instructions for GitHub Copilot (GitHub Docs)
- Support for different types of custom instructions (GitHub Docs)
- About GitHub Copilot code review (GitHub Docs)
- About customizing GitHub Copilot responses (GitHub Docs)
- Code reviewer: custom instructions example (GitHub Docs)
- Use custom instructions in VS Code (Visual Studio Code Docs)
- picomatch (GitHub)
This post was created with AI assistance.
👉 Next step: New here? My start page collects the best articles, tools and learning paths in one place.
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)