Writing Once and Running Everywhere
A platform crosswalk, the portable core, prompting to the intersection, and what is not worth trying to unify.
Give the crosswalk and the portability strategy because most organizations run more than one of these.
43.1 The Crosswalk #
AGENTS.md is left out of the cells below because every platform here reads it, with one adapter and one conditional: Gemini CLI needs context.fileName in settings.json to list it alongside GEMINI.md, and Claude Code reads it natively from v2.1.277 but only where no CLAUDE.md or CLAUDE.local.md is present at or above the working directory—so the one-line import is what makes the behavior unconditional there (§15.2). That is what makes it the portable baseline rather than any one platform’s answer. Copilot’s paths are relative to .github/.
| Concept | Claude Code | Codex | Copilot | Cursor | Gemini CLI |
|---|---|---|---|---|---|
| Always-on instructions | CLAUDE.md | AGENTS.md | copilot-instructions.md | alwaysApply: true | GEMINI.md |
| Path-scoped | .claude/rules/ + paths | Nested AGENTS.md | applyTo frontmatter | globs frontmatter | Nested GEMINI.md |
| Description-triggered | Skills | Skills | Skills | Agent-requested rules | Skills |
| On-demand procedure | .claude/skills/ | Skills | skills/ | @rule-name | Skills |
| Delegation | .claude/agents/ | Subagents | Custom agents | Subagents | .gemini/agents/ |
| Enforcement | Hooks | Command rules | Policy controls | Hooks | Hooks |
| External tools | MCP | MCP | MCP | MCP | MCP |
| Distribution | Plugins | Plugins | Extensions | Plugins | Extensions |
Two cells filled in recently, and older comparisons still show them empty: Cursor distributes bundled configuration—rules, skills, agents, commands, hooks, MCP servers—as plugins through a marketplace, and Gemini CLI ships subagents defined in .gemini/agents/*.md with automatic and @-explicit delegation.80
The path-scoped row needs one qualification, since two different mechanisms share it. Copilot and Cursor scope by glob in frontmatter. Codex and Gemini CLI scope by directory: Gemini CLI scans for GEMINI.md files in a directory and its ancestors up to a trusted root when a tool touches a path there, loading them just in time rather than discovering them all up front.80 That is a path-scoping mechanism and the reason the cell is no longer blank, but it selects on where the file sits rather than on a pattern you write, so a rule that must apply to *.tsx wherever they live is not expressible in it.
Two things hold everywhere in that table. MCP is portable—one protocol, adopted across every surface in that table, now under neutral governance.39 And AGENTS.md is read by everything here, natively on four of the five surfaces, with one one-line adapter for Gemini CLI and the CLAUDE.md import where Claude Code’s conditional loading needs pinning down.
43.2 The Portable Core #
The strategy that works is straightforward: keep everything portable in AGENTS.md, and reserve platform-specific files for what genuinely cannot live there.
repository/
AGENTS.md ← canonical, portable, small
CLAUDE.md ← one line: @AGENTS.md
.github/
copilot-instructions.md ← one line: see AGENTS.md
instructions/
react.instructions.md ← Copilot path scoping
.cursor/rules/
react.mdc ← Cursor path scoping (same content)
.gemini/
settings.json ← context.fileName lists AGENTS.md
.agent/
skills/ ← portable skill sources
scoped/ ← canonical path-scoped sources
scripts/sync-agent-config.sh ← generates the derived files
The duplication between react.instructions.md and react.mdc is unavoidable, since the frontmatter schemas differ, but it should be generated, not maintained twice.
#!/usr/bin/env python3
"""Generate platform-specific scoped instruction files from one source."""
import sys, yaml
from pathlib import Path
SRC = Path(".agent/scoped") # canonical: frontmatter + body
def frontmatter(fields: dict) -> str:
"""Serialize, do not interpolate. A description containing ': ' composed
into a YAML scalar by hand produces a file that will not parse."""
return "---\n" + yaml.safe_dump(fields, sort_keys=False,
default_flow_style=False) + "---\n"
def split_source(path: Path) -> tuple[dict, str]:
"""Frontmatter ends at a delimiter line, never at the first three dashes
anywhere. `raw.split("---", 2)` truncates the metadata of any file whose
description legitimately contains `---`, and the KeyError it raises aborts
the run with a traceback instead of a named failure.
A delimiter sits at column 0, so compare with `rstrip` and not `strip`:
stripping the left side too matches an INDENTED `---`, which is ordinary
content inside a block scalar and cannot be a delimiter."""
lines = path.read_text().splitlines()
if not lines or lines[0].rstrip() != "---":
raise ValueError(f"{path}: must open with a --- delimiter line")
try:
end = next(i for i, l in enumerate(lines[1:], 1) if l.rstrip() == "---")
except StopIteration:
raise ValueError(f"{path}: frontmatter is never closed") from None
meta = yaml.safe_load("\n".join(lines[1:end])) or {}
# Shape before keys: a sequence or scalar root has no `.keys()`, and the
# AttributeError would be an uncaught traceback rather than a diagnostic.
if not isinstance(meta, dict):
raise ValueError(f"{path}: frontmatter must be a YAML mapping")
missing = {"description", "globs"} - meta.keys()
if missing:
raise ValueError(f"{path}: frontmatter missing {sorted(missing)}")
if not isinstance(meta["description"], str):
raise ValueError(f"{path}: description must be a string")
# Types, not just presence. `",".join()` over a bare string joins its
# CHARACTERS, so `globs: 'src/**/*.tsx'` emits `applyTo: s,r,c,/,...` and
# exits 0 — the rule still loads and now matches nothing it should.
# Check the CONTAINER before the elements: a mapping is iterable and
# yields its keys, so `globs: {include: ...}` would pass an element-only
# check and emit `applyTo: include`; an int is not iterable at all and
# raises TypeError, which the caller's `except ValueError` does not catch.
globs = meta["globs"]
if isinstance(globs, str):
globs = [globs] # one pattern, written bare: accept it
if (not isinstance(globs, list) or not globs
or not all(isinstance(g, str) and g.strip() for g in globs)):
raise ValueError(f"{path}: globs must be a pattern or a list of patterns")
return {**meta, "globs": globs}, "\n".join(lines[end + 1:]).strip()
def emit_copilot(name, meta, body):
return (
Path(f".github/instructions/{name}.instructions.md"),
frontmatter({"applyTo": ",".join(meta["globs"])})
+ f"<!-- GENERATED from .agent/scoped/{name}.md — do not edit -->\n\n"
+ body,
)
def emit_cursor(name, meta, body):
return (
Path(f".cursor/rules/{name}.mdc"),
frontmatter({"description": meta["description"],
"globs": ",".join(meta["globs"]),
"alwaysApply": False})
+ f"<!-- GENERATED from .agent/scoped/{name}.md — do not edit -->\n\n"
+ body,
)
MARKER = "<!-- GENERATED from .agent/scoped/"
OWNED = [(Path(".github/instructions"), "*.instructions.md"),
(Path(".cursor/rules"), "*.mdc")]
def owned_outputs() -> set:
"""Files this generator claims, found by its own marker so a hand-written
file in the same directory is never touched."""
return {f for d, pattern in OWNED for f in d.glob(pattern)
if MARKER in f.read_text()}
def main(check: bool = False) -> int:
srcs = sorted(SRC.glob("*.md"))
if not srcs:
print(f"no sources in {SRC}", file=sys.stderr)
return 2 # missing input is a failure, not a no-op
expected, stale = {}, []
for src in srcs:
try:
meta, body = split_source(src)
except ValueError as e:
print(e, file=sys.stderr)
return 2 # bad input fails before anything is written
for emit in (emit_copilot, emit_cursor):
path, text = emit(src.stem, meta, body)
expected[path] = text
# The failure a regenerating script cannot see: the source is gone, the
# generated file is still on disk, and every tool still loads it.
orphans = sorted(owned_outputs() - set(expected))
# Preflight every target before writing any of them. The marker protects
# orphan deletion; it does not protect the write loop, so a handwritten
# `.cursor/rules/react.mdc` sitting where a canonical `react.md` source also
# exists would be silently replaced without this.
collisions = sorted(p for p in expected
if p.exists() and MARKER not in p.read_text())
if collisions:
print("refusing to overwrite unowned files: "
+ ", ".join(str(p) for p in collisions), file=sys.stderr)
return 2
for path, text in expected.items():
if check:
if not path.exists() or path.read_text() != text:
stale.append(str(path))
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
if check:
problems = stale + [f"{p} (orphaned)" for p in orphans]
if problems:
print("out of sync: " + ", ".join(problems), file=sys.stderr)
return 1
return 0
for p in orphans: # only files carrying our marker
p.unlink()
print(f"removed orphan {p}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main(check="--check" in sys.argv))
Five things in that script are the difference between a generator and a habit. It serializes frontmatter rather than composing YAML by hand—a description as ordinary as React: UI rules interpolated into description: {value} yields a file that fails to parse with mapping values are not allowed here, and the failure surfaces in whichever tool reads it rather than in the generator. It has a --check mode, because “run it in CI” is not a drift gate: a script that regenerates always succeeds, so sync-agent-config.sh --check is what actually fails the build when a generated file no longer matches its source, and a missing source directory fails too rather than reporting success over nothing.
It parses the frontmatter delimiters as lines rather than splitting on the first three dashes it finds, which is the defect most such scripts ship with. raw.split("---", 2) treats a separator anywhere as the boundary, so a source whose description reads Use --- as a separator has its metadata truncated to the text before those dashes; globs then vanishes and the run dies on a KeyError traceback that names neither the file nor the field. Validating parsed metadata converts that into a named failure, and because the whole expected-output mapping is built before the write loop opens, a bad source anywhere in the set means nothing is written at all—which is the behavior you want from a generator whose outputs are read by other tools.
Then compare the line with rstrip rather than strip, which is the same bug one level in. A delimiter sits at column 0; strip also matches an indented ---, and an indented one is ordinary text inside a YAML block scalar:
description: |
First paragraph
---
Second paragraph
globs: ["src/**/*.tsx"]
That file is valid and PyYAML reads both fields from it, but a strip comparison ends the frontmatter at the indented dashes and the script rejects it for a missing globs. Trailing whitespace and CRLF line endings still need absorbing, which is what rstrip is for; leading whitespace is meaningful and must not be.
And it validates types, not merely that the required keys are present, which is the difference between a diagnostic and silent corruption. Write one pattern as a bare string—globs: 'src/**/*.tsx', which is natural and which YAML is happy to give you—and ",".join() iterates the string’s characters. Both files are emitted with applyTo: s,r,c,/,*,*,/,*,.,t,s,x, the run exits 0, --check exits 0 too, and the rule still loads in Cursor and Copilot while matching nothing it was meant to. Nothing anywhere reports a problem. A non-mapping root such as [description, globs] is the same class of hole from the other side: .keys() raises an uncaught AttributeError before any diagnostic is printed. Check the shape, check each field’s type, normalize the one ambiguity you accept, and refuse the rest by name.
And it tracks orphans, which is the case iterating over sources cannot see. Delete react.md and the loop simply stops visiting it: react.mdc and react.instructions.md stay on disk, and Cursor and Copilot keep loading a rule you deleted. Identifying generator-owned files by the GENERATED marker they already carry lets check mode report them and a normal run remove them.
The fifth is what that marker alone does not buy, and the obvious reading of the paragraph above is wrong about it. The marker governs deletion, not the write loop, which visits a path because a canonical source has that name and writes it whether or not something unowned sits there—so a handwritten .cursor/rules/react.mdc is overwritten by a run that exits 0. Preflight every target, refuse the whole run on an unowned collision, and write nothing until the set is clear.
The alternative, maintaining four copies by hand, produces divergence within a month, and divergent instruction files are worse than none because different developers get different behavior from the same repository.
43.3 Prompting to the Intersection #
Where a prompt must work across models, prompt to the intersection rather than to any single model’s habits (§33.5):
- Explicit structure. XML or Markdown sections, not implied organization.
- Explicit output contract. Never rely on a model’s default formatting.
- Explicit constraints, stated positively and negatively.
- No dependence on a specific reasoning style. Do not assume extended thinking; do not assume its absence.
- No model-specific phrasings. Anything discovered by optimizing against one model is a liability everywhere else.
And run the eval suite (§34) against every model you support. A prompt that scores 0.91 on one and 0.68 on another is not portable, whatever its structure looks like.
43.4 What Not to Try to Unify #
Three areas differ enough between platforms that abstraction costs more than it saves:
The models differ—explicit breakpoints, implicit modes, and a storage meter are not the same mechanism wearing different names. Configure per platform.
Event names, payload shapes, and block semantics differ. Write them per platform and keep the enforced policy in a shared script that each hook calls.
The frontmatter schemas and the delegation semantics differ enough that a generated definition is usually wrong in a subtle way.
References cited in this section
2 of 81 · numbering matches the PDF
- 80Cursor, "Plugins," Cursor documentation and Gemini CLI, "Subagents," https://geminicli.com/docs/core/subagents/, both verified September 25, 2026; and Gemini CLI, "Provide context with GEMINI.md files," the project's own documentation at https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/gemini-md.md, verified September 27, 2026. Vendor documentation. Cited for the two §43.1 crosswalk cells that older comparisons show empty: Cursor packages rules, skills, agents, commands, hooks and MCP servers as plugins distributed through a reviewed marketplace and team marketplaces, and Gemini CLI defines subagents in .gemini/agents/*.md with automatic delegation and explicit @-invocation. The third source is what supports §43.1's path-scoped Gemini cell, which the first two do not: it sets out the three-tier context hierarchy—global, workspace, and just-in-time—and documents that when a tool accesses a file or directory the CLI "automatically scans for GEMINI.md files in that directory and its ancestors up to a trusted root," loading them only when needed. An earlier revision of this document made that claim against this entry before the supporting page was in it.cursor.com/docs/plugins ↗
- 39Linux Foundation, "Linux Foundation Announces the Formation of the Agentic AI Foundation (AAIF), Anchored by New Project Contributions Including Model Context Protocol (MCP), goose and AGENTS.md," December 2025 and the AGENTS.md specification at https://agents.md/. Primary announcement plus specification. Establishes neutral governance for both MCP and AGENTS.md, which is the durable signal for an organization standardizing on either.www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation ↗