Skip to content

AI Integration · Developer Workflow

Writing AI IDE Rules That Actually Work: Cursor, Windsurf, and Copilot

The AI IDE tools everyone uses have a feature most developers set up once and never tune: custom rules. Here's how to write rules that change how the tool generates code, not just what it says it will do.

Anurag Verma

Anurag Verma

7 min read

Writing AI IDE Rules That Actually Work: Cursor, Windsurf, and Copilot

Sponsored

Share

Every developer using Cursor, Windsurf, or GitHub Copilot has access to the same base model. What separates useful AI code generation from generic output is context — and rules files are the primary way to inject project-specific context that sticks across every session.

Most developers set up a .cursorrules file once, copy something from the internet, and forget it. The quality of AI suggestions they get reflects that neglect. Here’s a more deliberate approach.

What Rules Files Are

Each tool has its own format:

  • Cursor: .cursorrules in the project root, or per-directory .cursor/rules/*.mdc files in newer versions
  • Windsurf: .windsurfrules in the project root
  • GitHub Copilot: .github/copilot-instructions.md for repository-level instructions

All three feed these instructions into the system prompt for every AI interaction in the relevant context. The model reads them before generating any code.

This is different from typing instructions into a chat window. Chat instructions apply to one session. Rules files apply to every session, every completion, every edit in the repository — from anyone on the team.

What Rules Can and Can’t Change

Before writing rules, it helps to understand the limits.

Rules can reliably change:

  • Which patterns the model reaches for by default (functional vs OOP, async vs sync)
  • Naming conventions for files, functions, and variables
  • Which libraries the model suggests for common tasks
  • Whether the model writes tests alongside implementations
  • Comment style and docstring format
  • Error handling patterns (return tuple vs raise exception vs Result type)

Rules struggle to reliably change:

  • Things that require the model to know your runtime environment
  • Precise business logic that isn’t expressible as a repeatable pattern
  • Anything the model must reason about from scratch rather than pattern-match

The Structure of a Good Rules File

Generic rules like “write clean code” or “follow best practices” do nothing. The model already tries to do that. Useful rules are specific enough that a different rule would produce different output.

Here’s a real .cursorrules file from a FastAPI + SQLAlchemy project:

# Project: Backend API — FastAPI + SQLAlchemy 2.0 + PostgreSQL

## Language and runtime
- Python 3.12+. Use match/case for enum dispatch.
- All async. No synchronous database calls in route handlers.
- Type annotations on every function parameter and return value.

## Database
- SQLAlchemy 2.0 ORM with async sessions. Not SQLAlchemy 1.x patterns.
- Use `select()` statements, not `session.query()`.
- Do not use `lazy=True` relationships. Prefer explicit `selectinload` or `joinedload`.
- All schema changes go through Alembic migrations. Never call `create_all()`.

## API design
- Route parameters use snake_case. Response models use camelCase (Pydantic alias_generator).
- All endpoints return Pydantic response models, not raw ORM objects.
- Errors raise `HTTPException` with appropriate status codes. No generic 500 returns.
- Pagination uses `limit` and `offset` query parameters.

## Dependency injection
- Auth: inject `current_user: User = Depends(get_current_user)` for authenticated endpoints.
- DB: inject `db: AsyncSession = Depends(get_async_session)`.
- Never instantiate services directly in routes. Use Depends.

## Testing
- pytest + pytest-asyncio. All tests are async.
- Use the `override_dependency` pattern for mocking.
- One test file per router. Test both success and error paths.
- Do not use `unittest.mock`. Prefer factory functions and real objects.

## What not to generate
- No print statements in production code. Use structlog.
- No hardcoded URLs or API keys. Reference settings.
- No `time.sleep()`. Use asyncio.sleep() or background tasks.

Notice what makes this useful: every rule is specific enough to have an opposite. “Use select() statements, not session.query()” gives the model a clear choice. “Write clean code” does not.

Per-Directory Rules in Cursor

Cursor’s newer .cursor/rules/*.mdc format supports per-directory rules that only activate in relevant contexts:

.cursor/rules/
├── global.mdc          # Always applies
├── api.mdc             # Rules for the /api directory
├── frontend.mdc        # Rules for /frontend
└── tests.mdc           # Rules for /tests

Each .mdc file includes frontmatter that controls when it activates:

---
globs:
  - "api/**/*.py"
  - "api/**/*.pyi"
---

# API Layer Rules

- All route functions must have an explicit response_model in the decorator.
- Parameter validation happens in Pydantic schemas, not in the route body.
- Business logic lives in service classes, not in route handlers.

This is particularly useful on monorepos where frontend and backend live together. Frontend rules (React patterns, CSS conventions) shouldn’t pollute the backend context.

Windsurf Rules and Cascade

Windsurf’s .windsurfrules syntax is similar to Cursor’s. One distinction: Windsurf’s Cascade reads rules in combination with your open files and conversation history. Rules that reference file names or directories Windsurf can see in context get weighted more heavily.

This means rules like “when editing files in the components/ directory, always use design system tokens from tokens.ts” work better in Windsurf than a generic directive to “use design tokens.”

# Frontend Component Rules

## Styling
- Use CSS modules, not inline styles and not styled-components.
- Color values come from tokens.ts. No hardcoded hex values.
- Spacing uses the 4px grid: 4, 8, 12, 16, 24, 32, 48px.

## Components
- One component per file. File name matches component name.
- Props interface defined in the same file, named `ComponentNameProps`.
- Default exports only. No named component exports.

## State management
- Local state: useState.
- Shared state: Zustand stores in /stores directory.
- Server state: TanStack Query. No useState for data fetching.

GitHub Copilot Repository Instructions

Copilot’s .github/copilot-instructions.md works the same way, applying to the Copilot experience in VS Code and JetBrains:

# Copilot Instructions

This is a Next.js 16 application using the App Router with TypeScript strict mode.

## Conventions
- All pages in app/ directory use Server Components by default.
- Add "use client" only when the component needs browser APIs or interactivity.
- Data fetching: use fetch() in Server Components. No client-side data fetching except for mutations.
- Database: Drizzle ORM with PostgreSQL. Use the db client from lib/db.ts.

## TypeScript
- Strict mode is on. No `any` types.
- Use `satisfies` for object literals where the shape is known.
- Prefer `interface` for objects, `type` for unions and intersections.

## Testing
- Vitest for unit tests, Playwright for E2E.
- Test files colocated with the code they test, ending in .test.ts.

One important caveat: Copilot instructions are visible to anyone with repository access. Don’t include anything that reveals sensitive internal architecture or could assist an attacker in mapping your systems.

Write Rules for Your Stack, Not Generic Advice

Teams often copy rules from the internet for a different stack and wonder why they don’t help. The most valuable rules encode your team’s specific decisions, not general language advice.

Worth encoding:

  • Which specific library versions you’re on (SQLAlchemy 2.0 generates different code from 1.4)
  • Which libraries are approved (and which you’ve evaluated and rejected)
  • Your project’s patterns for common operations: how you structure service classes, what your error response format looks like, how you handle auth
  • What the model should not suggest (if you’ve decided against a pattern, say so explicitly)

Not worth encoding:

  • Basic language style that a linter already enforces
  • Generic advice the model follows anyway
  • Anything better expressed as an actual lint rule or template

Keeping Rules Current

Rules files decay. You upgrade a library. You change your state management approach. You start a new service with different conventions. If the rules file doesn’t get updated, the model generates code following patterns you’ve moved away from — and it does so confidently, without flagging that the suggestion might be outdated.

Two practices that help:

  1. Review the rules file when you change an architectural decision. Treat it as part of any ADR (Architecture Decision Record) update.
  2. Add “last reviewed: YYYY-MM” comments to major sections. When a section is over 6 months old, verify it still reflects how you’re building.

The 15 minutes it takes to keep a rules file current is much less than the time spent correcting code generated from stale context. And on teams of 3 or more, good rules multiply their value — every developer benefits from the context that one person took the time to encode.

Sponsored

Enjoyed it? Pass it on.

Share this article.

Sponsored

The dispatch

Working notes from
the studio.

A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.

No spam, ever. Unsubscribe anytime.

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored