DEV Community

Cover image for How I Wrote an AGENTS.md for an Open Source WordPress Plugin
Ubayed Bin Sufian
Ubayed Bin Sufian

Posted on

How I Wrote an AGENTS.md for an Open Source WordPress Plugin

I'm an active contributor to WPFAevent, a WordPress plugin maintained by FOSSASIA. A few weeks into working on it alongside AI coding agents, the friction got obvious enough that I opened an issue on the repo myself: the project needed an AGENTS.md. Around the same time I'd been reading through the AGENTS.md series — what belongs in the file, why it rots, how to keep it true. This is the story of actually writing one against a real, active codebase, and what ended up in the version that got merged.

The problems that pushed me to write it

This wasn't a theoretical exercise. A few weeks of using different AI coding agents on this repo made the case on their own:

  • GitHub Copilot was burning through AI credits fast. Because it had to figure out the same project details for every task, like build commands, coding conventions, and where files were located.
  • Other agents hallucinated constantly. They suggested test commands that didn't exist, made up capability names, and assumed the wrong folder structure.
  • Agents refactored code nobody asked them to touch. "Fix this bug" would come back with unrelated files reformatted or restructured along the way.
  • Version numbers got bumped without being asked. More than once, an agent decided a small fix was also a good moment to cut a version bump.
  • Architectural files changed more than they should have. composer.json, composer.lock — config an agent has no business touching unless explicitly told to — got edited in the course of "just fixing a bug or while introducing a new feature."
  • Some PRs became unreviewable. A few agent-generated PRs touched around 40 files and added close to 7,000 lines in one go. Reviewing that by hand took far longer than the change deserved, and even GitHub Copilot's own PR review fell over on diffs that large — it couldn't give useful, focused feedback on a change that size.

Every one of those is a symptom of the same root problem: the agent had no source of truth for the project, so it was guessing, and guessing agents are expensive and destructive in roughly equal measure.

What AGENTS.md actually is, and why it's not just another README

AGENTS.md is a single Markdown file at the root of a repository that briefs AI coding agents on how to work in that specific codebase — the real build and test commands, where things live, which conventions are non-negotiable, and which changes need a human in the loop before they happen. It's become something of an open, tool-agnostic standard: most of the major coding agents (Copilot, Claude Code, Cursor, Codex, and others) look for it by default.

The distinction that matters most is this: README.md is for humans, AGENTS.md is for the agent. A README explains what the project is and why it exists — the pitch, the features, the installation steps for an end user. AGENTS.md answers a completely different question: how do I work in this repo without breaking it? The real test commands. The directories that matter. The things that must never be touched without asking first.

It's necessary for a very practical reason: without it, an agent has to reconstruct all of that context from scratch, every single time, by reading through files and guessing at conventions. That's slow, it burns tokens/credits, and worse — a guess that looks plausible is exactly how you get a hallucinated test command or an invented capability name that quietly breaks something. A short, accurate AGENTS.md turns "figure out the project" into "read one file," and turns "guess and hope" into "follow the rule that's written down."

The project

WPFAevent integrates Eventyay-based events into WordPress — landing pages, speakers, schedules, code of conduct, all renderable through page templates, Gutenberg blocks, or shortcodes. PHP 7.4+, WordPress 5.8+, a Composer-based PHP toolchain (PHPCS, PHPStan, PHPUnit), with JavaScript tooling (ESLint, Prettier) landing in a separate PR around the same time.

It's a real, active, moderately sized codebase — includes/, admin/, public/, a test suite, WordPress coding standards enforced by CI. Exactly the kind of repo where an AI coding agent's first move matters: does it guess the test command, or does it know it on sight?

Writing it from the repo, not from memory

Even as an active contributor who already knew the shape of the project, I didn't want to write AGENTS.md from what I remembered — memory drifts, and an agent trusts whatever's written down without questioning it the way a human would. So I went back to the primary sources directly, cloning a fresh copy and reading the actual config files rather than describing the project from recall:

  • composer.json for the real scripts (phpcs, phpcbf, phpstan, test, setup-tests)
  • phpcs.xml for the actual coding-standard ruleset and the custom capability names it already declares
  • phpstan.neon.dist for the static analysis config and target PHP version
  • phpunit.xml.dist for the real test suite names (Unit, Integration) and where they live
  • tests/README.md for the actual local test-environment setup
  • the real directory tree, not the one described in prose

That last one mattered more than I expected. The README's directory structure section didn't match what was actually in the repo — different file names, a stale sketch of the plugin from an earlier stage of the project. If I'd copied that into AGENTS.md, I'd have shipped a lie into the one file an agent trusts unconditionally. Also worth calling out: the repo already had package.json and ESLint/Prettier tooling arriving in a parallel PR, so I made the npm commands in AGENTS.md conditional on that file actually being present, rather than asserting it as settled fact before it had landed.

Writing the file, section by section

With real commands, a real tree, and real config files in hand, here's what went into the file and why each part earns its place:

  • Orientation — one line at the top: what the project is, the language, the framework, nothing more. It sets the agent's priors before it reads a single line of code, so it isn't opening a PHP file wondering if this is a Laravel app.
  • Setup & tests — copy-pasteable commands, and tests ranked above build. Tests are the only way an agent can check its own work against reality instead of trusting a stale belief about what "done" looks like. Given that hallucinated test commands were one of the exact problems I was trying to solve, this section directly targets that.
  • Lint & static analysis — the real PHPCS, PHPStan, and (conditional) ESLint/Prettier commands, called out separately from tests because they're a different kind of check with a different failure mode.
  • Where things live — a short map of includes/, admin/, public/, tests/ and what each holds, not a full directory dump. The agent can run ls; what it can't do is know that eventyay-importer/ already has repository and API-client classes it should be extending, not duplicating.
  • Conventions — specific and verifiable, not vague. "Follow WordPress Coding Standards, enforced by composer phpcs" rather than "write clean code." Wherever a linter or config file already owns a rule, the file points at that config instead of restating it, so the rule can't quietly drift out of sync.
  • Existing patterns — before writing new code: search for something similar already in the codebase, reuse the helper classes, extend existing hooks instead of building a parallel system next to them. This one exists directly because of the refactoring and reinvention problem — WPFAevent has real internal structure (repositories, an importer layer, a cache class), and an agent that doesn't know that exists will happily build a second version of it two directories over.
  • Guardrails, in three tiers — what's always safe, what needs to be asked about first, and what's a flat no. This is where the version-bump problem and the architectural-file problem get addressed directly: don't touch composer.json / package.json / phpcs.xml / phpstan.neon.dist / phpunit.xml.dist without asking, and never bump plugin or package version numbers unless explicitly told to. A flat "don't" list is weaker than a tiered map, because it doesn't tell the agent what it is free to do without checking in first.
  • Definition of Done — mechanically checkable, so the agent knows when it's actually finished instead of guessing: PHPCS exits 0, PHPStan exits 0, tests pass, changes committed on a branch.
  • When stuck — a default escalation path: ask a question, propose a short plan, or open a draft PR — rather than pushing large speculative changes across multiple directories at once.
  • Security & secrets — where the real credentials live (WP admin settings, not the repo) and an explicit "never hardcode these."
  • Commit & PR — branch discipline, PR size capped at roughly 300 lines, splitting unrelated changes into separate PRs, Conventional Commit messages. This one is a direct response to the ~40-file, ~7,000-line PRs — a size limit written into the file is the difference between a change a human (or Copilot's review) can actually evaluate and one that just gets rubber-stamped because nobody has time to read it properly.

Every one of those sections maps to something that was actually going wrong before the file existed, not something added for the sake of completeness.

The file that got merged

# AGENTS.md
WordPress plugin (PHP 7.4+, WordPress 5.8+) integrating Eventyay events into WordPress via page templates, Gutenberg blocks, and shortcodes.

JavaScript tooling is optional. Run npm commands only when `package.json` is present or JavaScript files are being modified.

## Setup
- composer install
- npm install              # if JavaScript tooling is needed

## Run the tests
- composer test                               # full PHPUnit suite
- composer test -- --testsuite Unit           # unit tests only
- composer test -- --testsuite Integration    # integration tests only (needs local WP test env)
- composer setup-tests                        # bootstrap the local WordPress test DB (bin/install-wp-tests.sh

## Lint & static analysis
- composer phpcs            # WordPress Coding Standards (phpcs.xml)
- composer phpcbf           # auto-fix PHPCS violations
- composer phpstan          # static analysis (phpstan.neon.dist)
- npm run lint              # ESLint (only if package.json is present)
- npm run format            # Prettier (only if package.json is present)
- npm run check             # lint + phpcs + phpstan (only if package.json is present)

## Where things live
- wpfaevent.php — plugin bootstrap (entry point, defines WPFAEVENT_VERSION/PATH/URL)
- includes/ — core logic: class-wpfaevent.php (main class), class-wpfaevent-loader.php (hooks), class-wpfaevent-templates.php (page templates), cpt/, taxonomies/, meta/, helpers/, cache/, cli/, eventyay-importer/ (API client, repositories, JSON:API parsing)
- admin/ — admin settings pages, dashboard, Eventyay sync/importer UI, partner dashboard
- public/ — public-facing rendering: class-wpfaevent-public.php, partials/, templates/, css/, js/
- tests/ — PHPUnit; tests/unit/ (*Test.php) plus tests/*.php in tests/ (e.g., calendar-test.php); phpunit.xml.dist references tests/integration/ for the Integration suite (currently not present in the repo).
- languages/ — i18n .pot file (Text Domain: wpfaevent)
- bin/install-wp-tests.sh — bootstraps the local WP test database

## Conventions
- Follow WordPress Coding Standards — enforced by `composer phpcs` (phpcs.xml); don't hand-format, run phpcbf.
- Keep PHPStan clean at the configured level (phpstan.neon.dist).
- All user-facing strings wrapped in `__()` / `_e()` with Text Domain `wpfaevent`.
- Core logic in includes/, presentation in public/partials/ and public/templates/ — don't mix business logic into templates.
- Custom capabilities (delete_events, delete_speakers, edit_events, edit_speakers, publish_events, publish_speakers) are listed in phpcs.xml for the WordPress.WP.Capabilities sniff — use them, don't invent new ad hoc capability strings.
- No committed real speaker/event images or large demo data — use placeholders only.
- If JS tooling (package.json) is present in the working tree, JS is linted via `@wordpress/eslint-plugin` and formatted via `@wordpress/prettier-config` — obey those configs, don't restate style rules.
- Keep styles in the appropriate CSS files; avoid inline CSS unless explicitly required.

## Existing patterns
Before implementing anything:
- Search for similar functionality before adding new code.
- Follow existing naming conventions.
- Reuse helper classes where possible.
- Extend existing hooks instead of introducing parallel systems.

## Guardrails
- Always: Read relevant files before editing. Follow the project conventions. Run the required validation commands before considering work complete.
- Ask first: running `composer test -- --testsuite Integration` against anything but a local/throwaway DB, adding new dependencies, changing phpcs.xml / phpstan.neon.dist / phpunit.xml.dist, touching bin/install-wp-tests.sh.
- Never:
  - commit vendor/, node_modules/, coverage/, or .phpunit.result.cache.
  - commit real speaker/event data or large binaries.
  - commit directly to main - branch and open a PR.
  - commit secrets or API endpoint credentials.
  - Update plugin or package version numbers unless explicitly requested.

## Definition of Done
Done when: `composer phpcs` exits 0 · `composer phpstan` exits 0 · `composer test` passes · (if package.json is present and JS files were modified) `npm run lint` exits 0 · changes committed on a feature branch with a clear message.

## When stuck
Ask a clarifying question, propose a short plan, or open a draft PR with notes - don't push large speculative changes across includes/, admin/, and public/ at once.

## Security & secrets
Eventyay API endpoint URLs and cache TTL are configured via **Settings → Event Plugin** in the WP admin, not committed to the repo. Never hardcode API credentials or real endpoint tokens in includes/ or admin/.

## Commit & PR
- Branch from `main`; never commit directly to `main`.
- Keep PRs focused and under ~300 lines where practical.
- Split unrelated changes into separate PRs.
- Before opening a PR, ensure the Definition of Done is satisfied.
- Keep translation strings wrapped correctly.
- Use Conventional Commit messages (e.g. `feat:`, `fix:`, `docs:`, `refactor:`)
Enter fullscreen mode Exit fullscreen mode

It's around 50 lines of actual instruction — comfortably inside the "short enough to trust every line" range the series argues for, and every command in it is one I ran myself before it went in the file.

What I'd tell someone doing this for the first time

A few things stuck with me more than the rest:

Read the repo, not the README. They drift apart faster than you'd think, and the README gets the benefit of a human's skepticism when it's wrong. AGENTS.md doesn't get that benefit — an agent runs what it's told.

Point at configs instead of copying them. "Follow the WordPress Coding Standards enforced by composer phpcs" survives a phpcs.xml rule change. "Use 2-space indentation, prefer arrays over array()" doesn't — the moment the linter config changes, that line is quietly wrong and nobody notices until an agent trusts it.

Every guardrail should trace back to something that actually went wrong. I didn't write the "don't bump version numbers" or "ask before touching phpcs.xml/phpstan.neon.dist" rules speculatively — they're in the file because agents had already done those exact things on this repo before AGENTS.md existed. A guardrail list that isn't grounded in real incidents tends to either miss the actual landmines or pad itself with rules nobody needed.

A stale line about a directory that doesn't exist is worse than no line at all. phpunit.xml.dist references tests/integration/ for the Integration suite, but that directory isn't actually in the repo yet — so the file says exactly that instead of implying a folder that isn't there. A file that looks authoritative and is quietly wrong gets obeyed exactly as confidently as one that's right.

The file merged as PR #192. It's short, every command in it runs, and the next contributor — human or agent — gets the thirty-second version of everything it took me an afternoon of reading real config files to learn.

References

Top comments (0)