This article provides a step by step guide to publishing a markdown article to Substack with publishing-kit, an agent skill for Claude Code, Codex and Antigravity. Substack is the kit's fifth destination, after dev.to, Medium, AWS Builder Center and LinkedIn, and every output below comes from publishing the kit's own walk-through to a Substack publication.
One markdown file goes in. A Substack post comes out, with every code block, image and link checked in the draft Substack saved before it goes live.
Substack has no publishing API. A post goes in through its web editor, and that editor decides what survives.
| Content | In the saved draft | | :--- | :--- | | Multi-line code blocks | 🟢 kept, same line and character counts | | Images on public URLs | 🟢 fetched and re-hosted by Substack, alt text kept | | Plain links | 🟢 kept | | Tables | ❌ no table element exists in the editor | | A link whose text is inline code | ❌ the link is dropped, the code is kept | | A code block marked text | ❌ saved as a maths (LaTeX) block | | Section headings | ⚠️ kept at the level pasted, in six distinct sizes |
None of the failures produces an error. The table disappears, or a link becomes plain monospace text, and the editor saves it.
Substack's editor is built on ProseMirror, and its schema lists every element and mark a post can hold. The code mark is declared with excludes: "", which means inline code can carry no other mark: no bold, no italic, no link.
So ` skills/publishing/SKILL.md arrives as skills/publishing/SKILL.md, and the editor keeps the code and discards the link. The kit's walk-through has two links written that way, and pasted as-is, its saved draft holds 2 link targets out of 4.
The fix is to take the out of the anchor before pasting. The link survives as plain text, which keeps the reader's way to the page.
Both lack tables, so the kit feeds Substack the same HTML it builds for Medium, where every table is already an image. After that they part ways: Code: Medium's importer flattens multi-line code to one line. Substack keeps it as a real code block. Alt text: Medium's editor blanks it on paste. Substack keeps it. Headings: Medium has two heading sizes, so the kit demotes sections to for it. Substack has six, and renders at 21.375px against 19px body text, so a section heading barely stands out. Publishing: both default to notifying subscribers. On Substack that is an email to every subscriber and a push to the Substack app.
At This Point You Should Have… publishing-kit 0.32.0 or later in Claude Code, Codex or Antigravity, with browser automation that can run JavaScript in the page. A Substack publication, signed in, in the browser the agent drives. An article already built for Medium with make-medium.py, its images committed and pushed.
Tables and box-drawing diagrams become PNGs under medium/img/, and -hosted.html points at them by their public GitHub URL. Substack fetches each one and re-hosts it on its own image server.
The editor page's content security policy blocks a fetch to the local machine, so the HTML travels in window.name, which survives a navigation in the same tab. The agent serves the repository on 127.0.0.1, opens the hosted HTML there, and stores it and the kit's Substack helper together:
Then the same tab opens https://.substack.com/publish/post?type=newsletter. That URL creates a new draft and redirects to /publish/post/, and the payload arrives intact: 20,066 characters of HTML in this run.
In the editor, the helper loads from the payload and adapts the HTML before pasting it:
prepare() removes the title block, because Title and subtitle are separate fields, unwraps the two links around inline code, and promotes Medium's sections to . It also clears the language class from every : pasted as-is, a block fenced as js await ss.paste() json {"chars": 9020, "pasted": true} text codeblock 4, lines 2/2/1/4 image2 2, both substack-post-media.s3.amazonaws.com, alt kept heading 7 (h3 7) link targets https://github.com/xbill9/publishing-kit https://github.com/xbill9/publishing-kit/blob/main/skills/publishing/SKILL.md https://github.com/xbill9/publishing-kit/tree/main/articles/publishing-kit-skill https://claude.com/claude-code text audience everyone comments everyone "Send via email and the Substack app" checked "Schedule time to email and publish" unchecked button "Send to everyone now" js await ss.publish({email: true, audience: "everyone"}) json {"audience": "everyone", "emailsentat": "2026-10-05T17:19:32.552Z", "id": 218966232, "post_date": "2026-10-05T17:19:32.878Z", "slug": "streamline-publishing-with-a-claude", "url": "https://xbill9.substack.com/p/streamline-publishing-with-a-claude"} text substack = https://xbill9.substack.com/p/streamline-publishing-with-a-claude shell python3 make-slack.py devto-publishing-kit.md text ok Builder Center: HTTP 200 WARN Medium: HTTP 403 to a browser UA (dev.to/Medium bot wall, not a broken link) ok Substack: HTTP 200 ok Dev.to (aws-builders): HTTP 200 ok LinkedIn: HTTP 200 ok no markdown left; Slack renders none of it
Substack answers a published post with 200 and a missing slug with 404, to a client with no cookies, so its links are checked like any other. A substack = PENDING line fails the run until the post is live.
The public page carries the post's markup more than once: the served HTML for this post holds 8 and 16 . Inside the class="body markup" element it holds 4 and 7, matching the draft. Substack also adds an anchor link to every heading, at /i//.
The editor renders what it holds, and a dropped link still looks like code on screen. The JSON document behind the draft lists every link target, so a missing one is a line that is absent from the list.
