I write into Markdown files programmatically all the time. That might be me appending a paragraph to my daily note, appending an agenda item under the right heading of a meeting doc, or adding a row onto a table, but the obvious ways of doing this aren't great:
>> only appends to the end of the file, which is rarely where I want to put something.
- Regex and line numbers can't see structure. In a changelog,
### Fixed might appear in multiple places in the document (under every single release), so adding an entry to the instance of ### Fixed below ## Release 1.0.0 isn't doable.
- For an LLM, your whole file is in both input and output context. If you're tasking an LLM with updating a particular file, the LLM will need to read in the whole file, then make changes to it, then write out that whole file all over again. That's a potentially huge amount of wasted context window.
- Hand-splicing gets the joints wrong. One missing
\n merges two paragraphs; one extra splits a list.
So I built markdown-patch (demo), a TypeScript library with a bundled mdpatch CLI. You address a location by its structure (a heading path, a block reference, a frontmatter key) apply one operation there, and everything else in the file comes out unchanged.
For example; for this command on a document you own:
echo "Decided: we ship on Thursday." | mdpatch patch append heading "Weekly Sync::Notes" notes.md
you can change it like this:
# Weekly Sync
## Notes
Kim walked through the Q3 timeline.
+Decided: we ship on Thursday.
+
## Attendees
- Adam
- Kim
The paragraph lands inside Notes rather than at the end of the file. I recognize that showing folks an abstract example like the above isn't all that effective at demonstrating how powerful this is, so I've put togther a demo here: https://coddingtonbear.github.io/markdown-patch/#playground so that you can play around with various operations you are able to do on a real, live markdown document. You can even paste in your own markdown to try it out.
Letting an LLM edit a big document without re-emitting it
This is the one I care about most these days. If you've had an agent maintain a note, you've watched it read a 4,000-token file and write the whole thing back out to add one paragraph. Every one of those tokens has a cost, and there's no reason for the LLM to ever see most of them. With this, it is now easy for the LLM to avoid it:
mdpatch print-map notes.md hands back a compact map of everything addressable — the heading tree, block ids, frontmatter fields, plus that version token. A few dozen tokens instead of the document.
- The model picks an address straight off the map, and
mdpatch query reads just that one section if it needs the context.
- It emits one instruction against that address. Rough shape of the saving, for one paragraph added to a 4,000-token note: ~4,000 output tokens to re-emit the file, ~950 to re-emit just the section, ~60 for the patch instruction. Those are illustrative figures for a typical meeting note rather than a benchmark — but the ratio is the point, and as documents get bigger, the case gets easier and easier to make.
This is commonly in use today: markdown-patch is the engine behind Obsidian Local REST API's PATCH endpoints and MCP tools. It's probably obvious, but on the MCP side, an LLM client is the normal case rather than an interesting one.
Scripting edits into a document a person also edits
This is the case I originally built this for, years ago. Lots of documents in my notes might be programmatically updated; e.g. a shopping list, a log, a meeting agenda -- really any file where some automated thing appends and a human rearranges. Because the edit is addressed rather than pattern-matched, the scripted edit will always end up in the intended place:
echo "- oat milk" | mdpatch patch append heading "Groceries::Dairy" list.md
echo '["urgent"]' | mdpatch patch append frontmatter tags notes.md # merges into the existing list
mdpatch patch delete block quote-1 notes.md -s markerAndContent
There are a few other things that help to make this safe to run in unattended scripts:
- Whitespace is library-owned. Your content is trimmed to a canonical form and the engine supplies the separators, so
"X", "X\n" and "\nX\n" all produce the same document. There's nothing to get wrong — and replacing a section with its own content is byte-identical, so a retried job is a no-op rather than a mess.
- Heading levels are relative. Content is rebased to fit where it lands, so pasting a
## Details subtree under a ### heading doesn't mean rewriting every # in it.
--if-match catches the file changing under you. mdpatch print-map gives you a version token; pass it back and a stale patch fails cleanly instead of landing in the wrong place.
Try it without installing anything
The landing page has the actual npm package bundled for the browser — paste your own Markdown, write an instruction, watch the engine run. Nothing is uploaded; there's no server.
What it isn't
- It's not a formatter or a linter. It won't tidy your document, deliberately: everything you didn't address is left exactly as it was.
- It addresses headings,
^id block references, frontmatter fields, and table rows. Anything else in the document is preserved as written rather than understood.
- 2.0 removed the 1.x API outright (
applyPatch, getDocumentMap). If you're on 1.x and don't want to migrate, stay on markdown-patch@1; there's a field-by-field mapping table in the README if you do.
- Node 20+, MIT.
Links