TypeScript CLI for publishing local Markdown files to a user-owned Substack publication. Supports dual-transport: HTTP API for automated publishing and browser automation (Playwright + Stagehand) for full editor interaction.
- ✅ Markdown → ProseMirror — Parse Markdown with front matter into Substack-compatible JSON
- ✅ Dual Transport —
--transport apifor API-driven or--transport browserfor full editor interaction - ✅ API Publishing — Create, update, publish, and schedule drafts via Substack's API
- ✅ Browser Automation — Local Chrome or Browserbase remote sessions
- ✅ Media Upload — Upload images via base64 data URLs
- ✅ MCP Server — 17 tools, 2 resources, 2 prompts for AI agents
- ✅ Rich Content — Tables, embeds, paywall, subscribe, code blocks, blockquotes
- ✅ Draft Management — Mappings, optimistic concurrency, section resolution, duplicates
- ✅ Workflow Traces — Capture, review, and compare browser artifacts
- ✅ Quality Gates — Format → Lint → TypeScript → Build → Test with enforced baseline coverage → Mutation
npm install -g @edithatogo/substack-cli
npx @edithatogo/substack-cli inspect examples/basic.mdCompletions are generated by the installed CLI:
substack-cli completion bash
substack-cli completion zsh
substack-cli completion powershellFor bash or zsh, add source <(substack-cli completion bash) or source <(substack-cli completion zsh) to your shell profile. The npm package also includes scripts/install-completions.sh and scripts/install-completions.ps1 as user-run helpers that generate completions from the installed binary; generated scripts/completions.* files are intentionally not shipped.
# 1. Configure
substack-cli config set-publication https://yourpub.substack.com
# 2. Inspect a Markdown file
substack-cli inspect examples/basic.md
# 3. Create a draft (dry-run first)
substack-cli draft post.md --dry-run
# 4. Publish
substack-cli publish post.md --yes
# 5. Schedule
substack-cli schedule post.md --at "2026-06-01T09:00:00Z" --yes- Final publish and schedule button flows.
npm install
npm run build
npm test
npm run qualityWindows/OneDrive-safe install:
npm install --cache .npm-cache --prefer-onlineThe repository includes the same npm cache settings in .npmrc, and .npm-cache/ is
ignored. This avoids global cache rename collisions and cleanup locks commonly seen in
OneDrive or SharePoint-synced workspaces.
Create a local .env from .env.example:
Copy-Item .env.example .envSet:
BROWSERBASE_API_KEYBROWSERBASE_PROJECT_IDSUBSTACK_PUBLICATION_URLSUBSTACK_EMAILandSUBSTACK_PASSWORDonly if you wantauth login --auto-loginSUBSTACK_COOKIEonly if you want to test the internal API adapter without reading the local browser profile
node dist\cli.js inspect examples\basic.md
node dist\cli.js prepublish examples\basic.md
node dist\cli.js prepublish examples\basic.md --mode schedule --at 2026-05-01T09:00:00Z
node dist\cli.js draft examples\basic.md --dry-run
node dist\cli.js draft examples\basic.md --transport auto
node dist\cli.js config set-publication https://example.substack.com
node dist\cli.js config set-runtime local
node dist\cli.js doctor
node dist\cli.js policy
node dist\cli.js mcp surface
node dist\cli.js mcp summary
node dist\cli.js mcp serve
node dist\cli.js api auth status --source local-profile
node dist\cli.js api inventory --source local-profile --post-limit 10
node dist\cli.js api payload examples\basic.md
node dist\cli.js api media examples\media.md
node dist\cli.js api draft create examples\basic.md
node dist\cli.js api draft observe --timeout-seconds 180
node dist\cli.js api draft contract .substack-cli\draft-captures\example.json
node dist\cli.js api draft contract-matrix .substack-cli\draft-captures\a.json .substack-cli\draft-captures\b.json
node dist\cli.js api draft contract-matrix .substack-cli\draft-captures\a.json .substack-cli\draft-captures\b.json --out fixtures\draft\matrix.json
node dist\cli.js api draft contract-matrix-compare fixtures\draft\expected.json fixtures\draft\actual.json
node dist\cli.js api draft duplicates examples\basic.md
node dist\cli.js api draft section examples\basic.md
node dist\cli.js api draft inspect examples\basic.md
node dist\cli.js api draft review .substack-cli\draft-captures\example.json
node dist\cli.js api draft compare .substack-cli\draft-captures\expected.json .substack-cli\draft-captures\actual.json
node dist\cli.js api draft fixture .substack-cli\draft-captures\example.json --out fixtures\draft\baseline.json
node dist\cli.js api draft mappings
node dist\cli.js api draft link examples\basic.md --draft-id 123
node dist\cli.js auth status
node dist\cli.js auth login --wait-seconds 120
node dist\cli.js auth login --auto-login --wait-seconds 120
node dist\cli.js auth login --auto-login --pause-before-password --wait-seconds 300Capture and compare parser fixtures:
node dist\cli.js schema capture examples\basic.md --out fixtures\prosemirror\basic.json
node dist\cli.js schema validate fixtures\prosemirror\basic.json
node dist\cli.js schema compare examples\basic.md fixtures\prosemirror\basic.jsonPublishing and scheduling require explicit confirmation:
node dist\cli.js prepublish examples\basic.md
node dist\cli.js publish examples\basic.md --dry-run
node dist\cli.js publish examples\basic.md --review-only --yes --trace-out .substack-cli\publish-traces\review.json
node dist\cli.js publish examples\basic.md --transport browser --yes
node dist\cli.js publish examples\basic.md --yes
node dist\cli.js schedule examples\basic.md --at 2026-05-01T09:00:00Z --yes
node dist\cli.js schedule examples\basic.md --at 2026-05-01T09:00:00Z --transport auto --yes
node dist\cli.js trace review .substack-cli\publish-traces\review.json
node dist\cli.js trace compare .substack-cli\publish-traces\review.json .substack-cli\publish-traces\publish.json
node dist\cli.js trace fixture .substack-cli\publish-traces\review.json --out fixtures\trace\review.jsonPublish and schedule commands run the same prepublish validation first and stop early if the payload is not compatible. Use --trace-out to capture a local JSON review artifact for later comparison.
Use front matter for metadata:
---
title: "Example post"
subtitle: "Generated from Markdown"
tags: [example, markdown]
audience: everyone
section: original-essays
comments: enabled
---Media examples:


<img src="https://example.com/native.png" alt="Native alt" data-caption="Native caption">inspect prints a media manifest for every image. Markdown image titles and inline HTML
data-caption values are recorded there as caption metadata; a normal paragraph after an
image stays visible paragraph text and is not treated as native caption metadata.
Supported custom markers:
{{paywall}}
{{subscribe: Subscribe for future posts}}
{{youtube: https://www.youtube.com/watch?v=VIDEO_ID}}
{{embed: https://example.com/article}}
{{podcast: https://open.spotify.com/episode/ID}}| ProseMirror Node | Markdown Source | Notes |
|---|---|---|
paragraph |
Plain text | Default block |
heading |
# through ###### |
Levels 1-6 |
bulletList / listItem |
- , * |
Nested lists supported |
orderedList / listItem |
1. |
Nested lists supported |
blockquote |
> |
Nested blockquotes supported |
codeBlock |
``` or ```language |
Language annotation preserved |
horizontalRule |
--- |
Thematic break |
image |
 or <img> |
Caption metadata via data-caption or Markdown title |
embedNode |
{{youtube:}}, {{embed:}}, {{podcast:}} |
URL and embed type stored |
paywallDivider |
{{paywall}} |
Atom block, no content |
subscribeWidget |
{{subscribe: label}} |
Label attribute |
table / tableRow / tableCell / tableHeader |
GFM tables | Non-resizable, inline marks supported |
hardBreak |
Line break | |
text |
Text content |
| ProseMirror Mark | Markdown Source |
|---|---|
bold |
**text** or __text__ |
italic |
*text* or _text_ |
strike |
~~text~~ |
code |
`text` |
link |
[text](url) |
These constructs pass through the parser but are either not mapped to a named ProseMirror node or may render differently in Substack's editor. The inspect command reports them as compatibility issues before any publish attempt:
| Feature | Why / Fallback |
|---|---|
| Image galleries | No multi-image group node; single images work independently |
| Substack native captioned images | Captions parsed from data-caption but no separate caption element |
| Native audio/video | All media embeds use embedNode; no dedicated audio/video ProseMirror node |
Math / LaTeX ($$, $) |
No MathExtension registered; falls through as plain text |
| Callouts / pull quotes | No Substack blockquote variant; rendered as standard blockquote |
| Buttons | No Substack button element; would be stripped if HTML is unrecognized |
Task lists (- [ ]) |
Syntax not processed; renders as plain listItem |
Mentions (@user) |
No mention extension |
| Footnote references | No footnote ProseMirror node |
| Figure / figcaption | HTML5 <figure> not handled; children still parsed |
| Auto-generated heading IDs | StarterKit heading extension does not emit id attributes |
| Image dimensions | image node does not carry width/height attrs |
| Substack editor node names | Custom node names (paywallDivider, subscribeWidget, embedNode) may not match Substack's internal schema |
The inspect command reports the full list of detected node types, mark types, and any unmapped types in compatibility:
$ substack-cli inspect examples/formatting.md
{
"compatibility": {
"ok": true,
"supportedNodeTypes": ["blockquote", "bulletList", ...],
"unsupportedIssues": null
}
}
On Windows, prefer a repo path outside OneDrive. If OneDrive is unavoidable, use the
repo-local .npm-cache/ configured by .npmrc, and set SUBSTACK_CLI_STATE_DIR to a
non-synced path such as %LOCALAPPDATA%\substack-cli-state when browser-profile locks
or sync cleanup interfere with local runtime state.