Every engineering team is shipping more internal software than it used to, and most of it is tooling rather than product. The pattern repeats across teams that otherwise have nothing in common:
- An onboarding assistant that answers questions about the deployment pipeline.
- A service that assembles context for a coding agent before it opens a pull request.
- A weekly digest of architectural decisions for the people who were not in the room.
These are small applications with one big shared dependency, because each of them has to read what the team knows and most of them have to write back.
That dependency is usually what decides whether the thing works at all. The model is rarely the problem and neither is the interface, because what actually matters is whether the layer underneath gives you four things:
- A stable address: for every document, so a rename does not orphan an index entry.
- Cheap change detection: so a sync job does not re-download the corpus to discover that nothing moved.
- A record of who changed what: so an answer can be traced back to a decision and a person.
- A permission model machines inherit: so your tooling enforces the same rules the editor does.
That layer is infrastructure, and it is worth treating like infrastructure.
The read path
The REST API lives at HackMD API and authenticates with a bearer token generated in your settings. Listing and reading are separate operations with different costs, which is the first thing to design around.
# Metadata for every note you can reach. curl -H "Authorization: Bearer $HACKMD_TOKEN" \ https://api.hackmd.io/v1/notes # Content for one note. curl -H "Authorization: Bearer $HACKMD_TOKEN" \ https://api.hackmd.io/v1/notes/$NOTE_ID
Three fields in the list response carry most of the weight for anything you build on top:
idis the addressable key and the one your index should store, whereasshortIdandpermalinkare convenient for humans and best kept out of storage, because a permalink can be changed.lastChangedAtis your change signal, and it arrives in the list response rather than only on the individual note, which is what makes a delta sync possible. One call tells you which notes moved and you only pay for content on those.readPermissionandwritePermissioncome back with every note, so your retrieval layer can enforce the same rules the editor does instead of inventing a second access model alongside it.
Change detection gets cheaper still with conditional requests, because GET /notes/{noteId} supports If-None-Match and the official Node client treats a 304 Not Modified as a success rather than an error.
import HackMDAPI from '@hackmd/api' const client = new HackMDAPI(process.env.HACKMD_TOKEN!) const cached = await cache.get(noteId) const fresh = await client.getNote(noteId, { etag: cached.etag }) if (fresh.status !== 304) { await cache.put(noteId, { content: fresh.content, etag: fresh.etag }) await reindex(noteId, fresh.content) }
The list response names the candidates and the ETag confirms whether the body actually moved, which means most sync runs end up touching a handful of documents rather than the whole corpus.
Backpressure is part of the contract
Every response carries the rate limit state, so a client always knows where it stands without having to guess:
X-RateLimit-UserLimitfor the calls allowed in the current window.X-RateLimit-UserRemainingfor the calls left in it.X-RateLimit-UserResetfor the timestamp when the window resets.
The official client reads all three when it raises a TooManyRequestsError on a 429, and it retries with exponential backoff. One detail is worth copying if you write your own client, which is that it retries GET, HEAD, OPTIONS, PUT and DELETE but deliberately excludes POST and PATCH, because those are not idempotent. A blind retry on a timed-out POST /notes leaves you with two notes, and the same retry on a PATCH can clobber a concurrent edit with a stale body.
Version history is an endpoint, not just a UI
Most tools treat revision history as a feature of the editor, which makes it useful to a human scrolling a timeline and invisible to your software. HackMD exposes it as a queryable resource instead.
GET /notes/{noteId}/versions # list saved versions POST /notes/{noteId}/versions # create a named version PATCH /notes/{noteId}/versions # update a named version GET /notes/{noteId}/versions/compare # unified diff between two refs GET /notes/{noteId}/versions/{versionId} # one version, with content
Listing is a query rather than a dump.
GET /versions accepts named_only, q, created_by, created_after and created_before alongside page and limit, and it returns a paginated data array with a meta.total. That means a question like “every checkpoint this service account created last week” is a single filtered call, rather than a full history download you sift through on your own machine.
curl -H "Authorization: Bearer $HACKMD_TOKEN" \ "https://api.hackmd.io/v1/notes/$NOTE_ID/versions?named_only=true&limit=20"
{ "data": [ { "id": "v_01H…", "name": "Spec approved in architecture review", "name_source": "user", "description": "Sign-off before build started", "created_at": "2026-08-14T09:12:44.000Z", "created_by": { "user_id": "…", "display_name": "James", "photo": "…" }, "authorship": [ { "user_id": "…", "display_name": "James", "photo": "…" }, { "user_id": "…", "display_name": "Ana", "photo": "…" } ], "content_available": true } ], "meta": { "total": 42, "total_pages": 3, "page": 1, "limit": 20 } }
Two of those fields carry more weight than the rest:
name_sourceseparates versions that someone deliberately named from the ones the system captured on its own, which is what makesnamed_only=truea meaningful filter rather than a cosmetic one.authorshiplists everyone who contributed to that version instead of only the last person to touch the note, and that is precisely the field an onboarding assistant needs when the question is not what a document says but who to talk to about it.
Checkpoints are first-class.
A POST creates a named version either from the live note content or from an existing saved version.
curl -X POST -H "Authorization: Bearer $HACKMD_TOKEN" \ -H "Content-Type: application/json" \ -d '{"base":"note_content","name":"Runbook validated during INC-482","description":"Steps confirmed live"}' \ "https://api.hackmd.io/v1/notes/$NOTE_ID/versions"
The endpoint also refuses to create a meaningless checkpoint, returning a 409 when the content has not changed or the version is already named, which means a CI step that checkpoints a spec on merge is safe to run on every merge. A PATCH then edits the name and description afterwards, so a checkpoint can be created automatically and labelled properly once a human knows what it turned out to mean.
Diffs are a first-class read.
The compare endpoint takes a base and a target and returns { "unified_diff": "..." }. Because that is a standard format, you can feed it to a patch parser, render it in a weekly digest, or hand it straight to a model, and sending a forty-line diff instead of a four-thousand-word runbook is the difference between a cheap call and an expensive one.
The API tells you what it can reconstruct.
Every version carries a content_available flag, so you branch on a field rather than on a failed request, and GET /versions/{versionId} returns the content whenever it is there. The errors are equally specific, since the error body includes latest_version_id and latest_name_source, which lets a client holding a stale reference recover in the same round trip instead of listing everything again.
Taken together, this is what makes agent write-back safe to adopt. When an agent edits a note the revision is retained, attributed and diffable, so a human can see exactly what the machine changed and review stops depending on anyone happening to notice.
It is worth knowing that GET /history is a different and complementary thing, because it returns the notes a user recently opened and therefore makes a strong recency signal when you are ranking candidates for an agent’s context window. Versions tell you how a document evolved, while history tells you what people are actually reading.
The same layer, a different socket
The shape of the problem changes when the consumer is an agent rather than a cron job. HackMD runs an official hosted MCP server, so there is no local process to operate, and it speaks Streamable HTTP, connects to Claude Desktop, Claude Code, Cursor and Windsurf, and supports both reading and writing across personal and team workspaces. Notes are also exposed as MCP resources serving raw text/markdown, which lets a resource-aware client pull a document into context without invoking a tool at all.
Choosing between the two comes down to who decides what gets read:
- Use REST when your code decides: which covers deterministic sync, indexing and anything you want to keep under test.
- Use MCP when the model decides: which covers exploratory work, agents following a thread, and any case where enumerating the right notes in advance is the hard part.
Most teams end up running both, alongside content negotiation for simpler readers, since appending .md to a note URL or sending an Accept: text/markdown header returns clean Markdown from the same address a human visits.
The point
None of this is exotic, and that is the argument. Stable IDs, conditional requests, rate limit headers and a version history you can filter, diff and write to are all ordinary infrastructure, and they are the difference between an internal tool that holds up and one that quietly drifts out of date.
Your internal software is only as good as the substrate it reads from. If you can get addressing, change detection, provenance and permissions from the layer underneath, then the six months you would have spent rebuilding them goes into the part that is actually yours.
