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.mdindex — 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/specsand 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-editorBoth 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.mjsThen 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 .envWhichever 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.json4. 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:4801Room 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 rulesThe 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 numberspush 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
npm install -D @alagrede/confluence-md-sync md-browser-editor, and the seven scripts.npx confluence-md-sync init, setrootId(the parent) andoutDir(a directory of its own).- Credentials in
~/.config/confluence-md-sync/envor an ignored.env. yarn pull:dry,yarn pull,yarn read— then commit the mirror with its images.npx md-browser-editor init-agent .at the root, and a pointer todocs/specsinCLAUDE.md/AGENTS.md.- 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.
Znote is a local-first Markdown editor with executable code blocks and AI grounded in your notes. Opens Obsidian vaults natively.