The i18n section below is the one to paste into AGENTS.md: ten short rules that tell a coding agent to route every user-visible string through the translation function, key it by feature, never concatenate fragments, never fake plurals with a ternary, and never touch a generated locale file. globalize.now is AI-powered localization infrastructure, so we read a lot of agent-written i18n, and the same handful of mistakes account for nearly all of it. Each rule maps to one of them.

The second half of this post is about where the file stops working. Two of the code surfaces developers use most never read it, and no wording fixes that.

What should the i18n section of AGENTS.md say?

This block. Adjust the three file paths and the lint command to your project and leave the rest.

## Internationalization

- Every user-visible string goes through the translation function. That
  includes JSX text, input hint text, `title`, `aria-label`, `alt`, toast and
  error messages, empty states and the document `<title>`.
- The source locale is `en`. The catalog is `locales/en.json`. Add the key in
  the same change that uses it.
- Keys are namespaced by feature: `checkout.summary.total`, not `total3`.
  Search the catalog for an existing key before creating one.
- Never concatenate translated fragments. Use interpolation:
  `t('cart.items', { count })`, not `t('cart.you_have') + count`.
- Plurals use the library's plural forms (`_one` / `_other` keys, or ICU
  `{count, plural, ...}`). Never `count === 1 ? 'item' : 'items'`.
- Dates, numbers and currency go through `Intl.DateTimeFormat` and
  `Intl.NumberFormat` with the active locale. Never a bare
  `toLocaleString()`, never a currency symbol glued to a number.
- Do not hardcode the locale list, language names or text direction in
  components. Read them from `i18n/config.ts`.
- Do not translate brand names, code identifiers, URLs or environment values.
- Do not edit locale files other than `en`. They are generated. If a
  translation looks wrong, fix the source key or the glossary, not the file.
- Before finishing, run `npm run lint`. The `i18next/no-literal-string`
  rule must pass.

Ten rules is deliberate. Cursor's own rules documentation tells you to keep rules focused on patterns you use frequently and to use a linter rather than pasting a style guide, and Claude Code's guidance is the same: write instructions concrete enough to verify, and keep the file short because adherence drops as it grows. An i18n section that runs to forty bullets gets skimmed.

Why these ten rules and not just "use i18n"?

Because "use i18n" is what the agent already believes it is doing when it writes <Button>Save changes</Button>. Vague instructions get satisfied by the agent's own idea of compliance. Each rule below names a specific output and forbids it.

Literals in attributes. Agents learn that JSX children need translating and then write aria-label="Close" and alt="Company logo" as plain strings, because attributes look like configuration rather than copy. Listing the attributes by name closes that gap; a general "translate everything" does not.

Concatenation. t('greeting') + ' ' + name + '!' renders fine in English and cannot be translated into any language that puts the name first, or inflects it. Interpolation gives the translator one string with one variable to move.

Ternary plurals. count === 1 ? 'item' : 'items' is the thing we most often see an agent write after i18n is set up, and it is wrong for every language with more than two plural forms. The rule names the pattern so the agent can pattern-match against it. If your catalog uses ICU, what ICU MessageFormat is and where it breaks in AI-generated apps covers the syntax.

Generated locale files. This one exists because agents are helpful. Asked to fix a German typo, an agent will open locales/de.json and edit it, which works until the next translation job regenerates the file from source. Telling it that non-source files are generated, and where the actual fix goes, stops a class of silent regressions.

Editing en in the same change. Without this, the agent adds t('checkout.summary.total') and moves on, and the key renders as its own name until someone notices. The catalog entry and its first use belong in one diff.

Where does each tool actually read the file?

The same content goes in different places depending on the agent, and the placement decides whether the rule is always in context or loaded only when it matters.

AGENTS.md at the repository root is the shared location. The format is plain Markdown with no required fields, stewarded by the Agentic AI Foundation under the Linux Foundation, and read by Cursor, Codex, the Copilot coding agent and a long list of others. Nested files are supported by the spec, with the closest one to the edited file winning.

Cursor reads AGENTS.md directly, including nested ones, and also has its own scoped format. A rule scoped to UI files stays out of context until a matching file is open:

---
description: i18n rules for user-visible strings
globs: src/**/*.tsx, src/**/*.jsx
alwaysApply: false
---
(paste the Internationalization section here)

The file must end in .mdc and live in .cursor/rules/; a plain .md in that folder is ignored because it carries no frontmatter.

GitHub Copilot in VS Code has three options. .github/copilot-instructions.md applies to every chat request. A .github/instructions/i18n.instructions.md file with applyTo: "**/*.tsx,**/*.jsx" in its frontmatter applies only when those files are created or modified. And AGENTS.md at the repository root is read by the Local agent when the chat.useAgentsMdFile setting is on; nested AGENTS.md files in subfolders sit behind a separate experimental setting, chat.useNestedAgentsMdFiles, which is off by default. The Copilot-specific behaviour is covered in how to add i18n to an app built with GitHub Copilot.

Claude Code reads CLAUDE.md, and since v2.1.277 reads a root AGENTS.md on its own when the project has no CLAUDE.md above the working directory. If you keep both, the documented pattern is an @AGENTS.md import at the top of CLAUDE.md so the file is loaded once. For scoping, .claude/rules/i18n.md with a paths: list in its frontmatter loads only when Claude reads a matching file. The full Claude Code workflow is in how to localize an app Claude Code generated.

The practical arrangement for a repo that several agents touch: the ten rules in a scoped file for whichever agent your team uses most, and a two-line pointer in AGENTS.md ("UI strings: see the i18n rules in .cursor/rules/i18n.mdc, and follow them") so any agent without scoping support still gets the instruction.

Which code surfaces never read AGENTS.md?

Inline completions. This is documented by both vendors, and it is the reason the rules file cannot be the whole answer.

Cursor's rules FAQ answers "Do rules impact Cursor Tab or other AI features?" with a plain no. Rules feed Agent; Tab, the autocomplete that fills in the line you are typing, does not see them. VS Code's custom-instructions page carries the same note for Copilot: instructions are not taken into account for inline suggestions as you type in the editor.

So the surface where a developer types <p>No results found</p> and accepts the ghost text is exactly the surface the rules never reach. Chat and agent mode will key the string; the completion that finished the line while you were thinking about something else will not. Over a week of normal work the codebase accumulates literals from the one path the instruction file does not cover, and the developer, having written a careful rules file, assumes the agent is ignoring it.

Claude Code has no inline completion surface, but its documentation makes the equivalent point from the other side: CLAUDE.md content is context, not enforced configuration, and to block an action regardless of what the model decides you use a hook. That is the correct mental model for every tool here. Instruction files shift probabilities. They do not enforce.

What actually enforces the rule?

A lint rule in CI, and it is a one-line addition once the rules file already tells the agent to run it. eslint-plugin-i18next ships no-literal-string; turn it on for your component directories and a literal in JSX fails the build rather than getting a polite reminder. The setup, the rule's options, and why instruction and enforcement are two different layers are laid out in why Cursor keeps adding hardcoded strings after i18n setup, so this post will not repeat them.

Two things the lint layer gives you that the rules file cannot. It catches the inline-completion output, because it runs on the file, not on the conversation. And it turns the agent's own loop into the fix: the last rule in the block above tells the agent to run lint before finishing, and an agent that sees no-literal-string fail will key the string itself in the same session.

For Claude Code specifically, a PreToolUse or post-edit hook that runs the linter on the file just written is the enforcement mechanism its docs point at. Hooks run as shell commands at fixed points and apply whether or not the model chose to follow the rule.

What happens to the strings that still get through?

They need extracting, keying against the existing namespaces, and translating, and that is the part worth automating instead of re-prompting. A red build tells you a literal exists. Someone still has to turn it into a key, add it to en, and get it into every other locale.

This is the layer globalize.now sits at. Conversion is one-time and happens in the app: connect the repository, and the codebase is converted once, with the catalog delivered as a pull request you review. After that, push jobs translate new catalog units as they land, so a key added in Tuesday's PR has its translations in Wednesday's. The runtime library, the catalog format and the rules file above are all yours; the developer reference covers how agents in Cursor, Claude Code, Codex and Copilot pick up the post-setup role, and the Cursor integration is the shortest walkthrough.

Two related failure modes have their own posts. If the problem is the agent rewording copy that was already keyed, that is how to stop AI agents rewriting your UI copy, and the fix is the same source-locale discipline as rule nine above. If the locales exist but keep diverging, why translation files drift out of sync explains the routing failure behind it.

Where do you start?

Paste the block, scope it to your UI directories, and turn on no-literal-string in CI the same afternoon. Then watch what the lint rule catches over a week; that is your measure of how much the rules file was ever going to do on its own. If you are shipping an AI-built app and want the extraction and translation of everything that still gets through handled rather than maintained, the vibe coders page is the overview and pricing is on its own page.

globalize.now turns hardcoded app copy into translation-ready locale files and keeps them updated as you ship.

Try globalize.now free