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.md and an applyTo pattern, Copilot only gets the rules that match the changed file. With excludeAgent you 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.md always applies, no matter which file is involved.
  • Path-specific: any number of NAME.instructions.md files under .github/instructions/, including subfolders. They only kick in when their applyTo pattern matches the file.
  • Agent instructions: AGENTS.md anywhere in the repo. Alternatively, GitHub accepts a single CLAUDE.md or GEMINI.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.

Diagram: personal, repository and organization instructions flow to Copilot together

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:

  • *.ts only matches files in the root directory, not src/app.ts.
  • src/*.ts matches src/app.ts, but not src/lib/app.ts.
  • **/*.ts matches every TypeScript file in the whole repo.
  • **/handlers/**/*.ts matches every handlers folder 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

  1. Open a pull request that changes a file under infra/ and request Copilot as a reviewer. Comments about Cfn* constructs without a justification show that infra.instructions.md is applied.
  2. 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.
  3. In VS Code, start a new chat about a file under infra/ and expand the References in the response. The matching .instructions.md should 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-54lg

Related posts