Skip to content

AGENTS.md Convention

AI coding agents need project-specific instructions: naming conventions, architecture rules, test commands, off-limits directories, language preferences. Without a discovery mechanism, every user must pass these as flags on every invocation, or paste them into every chat session. The AGENTS.md convention is the ecosystem’s answer: a Markdown file committed to the repository that the agent automatically discovers, reads, and injects into the system prompt before the user sends a single message.

The hard part is hierarchy. A monorepo might have:

/AGENTS.md → repo-wide rules: "use conventional commits, never edit generated files"
/backend/AGENTS.md → backend rules: "SQLite only, no ORMs"
/backend/api/AGENTS.md → API-layer rules: "all responses must include a request_id field"

When the agent’s working directory is /backend/api/, it should see all three files, most-specific-last, so that deeper rules can override or supplement higher ones. Implementing this correctly requires anchoring the search at the Git root (not the filesystem root), walking down to CWD, and concatenating in the right order—while keeping the total byte count bounded to avoid burning context on documentation.

A secondary problem is override files. Teams often commit AGENTS.md to version control but need a local, untracked file with personal preferences that should not be shared. The override mechanism (AGENTS.override.md in Codex) solves this without requiring gitignore gymnastics.


Reference commit: b9050e1d5faf8096eae7a46a9ecc05a86231384b

Aider does not implement automatic AGENTS.md discovery. There is no code in Aider that walks the directory tree searching for an instruction file. Instead, Aider uses an explicit opt-in model: the user names every read-only file they want injected into context.

Defined in aider/args.py:

group.add_argument(
"--read",
action="append",
metavar="FILE",
help="specify a read-only file (can be used multiple times)",
).complete = shtab.FILE

Processed in aider/main.py (lines 681–687):

read_only_fnames = []
for fn in args.read or []:
path = Path(fn).expanduser().resolve()
if path.is_dir():
read_only_fnames.extend(str(f) for f in path.rglob("*") if f.is_file())
else:
read_only_fnames.append(str(path))

If the argument is a directory, Aider recursively includes every file inside it via rglob("*"). If it is a file, it resolves the path and adds it. No parent directory walking occurs at any point.

The flag can also be specified in .aider.conf.yml for permanent per-project defaults:

read:
- CONVENTIONS.md
- docs/architecture.md

Read-only files are held on BaseCoder.abs_read_only_fnames (a set of resolved paths). When building the message list, base_coder.py calls get_read_only_files_content() (lines 659–670), which formats each file as a fenced code block with a relative path header:

def get_read_only_files_content(self):
prompt = ""
for fname in self.abs_read_only_fnames:
content = self.io.read_text(fname)
if content is not None and not is_image_file(fname):
relative_fname = self.get_rel_fname(fname)
prompt += "\n"
prompt += relative_fname
prompt += f"\n{self.fence[0]}\n"
prompt += content
prompt += f"{self.fence[1]}\n"
return prompt

The aggregated content is then prepended with a prompt from base_prompts.py (line 50):

read_only_files_prefix = """Here are some READ ONLY files, provided for your reference.
Do not edit these files!
"""

And injected as a user/assistant exchange in the message list (lines 765–777):

read_only_content = self.get_read_only_files_content()
if read_only_content:
readonly_messages += [
dict(role="user", content=self.gpt_prompts.read_only_files_prefix + read_only_content),
dict(role="assistant", content="Ok, I will use these files as references."),
]

The assistant’s acknowledgement turn is a prompt-engineering trick: it conditions the model to treat those files as background context rather than as an active conversation turn.

Aider’s philosophy is explicit over implicit. There is no magic auto-discovery. Users control exactly what the model sees. The downside is friction: every new team member must know to add --read AGENTS.md or configure it in .aider.conf.yml. There is no onboarding path for a fresh git clone.


Reference commit: 4ab44e2c5cc54ed47e47a6729dfd8aa5a3dc2476

Codex implements full hierarchical discovery in codex-rs/core/src/project_doc.rs. The module comment is itself the specification:

//! 1. Determine the Git repository root by walking upwards from the current
//! working directory until a `.git` directory or file is found. If no Git
//! root is found, only the current working directory is considered.
//! 2. Collect every `AGENTS.md` found from the repository root down to the
//! current working directory (inclusive) and concatenate their contents in
//! that order.
//! 3. We do **not** walk past the Git root.

Discovery Algorithm: discover_project_doc_paths

Section titled “Discovery Algorithm: discover_project_doc_paths”

The function discover_project_doc_paths (line 182) takes a Config and returns Vec<PathBuf> in root-to-CWD order.

Phase 1 — walk up to find Git root:

let mut chain: Vec<PathBuf> = vec![dir.clone()];
let mut git_root: Option<PathBuf> = None;
let mut cursor = dir;
while let Some(parent) = cursor.parent() {
let git_marker = cursor.join(".git");
let git_exists = match std::fs::metadata(&git_marker) { ... };
if git_exists {
git_root = Some(cursor.clone());
break;
}
chain.push(parent.to_path_buf());
cursor = parent.to_path_buf();
}

The chain is built CWD-first (deepest first). Note that .git can be a file (for git worktrees) or a directory; std::fs::metadata covers both.

Phase 2 — build root-to-CWD search order:

let search_dirs: Vec<PathBuf> = if let Some(root) = git_root {
let mut dirs: Vec<PathBuf> = Vec::new();
let mut saw_root = false;
for p in chain.iter().rev() {
if !saw_root {
if p == &root { saw_root = true; } else { continue; }
}
dirs.push(p.clone());
}
dirs
} else {
vec![config.cwd.clone()]
};

The chain is reversed (now root-first) and trimmed to only include directories at or below the Git root. If no .git was found, only CWD is searched.

Phase 3 — scan each directory for instruction files:

let candidate_filenames = candidate_filenames(config); // ["AGENTS.override.md", "AGENTS.md", ...fallbacks]
for d in search_dirs {
for name in &candidate_filenames {
let candidate = d.join(name);
match std::fs::symlink_metadata(&candidate) {
Ok(md) => {
let ft = md.file_type();
if ft.is_file() || ft.is_symlink() {
found.push(candidate);
break; // stop at first match per directory
}
}
...
}
}
}

The filename priority within each directory is:

  1. AGENTS.override.md — local override, intended to be gitignored
  2. AGENTS.md — the standard version-controlled file
  3. Any additional fallback names from config.project_doc_fallback_filenames

The break after the first match means that a directory with both AGENTS.override.md and AGENTS.md will only contribute the override file.

Reading and Byte-Budgeting: read_project_docs

Section titled “Reading and Byte-Budgeting: read_project_docs”

The constant PROJECT_DOC_MAX_BYTES = 32 * 1024 (32 KiB, defined at line 110 of config/mod.rs) caps total bytes read. Each file is read with a tokio::io::BufReader::take(remaining) — the OS-level read itself is capped; the excess is never even loaded into memory. Files that exceed the remaining budget are silently truncated with a tracing::warn!. Multiple files are joined with "\n\n":

Ok(Some(parts.join("\n\n")))

Merging with CLI Instructions: get_user_instructions

Section titled “Merging with CLI Instructions: get_user_instructions”

If the user also set config.user_instructions (via --instructions CLI flag or config file), the base instructions are written first, then the project doc is appended with a visual separator:

const PROJECT_DOC_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";

The assembled string is wrapped in a structured UserInstructions struct (instructions/user_instructions.rs) and converted to a ResponseItem::Message with role: "user":

format!(
"{USER_INSTRUCTIONS_PREFIX}{directory}\n\n<INSTRUCTIONS>\n{contents}\n</INSTRUCTIONS>",
directory = ui.directory,
contents = ui.text
)

Where USER_INSTRUCTIONS_PREFIX = "# AGENTS.md instructions for ". The final wire format for a session in /home/user/myproject/backend/ looks like:

# AGENTS.md instructions for /home/user/myproject/backend/
<INSTRUCTIONS>
[root AGENTS.md contents]
[/src AGENTS.md contents]
[/src/backend AGENTS.md contents]
--- project-doc ---
[config.user_instructions if set]
</INSTRUCTIONS>

This is sent as a user-role turn before the first assistant turn. The is_user_instructions() predicate on UserInstructions lets Codex identify and skip these messages when summarizing or replaying history.

When the ChildAgentsMd feature flag is enabled, Codex appends HIERARCHICAL_AGENTS_MESSAGE (from codex-rs/core/hierarchical_agents_message.md) to the instructions. This tells the model to respect the directory-scoping semantics: rules in a deeper AGENTS.md override those at a higher level, and AGENTS.override.md overrides both.


Reference commit: 7ed449974864361bad2c1f1405769fd2c2fcdf42

OpenCode’s discovery logic lives entirely in packages/opencode/src/session/instruction.ts. The module is more featureful than Codex’s: it supports multiple file types, global configuration files, remote HTTP(S) instructions, and a dynamic walk that fires when the agent reads a file deep inside the repo.

const FILES = [
"AGENTS.md",
"CLAUDE.md",
"CONTEXT.md", // deprecated
]

The fallback list provides compatibility: teams that previously used CLAUDE.md (Claude Code’s convention) or CONTEXT.md (an older OpenCode name) get their files picked up automatically. Only the first matching filename type is used — if AGENTS.md exists anywhere in the walk, CLAUDE.md is ignored.

Walk-up Primitives: Filesystem.findUp and Filesystem.globUp

Section titled “Walk-up Primitives: Filesystem.findUp and Filesystem.globUp”

Both live in packages/opencode/src/util/filesystem.ts. findUp searches for an exact filename:

export async function findUp(target: string, start: string, stop?: string) {
let current = start
const result = []
while (true) {
const search = join(current, target)
if (await exists(search)) result.push(search)
if (stop === current) break
const parent = dirname(current)
if (parent === current) break // filesystem root
current = parent
}
return result
}

globUp is the same loop but uses Bun.Glob for pattern matching, enabling entries like ".cursor/rules/*.md" in opencode.json.

Unlike Codex (which anchors at .git), OpenCode uses Instance.worktree as the stop boundary. This is the project root as OpenCode has detected it — typically the .git directory’s parent, but configurable.

The systemPaths() function (line 71) assembles the full set of instruction files to load at session start.

Step 1 — local project files:

for (const file of FILES) {
const matches = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
if (matches.length > 0) {
matches.forEach((p) => { paths.add(path.resolve(p)) })
break // stop after first file type found
}
}

Filesystem.findUp collects ALL matching files along the walk — both /AGENTS.md and /backend/AGENTS.md if both exist. The break only stops trying different filename types, not different directories. This is the inverse of Codex’s top-down walk: OpenCode walks bottom-up from Instance.directory toward Instance.worktree, collecting every hit.

Step 2 — global configuration files:

function globalFiles() {
const files = []
if (Flag.OPENCODE_CONFIG_DIR) {
files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
}
files.push(path.join(Global.Path.config, "AGENTS.md")) // ~/.config/opencode/AGENTS.md
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
}
return files
}

OPENCODE_CONFIG_DIR takes precedence. If unset, ~/.config/opencode/AGENTS.md is tried. If Claude Code compatibility is not disabled, ~/.claude/CLAUDE.md is also loaded, enabling users who have a global CLAUDE.md for Claude Code to get the same instructions in OpenCode without duplication.

Step 3 — custom instructions from opencode.json:

{
"instructions": [
"CONTRIBUTING.md",
".cursor/rules/*.md",
"https://example.com/company-standards.md"
]
}

Relative paths are resolved via resolveRelative() which calls Filesystem.globUp, enabling glob patterns. Absolute paths are scanned with Bun.Glob. HTTP(S) URLs are fetched at session start with a 5-second timeout in system().

The system() function (line 118) reads all paths and formats each file with a prefix:

return content ? "Instructions from: " + p + "\n" + content : ""

Remote URLs use the same prefix format:

.then((x) => (x ? "Instructions from: " + url + "\n" + x : ""))

The resulting array of strings is spread into the system parameter when calling the model (prompt.ts, line 653):

const system = [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())]

Each instruction file becomes a separate system message entry, rather than one concatenated blob. This gives the model structural separation between the environment context and each instruction source.

OpenCode has a second injection path that fires dynamically when the agent reads a file. The resolve() function (line 171) walks upward from the directory containing the file being read, looking for instruction files that were not already loaded at session start:

while (current.startsWith(root) && current !== root) {
const found = await find(current)
if (found && found !== target && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) {
claim(messageID, found)
// read and append to results
}
current = path.dirname(current)
}

This “lazy discovery” means an AGENTS.md in a deeply nested subdirectory gets loaded automatically the first time the agent opens any file in that directory, even if the agent did not start there. The claims map (keyed on messageID) prevents the same file from being injected more than once per message.

VariableEffect
OPENCODE_DISABLE_PROJECT_CONFIGDisables local project file discovery entirely
OPENCODE_DISABLE_CLAUDE_CODE_PROMPTSkips ~/.claude/CLAUDE.md
OPENCODE_CONFIG_DIROverrides global config directory path

The 32 KiB silent truncation (Codex). Codex applies BufReader::take(remaining) at the OS read level. There is no error and no truncation marker in the output. A 40 KiB AGENTS.md is silently clipped at 32 768 bytes mid-sentence. The warning only appears in tracing output, not in the chat UI. Users who write verbose instruction files will hit this without realizing rules at the bottom are never seen by the model.

Filename type priority vs. directory priority are orthogonal. In OpenCode, break after finding the first file type means: if AGENTS.md exists anywhere in the upward walk, CLAUDE.md is skipped entirely — even if a closer directory has CLAUDE.md and only a distant directory has AGENTS.md. This surprises users migrating from Claude Code who have CLAUDE.md locally and find it ignored because there is a AGENTS.md somewhere higher in the tree.

Git worktrees and submodules. Codex’s walk stops at the first .git marker, whether directory or file. In a git worktree, .git is a file pointing to the main repository. Codex correctly recognizes it and stops. Submodules are a different problem: a submodule contains its own .git directory, so Codex will NOT walk up to the parent repository’s AGENTS.md when the CWD is inside a submodule. Whether this is a feature or a bug depends on the monorepo structure.

The AGENTS.override.md file is not automatically gitignored. Codex checks for AGENTS.override.md first in every directory. The intent is local overrides that are gitignored, but Codex does not add anything to .gitignore automatically. Teams that create override files and forget to gitignore them will accidentally commit them.

Dynamic discovery races with context budgets. OpenCode’s resolve() path injects instruction files mid-conversation when the agent first reads a file in a new directory. This means the effective system prompt grows during a session. If context is already near the model’s limit, the injected instructions can push the session over and cause silent truncation or errors. There is no pre-flight check that warns about this.

HTTP instructions fail silently. OpenCode fetches remote instruction URLs with AbortSignal.timeout(5000) and catches all errors. A DNS failure, expired certificate, or 404 results in an empty string with no user-visible warning. If a team relies on a shared remote instructions URL and that server goes down, every developer’s sessions silently lose those rules.

Aider’s explicit model has a different failure mode. If a developer forgets to add --read AGENTS.md to their command, they get zero project instructions. There is no safety net. The explicit approach is more predictable but requires team process to work — a shared Makefile target or onboarding documentation that says “always run aider with --read AGENTS.md.”


OpenOxide should implement hierarchical discovery closer to Codex (Git-anchored, root-to-CWD order, deterministic) with two additions from OpenCode (global config files, override filename support). The dynamic per-file injection from OpenCode is worth implementing but gated behind a config flag.

  • git2 — for detecting the repository root. git2::Repository::discover(&cwd) returns the .git-anchored root without manual upward walking. Handles worktrees, submodules, and bare repos correctly. This is strictly better than reimplementing the .git walk by hand.
  • tokio::fs — async file reading with take(budget) for byte-capping, same pattern as Codex.
  • globset — for glob pattern matching in custom instruction paths from config (the .cursor/rules/*.md use case).
pub trait InstructionSource: Send + Sync {
/// Return instruction text with its source label, e.g. ("AGENTS.md", content).
async fn load(&self) -> Option<(String, String)>;
}

Three implementations:

  • ProjectDocSource — hierarchical AGENTS.md discovery using git2
  • GlobalConfigSource~/.config/openoxide/AGENTS.md and opt-in ~/.claude/CLAUDE.md
  • CustomFileSource — arbitrary paths and globs from openoxide.toml
1. git2::Repository::discover(&cwd)? → workdir() → repo_root
2. Build dir_chain: repo_root, repo_root/a, repo_root/a/b, ... cwd
3. For each dir in chain:
a. Check dir/AGENTS.override.md (gitignored local override)
b. Check dir/AGENTS.md
c. Check configured fallback names
d. Break after first hit per directory
4. Read each found file with tokio::fs + AsyncReadExt::take(remaining_budget)
5. Concatenate with "\n\n" separator
6. Prepend config.instructions with PROJECT_DOC_SEPARATOR if both present

Default: 32 768 bytes (match Codex). Configurable via openoxide.toml:

[context]
project_doc_max_bytes = 65536

Unlike Codex, log a warn! with a summary shown in the TUI status bar when truncation occurs, so the user knows rules were dropped.

Codex’s UserInstructions XML wrapper is clean and worth copying. The injection goes as the first user-role message in the conversation, before any user input:

# AGENTS.md instructions for {cwd}
<INSTRUCTIONS>
{assembled_instructions}
</INSTRUCTIONS>

Tag the message with a metadata flag so the session storage layer can recognize and skip it during history compaction (same as Codex’s is_user_instructions() predicate).

Support AGENTS.override.md with the same semantics as Codex. Additionally, generate a .gitignore warning in the TUI if an AGENTS.override.md is detected but not listed in the repo’s .gitignore. A one-line check on startup prevents accidental commits of personal override files.

Behind a config flag context.dynamic_instruction_discovery = true (default: false), implement OpenCode’s resolve() pattern: when the agent reads a file, scan the file’s parent directories up to repo root for instruction files not yet loaded, and inject them as additional system turns. Gate it behind a flag because it has non-obvious context budget implications and is surprising to users who do not expect the system prompt to change mid-session.