Link checking can be fun
Projects I work on seem to be amassing more and more documentation.
It starts small, ADRs (Architectural Decision Documents) to capture decisions, runbooks on how to operate processes,
But then it goes further. Now we have every meeting transcribed, it should live in the repo, right? It could be invaluable context, and be relevant to a piece of code or documentation.
This culminates in full directories of Markdown. Tonnes of the stuff!
You end up needing a librarian to keep all those documents in good working order. How often do you read comments, module docs or markdown documentation which is out of date, no longer relevant, or reference no longer existent material?
With the magic of LLM technologies, we now have a persistent shepherd of these important docs. Coding agents love Markdown!
And it works surprisingly well. Markdown links are explicit, portable and readable in a diff, and understood by people and agents. With pure markdown on disk, I get a useful web of context without building an actual knowledge base, running an indexing service, or asking an embedding model to guess which document I meant.
It works until a file moves.
This led me down the path of implementing my own specialised OCaml program, which runs as a coding agent hook.
Whenever an agent edits one of these Markdown docs, it checks that every local link still leads somewhere. If a link is broken, the hook gives the file, line, and missing target back to the model and tells it to look again.
This hook dramatically reduces the number of hallucinations I get in comments and documentation. References can actually be trusted!
Most of the time I barely even notice it running.
The tiny knowledge graph?
Calling a folder of Markdown a knowledge graph sounds grander than it is, but the links really are doing that job. This sort of sentence is both documentation and an instruction for how to find more context:
The deployment invariants are documented in
[the architecture map](../architecture/current-system.md).
Its a nice piece of progressive disclosure. It screams "interested? Follow me for more...".
A person can follow it. An agent can follow it. A mechanical checker can verify that the promised context still exists.
[Without LLMs] any deterministic document checker cannot tell me whether two files agree, or whether the linked doc is actually any good. But it can enforce a much more focused verification
If one document says another document contains relevant context, that document must still exist.
I like this approach because Markdown remains the source of truth and Git remains the history. There is no database to migrate, no proprietary graph to maintain, and no opaque retrieval step between the instruction and its supporting context. The links are crude, but honest.
An excuse to write some Gleam
I went to Gleam Gathering 2026 and had a great time. As tends to happen after spending time around people who are excited about a language, I came home wanting an excuse to write some Gleam.
A local link checker seemed about the right size: small enough to finish, but useful enough that I might keep running it afterwards.
The first version3310bc7
was little more than a parser looking for Markdown-shaped links. Within a couple of days it had separate parsing and resolution code, tests, support for multiple targets and globs, and useful exit codes.
Gleam was a joy for discovering the shape of the program. Pattern matching and explicit result types suited a tool whose basic job is to turn CLI arguments and filesystem state into either a clean exit or a precise error.
Then I pointed it at real documents.
Real Markdown fights back
The expression [some text](some path) looks simple until the path belongs to an actual repository.
Links need to resolve relative to the file containing them, not wherever the checker happened to be launched. Filenames contain spaces and parentheses. Paths can arrive with percent signs encoded all over the place. Some links point to directories. Fragments matter to a Markdown reader but not to a filesystem existence check. External URLs, email addresses, phone numbers, bare anchors, and query strings are not local files at all.
A single early change ended up handling most of that at once:
Resolve links like the document does8a4c868
The next few changes made it feel like a tool rather than a script: recursive directory scanning, deterministic summaries, release binaries, and exclusion patterns for the parts of a repository that should not be checked.
It works, but what about distribution?
The Gleam version compiled to an Erlang escript. That is a perfectly reasonable way to ship a Gleam CLI, but it was a poor fit for this particular one.
Link Verifier is supposed to start constantly, inspect a small amount of text, report quickly, and disappear. Booting the BEAM for every invocation meant that runtime startup was painful. It also meant that downloading the executable was not enough: the machine running it still needed Erlang/OTP.
Gleam had been excellent for discovering the program. But what I really wanted was a small native binary that would just run near instantaneously.
Enter OCaml
I had wanted to do more with OCaml for years. Its promises are extremely attractive for this kind of work: expressive types, pattern matching, immutable functional code, native compilation, and small executables.
But the downside is the steep learning curve. To a novice used to the C family of languages, it feels very alien and unfamiliar. Along with the tooling — very confusing how the pieces fit together!
Coding agents make it a way more viable option today.
What do agents make of OCaml?
The question was not whether OCaml could implement a Markdown link checker. The more interesting question now, was whether working with a coding agent could make the refactor simple, and the code understandable to someone who doesn't really understand the language!
I already had something especially valuable for the experiment: a working implementation and a. full test suite, so I could express the exact behaviour I wanted very clearly. The agent could help translate the structure, set up dune and opam, choose established libraries, and explain compiler errors. And since there was already a fully functioning program, all the agent had to do was match the behaviour.
A single verifible outcome! The holy grail in agentic engineering!
The refactor went very, very well.
Rewrite Link Verifier in OCamlae3e8ed
The change replaced the Gleam implementation completely while preserving targets, globs, exclusions, exit codes, and diagnostics. It carried the behaviour across with 38 tests, made output ordering deterministic, and improved the error produced by an invalid exclusion pattern.
The resulting executable was roughly 2.5 MB and had no runtime dependency. On the benchmark dataset, the measured workloads became roughly 16 to 33 times faster:
| Workload | Gleam | OCaml |
|---|---|---|
| Single file | 112.39 ms | 3.39 ms |
| Recursive directory | 151.00 ms | 9.58 ms |
| Wildcard | 173.11 ms | 5.84 ms |
The numbers in that table are very pleasing to behold, but the change to the distribution is what mattered way more to me. On a new machine I can just download one executable, put it in a hook, and forget about the implementation language entirely.
From translated code to OCaml code
A successful port is not necessarily idiomatic code. My first OCaml implementation still contained patterns inherited from the way the previous program had been structured and from my own limited familiarity with the language.
That became the next part of the experiment. Rather than stopping when the tests passed, I used the working program as a way to learn what better OCaml looked like:
Replace mutable reference patterns7e2ba10
and then:
Use more idiomatic standard-library patterns06ad61f
This is where working with an agent was particularly interesting. Getting a foreign syntax to compile is one level of assistance. Being able to ask why a pattern feels wrong, compare it with the conventions of the language, and then immediately exercise the result against an existing test suite is much more useful.
The best evidence that the rewrite had settled was that the language soon stopped being the story. I went back to adding behaviour.
Ignore links inside code examples2c993f4
The program was no longer just an "OCaml port". It was gaining features in its own right.
A deterministic tool verifying a probabilistic editor
Today the checker runs as an agent hook while I work. The arrangement is super simple.
The agent edits some Markdown. Link Verifier scans it. If every local target exists, nothing happens. If a target is missing, the program returns an exact location:
docs/guide.md:42: broken link -> ./missing-file.md
The checker does not attempt to repair the link. It does not know whether the destination moved, whether the relative path is wrong, whether the reference was copied from another directory, or whether the missing document should be restored.
The model does have enough context to investigate those questions, so the hook tells it to look again.
I really like this division. A small native program answers the question it can answer deterministically: does the promised path exist? The agent handles the contextual correction. Neither needs to pretend to be the other.
It also means I can keep developing the linked-Markdown pattern across projects without committing to a full knowledge-base system. Plans can refer to decisions. Agent instructions can point at architecture. Documentation can be split into useful pieces instead of one enormous context file. Git records how it all changes, and Link Verifier makes sure the paths between those pieces have not silently disappeared.
Every once in a while I can even just run it over the entire repo and see what links may have changed by humans or agents and what needs to be fixed.
I started the project because I wanted an excuse to write some Gleam. It became an excuse to explore some more OCaml, and coding agents made the learning experience way more engaging and less intimidating.
Now it is one of the least exciting parts of my setup, that just works.
It turns out link checking can be fun.