GitHub Copilot's custom instructions feature lets you give the AI context about your project. For a small repository, a single copilot-instructions.md file with build commands and a few conventions is enough. But what happens when your codebase has 500,000+ lines of code, 19 projects, four target frameworks, two code generation pipelines, and domain-specific patterns that span everything from zero-allocation buffer management to ECMA-262 regex translation?
We hit that wall with Corvus.Text.Json. A flat instructions file couldn't capture the depth of knowledge an AI assistant needs to work effectively across the whole codebase. We ended up building something more structured: a library of 20 modular skill files alongside a main instructions document. In total, that gave us 2,438 lines of AI-consumable context.
This post walks through what we built, the design principles behind it, and how you can apply the same approach to your own projects.
If you're interested in the broader landscape of AI-assisted coding tools, my colleague Mike Evans-Larah wrote an excellent overview: AI-assisted coding is four decisions, not one. This post goes deep on one of those decisions - the "Capabilities" layer, specifically how you structure the instructions and knowledge that shape what your AI assistant can do.
The building blocks
Before diving into what we built, here's a quick summary of what GitHub gives you to work with. There are three official instruction types:
| Type | Location | Scope |
|---|---|---|
| Repository-wide | .github/copilot-instructions.md |
Every request in the repo |
| Path-specific | .github/instructions/*.instructions.md |
Requests involving files matching a glob pattern |
| Agent instructions | AGENTS.md (anywhere in the repo) |
Nearest file in directory tree takes precedence |
Path-specific instructions need an applyTo field in their YAML frontmatter to specify which files they apply to:
---
applyTo: "**/*.cs"
---
Use 4-space indentation and file-scoped namespaces.
Separately from these three instruction types, VS Code and Copilot CLI support skills. These are markdown files in .github/skills/<name>/SKILL.md that the assistant can discover and invoke by name. Each skill has a YAML frontmatter block with a name and description, and the assistant decides which skills are relevant to the current task. Skills are not the same as repository custom instructions. They're a distinct mechanism, and not all Copilot surfaces support them equally.
Skills are the key building block for what we've done. They let you decompose a large codebase's knowledge into modular, independently-addressable units.
What we built
Our Corvus.Text.Json repository has this structure:
.github/
├── copilot-instructions.md (383 lines - the essentials)
└── skills/
├── corvus-analyzers/SKILL.md (68 lines)
├── corvus-benchmarks/SKILL.md (89 lines)
├── corvus-bowtie-testing/SKILL.md (121 lines)
├── corvus-buffer-and-pooling/SKILL.md (163 lines)
├── corvus-build-and-test/SKILL.md (98 lines)
├── corvus-codegen/SKILL.md (111 lines)
├── corvus-docs-website/SKILL.md (87 lines)
├── corvus-ecma-regex/SKILL.md (74 lines)
├── corvus-keywords-and-validation/SKILL.md (119 lines)
├── corvus-low-alloc-data-structures/SKILL.md (190 lines)
├── corvus-mutable-documents/SKILL.md (89 lines)
├── corvus-numeric-types/SKILL.md (74 lines)
├── corvus-parsed-documents-and-memory/SKILL.md (99 lines)
├── corvus-query-languages/SKILL.md (138 lines)
├── corvus-standalone-evaluator/SKILL.md (108 lines)
├── corvus-test-suite-regeneration/SKILL.md (65 lines)
├── corvus-v4-migration/SKILL.md (103 lines)
├── corvus-yaml/SKILL.md (71 lines)
├── ref-struct-delegates/SKILL.md (112 lines)
└── reviewing-skills/SKILL.md (76 lines)
The main copilot-instructions.md covers the essentials that apply to every task: build commands, project overview, architecture overview, key conventions, and the stackalloc/ArrayPool rent pattern. It's what every interaction needs.
The 20 skill files cover specific concerns. When a developer asks the assistant to help with mutable documents, the assistant loads the corvus-mutable-documents skill. When they're debugging benchmarks, it loads corvus-benchmarks. The assistant only pays attention to the knowledge relevant to the current task.
What a skill looks like
Here's our corvus-mutable-documents skill, condensed to show the structure (the full file is on GitHub):
---
name: corvus-mutable-documents
description: >
Create and manipulate mutable JSON documents using JsonWorkspace,
JsonDocumentBuilder, and the builder pattern. Covers workspace creation
(rented vs unrented), the canonical parse-build-mutate-serialize pattern,
deep property mutation, array operations, cloning, and RFC 6902 JSON Patch
via PatchBuilder. USE FOR: writing code that creates or modifies JSON,
understanding the V5 mutation model, implementing JSON Patch operations,
working with JsonWorkspace. DO NOT USE FOR: read-only parsing
(use corvus-parsed-documents-and-memory), V4 mutation patterns
(use corvus-v4-migration).
---
# Mutable Documents
## JsonWorkspace
A scoped container for pooled memory used during mutable JSON operations.
```csharp
// Preferred - rents from thread-local cache
using JsonWorkspace workspace = JsonWorkspace.Create();
// When you need explicit lifetime control
JsonWorkspace workspace = JsonWorkspace.CreateUnrented();
```
Always use a `using` block. `Dispose()` returns the workspace to the
thread-local cache (rented) or disposes all child documents (unrented).
## Canonical Mutation Pattern
```csharp
using JsonWorkspace workspace = JsonWorkspace.Create();
using ParsedJsonDocument<JsonElement> sourceDoc =
ParsedJsonDocument<JsonElement>.Parse(json);
// Convert immutable → mutable
using JsonDocumentBuilder<JsonElement.Mutable> builder =
sourceDoc.RootElement.CreateBuilder(workspace);
JsonElement.Mutable root = builder.RootElement;
// Mutate
root.SetProperty("name"u8, "new value"u8);
root.RemoveProperty("oldProp"u8);
// Serialize
string result = root.ToString();
```
## Multiple Builders Per Workspace
## Cloning
## JSON Patch (RFC 6902)
## Common Pitfalls
- Forgetting to dispose workspace/builder
- Using stale element references after mutation
- Not disposing BeginPatch() via GetPatchAndDispose()
## Cross-References
- For read-only parsing, see `corvus-parsed-documents-and-memory`
- For dispose analyzers (CTJ004-006), see `corvus-analyzers`
- For V4→V5 mutation model changes, see `corvus-v4-migration`
Every section is self-contained, with copy-paste-ready code blocks, a pitfalls section that catches common mistakes before they happen, and cross-references that tell the assistant where to look next if the task spans multiple concerns.
Design principles
These principles emerged through months of iterating on the instructions as we used them. They weren't designed upfront. They're patterns we noticed working well and then applied consistently.
Modular by concern
Each skill covers exactly one concern. corvus-buffer-and-pooling covers the stackalloc/ArrayPool pattern. corvus-ecma-regex covers regex translation. corvus-mutable-documents covers the mutation model. No skill tries to do everything.
This matters because AI context windows are finite. When the assistant loads a skill, it gets 55–190 lines of focused, relevant knowledge. It does not get a 2,300-line wall of text where the relevant paragraph is buried somewhere in the middle.
Scope boundaries
Every skill has explicit "USE FOR" and "DO NOT USE FOR" fields in its YAML description:
description: >
Build, test, and run the Corvus.JsonSchema solution correctly. ...
USE FOR: building the solution, running tests, diagnosing test failures,
understanding TFM targeting, finding the right test project for a feature area.
DO NOT USE FOR: benchmark execution (use corvus-benchmarks), code generation
(use corvus-codegen), test suite regeneration (use corvus-test-suite-regeneration).
The "DO NOT USE FOR" entries are arguably more important than the "USE FOR" entries. They prevent the assistant from hallucinating advice about benchmarks when it's loaded the build-and-test skill. And each redirect points to the correct skill by name, so the assistant can load the right one instead.
Cross-reference network
Skills cross-reference each other, forming a network. Some are hub skills referenced by many others:
corvus-build-and-test- referenced from benchmarks, Bowtie testing, codegen, docs, query languagescorvus-parsed-documents-and-memory- referenced from buffers, low-alloc structures, mutable documents, YAML, numericscorvus-keywords-and-validation- referenced from codegen, standalone evaluator, regex, numerics, test suite regeneration
Other skills are leaves, focused on narrow concerns. The network prevents duplication: the buffer pooling pattern is defined once in corvus-buffer-and-pooling and referenced from everywhere that uses it. Some pairs reference each other bidirectionally (for instance, corvus-parsed-documents-and-memory and corvus-mutable-documents), which is fine - they're complementary concerns that often come up together.
Code-first examples
Every skill includes copy-paste-ready code blocks with real syntax and real file paths. They are not pseudocode or abstract descriptions, but actual commands that work in the repository as-is:
# Run all tests (standard)
dotnet test Corvus.Text.Json.Test.slnx \
--filter "category!=failing&category!=outerloop"
# Run a single test class
dotnet test Corvus.Text.Json.Test.slnx \
--filter "FullyQualifiedName~ParsedJsonDocumentTests&category!=failing&category!=outerloop"
This is critical. An assistant that generates a test command missing the mandatory category filters will watch the build fail for minutes before figuring out what went wrong. The skill gives it the exact command that works.
Truth-seeking instructions
The codebase has areas where the surface API has changed over time, and documentation can drift. Rather than trying to keep instructions perfectly synchronised, we tell the assistant to verify:
IMPORTANT: When writing documentation, examples, or instructions that reference Source Generator attributes or CLI tool options, always verify the exact parameter names and types by checking the source code.
And in the codegen skill:
IMPORTANT: Never invent option names. Verify against
GenerateCommand.cs.
This is a different philosophy from "document everything perfectly." Instead, we tell the assistant: "here's approximately what exists, and here's where to check the ground truth." It's more resilient to drift than trying to maintain a perfect mirror of every API surface.
Canonical patterns
For operations that have multiple valid approaches, we document the one way we want the assistant to use. Our mutable documents skill shows a single canonical parse→build→mutate→serialize pattern. The buffer-and-pooling skill shows exactly one rent/return pattern with try/finally.
This reduces decision fatigue. Without a canonical pattern, the assistant might generate three different approaches across three files, all technically correct but inconsistent. With one, every generated code block follows the same shape.
Configuration tables
We use tables to centralise reference data:
| Solution | Purpose |
|---|---|
Corvus.Text.Json.slnx |
Main V5 solution - libraries + tests (use for dotnet build) |
Corvus.Text.Json.Test.slnx |
Tests only (use for dotnet test) |
Corvus.Text.Json.Benchmarks.slnx |
Benchmark projects only |
Tables are efficient in context windows and easy to scan. They also force you to be precise. You can't hand-wave in a table cell.
How instructions evolve
These instructions aren't write-once. They evolve as we work with the codebase, through three feedback loops.
The memory system
Copilot CLI stores facts it learns during sessions. When it discovers something the hard way, such as "always use FullyQualifiedName not ClassName in test filters", it stores that as a memory. Next session, it checks those memories before acting. Over time, important facts bubble up into the instructions or skills themselves.
The verification loop
The most effective feedback loop is simple: use the assistant and watch what it does, then fix the instructions when it gets something wrong.
When an assistant generates a test command without mandatory category filters, that's a sign the build-and-test skill needs a more prominent warning. When it uses ParseValue instead of Parse in a code example, that's a convention that needs to be stated explicitly. When it tries to add a file to a project without an explicit <Compile Include> entry, that tells you the "no glob includes" convention isn't landing.
Each failure is a signal. The fix isn't just to correct the output. It's to update the instruction or skill so the same mistake doesn't happen again. Over time, the instructions accumulate the hard-won knowledge of what actually trips the assistant up, rather than what you thought might be important in the abstract.
We also run automated checks where we can. A code sample catalog system tracks every code block in our documentation and skill files, running as part of CI to catch examples that stop compiling. But the human feedback loop matters more. It is the part where we notice patterns of failure and promote fixes into instructions.
Discovery through use
The best conventions aren't designed upfront. They're discovered through bugs. We originally didn't have a convention about Parse vs ParseValue. Then we found the same confusion appearing in documentation examples, in generated code samples, and in AI-assisted edits. After fixing it in several places, we wrote it into the instructions as a convention: "prefer Parse + using in examples." The instruction emerged from the pattern of fixes, not from a design meeting.
Making review a habit
After enough cycles of "finish work, discover a skill has drifted, fix it," we noticed the review step itself needed codifying. So we wrote a skill for it: reviewing-skills documents when to review (after API changes, build infrastructure changes, architecture changes), what to check (code examples against real signatures, numeric values against source constants, scope boundaries, cross-references), and how to run a full audit across all skills using parallel agents.
This is the system becoming self-maintaining. When the assistant finishes a piece of work that changes a public API, it can load the reviewing-skills skill and check whether any other skills reference the old signature. The review checklist catches the kinds of drift we kept finding manually. That includes a constructor that gained a parameter, a file path that moved, or a pipeline step count that changed. Turning that checklist into a skill means the assistant applies it consistently, rather than relying on us to remember.
When an instruction fails to trigger
Sometimes the instruction exists but still doesn't fire. We had a CI gate that checked whether our documentation catalog was in sync. When it failed, the assistant regenerated the catalog and committed. It did so without following the documented verification workflow that the gate was designed to enforce. The instruction was there; it just didn't activate at the right moment.
The root cause was structural. The verification workflow was framed as "when you edit a documentation file, do these steps". But the assistant was in "fix the CI build" mode, not "I edited documentation files" mode. The trigger was attached to the wrong entry point. The fix was to promote the check to a standalone pre-commit gate with its own heading, anchored to the commit point rather than to individual file edits. Commits are where all changes converge regardless of how they were made, so they're the natural place to enforce verification.
This kind of root-cause analysis matters enough that we added it to the reviewing-skills skill itself. When a skill or instruction exists but fails to trigger, we now ask: was it framed for the wrong trigger? Was it buried inside a larger workflow? Did it only describe the happy path? Did multi-turn conversation obscure the activation point? Each question points to a different structural fix. Each fix makes the instruction more resilient to the specific failure mode that exposed it.
Applying this to your codebase
You don't need 20 skills to start. Here's a progression that works:
Start with copilot-instructions.md. Cover the basics that every task needs:
- How to build the project (exact commands, not "run the build script")
- How to run tests (with any mandatory filters or exclusions)
- Key architectural patterns (where code lives, how it's organised)
- Conventions that are hard to infer from code alone (naming, style, patterns)
Identify natural skill boundaries. Look for areas where:
- You find yourself re-explaining the same thing to the assistant
- Different tasks need deep knowledge of different subsystems
- The assistant makes the same mistake repeatedly in a specific area
Each of these is a candidate for a skill file.
Add scope boundaries from the start. Every skill should have "USE FOR" and "DO NOT USE FOR" in its description. This prevents the assistant from applying the wrong knowledge to a task.
Let instructions evolve. Don't try to document everything upfront. Write instructions for the areas where the assistant struggles, then update them when bugs appear and prune information that turns out not to matter.
Prompts to get started
If you want to use an AI assistant to help bootstrap your own instructions, here are some prompts:
Explore this codebase and write a
.github/copilot-instructions.mdthat covers: the build system (exact commands that work, with any prerequisites), the test suite (how to run it, any mandatory filters), the project structure (where source, tests, and config live), and any conventions that aren't obvious from reading a single file.
Look at the areas of this codebase where different concerns require deep, specialised knowledge. Suggest a set of skill files that would help an AI assistant work effectively in each area. For each, explain what it would cover and why it deserves its own file rather than a section in the main instructions.
Review my existing
.github/copilot-instructions.mdand identify: instructions that are too vague to be actionable, areas where the assistant would need to search the codebase to fill in gaps, and conventions that are mentioned but not demonstrated with concrete examples.
And when things go wrong despite having instructions in place:
You just [describe what happened - e.g., "regenerated the catalog without verifying the code blocks"]. There is an instruction that should have prevented this. Root-cause why it didn't trigger: was it framed for the wrong situation? Was it buried inside a longer workflow? Did it only cover the happy path? Propose a specific change to the instruction that would have caught this.
Follow the instructions in our [skill/instruction file] and review our skills and instructions. Tell me what you find.
Look at the last three mistakes you made in this session. For each one, check whether an instruction or skill exists that should have prevented it. If it exists, explain why it failed to activate. If it doesn't exist, draft one.
The instructions will improve fastest through actual use. When the assistant struggles, fix the root cause in the instructions and try again.
One important caveat: instruction files are committed to your repository and visible to anyone with access. Never put secrets, API keys, internal URLs, or other sensitive information in instruction or skill files. If the assistant needs access to something sensitive, use environment variables or MCP server integrations instead.
The numbers
For Corvus.Text.Json, we ended up with:
| Count | Lines | |
|---|---|---|
| Main instructions | 1 | 383 |
| Skill files | 20 | 2,055 |
| Total | 21 | 2,438 |
That's a significant investment. But each skill was written to solve a real problem. That might be a pattern the assistant kept getting wrong, a subsystem it couldn't navigate, or a convention it couldn't infer. None of them exist for the sake of completeness; every one earns its keep through daily use.
The result is an assistant that can build the project on the first try, run tests with the right filters, generate code that follows our conventions, navigate the code generation pipeline, and work with our mutable document model. It can do all of this without lengthy back-and-forth to establish context. That's a meaningful productivity gain when you're working in a codebase this size.
