Instruction Files — Turning AI Coding Assistants Into Team-Aware Engineers

READER BEWARE: THE FOLLOWING WRITTEN ENTIRELY BY AI WITHOUT HUMAN EDITING.

Introduction

AI coding assistants are trained on billions of lines of public code. That breadth is their superpower and their limitation. The model has never seen your internal/auth package, doesn’t know that your team migrated off unittest to pytest three years ago, and has no idea that all HTTP services in your org must run on FastAPI with a specific dependency injection pattern.

Without that context, every suggestion the assistant makes is a statistical guess based on what “most” codebases do — not what your codebase does. It will use requests when you’ve standardised on httpx, suggest class-based views when your team prefers function-based views with explicit dependency injection, and write perfectly idiomatic Python that violates five of your architecture’s non-negotiables.

Instruction files are the fix. They are Markdown documents you commit to your repository that the AI reads automatically before every interaction. Think of them as your team’s standing orders — the things every new engineer is expected to absorb during onboarding, now encoded in a format that the AI assistant can consume directly. This post explains how to write them, where to put them, and what the best open-source projects in the world can teach us about which conventions are worth encoding.


Where Instruction Files Live

Different tools support different paths. The most widely adopted location is:

.github/copilot-instructions.md   ← GitHub Copilot (VS Code, JetBrains, etc.)

Other tools and configurations you may encounter:

.copilot/instructions.md          ← Alternative Copilot layout
.ai/instructions.md               ← Vendor-neutral convention

For file-scoped instructions — rules that apply only when the agent is working with specific file types — VS Code’s Copilot also supports glob-pattern-based instruction files configured in .vscode/settings.json:

{
  "github.copilot.chat.codeGeneration.instructions": [
    {
      "file": ".github/copilot-instructions.md"
    },
    {
      "pattern": "**/*.test.ts",
      "file": ".github/instructions/testing-ts.md"
    },
    {
      "pattern": "db/migrations/**",
      "file": ".github/instructions/migrations.md"
    }
  ]
}

This lets you keep global conventions in the root file and specialised rules in scoped files that only load when they’re relevant — avoiding context window bloat on tasks where they don’t apply.


What Open-Source Projects Can Teach Us

Some of the largest, most carefully maintained codebases in the world have exhaustive contributor guides. These guides encode exactly the kind of conventions that belong in an instruction file. Let’s look at four of them and extract the rules most worth capturing.

Kubernetes: Architecture Boundaries and Code Organisation

The Kubernetes contributor guide and its associated docs enforce strict package boundaries. Key conventions that would translate directly into an instruction file:

  • Package naming: Packages under staging/ are logically separate modules published as independent Go modules. No code outside staging/ should import from a staging package’s internal packages.
  • API versioning: All new API fields must go through the API review process. Types are versioned (v1, v1beta1); internal types live under pkg/apis/<group>/types.go, not in versioned packages.
  • Error handling: The project uses k8s.io/apimachinery/pkg/api/errors for API-level errors. Raw fmt.Errorf is acceptable for internal logic, but API responses must always use the structured error types.

Encoded as an instruction file rule:

## Package Boundaries

Never import packages under `staging/src/` from outside the `staging/` tree.
Each `staging/` package is published as an independent Go module; treat it as
an external dependency and import the published module path instead.

## API Types

New API fields belong in the internal type file (`pkg/apis/<group>/types.go`)
and require a corresponding entry in the versioned conversion file before they
appear in a versioned API. Do not add fields directly to versioned structs.

## Error Responses

Use `k8s.io/apimachinery/pkg/api/errors` for constructing API error responses.
Do not return raw `errors.New()` or `fmt.Errorf()` from any function that
writes an HTTP response.

Django: Coding Standards and Backwards Compatibility

The Django coding style guide is a model of specificity. It goes well beyond “follow PEP 8” into Django-specific conventions:

  • Import ordering: Django uses a strict four-block import ordering: stdlib → Django modules → third-party → local app. This is enforced by isort with a Django profile, but the intent behind the rule (making dependencies explicit at a glance) is worth encoding for the agent.
  • Model field ordering: Fields, then properties, then methods. Within methods: __str__ first, then save, then get_absolute_url, then custom methods. Alphabetical ordering within each group.
  • Backwards compatibility: Any change to a public API must maintain backwards compatibility for two feature releases. Deprecation warnings must use RemovedInDjango<version>Warning.

Encoded as an instruction file rule:

## Django Import Ordering

Follow Django's four-block import convention enforced by isort with
`--profile django`. Blocks in order:
1. Python standard library
2. Django (`django.*`)
3. Third-party packages
4. Local app imports (relative)

Never mix blocks. A blank line separates each block.

## Model Layout

Within a Django model class, order members as follows:
1. Field definitions (alphabetical within type groups)
2. `Meta` inner class
3. `__str__`
4. `save` override (if any)
5. `get_absolute_url` (if any)
6. Custom methods (alphabetical)

## Backwards Compatibility

Any change to a public Django API must work without modification for users
on the two previous feature releases. If a behaviour must change, add a
`RemovedInDjango<NN>Warning` deprecation warning for one full release cycle
before removing the old behaviour.

Airbnb JavaScript Style Guide: Naming and Framework Conventions

The Airbnb JavaScript Style Guide is one of the most widely adopted JS style guides in existence, and it encodes a level of naming and structural convention that ESLint alone cannot fully enforce:

  • Naming: camelCase for variables and functions, PascalCase for constructors and React components, SCREAMING_SNAKE_CASE for module-level constants that are truly immutable, _leadingUnderscore is forbidden even for “private” properties.
  • Destructuring: Always destructure object properties when accessing more than one property from an object. Always destructure function parameters when a function takes three or more named values.
  • Arrow functions: Use arrow functions for callbacks and anonymous functions. Use named function expressions (not declarations) for anything that will be referenced by name.

Encoded as an instruction file rule:

## Naming

- Variables and functions: `camelCase`
- Constructors and React components: `PascalCase`
- True module-level constants: `SCREAMING_SNAKE_CASE`
- Never use a leading underscore to indicate "private". If something should
  not be accessed externally, enforce it structurally (closure, module scope,
  WeakMap) rather than by naming convention.

## Destructuring

When accessing more than one property from an object, always destructure:

```js
// ✅
const { name, email, role } = user;

// ❌
const name = user.name;
const email = user.email;
const role = user.role;
```

When a function accepts three or more named parameters, use a destructured
options object instead of positional arguments.

## Callbacks

Use arrow functions for inline callbacks. Never use `.bind(this)` in JSX;
use class properties with arrow functions or hooks instead.

Rust Compiler: Contribution and Unsafe Code Guidelines

The rustc contributing guide enforces extremely precise conventions around unsafe code and internal APIs:

  • unsafe blocks: Every unsafe block must have a comment directly above it explaining the invariant that makes the operation sound. The comment is not optional; it is required by code review.
  • Internal compiler types: Types in rustc_middle are the shared data structures of the compiler. New types should be added there only if they are truly shared; type-specific data belongs in the crate that owns the pass.
  • Test organisation: Unit tests live in a tests/ submodule within the same file as the code they test (Rust’s #[cfg(test)] convention). Integration tests live under the top-level tests/ directory and are organised by feature area.

Encoded as an instruction file rule:

## Unsafe Code

Every `unsafe` block must be preceded by a comment that:
1. Explains why the operation is safe at this call site
2. Names the invariant being upheld
3. References any relevant documentation or prior art

Example:
```rust
// SAFETY: `ptr` was allocated by `Box::new` on line 42 and ownership
// was transferred via `Box::into_raw`. We are the sole owner and the
// pointer has not been freed.
unsafe { Box::from_raw(ptr) }
```

Never write a bare `unsafe { ... }` block without a SAFETY comment.
A CI check enforces this; the build will fail without it.

## Adding Shared Types

Only add types to `rustc_middle` if they are consumed by more than one
compiler pass. If a type is only used within a single pass, define it in
that pass's crate.

What Instruction Files Can Encode

The examples above hint at a taxonomy. Here are the five categories of convention that instruction files handle best, with examples drawn from real teams.

1. Architecture Decisions

Architecture decisions are the hardest conventions to communicate because they require understanding why, not just what. An instruction file is one of the few places where you can explain the reasoning in prose and have the agent genuinely take it into account.

## Service Boundaries

All HTTP services must use FastAPI with the dependency injection patterns
established in `services/api/`. Specifically:

- Dependencies (database sessions, auth context, feature flags) are injected
  via FastAPI's `Depends()` mechanism, not imported as module globals.
- Never import `db_session` directly. Always receive it as a parameter:
  `db: AsyncSession = Depends(get_db)`.

The reason: module-global state makes async services unpredictable under
concurrent requests and makes unit testing require monkeypatching. Dependency
injection keeps each request's state isolated and makes dependencies explicit
in the function signature.

2. Code Organisation Conventions

Where things live matters as much as how they’re written. AI assistants will default to “reasonable” placement — which may not match your project’s layout.

## File Organisation (Python Services)

```
services/<service-name>/
├── api/          ← FastAPI routers only (no business logic)
├── models/       ← SQLAlchemy ORM models
├── schemas/      ← Pydantic request/response schemas
├── services/     ← Business logic (no HTTP, no ORM)
├── repositories/ ← Database queries (returns domain objects, not ORM instances)
└── tests/
    ├── unit/     ← No I/O; all dependencies mocked
    └── integration/ ← Real database, no network calls to external services
```

Never put business logic in routers. Routers call service functions; service
functions call repository functions. The dependency direction is strict:
`api → services → repositories → models`.

3. Framework Usage Requirements

If your codebase has standardised on a specific library or pattern, the assistant needs to know — otherwise it will suggest the generic alternative.

## HTTP Client

Use `httpx.AsyncClient` for all outbound HTTP calls. Do not use `requests`,
`aiohttp`, or `urllib`. The `httpx` client is configured with default timeouts
and retry behaviour in `shared/http.py`; use `get_http_client()` from that
module rather than instantiating `httpx.AsyncClient` directly.

## Logging

Use `structlog` with the configuration from `shared/logging.py`. Never call
`logging.getLogger()` directly. Log at INFO level for significant business
events, DEBUG for implementation detail. Log statements must include a
`request_id` bound variable; it is injected automatically by the middleware
in `shared/middleware.py`.

4. Naming Conventions

Naming rules that go beyond what a linter enforces — particularly cross-language or cross-layer conventions — are ideal instruction file content.

## Naming Conventions

### Database and API

- Database columns: `snake_case`
- API request/response fields: `camelCase` (Pydantic models use `model_config =
  ConfigDict(alias_generator=to_camel)` for automatic conversion)
- Do not expose database column names directly in the API; define explicit
  Pydantic schemas with intentional field names.

### Event Names

Domain events follow the pattern `<Noun><PastTenseVerb>`, for example:
`UserRegistered`, `OrderShipped`, `PaymentFailed`. Never use present tense
(`UserRegisters`) or imperative (`RegisterUser`) for event names.

### Feature Flags

Feature flag names use `kebab-case` and follow the pattern
`<area>-<feature>-<state>`, for example: `checkout-new-pricing-enabled`,
`api-rate-limiting-active`. Register new flags in `config/feature_flags.py`
before using them in code.

5. Testing Expectations

Testing conventions are frequently violated by AI assistants because the model defaults to the most common patterns in public code, which may not match your team’s choices.

## Testing

All tests use `pytest` with `pytest-asyncio` for async code. Do not use
`unittest.TestCase` or `asyncio.run()` in tests.

### Test Naming

Test functions follow the pattern `test_<unit>_<scenario>_<expected_outcome>`:

```python
# ✅
def test_create_user_with_duplicate_email_raises_conflict_error():
def test_get_order_when_not_found_returns_404():

# ❌
def test_user_creation():
def test_order():
```

### Fixtures

Fixtures are defined in `conftest.py` at the closest common ancestor of the
tests that use them. Use `scope="function"` by default. Only widen to
`scope="session"` for fixtures that establish a real database connection.

### Marks

- `@pytest.mark.integration` — test requires a running database
- `@pytest.mark.slow` — test takes more than 2 seconds
- `@pytest.mark.external` — test calls an external service

The CI fast-feedback loop excludes `integration`, `slow`, and `external` tests.
Run them locally with `pytest -m "integration or slow or external"`.

Best Practices for Writing Instruction Files

Be Highly Specific

The agent already tries to write good, idiomatic code. Vague instructions (“write clean code”, “follow best practices”) add noise without adding signal. Every instruction should say something the model could not have guessed from the code alone.

<!-- ❌ Vague — the agent already knows this -->
Write clean, readable code with good error handling.

<!-- ✅ Specific — encodes a convention the agent could not infer -->
All user-facing error messages must go through `errors.UserError(code, message)`
where `code` is a string from the enum in `errors/codes.py`. Never return a
raw exception message to the caller; those are logged internally but never
surfaced to the API response.

Provide Examples

Prose descriptions of conventions are often ambiguous in edge cases. A short before/after code snippet eliminates the ambiguity.

## Repository Return Types

Repository methods return domain objects, not ORM instances. Use dataclasses
or Pydantic models defined in `models/domain/`.

```python
# ✅ Repository method returns a domain object
async def get_user(self, user_id: UUID) -> User:
    row = await self.db.get(UserRow, user_id)
    return User(id=row.id, email=row.email, created_at=row.created_at)

# ❌ Do not return ORM rows from repository methods
async def get_user(self, user_id: UUID) -> UserRow:
    return await self.db.get(UserRow, user_id)
```

Avoid Duplicating Linter Rules

Your linter already enforces import ordering, indentation, and unused variable detection. Encoding those rules in the instruction file wastes context window space on things the tooling already handles automatically. Save the instruction file for conventions the linter cannot see.

Good candidates for instruction files:

  • Architecture rules (which packages can import what)
  • Business logic placement rules
  • Naming conventions that span layers or languages
  • Framework and library selection policies
  • Testing strategy and organisation

Poor candidates (linter already handles these):

  • Line length
  • Import ordering
  • Indentation
  • Unused variables
  • Missing return types (if you run mypy)

Keep Instructions Concise but Contextual

Each instruction should include enough context that the agent understands the intent, not just the rule. An agent that understands intent handles edge cases better than one that pattern-matches against a surface rule.

<!-- ❌ Rule without context — agent may misapply it at edges -->
Don't add retry logic in service clients.

<!-- ✅ Rule with context — agent understands why and won't fight the rule -->
Don't add retry logic in individual service clients. All retry behaviour
is centralised in `shared/resilience.py` using a token-bucket rate limiter.
Putting retries in service clients causes cascading thundering-herd failures
during outages; centralising them lets us tune retry budgets globally and
observe retry rates in one place.

A Complete Example

Here is a complete .github/copilot-instructions.md for a hypothetical FastAPI service:

# Copilot Instructions

This repository contains a FastAPI microservice for order management.
Python 3.12+. PostgreSQL 16 via asyncpg. All async; no synchronous I/O.

## Architecture

Strict layered architecture. Dependency direction: `api → services → repositories → models`.
Never import across layers in the wrong direction.

- `api/` — FastAPI routers. No business logic. Route handlers call service functions.
- `services/` — Business logic. No ORM, no HTTP. Calls repository functions.
- `repositories/` — Database access. Returns domain objects (not ORM rows).
- `models/` — SQLAlchemy ORM models (internal only) and Pydantic domain objects.

## Dependencies

Inject all shared dependencies via FastAPI's `Depends()`:
- Database session: `db: AsyncSession = Depends(get_db)` from `shared/db.py`
- Auth context: `user: AuthUser = Depends(require_auth)` from `shared/auth.py`
- Feature flags: `flags: FeatureFlags = Depends(get_flags)` from `shared/flags.py`

Never import `db_session`, `current_user`, or `feature_flags` as module globals.

## HTTP Client

Use `get_http_client()` from `shared/http.py`. Do not instantiate `httpx.AsyncClient` directly.

## Error Handling

Raise `errors.UserError(code, message)` for errors that should be returned to the caller.
All other exceptions are caught by the global exception handler, logged with the request ID,
and returned as a generic 500. Never return raw exception messages in API responses.

## Testing

- Framework: `pytest` + `pytest-asyncio`. No `unittest.TestCase`.
- Test names: `test_<unit>_<scenario>_<expected_outcome>`
- Fast tests (no I/O): `tests/unit/`
- Database tests: `tests/integration/`, marked `@pytest.mark.integration`
- Run fast tests: `pytest tests/unit/`
- Run all tests: `pytest -m "integration" --run-integration`

This is 34 lines. It covers architecture, dependencies, error handling, and testing. It would take a new engineer 5 minutes to read and 30 minutes to fully absorb — and the AI can load it in milliseconds before every interaction.


Conclusion: Why Start Here

Of all the ways to customise an AI coding assistant — custom agents, prompt libraries, lifecycle hooks, fine-tuned models — instruction files have the highest return for the least investment. They require no infrastructure, no API keys, no model training, and no new tooling. You write a Markdown file, commit it to your repository, and every developer on the team benefits immediately without changing their workflow.

More importantly, instruction files address the right problem first. The root cause of most AI coding assistant frustration is not that the model is incapable; it’s that the model lacks context. Instruction files supply that context directly and persistently.

Start by auditing a week of AI suggestions that you had to correct. Group those corrections by type: wrong library, wrong test framework, wrong error handling pattern, wrong file placement. Each group is a candidate instruction. Write them down, be specific, include examples, and commit the file. You’ll see the improvement immediately.

Once you’ve lived with a good instruction file for a few weeks, the gaps will become obvious. Those gaps will tell you what to build next — a prompt for a repetitive workflow, a hook for a policy that needs enforcement, a custom agent for a specialised domain. Let the gaps drive the roadmap. Start with instruction files.