Cursor Rules: Durable Project Context
You keep re-explaining the same three rules to Cursor every session: keep this class a thin wrapper, test every new method, no default exports. Ask it something about your project cold, and it can only reason from the files it reads and whatever the model already knows about your framework, not from a single decision your team already made. That's the gap this lesson closes.
Every lesson in this module runs against the same small repo, so you're working in one place instead of re-orienting five times. Meet it now, before any of it changes.
The project: order-api
order-api is a tiny Node/Express-style TypeScript service for looking up orders. Small enough to hold in your head. Real enough that Cursor has actual code to react to, not a toy with nothing in it.
order-api/
src/
orders.ts -- getOrder(id), listOrders(customerId): reads an in-memory Map
client.ts -- OrderClient class: thin wrapper callers use to hit orders.ts
server.ts -- three Express routes wired to client.ts
test/
orders.test.ts -- a handful of passing tests for orders.ts
package.json
Lesson 2 had you open a repo of your own choosing. This lesson doesn't: order-api is a real,
public, runnable copy of the tree above, and every lesson from here through lesson 10 assumes
you're sitting in it. Get it now, before the rest of this lesson.
git clone https://github.com/profrodai/profrodai-resources.git
cd profrodai-resources/courses/agentic-coding-with-cursor/order-apinpm install
npm testFour tests, all passing. That's your confirmation the repo is intact before Cursor touches anything in it.
File > Open Folder, and select the order-api folder specifically, not the parent
profrodai-resources clone. The rest of this lesson, and lessons 5 through 10, all assume
order-api is the folder Cursor has open.
// src/orders.ts
interface Order {
id: string;
customerId: string;
total: number;
status: "pending" | "shipped" | "delivered";
}
const orders = new Map<string, Order>([
["ord_1", { id: "ord_1", customerId: "cus_1", total: 42.5, status: "shipped" }],
["ord_2", { id: "ord_2", customerId: "cus_1", total: 18.0, status: "pending" }],
["ord_3", { id: "ord_3", customerId: "cus_2", total: 91.2, status: "delivered" }],
]);
export function getOrder(id: string): Order {
const order = orders.get(id);
if (!order) throw new Error(`Order not found: ${id}`);
return order;
}
export function listOrders(customerId: string): Order[] {
return [...orders.values()].filter((o) => o.customerId === customerId);
}// src/client.ts
import { getOrder, listOrders } from "./orders";
export class OrderClient {
get(id: string) {
return getOrder(id);
}
listForCustomer(customerId: string) {
return listOrders(customerId);
}
}// src/server.ts
import express from "express";
import { OrderClient } from "./client";
const app = express();
app.use(express.json());
const client = new OrderClient();
app.get("/orders/:id", (req, res) => {
res.json(client.get(req.params.id));
});
app.get("/customers/:id/orders", (req, res) => {
res.json(client.listForCustomer(req.params.id));
});
app.post("/orders", (req, res) => {
res.status(201).json({ received: true });
});
export default app;// test/orders.test.ts
import { getOrder, listOrders } from "../src/orders";
test("getOrder returns a known order", () => {
expect(getOrder("ord_1").status).toBe("shipped");
});
test("getOrder throws for an unknown id", () => {
expect(() => getOrder("nope")).toThrow();
});
test("listOrders filters by customer", () => {
expect(listOrders("cus_1")).toHaveLength(2);
});
test("listOrders returns an empty array for an unknown customer", () => {
expect(listOrders("cus_nobody")).toEqual([]);
});Nothing here changes in this lesson. You're not touching a source file today. The only thing you're going to write is a rule file, and the only thing you're going to prove is that Cursor reads it without being told to.
What a rule file actually is
Ask Cursor's Agent something about order-api right now, cold, and it can only reason from what it can see: the files it reads, plus whatever general knowledge the model already has about Express and TypeScript. It doesn't know that this team writes tests for every new method, or that OrderClient is supposed to stay a thin pass-through. Those are decisions your team made, and unless they're written down somewhere Cursor reads automatically, you're the one repeating them, turn after turn, forever.
.cursor/rules/*.mdc is where you write them down once. A rule file is a plain Markdown file with a short YAML header, saved inside a .cursor/rules/ folder at your project root. Cursor loads every rule that folder contains at the start of a session and again as context shifts, no invocation required. You don't @mention a rule to get it read. It's just there, the way a colleague who's been on the team for two years is just there, correcting a PR before you finish opening it.
Don't take that on trust. The "proving it landed" section further down has you confirm the loading behavior directly against your own project, by asking Agent a question that only makes sense if it actually read the rule, rather than asserting it here and moving on.
Here's a complete one for order-api, paste-ready as .cursor/rules/order-api.mdc:
---
description: Conventions for the order-api service
globs: src/**/*.ts
alwaysApply: false
---
# order-api conventions
- `OrderClient`'s methods must stay synchronous with `orders.ts`'s
interface. If `orders.ts` adds a parameter or changes a return
type, `client.ts` mirrors it in the same change, not later.
- Every new method on `OrderClient` needs a matching test in
`test/`. No method ships without one.
- This project uses named exports only. Never `export default`,
anywhere in `src/`.Three conventions, each one a decision this team already made and would otherwise have to restate to Cursor every single time: keep the client thin and synchronous, test every new method, no default exports. None of them are exotic. That's the point. Rules aren't for teaching Cursor Express or TypeScript, it already knows those. They're for the local, arbitrary-looking decisions that only make sense because this team made them, on this project, and that Cursor has no way to infer from the code alone.
alwaysApply: true means exactly that: the rule loads in every session, and globs is ignored entirely. Scoping only happens with alwaysApply: false plus a globs pattern, which is what both rule files below actually use. Set both alwaysApply: true and a globs pattern on the same file, and the glob does nothing; the rule fires everywhere and the scoping you thought you wrote silently isn't there.
One rule file isn't one rule for the whole repo
The globs field in that header is doing real work, but only because alwaysApply is false: src/**/*.ts scopes this rule to source files. It has nothing useful to say about test/orders.test.ts, so it doesn't apply there.
Testing conventions are a different set of decisions, so they get a different file: .cursor/rules/testing.mdc, scoped only to test/**.
---
description: Testing conventions for order-api
globs: test/**/*.ts
alwaysApply: false
---
# Testing conventions
- Use `test()`, not `describe()` blocks. This project keeps tests
flat; no nested suites.
- Every test file imports directly from `src/`, never through
`OrderClient`. Test the source functions, not the wrapper.
- Name test files `<source-file>.test.ts`, matching the file
under test exactly.This is the part that's easy to undersell: a single flat file can't do this. One .cursorrules file (the older, single-file convention Cursor deprecated in favor of .cursor/rules/) applies everywhere or nowhere; you can't scope half of it to test/** and the other half to src/** without the file itself growing conditionals to fake what glob scoping does natively. Cursor still reads an existing .cursorrules file today, so nothing breaks if one is already sitting in an older project, but it's absent from current Cursor documentation entirely and gets none of what's shipped since: glob scoping, per-rule activation modes, none of it. There's no reason to reach for it on a new project.
Proving it landed
Writing the file is the easy half. The half worth actually doing is confirming Cursor picked it up, because a typo'd glob or a misplaced folder fails silently, you just get generic answers back and might not notice for weeks.
Open Agent in order-api with both rule files saved, and ask it something that only makes sense if it's read them:
Why does OrderClient.listForCustomer just call listOrders directly
instead of adding a cache?
A version of Cursor that hasn't read the rule answers from general software-engineering instinct: something about premature optimization, or a generic "add caching if you measure a need for it." A version that has read order-api.mdc answers differently, closer to: because this project's convention keeps OrderClient a thin, synchronous pass-through over orders.ts, per the project's rules, so adding a cache here would break that. It's citing your rule back to you, by name, unprompted. Nobody re-explained the convention in the prompt above. It was already in scope.
Check three things in order: the file is actually at .cursor/rules/order-api.mdc (not .cursorrules/ or cursor-rules/, both easy typos), the YAML frontmatter parses (a bad indent silently breaks it), and the glob actually matches the file you're asking about. A rule scoped to src/**/*.ts won't fire on a question about test/orders.test.ts.
Checkpoint
What's ahead
The rule file is in place. Nothing in order-api has changed yet, and that was deliberate: this lesson was about writing durable context, not using it to touch code. Lesson 5 is where that changes, the first real request against this repo, and where right-sizing that request turns out to matter as much as the rules backing it up.
The first real request against order-api: adding a cache to OrderClient, and why how you scope the ask decides how much you have to review after.
Reply here and it goes straight to Rod. Same as replying to one of his emails.