Two files at the root of Nakodo's repository are not really ours. AGENTS.md is 678 bytes, CLAUDE.md is 11 bytes, and both arrived in the first commit of the project, written by create-next-app. CLAUDE.md is one line:
@AGENTS.md
And AGENTS.md is one marker-delimited block, which I am quoting exactly as the framework writes it, em dashes and all:
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
A file in the working tree that the dev server maintains is an unusual thing for a framework to do, so I went and read all of it: the writer, the gate in front of the writer, and the payload it points at. Nakodo is on next@16.3.7, and every number below is from that install.
The writer is 179 lines and refuses to guess
node_modules/next/dist/server/lib/generate-agent-files.js has one public job, writeAgentFiles(projectDir), and three branches:
if (agentsMdExists && (agentsMdHostsBlock || !claudeMdHostsBlock)) {
return { agentsMd: upsertFile(agentsMdPath, block), claudeMd: 'skipped' };
}
if (claudeMdExists) {
return { agentsMd: 'skipped', claudeMd: upsertFile(claudeMdPath, block) };
}
// Neither file exists — scaffold both, matching create-next-app.
The ordering is the interesting part. If you already keep the block in CLAUDE.md and have no AGENTS.md, it edits CLAUDE.md and leaves you alone. If you have an AGENTS.md, that wins, and CLAUDE.md is never touched. Only on a repository with neither does it create two files, and in that case the second one is a single @AGENTS.md import rather than a copy, which is why ours is 11 bytes. One source of truth, one pointer at it.
The upsert itself is careful in three ways that are easy to skip when you write this kind of code:
function detectEol(content) {
return /\r\n/.test(content) ? '\r\n' : '\n';
}
The block is normalised to the line endings the file already uses, so a Windows checkout does not end up with mixed EOLs. Then there is a loop that strips the markers of a previous generation of the same feature:
const LEGACY_AGENT_RULES_START_MARKER = '<!-- NEXT-AGENTS-MD-START -->';
const LEGACY_AGENT_RULES_END_MARKER = '<!-- NEXT-AGENTS-MD-END -->';
A while (true) loop removes every legacy block, trimming surrounding whitespace on both sides, so a project that ran the old codemod ends up with one current block instead of a stale one and a current one. And the idempotence check is an exact comparison, not a marker test:
const installed = extractAgentRulesBlock(content);
if (installed !== null && normalizeEol(installed, '\n') === block) return true;
That last line is the one with a consequence for you. Keeping the markers is not enough. If you shorten the text inside them, or fix a typo, or translate it, the next next dev decides your block is not current and rewrites it. The block is managed, and "managed" means byte-exact.
The gate is a 2.7 KB detector with 13 known agents
None of this runs unless the dev server believes an agent is present. The gate is one line:
async function ensureAgentRulesForDev(dir) {
if (await getAgentName() === null) return null;
if (hasCurrentAgentRules(dir)) return null;
return writeAgentFiles(dir);
}
getAgentName() is a memoised promise over @vercel/detect-agent, compiled into next/dist/compiled at 2,786 bytes, with a .catch(() => null) so a detection failure can never break a dev server. Its exported list of names is thirteen long:
cursor cursor-cli claude cowork devin replit gemini
codex antigravity augment-cli opencode github-copilot v0
And the detection is a waterfall of environment variables, in priority order: AI_AGENT first, then CURSOR_TRACE_ID, CURSOR_AGENT or CURSOR_EXTENSION_HOST_ROLE === 'agent-exec', GEMINI_CLI, any of CODEX_SANDBOX / CODEX_CI / CODEX_THREAD_ID, ANTIGRAVITY_AGENT, AUGMENT_AGENT, OPENCODE_CLIENT, CLAUDECODE or CLAUDE_CODE (with CLAUDE_CODE_IS_COWORK splitting off a separate name), REPL_ID, and any of three COPILOT_* variables.
The last check is not an environment variable at all:
const _ = "/opt/.devin";
// ...
try {
await access(_, constants.F_OK);
return { isAgent: true, agent: { name: A } };
} catch (e) {}
return { isAgent: false, agent: void 0 };
One fs.access on a fixed path, because Devin's sandbox has that directory and does not advertise itself in the environment. It is the only filesystem probe in the whole function, and it is last, which is also the only reason the function is async.
You can run the detector yourself in any Next 16 project:
node -e 'const {determineAgent,KNOWN_AGENTS}=require("next/dist/compiled/@vercel/detect-agent");
console.log(Object.keys(KNOWN_AGENTS).length);
determineAgent().then(r=>console.log(JSON.stringify(r)))'
In the shell I wrote this post in, that prints:
13
{"isAgent":true,"agent":{"name":"claude-code_2-1-300_agent"}}
Which is the finding worth taking away from the whole file. claude-code_2-1-300_agent is not one of the thirteen names. It came from AI_AGENT, and the AI_AGENT branch passes whatever it finds straight through:
if (process.env.AI_AGENT) {
const e = process.env.AI_AGENT.trim();
if (e) {
if (e === N || e === l) return { isAgent: true, agent: { name: N } }; // copilot-cli -> github-copilot
if (e === D) return { isAgent: true, agent: { name: D } }; // v0
return { isAgent: true, agent: { name: e } }; // anything at all
}
}
Two values get normalised, github-copilot-cli collapsing into github-copilot and v0 being allowed through explicitly, and everything else is accepted verbatim, version suffix included. For the yes-or-no question this feature asks, that is exactly right: an unknown agent is still an agent. If you are tempted to group telemetry by that name, though, the set is not thirteen values, it is whatever anybody puts in an environment variable.
Clear the variables and the whole feature switches off:
$ AI_AGENT= CLAUDECODE= CLAUDE_CODE= node -e '...'
cleared: {"isAgent":false}
There is an opt-out, and it is not experimental
I expected to have to write "there is no way to turn this off". There is, and I nearly missed it because it lives in the caller rather than in the generator:
// Gated on `agentRules` in next.config (default true).
if (initResult.agentRules !== false) {
const result = await ensureAgentRulesForDev(dir);
// ...
log.event(`Generated ${generated.join(' and ')} for AI agents. Set \`agentRules: false\` in next.config to disable.`);
}
agentRules: z.boolean().optional() is a top-level key in config-schema.js, not an experimental one, so it is a supported setting rather than a flag that disappears next minor. The gate is also inside if (isDev), so next build never writes to your tree. And the log line only prints when a file was actually created or updated, so in a repository like ours, where the block is already committed and current, a dev server start says nothing at all.
The payload is 3 MB of markdown in node_modules
The block's actual instruction is one sentence: read the guide in node_modules/next/dist/docs/ before writing any code. That directory is the part of this feature I would keep if I could only keep one:
| Section | Files | Bytes |
|---|---|---|
| 01-app | 283 | 2,765,666 |
| 02-pages | 164 | 350,446 |
| 03-architecture | 5 | 27,804 |
| 04-community | 3 | 21,661 |
| index.md | 1 | 3,138 |
456 files, 3,168,715 bytes of markdown. The largest single file is 01-app/03-api-reference/02-components/image.md at 61,104 bytes, followed by the authentication guide at 55,785 and generate-metadata at 49,181. There is a 43,177 byte upgrading/version-16.md, which is the file you want on day one of a migration.
The reason this matters more than the rules block: these docs are versioned with the package. nextjs.org/docs is the current release, your training data is whatever it was, and node_modules/next/dist/docs is the version that is actually installed and will actually run. For a framework that renamed middleware.ts to proxy.ts and changed what a route handler caches by default, the difference between "the docs" and "the docs for 16.3.7" is the difference between code that works and code that is confidently wrong.
It also means node_modules for a Next app is 3 MB bigger than it needs to be for running the app, which I have seen complaints about and which I think is a trade worth making exactly once per install.
What I would change
One thing, and it is the exact-match rule. The block it writes is good advice, and the two sentences it adds about its own mechanism ("Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean") are better documentation than most generated files manage. But a managed block that reverts my edits is a block I cannot tune, and the only supported response to disagreeing with one word of it is to turn the whole feature off with agentRules: false. A marker that opted out of rewriting, something like <!-- BEGIN:nextjs-agent-rules keep -->, would cost almost nothing.
The other thing is a smaller irony. Nakodo's own copy is linted by a test with a banned-characters list:
const BANNED = [/—/, /–/, /\bscrap(e|es|ed|ing|er|ers)\b/i, /\bhunt(s|ed|ing|er|ers)?\b/i, ...];
The em dash is first on that list, and the managed block contains two of them. They sit in a markdown file rather than in page copy, so the test never sees them, which is the right scope for the test and still makes me laugh every time I open the file.
The product all of this builds is live: how Nakodo works is the pipeline, and the guides are 22 of the 76 pages this Next 16 app renders. If you want to confirm the version from outside, curl -sI https://nakodo.app/ comes back with vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, which is the router's own fingerprint on every response.
Top comments (0)