Skip to main content
PCSalt logo
YouTube GitHub
Back to Workflow
Workflow · 8 min read

Localhost Markdown Viewer for Research Notes — Python Stdlib, SSE Live-Reload, launchd

How I built a tiny Python stdlib-only server that turns ~/research/docs into a live, chronologically and category-indexed markdown viewer on 127.0.0.1:6202 — with SSE auto-reload and launchd-managed auto-start. No MkDocs, no Node, no build step.


In the companion post on my Claude Code workflow, I described the habit of saving every substantive analysis to ~/research/docs/<category>/<date>_<slug>.md. The habit only works if the archive is readable — if I have to open a markdown file in an editor every time I want to glance at something, the habit dies in a week.

This post is the viewer that keeps it alive. It runs on 127.0.0.1:6202, watches the docs folder, and auto-reloads in the browser within a second of any save. It has zero non-stdlib dependencies, no build step, and starts at login via launchd.

This is the architecture and the design choices. I’m not dumping the full source — the interesting bits are the decisions, not the line count.

What I wanted, exactly

Five non-negotiables, in priority order:

  1. No editor required. Open a browser tab, see all my notes, click one, read it rendered.
  2. Two parallel indexes. A chronological view (what did I write when?) and a category view (what did I write about?). Both as collapsible panes.
  3. Live reload. I save a markdown file → the viewer reflects it within ~1 second. No manual refresh.
  4. Always-on. Starts when my Mac boots. Survives crashes. Doesn’t need me to remember to start it.
  5. Loopback only. It’s a personal tool. It must never be accessible from the LAN, much less the internet.

A few things I did not want:

  • A Node toolchain. I don’t want npm in my docs-viewing path.
  • A build step. Every save adding a “rebuild index” friction would kill the workflow.
  • Framework lock-in. I want to stop using it any month and lose nothing.

Why not MkDocs / Docusaurus / Obsidian

Each one fails at least one of my non-negotiables for this specific use case — and they’re all fine tools otherwise. Calling out the trade-offs because they’re not obvious until you try:

  • MkDocs (and Material) has a single nav model. Getting two parallel collapsible indexes (date + category) without a plugin or custom theme is more fighting-the-framework than I wanted.
  • Docusaurus is React-based and heavy for a personal docs viewer. The dev server feels right; the production build feels wrong; running the dev server forever feels wrong-er.
  • Obsidian is a beautiful tool, but it’s an editor with browse capabilities, not a browser with edit capabilities. I wanted the latter — open in any tab, no app launch.
  • Astro / Eleventy / Hugo — all great, all require a build step or a dev server, and the dev server isn’t designed to live for months.

The honest answer is: I’d already built a custom Python prototype before fully evaluating the alternatives, and once it worked, the migration cost wasn’t worth it. The prototype-to-permanent slide is real.

The stack

   ┌────────────────────────────────────────────┐
   │   Browser tab (http://127.0.0.1:6202/...)  │
   └────────────┬───────────────────────────────┘
                │  HTTP GET / + SSE

   ┌────────────────────────────────────────────┐
   │   Python http.server (stdlib only)         │
   │                                            │
   │   /              → viewer HTML             │
   │   /index.json    → rebuilt per request     │
   │   /docs/<path>   → raw markdown            │
   │   /events        → SSE stream              │
   └────────────┬───────────────────────────────┘


   ┌────────────────────────────────────────────┐
   │   Background mtime watcher (1s polling)    │
   │   walks ~/research/docs/, on change → SSE  │
   └────────────────────────────────────────────┘

The runtime is one Python process, one thread for http.server, one thread for the watcher, communicating over a small in-memory pub/sub.

No database. No build artifacts. No node_modules. The whole thing fits comfortably in a few hundred lines of Python and runs in ~20 MB RSS.

Decision 1 — Stdlib over framework

http.server, json, threading, pathlib, os.stat, urllib, and the markdown rendering of your choice. That’s it.

The reason isn’t purity — it’s durability. A stdlib-only script written today will keep working on whatever Python ships with macOS in 2030. A pip install-heavy script will break the next time I forget to set up a venv on a new machine. For a tool that needs to keep running for years with zero attention, the stdlib bias is the right call.

(For markdown rendering, the one exception I’d allow is a single small library like markdown or mistune. Even then, vendor it if you want to go fully zero-dep.)

Decision 2 — Per-request index, no build step

The naïve design has a separate “index builder” step that scans ~/research/docs/, writes index.json, and serves it as a static file. You then need cache-invalidation logic (“did the index file’s mtime drift behind any source file?”) and you’ll get it wrong.

I tried this. It was wrong twice in a week.

The dumb-but-correct design: /index.json is rebuilt on every request. Walk the tree, parse frontmatter from each file, sort, return. There is no stored index. There cannot be a stale index.

Two reasons this is fine in practice:

  • The archive holds a few hundred small markdown files. Walking + frontmatter-parsing takes under 50ms on a modern Mac.
  • The viewer only fetches /index.json when I open the page or when the watcher fires an event. It’s not a hot path.

If your archive grows past ~10k files this stops being free, but you’ll know when that day comes.

Decision 3 — SSE for live reload

Two real options for browser-side push: WebSockets and Server-Sent Events.

  • WebSockets are bidirectional and need a non-stdlib library (or a fair amount of stdlib gymnastics).
  • SSE is one-way (server → browser), stdlib-friendly (Content-Type: text/event-stream, write lines, flush), and the browser auto-reconnects if the connection drops.

For “tell the browser the file list changed,” one-way is exactly what I need. SSE wins.

The server endpoint is roughly:

def do_GET_events(self):
    self.send_response(200)
    self.send_header('Content-Type', 'text/event-stream')
    self.send_header('Cache-Control', 'no-cache')
    self.end_headers()
    queue = subscribe()  # in-process pub/sub
    try:
        while True:
            event = queue.get()
            self.wfile.write(f"data: {event}\n\n".encode())
            self.wfile.flush()
    except (BrokenPipeError, ConnectionResetError):
        unsubscribe(queue)

And the browser side is one line of useful code:

<script>
  new EventSource('/events').onmessage = () => location.reload();
</script>

(In the actual viewer I do something smarter than a full page reload — re-fetch /index.json and re-render the affected pane. But “reload the page” is a fine starting point.)

Decision 4 — mtime polling at 1 second

The watcher uses os.stat().st_mtime, walks the tree once per second, and emits an SSE event if anything changed since the last walk.

I evaluated alternatives. On macOS, fsevents would give push notifications instead of polling — at the cost of a non-stdlib dependency (watchdog or PyObjC). For an archive of a few hundred files, polling every second costs ~5ms. The trade isn’t worth it.

The robustness story is the part I underestimated. Polling does not care if files appear, disappear, are renamed, are moved between subdirectories, or are touched without modification (which fsevents sometimes doesn’t catch). Walk → compute a fingerprint → compare → emit. Brutally simple, brutally correct.

Decision 5 — Loopback bind, always

HTTPServer(('127.0.0.1', 6202), Handler)

Not 0.0.0.0. Not even localhost (which can resolve to either v4 or v6 depending on the machine — be explicit). 127.0.0.1 only.

The viewer has no auth. It serves my unfiltered research notes. There is no plausible reason it should be reachable from another machine. Bind to loopback and the question doesn’t come up.

Decision 6 — launchd user agent, KeepAlive=true

For “starts at login, restarts on crash, runs under my user account,” macOS’s answer is a launchd user agent — a plist in ~/Library/LaunchAgents/, not the system-wide /Library/LaunchDaemons/ (which runs as root and is overkill for a personal viewer).

The plist is essentially:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>local.research-viewer</string>

  <key>ProgramArguments</key>
  <array>
    <string>/usr/bin/python3</string>
    <string>/Users/you/research/.viewer/server.py</string>
  </array>

  <key>WorkingDirectory</key>
  <string>/Users/you/research</string>

  <key>RunAtLoad</key>
  <true/>

  <key>KeepAlive</key>
  <true/>

  <key>StandardOutPath</key>
  <string>/Users/you/research/.viewer/server.log</string>

  <key>StandardErrorPath</key>
  <string>/Users/you/research/.viewer/server.log</string>
</dict>
</plist>

Load it once:

launchctl load ~/Library/LaunchAgents/local.research-viewer.plist

RunAtLoad=true starts it at login. KeepAlive=true restarts it if the process dies. That’s the whole reliability story.

A small but important detail: if you edit the plist, launchctl unload and launchctl load it again. launchd reads the plist at load time and ignores subsequent edits until reload.

Decision 7 — TDD, even for ~300 lines

I wrote the tests first. Twelve for the indexer (frontmatter parsing, date sort, category grouping, missing-field handling, malformed YAML), eight for the server (status codes, content types, SSE handshake, loopback enforcement).

This sounds like overkill for a personal tool. It wasn’t. The indexer is the part where stale-index bugs would have lived if I’d skipped it, and the SSE handler is the part where “works on my machine, hangs on the next reboot” would have lived. Both got caught by tests, not by debugging the live viewer.

The rule I’ve internalised: if something runs in the background for months, the test suite is the only thing standing between you and a slow, silent failure.

What I’d build differently next time

Honestly, not much:

  • Markdown rendering is the one thing I might centralise — currently each request renders on demand. A small LRU cache keyed by file mtime would shave another few ms for free.
  • A “recent” view in the viewer UI, separate from the chronological one — show only the last 14 days, collapsed-by-category. The chronological pane is great for “what did I decide in March,” but day-to-day I mostly want “what did I just write.”
  • Search would be nice. A naïve full-text search across a few hundred small markdowns is fast enough to do in-process without a real search engine. Haven’t built it yet.

None of these are blocking. The viewer has been running for months, restarts itself when it crashes, and stays out of the way. That’s the whole point.

When to build this versus when to use something else

If you’re going to save analyses for less than a month to see if the habit sticks: don’t build this. Use VS Code’s markdown preview. The infra cost is unjustified until you know the habit is real.

If you’re going to save analyses for years, and you want zero ceremony between “save file” and “see file rendered”: something like this is worth the few-hundred-line investment. The freedom of having no Node toolchain, no build step, and no framework to upgrade is the kind of thing you appreciate gradually, then suddenly.

If you want collaboration, search, public sharing, or visual polish — use MkDocs Material or Quartz. This viewer optimises for a single user on a single machine. That’s a feature, but it’s also a limit.

Pick the tool that matches the lifespan and the audience. For a personal research archive that lives for years, owned by one person, served on one machine — stdlib Python, SSE, launchd, loopback. There’s nothing simpler that does the job, and that’s the whole reason I keep using it.