From Obsidian to jonte.au - a publishing bridge with no buttons
How I wired my Obsidian vault into the EmDash-backed jonte.au so publishing is just a folder move, via the official REST API and a stdlib-only Python script.
- How I wired my Obsidian vault into the EmDash-backed jonte.au so publishing is just a folder move, via the official REST API and a stdlib-only Python script.
- Structured across 7 core technical sections (1052 words).
- Published with deterministic SEO and RSS syndication.
jonte.au is in the middle of a migration. The old site is Next.js behind Cloudflare; the replacement is an Astro build with EmDash as the CMS, currently living at new.jonte.au until I'm ready to cut over. Every writing flow I've ever had dies in the same place: you write in one tool, then you paste into a web editor, then you fight that editor's opinion of markdown for ten minutes. My notes already live in an Obsidian vault that syncs everywhere. So the goal became simple: the publishing surface for my site should be a folder in the vault. Move a note from Drafts to Approved and it goes live, and that's the entire interaction.
The plugin that wasn't
EmDash is extensible, and my first instinct was to write a plugin running on Cloudflare Workers, sitting alongside the site. That idea died fast. Loading a local plugin needs a LOADER binding, and getting that binding to work sat behind Cloudflare Workers Paid: a monthly fee to move markdown fifty metres. The official EmDash REST API (documented at docs.emdashcms.com/reference/rest-api) does everything the plugin would have done, so I dropped the plugin path entirely and rewrote the publisher as a plain script talking to the API. The superseded Cloudflare plugin got deleted from the repo in the same sweep.
Dropping the plugin was the right call. The script is 349 lines of stdlib-only Python: urllib.request, json, re. No requests dependency, no deployment target, nothing to install. It runs anywhere Python 3 exists, which includes the agent container that already watches the vault.
The folder contract
The vault gets three folders under Hermes/Publish/:
Drafts/— drafts. The publisher never touches this folder.Approved/— the trigger. A note landing here gets published.Published/— where the note goes after it's live, with the URL stamped back into its frontmatter so future-me can find the canonical post.
Approval is a drag-and-drop in Obsidian. No admin panel, no login, no publish button. The gestural part matters to me: there is exactly one deliberate action between "draft" and "internet", and it happens in the tool I'm already in.
What the REST API actually wants
Four endpoints do the whole job:
GET /_emdash/api/content/{collection}?slug=...finds an existing entry by slug (the script tries the slug query param first, then falls back to scanning the list)POST /_emdash/api/content/{collection}creates an entry with statusdraftPATCH /_emdash/api/content/{collection}/{id}updates fields on a draftPOST /_emdash/api/content/{collection}/{id}/publishpromotes the draft to live
Auth is a Bearer personal access token generated in the EmDash admin, kept in an EMDASH_TOKEN environment variable rather than in the repo. Configuration lives in a small publisher.json: vault path, base URL (https://new.jonte.au), the collection (posts), and a field map.
Three schema details cost me an evening, all fixed in one commit:
- The envelope. Responses come wrapped as
{success, data}, and single items nest one level deeper underdata.itemwhile lists come back underdata.items. Unwrap wrong and you're staring atNonewondering whether the API is broken. It isn't; your indexing is. bodymust be an array of Portable Text blocks, not markdown, not HTML. EmDash's content model is Portable Text (the Sanity-style block format), so the script converts before it sends.dateanddescriptionmust be plain strings. Not nested objects, not ISO-wrapped structures. Getting that wrong was the difference between a 400 and a live post.
One more operational gotcha: the site sits behind Cloudflare bot protection, and urllib's default user agent gets challenged. Every request sends a browser User-Agent. Requests get a 30-second timeout, which has been plenty.
Markdown to Portable Text
Since EmDash wants Portable Text, the script carries a small markdown-to-Portable-Text converter. Inline text is parsed into spans with marks: bold, italic, code, and links (links carry a _key'd mark with href and blank: true). Block-level handling covers headings h1 through h6, blockquotes, bullet and numbered lists with a level, and code blocks. The one workaround worth knowing: EmDash has no horizontal-rule block, so a --- in markdown becomes a thin separator paragraph. It's a compromise, but it round-trips well enough visually.
Immutability and idempotency
Published entries are immutable via REST. You can't PATCH something that's already live, so the script checks status first: if the entry exists and is published, it no-ops and reports already_published instead of throwing. Drafts can be patched and re-published freely, which covers the "typo found ninety seconds after publishing" case (fix the note, republish). One thing to know before importing an archive: setting publishedAt directly, i.e. backdating, requires the content:publish_any permission, not just the regular publish right.
Closing the loop: the drafter cron
A publisher solves half the problem. The other half is that I don't write consistently, and the gap between "did a thing" and "wrote about the thing" is where posts go to die. So the other end of the pipeline is now automated too: a weekly cron job on my Hermes agent. Every Sunday at 09:00 Melbourne time it recalls the past week from its own long-term memory, and if there's something I genuinely shipped, fixed or learned with enough concrete detail to be worth 800-plus words, it writes a draft into Publish/Drafts/.
The cron is barred from writing to Approved/ and barred from calling the EmDash API. Approval stays a human gesture. And the drafting rule is that every command, version and number in a draft has to be traceable to something that actually happened. If the memory doesn't support a claim, the claim gets omitted rather than invented.
This post is the first output of that cron, which is either neat or slightly recursive depending on your tolerance for that sort of thing. The bridge code lives in a private repo (jontstaz/jonte-au-obsidian-emdash-bridge), and I get a Telegram ping in a "Blog Autodrafter" topic whenever it produces something.
What's next
- Cut new.jonte.au over to become the real jonte.au and retire the Next.js build.
- Teach the publisher to verify what it shipped: fetch the live URL and confirm the rendered content matches instead of trusting a 200.
- Keep the drafter honest. The first rule of an auto-drafter is that it doesn't get to be creative; if drafts start containing things I didn't do, the cron gets deleted, not the errors.
{
"slug": "from-obsidian-to-jonte-au-a-publishing-bridge-with-no-buttons",
"date": "2026-11-01",
"tags": [],
"readingTime": "6 min read",
"verifiedBy": "Jonte de Zwart"
}
Threat: Unbounded complexity and implicit runtime assumptions.
Control: How I wired my Obsidian vault into the EmDash-backed jonte.au so publishing is just a folder move, via the official REST API and a stdlib-only Python script.