One spec, three readers — Confluence for the PO, git for the devs, markdown for the agent

September 15, 2026

On most projects I work on, the specification lives in three places at once, and they do not agree.

The product owner writes it in Confluence, because that is where the rest of the company reads. The developers read it once, at the start of the feature, and then work from memory and from the code. And the AI agent — Claude Code, Codex, whatever runs in the repository — does not see it at all: it sees the code, so it reverse-engineers the intent from the implementation, which is exactly backwards.

Three weeks later the PO has edited a business rule, nobody noticed, and the agent is happily "fixing" the code to match the old behaviour.

The fix I landed on is boring: mirror the Confluence pages as markdown files, and commit them. The PO keeps writing in Confluence. The repository gets a copy the developers can diff and the agent can read. Corrections made from the repository go back to Confluence.

I have written about the two tools this relies on — confluence-md-sync, which does the mirroring, and md-browser-editor, which reads and edits the result and hands work to an agent. This post is about putting them together on a real project, and about why it is worth the ten minutes.

      PO                         Devs                        Agent
       │                          │                            │
  Confluence  ── yarn pull ──▶  docs/specs/*.md  (git)  ◀── reads, edits
       ▲                          │
       └──── yarn push:apply ─────┘   (after a reviewed change)

What each reader gets out of it

The product owner keeps Confluence

Nobody is asked to migrate, learn git, or write markdown. The pages stay where they are, with their comments, their permissions and their page history. The mirror is a copy the technical side of the team keeps for itself.

What changes for the PO is the feedback. Instead of "the spec for the booking calendar says X but we did Y" in a chat thread, a developer can propose the corrected paragraph as a diff, get it reviewed, and publish it back to the page. The page stays the source of truth, and it stays true.

The developers get a history of the spec

Once the specs are files in git, a pull turns every Confluence edit into something you can read in a terminal:

$ yarn pull
$ git diff --stat
 docs/specs/booking/rules.md            | 14 ++++++++------
 docs/specs/booking-calendar.md         |  3 ++-
-version: 6
+version: 7
 -A request can be cancelled until 24 hours before the booking starts.
+A request can be cancelled until 48 hours before the booking starts,
+except by an administrator.

That is the question "what changed in the spec since the last sprint?" answered with git log docs/specs. It also means a spec change and the code implementing it can travel in the same pull request, and that grep -r "cancel" docs/specs works.

The agent gets the intent, not just the implementation

This is the part that made me set it up on every project. An agent reads files. Give it the spec as files and you do not need a Confluence connector, an MCP server, a token in the agent's environment, or a round trip per page: it opens docs/specs/booking/rules.md the same way it opens src/booking/rules.ts.

And the mirror is built to be read by something that has never seen the project:

  • each directory has a generated README.md index — the Confluence tree, in Confluence's order, with a one-line entry per page — so the agent can orient itself in one read;
  • each page carries a frontmatter with its Confluence id, URL, version and last update date, so the agent can cite the page and knows how fresh it is;
  • screenshots and mockups are downloaded beside the page (assets/), referenced with relative paths — a multimodal agent can look at them.

Which turns prompts like these into things that actually work:

Read docs/specs/booking/rules.md and list the rules that are not implemented in src/booking.

The spec changed in the last pull — look at git diff HEAD~1 -- docs/specs and tell me what code is affected.

Write the acceptance tests for docs/specs/booking-calendar.md.

Setting it up

Everything below is taken from a project I set up this week: a room booking application whose functional specs live in one Confluence folder. It works the same in the application's own repository (under docs/) or in a small dedicated repository sitting next to it — that project uses the latter.

You need Node ≥ 18, a Confluence Cloud site, and read access to the pages. That is all.

1. Install the two tools and wire the scripts

npm install --save-dev @alagrede/confluence-md-sync md-browser-editor
# or: yarn add -D @alagrede/confluence-md-sync md-browser-editor

Both have zero runtime dependencies, so this adds two packages to your lockfile and nothing else. Mind the scope on the first one: the unscoped confluence-md-sync on npm is somebody else's project.

Then the scripts. This is the part that makes it a habit rather than a command someone has to look up:

{
    "scripts": {
        "pull": "confluence-md-sync pull",
        "pull:dry": "confluence-md-sync pull --dry-run",
        "push": "confluence-md-sync push",
        "push:apply": "confluence-md-sync push --apply",
        "read": "confluence-md-sync serve --open",
        "edit": "md-browser-editor serve ./docs --open",
        "mentions": "md-browser-editor mentions ./docs --json"
    },
    "devDependencies": {
        "@alagrede/confluence-md-sync": "^0.1.5",
        "md-browser-editor": "^0.4.0"
    }
}

push without :apply is deliberately the dry run: the command you type by reflex is the one that writes nothing. If the scripts collide with your application's, prefix them (docs:pull, docs:push…).

With Yarn, options go straight after the script name — yarn pull --only booking. With npm they need a -- first: npm run pull -- --only booking.

2. Say what to mirror

npx confluence-md-sync init     # writes confluence-md-sync.config.mjs

Then edit it. Here is the whole file from that project, comments included, because the comments are what the next person needs:

// What the sync follows. The one place to change to follow another
// Confluence folder, or to add one.
//
// `rootId` is read from the folder's Confluence URL:
//   https://your-org.atlassian.net/wiki/spaces/HANDBOOK/folder/1845363038
// The root itself is not mirrored: its children are.
//
// Credentials are not here: CONFLUENCE_BASE_URL / CONFLUENCE_EMAIL /
// CONFLUENCE_API_TOKEN, read from the shell, then ./.env (see README.md).

export default {
    sources: [
        {
            // "Room management" folder of the HANDBOOK space
            rootId: '1845363038',

            // This whole directory is rewritten by `yarn pull`: hand-written
            // notes go somewhere else (docs/notes, for instance).
            outDir: 'docs/specs',

            label: 'Room management',
        },
    ],

    serve: {
        title: 'Room booking — documentation',
        // Served by `yarn read`. docs/ rather than docs/specs, so that the
        // hand-written docs/README.md is the landing page.
        root: 'docs',
        port: 4801,
    },
};

Two things to get right. Point rootId at the parent of what you want — a folder or a parent page — not at a leaf: the children become the mirror. And give the mirror its own directory. pull treats outDir as a copy of Confluence and rewrites it, so anything hand-written in it is lost on the next pull. That is why outDir is docs/specs and not docs.

Following a second Confluence tree — the technical specs, the API guidelines — is one more entry in sources, with its own outDir.

3. Keep the credentials out of the repository

Three variables, and a personal API token:

CONFLUENCE_BASE_URL=https://your-org.atlassian.net/wiki
CONFLUENCE_EMAIL=you@example.com
CONFLUENCE_API_TOKEN=

They are looked up in the shell first, then in the file named by $CONFLUENCE_MD_SYNC_ENV, then in ./.env, then in ~/.config/confluence-md-sync/env — first value wins. Each person on the team uses their own token, so nobody can publish to a page they could not already edit in Confluence.

On that project, the team already had an env file with those exact variables for other internal tools. So rather than copying the token a second time, .env is a symbolic link to it:

ln -s ~/.config/your-team/env .env

Whichever you pick, ignore it — before the first pull, not after:

# Confluence tokens
.env
.env.*

# Personal Claude Code settings (.claude/commands/ is shared)
.claude/settings.local.json

4. Pull, look, commit

yarn pull:dry     # GETs only, writes nothing: checks the rootId and the token
yarn pull
yarn read         # http://127.0.0.1:4801
Room management (HANDBOOK) → docs/specs

   +  docs/specs/home.md
   +  docs/specs/rooms/index.md
   +  docs/specs/rooms/room-details.md
   +  docs/specs/booking-calendar.md
   …
   +  docs/specs/README.md

15 created, 0 updated, 0 unchanged, 71 attachment(s) downloaded.

Read a couple of pages in the preview, check that tables and screenshots came through, then commit the mirror, images included:

git add docs/ && git commit -m "Mirror the Confluence specs"

This first commit is the baseline. Every later pull is a diff against it, and that diff is the whole value of the exercise — so do not gitignore the mirror.

5. Give the agent its contract

npx md-browser-editor init-agent .

Run it at the root of the repository, where the agent runs — not inside docs/specs, where a file Confluence did not produce would be reported as an orphan at every pull. It writes .claude/commands/mentions.md for Claude Code (commit it: it is the team's /mentions command) and a managed section in AGENTS.md for Codex.

One addition I would make on any project where the specs sit next to the code: a few lines in CLAUDE.md or AGENTS.md, so the agent reaches for the specs without being told each time.

## Specifications

The functional specs are mirrored from Confluence in `docs/specs`
(index: `docs/specs/README.md`). Read the relevant page before changing
a feature's behaviour. That directory is rewritten by `yarn pull`:
do not create files in it.

6. Write the README a newcomer needs

The last piece is a short README, because a teammate who clones the repository should not have to read this post. Mine has a table of the commands and the two rules that bite:

Command What it does
yarn pull Confluence → docs/specs. GETs only: nothing moves in Confluence.
yarn pull:dry says what pull would change, writes nothing
yarn read serves the mirror as HTML on 127.0.0.1:4801
yarn edit opens docs/ in the markdown editor on 127.0.0.1:4830
yarn push dry run: lists the pages that would be published
yarn push:apply actually publishes to Confluence
yarn mentions lists, as JSON, the mentions left for the agent

Plus a docs/README.md as the landing page of yarn read, pointing at specs/ and saying, in bold, that it is rewritten by pull.

The result, in full:

.
├── .claude/commands/mentions.md    # /mentions, for Claude Code
├── .env -> ~/.config/your-team/env # ignored
├── .gitignore
├── confluence-md-sync.config.mjs   # what to mirror
├── docs/
│   ├── README.md                   # hand-written landing page
│   └── specs/                      # the mirror — rewritten by pull
│       ├── README.md               # generated index
│       ├── home.md
│       ├── assets/home/…
│       └── rooms/
│           ├── index.md
│           ├── room-details.md
│           └── assets/…
├── package.json
└── README.md                       # commands and rules

The day-to-day loop

Before starting on a feature, pull. yarn pull, then git diff: that is what the PO changed since you last looked. Commit it on its own ("Sync specs"), so the history separates what Confluence changed from what you changed.

Read in the editor, not in the raw file. yarn edit renders the tables and images in place — and it is where you notice what is wrong.

Mark what needs fixing, let the agent write it. Select the paragraph that no longer matches the application, ⌘M, "update this to match the current cancellation rule in src/booking". Then /mentions in Claude Code: it applies each instruction to its passage, drops the markers, and leaves a diff.

Review the diff, then publish.

yarn push --only booking-calendar          # dry run: what would be published
yarn push:apply --only booking-calendar    # publish
yarn pull                                  # refresh the version numbers

push replaces the page body, so it guards itself: it refuses a page that moved in Confluence since your pull, and skips a page whose text you did not change — a repository-wide push:apply cannot reformat pages nobody touched. Pull again afterwards so the frontmatter carries the new version.

And keep your own notes out of the mirror. A gap analysis, a list of open questions for the PO, notes on how the code deviates: docs/notes/, never docs/specs/. Declare it in serve.sections if you want it in the preview's sidebar.

Before you rely on it

The mirror is faithful enough to read, review and reason about — not to rebuild a page identically. Macros, layouts and column widths do not survive the trip to markdown; info and warning panels come down as blockquotes and do not go back up as panels. So a page that leans heavily on macros is best corrected in Confluence itself, then pulled. The confluence-md-sync post has the details.

Nothing is ever deleted, in either direction: a page removed or renamed in Confluence leaves its old file behind, and pull lists it at the end for you to git rm.

And a mirror is only as fresh as the last pull. It is a habit, not a live sync — which is also why it is cheap, and why it never writes to Confluence behind anybody's back.

The ten-minute checklist

  1. npm install -D @alagrede/confluence-md-sync md-browser-editor, and the seven scripts.
  2. npx confluence-md-sync init, set rootId (the parent) and outDir (a directory of its own).
  3. Credentials in ~/.config/confluence-md-sync/env or an ignored .env.
  4. yarn pull:dry, yarn pull, yarn read — then commit the mirror with its images.
  5. npx md-browser-editor init-agent . at the root, and a pointer to docs/specs in CLAUDE.md / AGENTS.md.
  6. A README with the commands, and the one rule: the mirror is rewritten by pull.

After that, the PO writes in Confluence, the developers read diffs, and the agent reads the same spec everybody else does.

And since the mirror is just a folder of markdown with its images beside it, you can also open docs/specs in Znote as a linked folder — the whole spec tree as notes, searchable, with an AI block next to the page you are questioning.

Get Znote →


Znote is a local-first Markdown editor with executable code blocks and AI grounded in your notes. Opens Obsidian vaults natively.